
1. 项目概述从响应式到实现原理如果你用过 Vue 3那一定对reactive函数不陌生。它能把一个普通的 JavaScript 对象变成一个“响应式”对象——当你修改对象的属性时依赖这些属性的视图会自动更新。这听起来像魔法但今天我们不谈魔法我们来聊聊这个魔法背后的“炼金术”自己动手模拟实现一个简化版的reactive。为什么我们要做这件事对于前端开发者尤其是 Vue 的使用者来说理解reactive的内部机制远比仅仅会调用 API 重要得多。它能帮你彻底搞明白 Vue 3 响应式系统的核心让你在遇到一些“诡异”的响应式失效问题时能一眼看穿本质而不是盲目地搜索“Vue 3 reactive 不生效怎么办”。更进一步这种基于 Proxy 的拦截和依赖收集思想是现代前端框架的通用设计模式理解了它你再看其他框架的类似特性就会有一种豁然开朗的感觉。简单来说这个模拟实现项目就是一次对 Vue 3 核心响应式原理的深度“解刨”。我们将从零开始构建一个具备基本响应式能力依赖收集、触发更新的系统。它不会像 Vue 源码那样考虑所有边界情况和极致性能但足以清晰地展示响应式系统的骨架和核心流程。无论你是想夯实基础的前端新手还是希望深入理解框架原理的进阶开发者跟着走完这一趟你都会对“响应式编程”有全新的、具象的认识。2. 核心思路与架构设计在动手写代码之前我们必须先把核心思路理清楚。Vue 3 的响应式本质上是一种“发布-订阅”模式的精巧应用而实现这一模式的关键在于 ES6 的Proxy和Reflect这两个 API。2.1 响应式系统的核心三要素一个最基础的响应式系统离不开三个核心角色目标对象 (Target)就是你想让它变成响应式的那个原始对象比如{ count: 0 }。副作用函数 (Effect)指那些依赖于响应式数据的函数。在 Vue 里这通常就是组件的渲染函数、计算属性或者watch回调。当响应式数据变化时这些副作用函数需要被重新执行。依赖关系图 (Dependency Graph)这是一个隐形的“关系网”它精确地记录了“哪个副作用函数依赖了哪个响应式对象的哪个属性”。当某个属性被修改时系统能根据这个关系图精准地找到所有需要重新执行的副作用函数。我们的模拟实现就是要搭建一个舞台让这三个角色能够协同工作。2.2 技术选型为什么是 ProxyVue 2 使用的是Object.defineProperty来拦截数据的get和set。这个方法有个众所周知的缺陷它无法检测到对象属性的添加或删除Vue 2 为此提供了Vue.set和Vue.delete。此外它对数组的拦截也需要特殊处理。Proxy则强大得多。它可以创建一个对象的代理从而基本拦截该对象的所有基本操作。我们可以为get读取属性、set设置属性、deleteProperty删除属性等操作设置“陷阱”trap。这意味着无论是修改已有属性、添加新属性还是删除属性甚至是通过for...in循环遍历只要在 Proxy 的拦截范围内我们都能感知到。因此选择Proxy作为实现基础是 Vue 3 响应式能力实现质的飞跃的关键也是我们模拟实现的不二之选。2.3 整体架构流程图虽然我们不能画图但可以用文字清晰地描述出整个数据流和逻辑初始化用户调用我们模拟的reactive函数传入一个普通对象target。创建代理函数内部为target创建一个Proxy实例并为其配置get和set陷阱。依赖收集 (发生在get陷阱)当副作用函数effect执行时如果它内部读取了某个响应式对象的属性例如obj.count就会触发该代理对象的get陷阱。在get陷阱中我们将当前正在执行的副作用函数记录到该属性对应的“依赖集合”中。这就建立了一个联系“obj.count这个属性被effect这个函数所依赖”。触发更新 (发生在set陷阱)当修改响应式对象的属性时例如obj.count 2会触发该代理对象的set陷阱。在set陷阱中我们根据被修改的属性名找到在第3步中收集到的所有依赖该属性的副作用函数。然后依次执行这些副作用函数。在 Vue 中这就触发了组件的重新渲染。这个“收集依赖 - 触发更新”的循环就是响应式系统最核心的脉搏。接下来我们就进入代码实战环节把这些概念一一具象化。3. 基础实现搭建响应式骨架让我们从一个最简单的版本开始逐步添加功能。首先在项目目录下创建一个my-reactive.js文件。3.1 实现核心的reactive函数我们的reactive函数目标很明确接收一个对象返回它的响应式代理。// 存储原始对象到其代理对象的映射防止重复代理同一个对象 const reactiveMap new WeakMap(); function reactive(target) { // 1. 只处理对象类型包括数组 if (typeof target ! object || target null) { console.warn(reactive() expects an object but got ${typeof target}.); return target; } // 2. 如果已经是代理对象直接返回通过一个自定义属性判断这里先简化 // Vue源码中使用了 __v_raw 等内部属性来标识 if (target.__v_isReactive) { return target; } // 3. 如果已经为该对象创建过代理则返回缓存的代理 const existingProxy reactiveMap.get(target); if (existingProxy) { return existingProxy; } // 4. 创建代理 const proxy new Proxy(target, { get(target, key, receiver) { console.log(拦截到读取操作属性 ${key.toString()}); // 使用 Reflect.get 完成默认的读取行为 const res Reflect.get(target, key, receiver); // 暂时直接返回值依赖收集逻辑稍后添加 return res; }, set(target, key, value, receiver) { console.log(拦截到设置操作属性 ${key.toString()} ${value}); // 使用 Reflect.set 完成默认的设置行为 const result Reflect.set(target, key, value, receiver); // 暂时直接返回触发更新逻辑稍后添加 return result; }, // 还可以添加 deleteProperty 等其他陷阱 }); // 5. 给代理对象打上一个标记并缓存到 Map 中 proxy.__v_isReactive true; reactiveMap.set(target, proxy); return proxy; }代码解析与注意事项WeakMap的使用我们使用WeakMap来缓存原始对象到代理对象的映射。WeakMap的键是弱引用这意味着当原始对象不再被其他地方引用时它可以被垃圾回收而WeakMap中的条目也会自动消失避免了内存泄漏。这是 Vue 源码中也采用的做法。ReflectAPI在 Proxy 的陷阱中我们使用Reflect对应的方法如Reflect.get、Reflect.set来执行对象的默认行为。这比直接操作target[key]更规范能确保this的指向正确receiver参数通常就是代理对象本身。重复代理处理通过reactiveMap和自定义标记__v_isReactive我们确保了同一个原始对象只会被代理一次这是响应式系统保持数据一致性的基础。现在你可以测试一下这个基础版本const obj { name: Vue, version: 3 }; const reactiveObj reactive(obj); console.log(reactiveObj.name); // 控制台会输出拦截到读取操作属性 name reactiveObj.version 4; // 控制台会输出拦截到设置操作属性 version 4目前它只会打印日志还没有真正的响应式能力。因为缺少了灵魂——依赖收集与触发更新。3.2 实现副作用追踪器effect副作用函数是响应式系统的“消费者”。我们需要一个机制来运行它并在它执行时自动追踪它所依赖的响应式属性。我们引入一个全局变量activeEffect用来存储当前正在执行的副作用函数。然后创建一个effect函数来包装用户传入的副作用。// 当前正在执行的副作用函数 let activeEffect null; function effect(fn) { // 用一个包装函数来执行用户传入的副作用 const effectFn () { // 在执行前将当前副作用函数设置为“激活”状态 activeEffect effectFn; try { // 执行用户函数这会触发响应式属性的 get 操作 fn(); } finally { // 执行完毕后清除激活状态 activeEffect null; } }; // 首次执行一次进行依赖收集 effectFn(); }关键点解析activeEffect就像一个全局的“指针”指向当前正在执行的副作用函数。当effectFn执行时activeEffect被设置为它自己。接着执行用户传入的fn()。如果fn内部读取了某个响应式属性触发get陷阱我们就能在get陷阱里知道是activeEffect依赖了这个属性。try...finally确保即使fn执行出错activeEffect也能被正确重置避免依赖收集错乱。3.3 构建依赖关系图Track 与 Trigger现在我们需要两个核心函数来管理依赖关系track(target, key)在get陷阱中调用用于“追踪”或“收集”依赖。它的工作是如果当前有激活的副作用 (activeEffect存在)就将这个副作用添加到target[key] 对应的依赖集合中。trigger(target, key)在set陷阱中调用用于“触发”更新。它的工作是找到target[key]对应的所有依赖副作用并执行它们。我们需要一个数据结构来存储这些关系。通常使用WeakMap嵌套Map再嵌套Set的结构。// 依赖关系存储结构 // WeakMap原始对象, Map属性名, Set副作用函数 const targetMap new WeakMap(); function track(target, key) { // 如果没有激活的副作用说明不是在 effect 内读取无需收集 if (!activeEffect) return; // 1. 根据 target 获取对应的 depsMap let depsMap targetMap.get(target); if (!depsMap) { depsMap new Map(); targetMap.set(target, depsMap); } // 2. 根据 key 获取对应的依赖集合 dep (一个 Set) let dep depsMap.get(key); if (!dep) { dep new Set(); depsMap.set(key, dep); } // 3. 将当前激活的副作用添加到依赖集合中 dep.add(activeEffect); // 为了方便后续清理我们也可以在 effectFn 上记录它属于哪些 dep进阶优化 } function trigger(target, key) { // 1. 根据 target 获取 depsMap const depsMap targetMap.get(target); if (!depsMap) return; // 该对象没有被追踪过任何属性 // 2. 根据 key 获取依赖集合 const dep depsMap.get(key); if (dep) { // 3. 执行所有收集到的副作用函数 // 注意这里需要创建一个副本再执行防止在执行过程中集合发生改变导致无限循环 const effectsToRun new Set(dep); effectsToRun.forEach(effectFn { effectFn(); }); } }现在让我们把track和trigger整合到reactive的 Proxy 陷阱中function reactive(target) { // ... 前面的类型检查、缓存逻辑不变 ... const proxy new Proxy(target, { get(target, key, receiver) { // 依赖收集 track(target, key); const res Reflect.get(target, key, receiver); return res; }, set(target, key, value, receiver) { const oldValue target[key]; const result Reflect.set(target, key, value, receiver); // 只有值真正发生变化时才触发更新避免不必要的重复触发 if (oldValue ! value) { trigger(target, key); } return result; }, }); // ... 缓存逻辑不变 ... return proxy; }3.4 第一个完整的响应式 demo让我们把所有代码组合起来看一个完整的例子// 将上面的 track, trigger, effect, reactive 函数定义放在这里 const state reactive({ count: 0 }); // 创建一个副作用依赖 state.count effect(() { // 这个函数内部读取了 state.count会触发 track console.log(count 发生了变化新值是${state.count}); }); // 修改 state.count会触发 trigger从而重新执行上面的 effect 函数 state.count; // 输出count 发生了变化新值是1 state.count 5; // 输出count 发生了变化新值是5恭喜你已经实现了一个最基础的、具备自动依赖收集和更新触发能力的响应式系统。虽然简陋但它已经抓住了 Vue 3reactive最核心的灵魂。4. 进阶完善处理边界与提升健壮性上面的基础版本虽然能跑但距离一个健壮的生产级实现还差得远。Vue 的源码处理了大量边界情况。我们来逐一完善。4.1 处理嵌套对象与深响应目前的实现只能让对象的第一层属性变成响应式。如果属性值本身又是一个对象那么这个嵌套对象内部的修改是无法被追踪的。const state reactive({ info: { name: Alice } }); effect(() { console.log(state.info.name); }); state.info.name Bob; // 不会触发 effect 执行为了解决这个问题我们需要在get陷阱中做一个判断如果读取到的属性值res是一个对象那么我们就递归地调用reactive将其也转换为响应式对象再返回。这个过程是惰性的只有在读取时才会进行转换。get(target, key, receiver) { track(target, key); const res Reflect.get(target, key, receiver); // 关键如果获取到的值是一个对象则返回它的响应式代理 if (typeof res object res ! null) { // 这里直接调用 reactive它会处理缓存避免重复代理 return reactive(res); } return res; }现在state.info返回的就是一个响应式代理对其属性.name的修改也能被正确追踪了。注意Vue 3 中reactive是深度的deep。但ref包装的.value如果是对象也会被reactive自动转换。我们的模拟实现了这个深度转换的特性。4.2 避免无限循环在 effect 中设置同一个属性考虑以下场景const state reactive({ count: 0 }); effect(() { state.count state.count 1; // 读取 count (track)然后设置 count (trigger) });这会导致无限循环effect执行 -get收集依赖 -set触发更新 -effect再次执行 - ...Vue 的解决方案是如果一个副作用正在执行那么由它自己触发的更新应该被跳过。我们需要在trigger函数中实现这个逻辑。修改effect和trigger函数function effect(fn) { const effectFn () { // 清理当前 effect 与之前所有属性的依赖关系防止遗留依赖后面讲 cleanup(effectFn); activeEffect effectFn; try { fn(); } finally { activeEffect null; } }; // 为每个 effectFn 初始化一个依赖集合 effectFn.deps []; // 存储所有包含本 effect 的 dep 集合 effectFn(); } // 清理函数将该 effect 从其所有关联的依赖集合中移除 function cleanup(effectFn) { for (const dep of effectFn.deps) { dep.delete(effectFn); } effectFn.deps.length 0; } // 修改 track 函数建立双向联系 function track(target, key) { if (!activeEffect) return; let depsMap targetMap.get(target); if (!depsMap) { depsMap new Map(); targetMap.set(target, depsMap); } let dep depsMap.get(key); if (!dep) { dep new Set(); depsMap.set(key, dep); } // 双向记录 dep.add(activeEffect); activeEffect.deps.push(dep); // 将当前 dep 记录到 activeEffect.deps 中 } // 修改 trigger 函数避免无限递归 function trigger(target, key) { const depsMap targetMap.get(target); if (!depsMap) return; const dep depsMap.get(key); if (!dep) return; // 关键创建一个新的 Set 来执行避免在遍历过程中原 Set 被修改cleanup 会删除 const effectsToRun new Set(); dep.forEach(effectFn { // 如果 trigger 触发的 effect 就是当前正在执行的 activeEffect则跳过 if (effectFn ! activeEffect) { effectsToRun.add(effectFn); } }); effectsToRun.forEach(effectFn effectFn()); }核心改动解释双向记录track时不仅将activeEffect加入dep还将dep反向记录到effectFn.deps数组中。这为清理遗留依赖提供了可能。cleanup函数在每次effectFn执行前将它从之前所有收集过它的依赖集合中移除。因为每次执行都会重新收集依赖这样可以避免“过期”的依赖比如分支条件变化导致不再读取某个属性继续触发不必要的更新。避免自触发在trigger中判断如果要触发的effectFn就是当前正在执行的activeEffect则跳过。这直接解决了在effect中设置其依赖的同一属性导致的无限循环问题。4.3 支持数组的变异方法对于数组直接通过索引set如arr[0] 1是可以被我们的set陷阱拦截的。但是像push、pop、shift、splice这些变异方法它们内部既会读取length属性也会设置元素和length属性如果处理不当可能会导致依赖收集混乱或多次触发更新。Vue 的做法是重写这些数组方法。我们模拟一个简单的push方法的重写思路// 获取数组的原型 const arrayProto Array.prototype; // 创建一个以数组原型为原型的对象 const arrayMethods Object.create(arrayProto); // 需要重写的方法列表 const methodsToPatch [push, pop, shift, unshift, splice, sort, reverse]; methodsToPatch.forEach(method { const original arrayProto[method]; // 在 arrayMethods 上定义重写后的方法 Object.defineProperty(arrayMethods, method, { value: function mutator(...args) { // 1. 先执行原始方法 const result original.apply(this, args); // 2. 获取这个数组对应的响应式代理的原始对象这里需要扩展 reactive 来存储原始对象引用 const ob this.__v_raw; // 假设我们在代理对象上存储了原始对象或观察者实例 // 3. 手动触发更新。对于 push/unshift/splice它们添加了新元素需要为这些新元素也建立响应式联系 // 这里简化处理通知所有依赖了这个数组的 effect实际上 Vue 会更精细地处理 if (ob) { ob.dep.notify(); // 假设观察者实例上有 dep 属性 } return result; }, enumerable: false, writable: true, configurable: true }); }); // 然后在创建数组的代理时需要修改原型链 function reactive(target) { // ... 前面的逻辑 ... const proxy new Proxy(target, { get(target, key, receiver) { // 如果是数组并且访问的是变异方法则返回我们重写的方法 if (Array.isArray(target) arrayMethods.hasOwnProperty(key)) { return Reflect.get(arrayMethods, key, receiver); } track(target, key); const res Reflect.get(target, key, receiver); if (typeof res object res ! null) { return reactive(res); } return res; }, // ... set 等其他陷阱不变 ... }); // 在代理对象上存储原始对象供重写的数组方法使用 proxy.__v_raw target; return proxy; }这是一个高度简化的示意。Vue 源码中的实现要复杂得多它通过一个“观察者”Observer类来管理数组和对象的依赖并为数组的每个索引和length属性都建立了精细的依赖收集关系。但通过这个例子你可以理解拦截数组变异方法的基本原理篡改原型链在方法执行后手动触发更新通知。5. 从模拟到源码理解差异与优化方向我们的模拟实现抓住了主干但对比 Vue 3 的真实源码 (vue/reactivity包)还有巨大的差距。了解这些差距能让你更深刻地理解工业级框架的考量。5.1 核心差异点分析性能优化依赖收集的粒度Vue 3 的依赖收集可以精确到对象的每个属性我们模拟的也是这样但它对数组和Map/Set等集合类型有更特殊的、性能更优的处理。批量更新与调度器 (Scheduler)Vue 不会在每次set后同步执行所有effect。它会将需要执行的effect推入一个队列在一个“微任务”周期如Promise.resolve().then()中批量执行。这避免了连续修改多个属性时导致的重复渲染或计算。我们的模拟是同步立即执行的。避免重复收集Vue 的track函数有更复杂的逻辑来避免在同一个effect运行周期内对同一个属性重复收集依赖。功能完整性ref的实现我们只模拟了reactive。ref用于包装原始值如数字、字符串其实现核心是一个具有.value属性的对象.value的get/set被拦截。ref在.value是对象时内部会自动调用reactive。computed的实现计算属性本质是一个特殊的effect它具有缓存特性。只有其依赖的响应式数据变化时它才会重新计算否则直接返回缓存值。实现它需要lazy惰性求值和dirty标记是否需要重新计算的概念。readonly、shallowReactive、shallowReadonlyVue 提供了一系列响应式 API 的变体用于创建只读、浅层响应式的对象这些都需要在 Proxy 的陷阱中做额外的拦截和判断。健壮性与边缘案例循环引用对象内部引用自身我们的递归reactive可能会栈溢出。Vue 通过缓存机制完美处理了这一点。原型链属性访问对象的原型链上的属性如toString是否应该触发trackVue 有细致的判断逻辑。Symbol 类型键Proxy可以拦截Symbol作为key的操作Vue 对一些内置的Symbol如Symbol.iterator有特殊处理。has、ownKeys等陷阱为了支持in操作符和for...in循环的响应式Vue 还实现了has和ownKeys陷阱。5.2 调试与问题排查技巧在实现或使用自己的响应式系统时你可能会遇到一些典型问题响应式失效直接解构const { count } reactiveObj解构出来的是原始值失去了响应性。解决方法是使用toRefs其原理是为每个属性创建一个ref引用。赋值给新变量let local state.obj; local.name ...local只是原始对象的引用修改它不会触发源代理的set陷阱。务必始终操作响应式代理本身。无限更新循环如果我们的trigger逻辑没做好“避免自触发”的判断很容易在effect中修改依赖项时导致循环。务必确保effectFn ! activeEffect这个检查。依赖收集不全确保副作用函数是在effect内执行的。如果在setTimeout或事件回调中访问响应式数据而此时activeEffect是null依赖就无法被收集。Vue 的模板编译和watchAPI 会确保渲染和侦听器函数被正确包裹在effect中。5.3 如何深入学习 Vue 3 响应式源码如果你对这个模拟实现感到不过瘾想啃真正的源码我建议的路径是定位源码Vue 3 的响应式系统独立在vue/reactivity包中。你可以去 Vue 的 GitHub 仓库找到packages/reactivity/src目录。从入口开始先看reactive.ts和effect.ts这两个核心文件。我们的reactive、effect、track、trigger都能在这里找到。关注核心类ReactiveEffect类是对副作用函数的封装它管理着依赖收集、调度执行和停止侦听等生命周期。TargetMap就是我们的targetMap。使用调试工具在浏览器中引入 Vue 的开发版本在effect或组件渲染函数中设置断点观察调用栈可以直观地看到track和trigger是如何被调用的。自己动手模拟实现一遍再去看源码你会发现那些看似复杂的代码突然变得亲切了。因为你已经理解了它要解决的核心问题剩下的只是工程上如何更优雅、更健壮地实现。这个模拟项目就像一张地图带你穿越了 Vue 3 响应式系统的核心地带。虽然我们省略了许多岔路和细节但主路已经清晰。希望这次旅程能让你下次在使用reactive、ref、computed时心中多一份了然调试时多一份底气。前端框架的魔法并不遥远它们的原理就建立在这些扎实的基础概念之上。理解了这些不仅是 Vue你在学习其他任何响应式库或框架时都会感到事半功倍。