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 技术选型:为什么是 Proxy?
Vue 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运行周期内对同一个属性重复收集依赖。
- 依赖收集的粒度:Vue 3 的依赖收集可以精确到对象的每个属性(我们模拟的也是这样),但它对数组和
功能完整性:
ref的实现:我们只模拟了reactive。ref用于包装原始值(如数字、字符串),其实现核心是一个具有.value属性的对象,.value的get/set被拦截。ref在.value是对象时,内部会自动调用reactive。computed的实现:计算属性本质是一个特殊的effect,它具有缓存特性。只有其依赖的响应式数据变化时,它才会重新计算,否则直接返回缓存值。实现它需要lazy(惰性求值)和dirty(标记是否需要重新计算)的概念。readonly、shallowReactive、shallowReadonly:Vue 提供了一系列响应式 API 的变体,用于创建只读、浅层响应式的对象,这些都需要在 Proxy 的陷阱中做额外的拦截和判断。
健壮性与边缘案例:
- 循环引用:对象内部引用自身,我们的递归
reactive可能会栈溢出。Vue 通过缓存机制完美处理了这一点。 - 原型链属性:访问对象的原型链上的属性(如
toString)是否应该触发track?Vue 有细致的判断逻辑。 - 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,你在学习其他任何响应式库或框架时,都会感到事半功倍。