尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

深入解析Vue 3响应式原理:从Proxy到依赖收集的模拟实现

深入解析Vue 3响应式原理:从Proxy到依赖收集的模拟实现 1. 项目概述从使用到实现理解响应式核心在Vue 3.0的开发中reactive()函数几乎是我们构建响应式数据对象的首选。我们习惯于写下const state reactive({ count: 0 })然后就能在模板或计算属性中丝滑地使用和追踪state.count的变化。但你是否想过这个看似简单的函数背后究竟是如何将普通对象“点石成金”变成响应式代理的呢当我们在控制台里看到Proxy对象时除了知道它很强大是否也曾好奇其内部运作的细节这次我们不满足于仅仅使用API而是要动手模拟实现一个简化版的reactive函数。这个“模拟实现”的目的绝非为了造一个能在生产环境替代Vue的轮子而是为了深入理解Vue 3响应式系统的核心设计思想——依赖收集与触发更新。通过亲手实现你会对Proxy、Reflect、WeakMap、Set这些看似枯燥的API有全新的、具象化的认识。你会发现Vue源码中那些精妙的设计本质上是对JavaScript语言特性的一次创造性组合。这个过程尤其适合两类朋友一是已经熟悉Vue 3基础用法希望技术深度再上一个台阶的开发者二是对JavaScript高级特性感兴趣想通过一个经典案例来融会贯通的学习者。我们将从零开始一步步构建一个具备基本响应式能力的系统并在这个过程中直面那些Vue团队已经为我们妥善处理好的边界情况和性能问题。相信我当你完成这个模拟实现后再回头去看Vue的官方文档或源码会有一种“原来如此”的通透感。2. 核心原理与架构设计拆解在动手写代码之前我们必须先把核心原理和整体架构想清楚。Vue 3的响应式系统抛弃了Vue 2基于Object.defineProperty的方案全面转向ES6的Proxy。这个转变带来了质的飞跃一是可以完美代理对象的所有属性包括新增、删除二是性能更优。我们模拟实现的核心就是围绕Proxy展开。2.1 响应式系统的三大支柱一个最简化的响应式系统离不开三个核心部分的协同工作代理层 (Proxy Handler)这是拦截器负责“抓现行”。当你对响应式对象进行get读取、set设置、deleteProperty删除等操作时Proxy的处理器对象会拦截这些操作。在这里我们将植入依赖收集和触发更新的逻辑。依赖管理中心 (Dependency Management)这是“记账本”和“通知中心”。我们需要一个结构来记录“哪个响应式对象的哪个属性被哪个副作用函数所依赖”。当属性变化时能根据这个记录精准地找到所有需要重新执行的副作用函数。副作用函数 (Effect)这是“订阅者”。任何依赖了响应式数据的函数比如组件的render函数、computed计算属性、watch侦听器本质上都是一个副作用函数。它需要在首次执行时“订阅”依赖并在依赖变化时被“通知”重新执行。我们的模拟实现将严格遵循这个架构。我们会先实现一个effect函数来包装副作用然后实现reactive函数来创建代理最后通过一个全局的“靶子-目标”映射关系来管理依赖。2.2 关键数据结构设计数据结构的设计直接决定了系统的效率和优雅程度。Vue 3源码中使用了多个WeakMap来构建一个精密的依赖关系树。我们将其简化但保留其精髓。targetMap(WeakMapObject, Mapstring | symbol, Set )这是最顶层的依赖仓库。它是一个WeakMap键是原始对象target值是一个Map。使用WeakMap的好处是键是弱引用不会影响原始对象的垃圾回收。当原始对象不再被使用时它和对应的依赖关系可以自动被回收。depsMap(Mapstring | symbol, Set )这是某个原始对象对应的属性依赖映射。它是一个Map键是属性名key值是一个Set里面存放了所有依赖了这个属性的副作用函数effect。使用Set可以自动去重避免同一个副作用函数被重复添加。activeEffect(Effect | undefined)这是一个全局变量用于临时存储当前正在执行的副作用函数。在effect函数执行时我们会把它赋值给activeEffect这样在后续的get拦截中就能知道是“谁”正在读取属性从而将其收集到对应的依赖集合中。这个targetMap - depsMap - depSet的三层结构是理解整个响应式系统的钥匙。它清晰地刻画了“对象 - 属性 - 依赖函数”的映射关系。3. 基础实现从零搭建响应式骨架理论铺垫完毕现在进入实战环节。我们将从最核心的effect和reactive函数开始。3.1 实现副作用追踪器effect副作用函数是响应式系统的起点。我们需要一个方法来包装用户传入的函数使其具备依赖收集的能力。// 用一个全局变量存储当前活跃的副作用函数 let activeEffect undefined; // 定义副作用函数类 class ReactiveEffect { constructor(fn) { this.fn fn; // 用户传入的原始函数 } run() { // 执行前将当前effect实例设置为活跃的 activeEffect this; try { // 执行用户函数这会触发响应式对象的get拦截从而完成依赖收集 return this.fn(); } finally { // 执行完毕后清空活跃effect activeEffect undefined; } } } // 对外暴露的effect函数 function effect(fn) { // 创建一个ReactiveEffect实例 const _effect new ReactiveEffect(fn); // 立即执行一次触发初始依赖收集 _effect.run(); // 返回一个runner函数可用于手动再次执行 return _effect.run.bind(_effect); }关键点解析activeEffect是一个全局“指针”它总是指向当前正在执行的副作用函数实例。effect(fn)的执行过程是创建实例 -run()-activeEffect指向自己 - 执行fn()-fn()内部读取响应式数据 - 触发get拦截 - 将activeEffect即自己收集为依赖 -fn()执行完毕 -activeEffect置空。这种设计巧妙地利用了JavaScript单线程同步执行的特性确保了在依赖收集的时刻activeEffect总是指向正确的“订阅者”。3.2 实现依赖收集与触发track trigger有了activeEffect我们就可以在代理的get和set拦截器中实现依赖的“记账”和“通知”了。我们先实现这两个最核心的工具函数。// 全局依赖存储仓库 const targetMap new WeakMap(); // 依赖收集函数在get拦截时调用 function track(target, key) { // 如果没有活跃的effect说明不是在effect上下文中读取无需收集 if (!activeEffect) return; // 1. 根据target从targetMap中取出对应的depsMap let depsMap targetMap.get(target); if (!depsMap) { depsMap new Map(); targetMap.set(target, depsMap); } // 2. 根据key从depsMap中取出对应的依赖集合depSet let depSet depsMap.get(key); if (!depSet) { depSet new Set(); depsMap.set(key, depSet); } // 3. 将当前活跃的effect添加到这个属性的依赖集合中 // 添加前先判断一下避免重复添加虽然Set本身能去重但减少操作总是好的 if (!depSet.has(activeEffect)) { depSet.add(activeEffect); // 这里可以建立一个反向关联用于cleanup进阶功能后续讨论 // activeEffect.deps.push(depSet); } } // 依赖触发函数在set拦截时调用 function trigger(target, key) { // 1. 根据target取出depsMap const depsMap targetMap.get(target); if (!depsMap) return; // 该对象从未被追踪直接返回 // 2. 根据key取出对应的依赖集合depSet const depSet depsMap.get(key); if (!depSet) return; // 该属性从未被追踪直接返回 // 3. 遍历depSet执行其中每一个effect的run方法 // 注意这里需要创建一个副本再遍历防止在执行effect时Set内容发生变化导致的无限循环 const effectsToRun new Set(depSet); effectsToRun.forEach(effect { effect.run(); }); }实操心得track的逻辑是“谁在读我我就记住谁”。它像是一个登记处在属性被访问时记录下访问者activeEffect。trigger的逻辑是“我变了我要通知所有记得我的人”。它像是一个广播站在属性被修改时通知所有登记过的访问者重新执行。在trigger中我们必须对depSet进行拷贝new Set(depSet)后再遍历执行。想象一个场景某个effect函数在执行时会修改它自己所依赖的属性这会导致trigger再次被调用。如果不拷贝在遍历集合的同时又向集合中添加新元素在某些JavaScript引擎中会直接抛出错误或者导致无限循环。3.3 实现reactive函数与Proxy处理器现在我们可以用track和trigger来武装我们的Proxy处理器了。// reactive函数将普通对象转换为响应式对象 function reactive(target) { // 基本参数校验 if (typeof target ! object || target null) { console.warn(reactive() expects an object but got ${typeof target}); return target; } // 这里可以添加缓存逻辑避免对同一个对象重复代理后续优化 // if (reactiveCache.has(target)) { return reactiveCache.get(target); } const handler { get(target, key, receiver) { // 使用Reflect.get获取原始值保证this绑定正确 const res Reflect.get(target, key, receiver); // 依赖收集 track(target, key); // 如果获取到的值也是对象则可以递归地将其也转为响应式惰性代理 if (res ! null typeof res object) { return reactive(res); } return res; }, set(target, key, value, receiver) { // 先获取旧值用于判断是否真的发生了变化 const oldValue target[key]; // 使用Reflect.set执行赋值操作 const success Reflect.set(target, key, value, receiver); if (success) { // 只有在新旧值不同或者都是NaNNaN ! NaN的情况下才触发更新 if (!Object.is(oldValue, value)) { // 触发更新 trigger(target, key); } } return success; // 必须返回布尔值表示设置是否成功 }, deleteProperty(target, key) { // 检查属性是否存在 const hadKey Object.prototype.hasOwnProperty.call(target, key); // 使用Reflect.deleteProperty执行删除 const success Reflect.deleteProperty(target, key); if (success hadKey) { // 触发更新 trigger(target, key); } return success; } }; return new Proxy(target, handler); }关键点解析Reflect的使用我们全程使用Reflect对应的方法如Reflect.get、Reflect.set。这不仅仅是风格问题。Reflect方法提供了更规范、更可靠的行为例如Reflect.set会返回一个布尔值表示操作是否成功而直接赋值或target[key] value则不会。这让我们能更精确地控制流程。惰性代理Nested Reactivity在get拦截器中我们发现如果获取的值res是一个对象就递归调用reactive(res)。这是一个“惰性”策略只有当你真正访问到深层属性时它才会被转换为响应式对象而不是一开始就递归代理整个对象树这有助于提升初始性能。变化检测在set拦截器中我们使用Object.is(oldValue, value)来比较新旧值。Object.is比更精确它能正确处理NaN NaN为false的情况Object.is(NaN, NaN)返回true。只有当值真正发生变化时才触发更新避免不必要的重渲染。删除属性触发更新deleteProperty拦截器也很重要。删除一个属性同样改变了对象的状态需要通知依赖该属性的副作用。现在我们已经有了一个最基础的、可工作的响应式系统让我们写个例子测试一下// 测试代码 const obj reactive({ count: 0, name: Vue }); effect(() { console.log(count发生了变化新值是${obj.count}); }); effect(() { console.log(name是${obj.name}); }); console.log(--- 开始修改 ---); obj.count 1; // 输出count发生了变化新值是1 obj.name React; // 输出name是React obj.count 1; // 值未变不会触发effect控制台无输出 delete obj.name; // 输出name是undefined4. 进阶优化与边界情况处理基础版本跑通了但距离一个健壮的系统还有差距。Vue的响应式系统处理了大量边界情况以提升开发者体验和运行性能。我们来逐一攻克。4.1 解决effect嵌套与无限循环问题在真实场景中effect可能是嵌套的比如组件渲染。此外如果effect函数内部修改了它自己所依赖的属性就会导致无限循环调用。我们的基础版本对这两个问题处理得并不好。1. 效应嵌套 我们用一个栈来管理活跃的effect而不是一个简单的变量。let activeEffect undefined; const effectStack []; // 新增effect调用栈 class ReactiveEffect { constructor(fn) { this.fn fn; this.deps []; // 新增用于存储所有包含本effect的depSet用于清理 } run() { // 如果当前effect已经在栈中说明是递归调用直接执行fn避免重复收集依赖一种处理方式 // 更常见的Vue策略是允许嵌套但依赖收集只关联到当前活跃的effect。 // 我们采用Vue的策略将当前effect压栈 if (effectStack.includes(this)) { return this.fn(); // 防止循环依赖导致栈溢出简单处理直接执行不改变activeEffect } try { effectStack.push(this); activeEffect this; return this.fn(); } finally { effectStack.pop(); activeEffect effectStack[effectStack.length - 1]; // 恢复上一个活跃的effect } } }这样当内层effect执行时activeEffect指向它执行完毕后activeEffect能正确恢复到外层的effect。这对于实现组件生命周期和watch的嵌套调用至关重要。2. 无限循环与依赖清理 一个effect可能在不同分支下依赖不同的属性。如果依赖关系不清理会导致过期依赖被错误触发。Vue的做法是在每次effect执行前清理掉它之前的所有依赖然后重新收集。function cleanup(effect) { // 遍历该effect记录的所有depSet从每个Set中删除本effect for (const depSet of effect.deps) { depSet.delete(effect); } // 清空deps数组 effect.deps.length 0; } class ReactiveEffect { constructor(fn) { this.fn fn; this.deps []; // 存储所有包含本effect的depSet } run() { // 执行前先清理旧的依赖 cleanup(this); try { effectStack.push(this); activeEffect this; return this.fn(); } finally { effectStack.pop(); activeEffect effectStack[effectStack.length - 1]; } } } // 同时需要修改track函数建立反向关联 function track(target, key) { if (!activeEffect) return; let depsMap targetMap.get(target); if (!depsMap) { depsMap new Map(); targetMap.set(target, depsMap); } let depSet depsMap.get(key); if (!depSet) { depSet new Set(); depsMap.set(key, depSet); } if (!depSet.has(activeEffect)) { depSet.add(activeEffect); // 反向关联将本depSet记录到activeEffect的deps中 activeEffect.deps.push(depSet); } }这个cleanup机制是响应式系统稳定的基石。它确保了effect的依赖集合总是最新的避免了“过期依赖”导致的错误更新或无限循环。例如在一个v-if控制的模板里条件变化后effect应该只依赖新条件分支下的数据。4.2 实现调度器scheduler与计算属性computed雏形Vue的effect支持传入一个scheduler选项用于控制副作用函数的执行时机这是实现异步更新队列、watch的flush选项等功能的基础。计算属性computed本质上也是一个特殊的effect它具有缓存和懒计算特性。实现调度器class ReactiveEffect { constructor(fn, scheduler null) { // 新增scheduler参数 this.fn fn; this.deps []; this.scheduler scheduler; } run() { cleanup(this); try { effectStack.push(this); activeEffect this; return this.fn(); } finally { effectStack.pop(); activeEffect effectStack[effectStack.length - 1]; } } // 新增触发effect执行的方法如果有scheduler则用scheduler否则直接run trigger() { if (this.scheduler) { this.scheduler(this); } else { this.run(); } } } function effect(fn, options {}) { const _effect new ReactiveEffect(fn, options.scheduler); _effect.run(); const runner _effect.run.bind(_effect); runner.effect _effect; // 将effect实例挂载到runner上方便访问 return runner; } // 修改trigger函数调用effect.trigger() function trigger(target, key) { const depsMap targetMap.get(target); if (!depsMap) return; const depSet depsMap.get(key); if (!depSet) return; const effectsToRun new Set(depSet); effectsToRun.forEach(effect { effect.trigger(); // 改为调用trigger方法 }); }现在我们可以控制effect的执行了const obj reactive({ count: 0 }); effect(() { console.log(obj.count); }, { scheduler(effect) { // 将effect放入微任务队列实现异步批量更新 Promise.resolve().then(() effect.run()); } }); obj.count; // 同步修改但console.log是异步执行的 console.log(同步代码先执行);实现简化版computed 计算属性是一个拥有getter和setter的响应式引用并且其值会被缓存。function computed(getterOrOptions) { // 统一参数处理 let getter, setter; if (typeof getterOrOptions function) { getter getterOrOptions; setter () { console.warn(Write operation failed: computed value is readonly); }; } else { getter getterOrOptions.get; setter getterOrOptions.set || (() { console.warn(Write operation failed: computed value is readonly); }); } let _dirty true; // 脏检查标志为true表示需要重新计算 let _value; let _effect; // 创建一个effect来追踪getter中的依赖 _effect new ReactiveEffect(getter, () { // scheduler当依赖变化时不立即重新计算只标记为脏 if (!_dirty) { _dirty true; // 这里可以触发依赖此computed的effect进阶功能需要另一个trigger } }); const computedObj { get value() { // 如果脏了重新运行effect计算新值 if (_dirty) { _value _effect.run(); _dirty false; } // 在这里我们还需要track这个computedObj的value属性以便其他effect依赖它简化版暂不实现 // track(computedObj, value); return _value; }, set value(newValue) { setter(newValue); } }; return computedObj; } // 使用示例 const state reactive({ a: 1, b: 2 }); const sum computed(() { console.log(computed getter executed); return state.a state.b; }); console.log(sum.value); // 输出: computed getter executed, 3 console.log(sum.value); // 输出: 3 (没有日志值被缓存) state.a 10; console.log(sum.value); // 输出: computed getter executed, 12 (依赖变化重新计算)这个简化版的computed实现了缓存_dirty标志和懒计算只在.value被访问且_dirty为true时计算。完整的实现还需要让computed自己成为一个可追踪的响应式源这涉及到更复杂的依赖关系链管理。4.3 代理缓存与只读代理为了避免对同一个原始对象重复创建代理我们可以增加一个缓存机制。同时实现一个类似readonly的代理也很有意义。// 代理缓存 const reactiveMap new WeakMap(); const readonlyMap new WeakMap(); // 只读代理缓存 function reactive(target) { return createReactiveObject(target, reactiveHandlers, reactiveMap); } function readonly(target) { return createReactiveObject(target, readonlyHandlers, readonlyMap); } function createReactiveObject(target, baseHandlers, proxyMap) { if (typeof target ! object || target null) { return target; } // 优先从缓存中取 const existingProxy proxyMap.get(target); if (existingProxy) { return existingProxy; } // 避免代理已经被代理过的对象简单判断Vue源码有更严谨的标记 // if (target.__v_raw) { return target; } const proxy new Proxy(target, baseHandlers); proxyMap.set(target, proxy); return proxy; } // 可写的处理器就是我们之前实现的handler const mutableHandlers { /* ...之前的get, set, deleteProperty... */ }; // 只读的处理器 const readonlyHandlers { get(target, key, receiver) { const res Reflect.get(target, key, receiver); // 只读对象依然需要track依赖对于计算属性依赖只读状态有用 track(target, key); if (res ! null typeof res object) { // 深层属性也转为只读 return readonly(res); } return res; }, set(target, key) { // 在开发模式下给出警告 console.warn(Set operation on key ${String(key)} failed: target is readonly., target); return true; // 在严格模式下set代理必须返回布尔值返回true表示“阻止”了写入虽然没写进去 }, deleteProperty(target, key) { console.warn(Delete operation on key ${String(key)} failed: target is readonly., target); return true; } };缓存机制用WeakMap实现键是原始对象值是代理对象。这保证了同一原始对象全局只有一个响应式代理符合直觉且节省内存。只读代理则通过一个拦截所有写入和删除操作并发出警告的处理器来实现。5. 常见问题、调试技巧与扩展思考在模拟实现和实际使用中你可能会遇到一些典型问题。这里记录一些排查思路和进阶思考。5.1 典型问题排查清单问题现象可能原因排查步骤与解决方案修改数据后effect没有执行1. 依赖未成功收集。2.activeEffect在track时为undefined。3. 新值旧值相同trigger被跳过。1. 检查effect函数是否确实同步读取了响应式属性。异步读取如在setTimeout中无法收集依赖。2. 在track函数开头加console.log(activeEffect, target, key)确认收集时机。3. 检查set拦截器中的新旧值比较逻辑。effect执行了但视图或依赖数据未更新1.effect函数内部有错误被静默吞没。2. 依赖的响应式对象层级过深惰性代理未生效。1. 在effect.run()的fn()调用外包裹try...catch并打印错误。2. 确保访问嵌套对象属性时路径上的每个对象都是响应式的或确认惰性代理逻辑正确。无限循环栈溢出1.effect函数内部修改了其依赖的属性且没有cleanup机制。2. 在trigger中直接遍历depSet而未创建副本。1. 实现并确保cleanup机制在effect.run()开始时被调用。2. 检查trigger函数确保是遍历new Set(depSet)。对数组使用push、pop等方法无效我们的基础Proxy只拦截了get、set、deleteProperty。数组的push会读取length属性并设置新元素但push方法本身来自Array.prototype未被代理。需要重写数组的变异方法push,pop,shift,unshift,splice,sort,reverse。在get拦截器中如果target是数组且key是这些方法名则返回一个我们包装过的函数在这个函数内部先执行原始方法再手动trigger与length和新增索引相关的依赖。这是Vue 2和Vue 3早期版本的做法Vue 3现在通过代理length和索引也能很好处理。5.2 调试技巧可视化依赖关系在开发复杂响应式逻辑时直观地看到依赖关系图能极大提升调试效率。我们可以写一个简单的调试函数function printDeps() { console.group(当前响应式依赖图); for (const [target, depsMap] of targetMap) { console.log(原始对象:, target); for (const [key, depSet] of depsMap) { console.log( - 属性 ${String(key)} 被以下effect依赖, Array.from(depSet).map(e e.fn.toString().slice(0, 80) ...)); } } console.groupEnd(); }在关键位置如effect执行后、数据修改前调用这个函数可以在控制台清晰地看到哪个对象、哪个属性被哪些函数所依赖。5.3 扩展思考Ref的实现与响应式丢失我们的reactive对原始值string, number, boolean等无能为力因为Proxy只能代理对象。Vue 3引入了ref来解决这个问题。ref的原理是创建一个包装对象{ value: xxx }然后对这个包装对象用reactive或类似的响应式处理。访问.value时进行依赖收集修改.value时触发更新。在模板中ref会被自动解包无需.value。另一个常见问题是“响应式丢失”。例如解构一个响应式对象const state reactive({ a: 1, b: 2 }); const { a, b } state; // a和b现在是原始值失去了响应性Vue 3提供了toRef和toRefs来解决。toRef会为响应式对象的某个属性创建一个ref这个ref与源属性保持同步。toRefs则是批量转换。它们的模拟实现核心是返回一个带有get和set访问器的对象在get中返回源对象的当前值并追踪依赖在set中修改源对象的值并触发更新。通过这个从零模拟reactive函数的过程我们不仅复现了核心功能更深入理解了Proxy、依赖收集、调度更新这些概念是如何协同工作构建出Vue 3强大的响应式系统的。这其中的设计模式和数据流思想对于理解现代前端框架乃至其他领域的响应式编程都有着深远的意义。下次当你再使用reactive、computed或watch时希望你的脑海中能浮现出这张清晰的依赖关系网。
返回列表