从零实现 Vue 3 reactive:基于 Proxy 的响应式原理深度解析
2026/9/7 0:43:18 网站建设 项目流程

1. 项目概述:从响应式到实现原理

如果你用过 Vue 3,那一定对reactive函数不陌生。它能把一个普通的 JavaScript 对象,变成一个“响应式”对象——当你修改对象的属性时,依赖这些属性的视图会自动更新。这听起来像魔法,但今天我们不谈魔法,我们来聊聊这个魔法背后的“炼金术”:自己动手,模拟实现一个简化版的reactive

为什么我们要做这件事?对于前端开发者,尤其是 Vue 的使用者来说,理解reactive的内部机制,远比仅仅会调用 API 重要得多。它能帮你彻底搞明白 Vue 3 响应式系统的核心,让你在遇到一些“诡异”的响应式失效问题时,能一眼看穿本质,而不是盲目地搜索“Vue 3 reactive 不生效怎么办”。更进一步,这种基于 Proxy 的拦截和依赖收集思想,是现代前端框架的通用设计模式,理解了它,你再看其他框架的类似特性,就会有一种豁然开朗的感觉。

简单来说,这个模拟实现项目,就是一次对 Vue 3 核心响应式原理的深度“解刨”。我们将从零开始,构建一个具备基本响应式能力(依赖收集、触发更新)的系统。它不会像 Vue 源码那样考虑所有边界情况和极致性能,但足以清晰地展示响应式系统的骨架和核心流程。无论你是想夯实基础的前端新手,还是希望深入理解框架原理的进阶开发者,跟着走完这一趟,你都会对“响应式编程”有全新的、具象的认识。

2. 核心思路与架构设计

在动手写代码之前,我们必须先把核心思路理清楚。Vue 3 的响应式,本质上是一种“发布-订阅”模式的精巧应用,而实现这一模式的关键,在于 ES6 的ProxyReflect这两个 API。

2.1 响应式系统的核心三要素

一个最基础的响应式系统,离不开三个核心角色:

  1. 目标对象 (Target):就是你想让它变成响应式的那个原始对象,比如{ count: 0 }
  2. 副作用函数 (Effect):指那些依赖于响应式数据的函数。在 Vue 里,这通常就是组件的渲染函数、计算属性或者watch回调。当响应式数据变化时,这些副作用函数需要被重新执行。
  3. 依赖关系图 (Dependency Graph):这是一个隐形的“关系网”,它精确地记录了“哪个副作用函数依赖了哪个响应式对象的哪个属性”。当某个属性被修改时,系统能根据这个关系图,精准地找到所有需要重新执行的副作用函数。

我们的模拟实现,就是要搭建一个舞台,让这三个角色能够协同工作。

2.2 技术选型:为什么是 Proxy?

Vue 2 使用的是Object.defineProperty来拦截数据的getset。这个方法有个众所周知的缺陷:它无法检测到对象属性的添加删除(Vue 2 为此提供了Vue.setVue.delete)。此外,它对数组的拦截也需要特殊处理。

Proxy则强大得多。它可以创建一个对象的代理,从而基本拦截该对象的所有基本操作。我们可以为get(读取属性)、set(设置属性)、deleteProperty(删除属性)等操作设置“陷阱”(trap)。这意味着,无论是修改已有属性、添加新属性还是删除属性,甚至是通过for...in循环遍历,只要在 Proxy 的拦截范围内,我们都能感知到。

因此,选择Proxy作为实现基础,是 Vue 3 响应式能力实现质的飞跃的关键,也是我们模拟实现的不二之选。

2.3 整体架构流程图

虽然我们不能画图,但可以用文字清晰地描述出整个数据流和逻辑:

  1. 初始化:用户调用我们模拟的reactive函数,传入一个普通对象target
  2. 创建代理:函数内部为target创建一个Proxy实例,并为其配置getset陷阱。
  3. 依赖收集 (发生在get陷阱)
    • 当副作用函数effect执行时,如果它内部读取了某个响应式对象的属性(例如obj.count),就会触发该代理对象的get陷阱。
    • get陷阱中,我们将当前正在执行的副作用函数,记录到该属性对应的“依赖集合”中。这就建立了一个联系:“obj.count这个属性被effect这个函数所依赖”。
  4. 触发更新 (发生在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.getReflect.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(); }); } }

现在,让我们把tracktrigger整合到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函数中实现这个逻辑。

修改effecttrigger函数:

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()); }

核心改动解释:

  1. 双向记录track时,不仅将activeEffect加入dep,还将dep反向记录到effectFn.deps数组中。这为清理遗留依赖提供了可能。
  2. cleanup函数:在每次effectFn执行前,将它从之前所有收集过它的依赖集合中移除。因为每次执行都会重新收集依赖,这样可以避免“过期”的依赖(比如分支条件变化导致不再读取某个属性)继续触发不必要的更新。
  3. 避免自触发:在trigger中,判断如果要触发的effectFn就是当前正在执行的activeEffect,则跳过。这直接解决了在effect中设置其依赖的同一属性导致的无限循环问题。

4.3 支持数组的变异方法

对于数组,直接通过索引set(如arr[0] = 1)是可以被我们的set陷阱拦截的。但是,像pushpopshiftsplice这些变异方法,它们内部既会读取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 核心差异点分析

  1. 性能优化

    • 依赖收集的粒度:Vue 3 的依赖收集可以精确到对象的每个属性(我们模拟的也是这样),但它对数组和Map/Set等集合类型有更特殊的、性能更优的处理。
    • 批量更新与调度器 (Scheduler):Vue 不会在每次set后同步执行所有effect。它会将需要执行的effect推入一个队列,在一个“微任务”周期(如Promise.resolve().then())中批量执行。这避免了连续修改多个属性时导致的重复渲染或计算。我们的模拟是同步立即执行的。
    • 避免重复收集:Vue 的track函数有更复杂的逻辑来避免在同一个effect运行周期内对同一个属性重复收集依赖。
  2. 功能完整性

    • ref的实现:我们只模拟了reactiveref用于包装原始值(如数字、字符串),其实现核心是一个具有.value属性的对象,.valueget/set被拦截。ref.value是对象时,内部会自动调用reactive
    • computed的实现:计算属性本质是一个特殊的effect,它具有缓存特性。只有其依赖的响应式数据变化时,它才会重新计算,否则直接返回缓存值。实现它需要lazy(惰性求值)和dirty(标记是否需要重新计算)的概念。
    • readonlyshallowReactiveshallowReadonly:Vue 提供了一系列响应式 API 的变体,用于创建只读、浅层响应式的对象,这些都需要在 Proxy 的陷阱中做额外的拦截和判断。
  3. 健壮性与边缘案例

    • 循环引用:对象内部引用自身,我们的递归reactive可能会栈溢出。Vue 通过缓存机制完美处理了这一点。
    • 原型链属性:访问对象的原型链上的属性(如toString)是否应该触发track?Vue 有细致的判断逻辑。
    • Symbol 类型键Proxy可以拦截Symbol作为key的操作,Vue 对一些内置的Symbol(如Symbol.iterator)有特殊处理。
    • hasownKeys等陷阱:为了支持in操作符和for...in循环的响应式,Vue 还实现了hasownKeys陷阱。

5.2 调试与问题排查技巧

在实现或使用自己的响应式系统时,你可能会遇到一些典型问题:

  • 响应式失效
    • 直接解构const { count } = reactiveObj,解构出来的是原始值,失去了响应性。解决方法是使用toRefs(其原理是为每个属性创建一个ref引用)。
    • 赋值给新变量let local = state.obj; local.name = '...'local只是原始对象的引用,修改它不会触发源代理的set陷阱。务必始终操作响应式代理本身。
  • 无限更新循环:如果我们的trigger逻辑没做好“避免自触发”的判断,很容易在effect中修改依赖项时导致循环。务必确保effectFn !== activeEffect这个检查。
  • 依赖收集不全:确保副作用函数是在effect内执行的。如果在setTimeout或事件回调中访问响应式数据,而此时activeEffectnull,依赖就无法被收集。Vue 的模板编译和watchAPI 会确保渲染和侦听器函数被正确包裹在effect中。

5.3 如何深入学习 Vue 3 响应式源码

如果你对这个模拟实现感到不过瘾,想啃真正的源码,我建议的路径是:

  1. 定位源码:Vue 3 的响应式系统独立在@vue/reactivity包中。你可以去 Vue 的 GitHub 仓库找到packages/reactivity/src目录。
  2. 从入口开始:先看reactive.tseffect.ts这两个核心文件。我们的reactiveeffecttracktrigger都能在这里找到。
  3. 关注核心类ReactiveEffect类是对副作用函数的封装,它管理着依赖收集、调度执行和停止侦听等生命周期。TargetMap就是我们的targetMap
  4. 使用调试工具:在浏览器中引入 Vue 的开发版本,在effect或组件渲染函数中设置断点,观察调用栈,可以直观地看到tracktrigger是如何被调用的。

自己动手模拟实现一遍,再去看源码,你会发现那些看似复杂的代码突然变得亲切了。因为你已经理解了它要解决的核心问题,剩下的只是工程上如何更优雅、更健壮地实现。

这个模拟项目就像一张地图,带你穿越了 Vue 3 响应式系统的核心地带。虽然我们省略了许多岔路和细节,但主路已经清晰。希望这次旅程能让你下次在使用reactiverefcomputed时,心中多一份了然,调试时多一份底气。前端框架的魔法并不遥远,它们的原理就建立在这些扎实的基础概念之上。理解了这些,不仅是 Vue,你在学习其他任何响应式库或框架时,都会感到事半功倍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询