十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零实现Vue 3响应式系统:基于Proxy与发布订阅模式

从零实现Vue 3响应式系统:基于Proxy与发布订阅模式 1. 从响应式到reactive为什么我们需要自己动手实现如果你正在使用Vue 3那么reactive函数对你来说一定不陌生。它是Composition API的基石一个简单的const state reactive({ count: 0 })就能让一个普通对象“活”起来其属性的任何变化都能自动触发视图更新。这背后的魔法就是响应式系统。作为开发者我们每天都在享受它带来的便利但你是否想过这个魔法背后的原理是什么为什么一个普通的JavaScript对象被reactive包裹后就拥有了“被观察”的能力仅仅当一个API的使用者是不够的。理解其内部机制不仅能让你在遇到复杂响应式问题时比如“为什么我的数组变化了视图没更新”、“为什么解构会失去响应性”快速定位根因更能让你在技术选型和架构设计上拥有更深的洞察力。今天我们不谈Vue源码里那庞大而精密的实现而是尝试从一个更本质、更简化的角度出发亲手搭建一个reactive函数的“迷你版”。我们将从零开始一步步揭示Proxy、Reflect、依赖收集与派发更新的核心逻辑。这个过程就像拆开一个精密的钟表看看里面的齿轮是如何咬合的。通过这个模拟实现你会清晰地看到Vue的响应式并非黑魔法而是建立在JavaScript语言特性Proxy和经典设计模式发布-订阅之上的优雅工程。准备好了吗让我们开始这场从“知其然”到“知其所以然”的探索之旅。2. 核心基石Proxy与ReflectAPI深度解析在动手之前我们必须先理解构建响应式系统的两块基石Proxy和Reflect。ES6引入的这两个API是Vue 3响应式系统抛弃Object.defineProperty实现全面升级的关键。2.1Proxy为对象创建一个“代理”层Proxy对象用于创建一个对象的代理从而实现对基本操作如属性读取、赋值、删除等的拦截和自定义。你可以把它想象成对象的“门卫”或“中介”任何对原始对象的访问都必须经过这个代理代理可以决定是放行、记录还是修改这次访问。它的基本语法是new Proxy(target, handler)。target要包装的目标对象可以是任何类型的对象包括数组、函数等。handler一个通常以函数作为属性的对象定义了代理对象上各种操作的自定义行为。这些函数被称为“捕获器”trap。对于我们实现reactive最关键的几个捕获器是get(target, key, receiver)拦截属性读取操作。set(target, key, value, receiver)拦截属性设置操作。deleteProperty(target, key)拦截delete操作。const raw { name: Alice }; const handler { get(target, key, receiver) { console.log(正在读取属性${key}); return target[key]; // 通常返回原始值但这里我们可以做更多 }, set(target, key, value, receiver) { console.log(正在设置属性${key} ${value}); target[key] value; return true; // 在严格模式下set操作必须返回true表示成功 } }; const proxy new Proxy(raw, handler); proxy.name; // 控制台输出正在读取属性name proxy.age 25; // 控制台输出正在设置属性age 25看到这里响应式的雏形已经出现了我们可以在get和set里“做文章”。当读取属性时我们可以记录“谁读取了我”当设置属性时我们可以通知“所有关心我变化的人”。这正是依赖收集与派发更新的基础。2.2Reflect安全地执行默认操作你可能注意到在上面的set捕获器里我们直接操作了target[key] value。这没问题但在更复杂的场景下比如继承、setter等直接操作目标对象可能不够安全或不符合规范。Reflect对象提供了拦截JavaScript操作的方法这些方法与Proxy的捕获器一一对应且其行为与语言的默认行为保持一致。使用Reflect来改写上面的set捕获器会更优雅set(target, key, value, receiver) { console.log(正在设置属性${key} ${value}); const success Reflect.set(target, key, value, receiver); // Reflect.set会返回一个布尔值表示操作是否成功 return success; }为什么一定要用Reflect并且要传入receiver参数这是一个关键细节。receiver参数指向代理对象本身或者继承自代理的对象。考虑以下场景const parent { _name: Parent }; const child Object.create(parent); // child继承自parent const handler { get(target, key, receiver) { // 如果直接返回 target[key]当访问 child 继承的属性时target 是 child但 _name 在 parent 上 // 使用 Reflect.get 并传入 receiver能确保正确地通过原型链查找并且 this 指向正确。 return Reflect.get(target, key, receiver); } }; const proxyChild new Proxy(child, handler);Reflect方法配合receiver能确保在涉及原型链和this绑定时行为与正常的JavaScript操作完全一致这是实现一个健壮响应式系统所必需的。注意在set捕获器中Reflect.set的返回值是一个布尔值true表示成功。在严格模式下如果set捕获器返回false会抛出一个TypeError。因此我们的模拟实现中set捕获器必须返回Reflect.set的结果。3. 构建响应式系统的核心依赖收集与派发更新理解了Proxy和Reflect我们知道了如何“拦截”操作。现在我们需要一套机制来管理“谁依赖了数据”以及“数据变化时通知谁”。这就是经典的发布-订阅模式在响应式系统中的体现。3.1 设计数据结构targetMap,depsMap,dep我们需要一个全局的数据结构来存储所有响应式对象、它们的属性以及每个属性对应的依赖即副作用函数。这个结构是一个嵌套的WeakMap。targetMap(类型:WeakMapobject, DepsMap): 键是原始目标对象target值是一个DepsMap。使用WeakMap是为了不影响垃圾回收当原始对象不再被引用时它可以被自动回收。DepsMap(类型:Mapstring | symbol, Dep): 键是对象的属性名key值是一个Dep依赖集合。Dep(类型:SetEffect): 一个Set里面存储了所有依赖于该属性的副作用函数effect。Set能自动去重。// 全局的依赖存储仓库 const targetMap new WeakMap(); // 当前正在运行的副作用函数用于依赖收集时关联 let activeEffect null; // 副作用函数包装器 function effect(fn) { const effectFn () { activeEffect effectFn; // 执行前将其设为当前活跃的effect try { fn(); } finally { activeEffect null; // 执行后清除引用 } }; effectFn(); // 立即执行一次触发依赖收集 return effectFn; // 返回这个包装后的函数便于后续手动执行 }为什么需要activeEffect当我们在effect中执行console.log(state.count)时会触发代理对象的get捕获器。此时我们需要知道是“哪个函数”正在读取这个属性以便将其收集为该属性的依赖。activeEffect就像一个全局的“指针”指向当前正在执行的副作用函数。3.2 实现依赖收集 (track)依赖收集发生在get捕获器中。当读取一个响应式对象的属性时我们需要将当前的activeEffect如果存在添加到该属性的依赖集合中。function track(target, key) { if (!activeEffect) return; // 没有活跃的effect直接返回例如在effect外部直接读取属性 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); // 将当前活跃的effect添加到依赖集合中 // 这里还可以建立反向链接用于cleanup但为了简化我们先不实现 }3.3 实现派发更新 (trigger)派发更新发生在set捕获器中。当修改一个响应式对象的属性时我们需要找到这个属性对应的所有依赖副作用函数并执行它们。function trigger(target, key) { const depsMap targetMap.get(target); if (!depsMap) return; // 该对象没有被追踪过 const dep depsMap.get(key); if (dep) { // 注意这里需要创建一个副本来执行防止在执行effect时Set发生改变导致无限循环 const effectsToRun new Set(dep); effectsToRun.forEach(effect effect()); } }3.4 将它们串联起来reactive函数的实现现在我们可以创建reactive函数了。它的核心就是创建一个Proxy并在其get和set捕获器中分别调用track和trigger。function reactive(target) { if (typeof target ! object || target null) { return target; // 非对象直接返回 } // 可选的如果已经是代理对象直接返回防止重复代理 // 在实际Vue源码中会用一个ReactiveFlags来标记 const handler { get(target, key, receiver) { const result Reflect.get(target, key, receiver); // 依赖收集 track(target, key); // 如果结果是对象递归地将其也转为响应式惰性代理 if (typeof result object result ! null) { return reactive(result); } return result; }, set(target, key, value, receiver) { const oldValue target[key]; const success Reflect.set(target, key, value, receiver); if (success) { // 只有值真正发生变化时才触发更新避免不必要的触发 if (oldValue ! value) { trigger(target, key); } } return success; }, deleteProperty(target, key) { const hadKey Object.prototype.hasOwnProperty.call(target, key); const success Reflect.deleteProperty(target, key); if (success hadKey) { trigger(target, key); // 删除属性也需要触发更新 } return success; } }; return new Proxy(target, handler); }几个关键点解析惰性代理Lazy Proxy在get捕获器中如果读取到的属性值是一个对象我们才递归地调用reactive将其也转为响应式。这比初始化时就深度遍历整个对象并全部代理要高效得多是一种按需转换的策略。变化检测在set捕获器中我们比较了新旧值oldValue ! value。只有值确实发生变化时才触发更新。这是一个重要的性能优化。注意这里使用的是严格不等于!对于对象引用只有引用改变才会触发。对于数组的push、pop等方法它们修改的是原数组引用没变所以我们需要特殊处理见下文。删除属性delete操作也会改变对象因此也需要在deleteProperty捕获器中调用trigger。4. 处理边界情况与进阶优化我们上面实现的是一个最基础的响应式系统。在实际的Vue 3中还需要处理许多边界情况和进行大量优化。这里我们探讨几个最常见和关键的问题。4.1 数组方法的拦截这是新手最容易踩的坑之一。我们的基础实现能拦截对数组索引的赋值如arr[0] 1和length属性的修改。但是像push、pop、shift、unshift、splice这些会修改数组自身的方法它们内部会读取和设置数组的length属性以及各个索引。如果仅仅依靠set我们无法知道是哪个方法引起的修改也可能无法正确地收集和触发所有相关依赖。解决方案重写数组方法。Vue 3的做法是在创建数组的代理时会先查找数组的原型然后创建一个继承自Array.prototype的新对象并重写其中的变异方法push,pop等。在这些重写的方法中先执行原始方法然后再手动调用trigger。// 一个简化的数组方法重写示例 const arrayInstrumentations {}; [push, pop, shift, unshift, splice].forEach(method { const original Array.prototype[method]; arrayInstrumentations[method] function(...args) { // 1. 暂停依赖收集防止内部读取操作产生不必要的依赖 // 2. 执行原始方法 const result original.apply(this, args); // 3. 手动触发更新通知所有依赖于此数组的effect // 这里需要触发两次一次是针对数组索引/内容的变更一次是针对length属性的变更 const ob this.__ob__; // 假设有一个指向响应式系统的引用 ob.dep.notify(); // 简化示意 return result; }; }); // 在代理的get捕获器中如果target是数组且访问的是重写的方法则返回重写后的方法 get(target, key, receiver) { if (Array.isArray(target) arrayInstrumentations.hasOwnProperty(key)) { return arrayInstrumentations[key]; } // ... 其他逻辑 }4.2 嵌套effect与effect栈考虑以下场景effect(() { console.log(state.a); effect(() { console.log(state.b); }); console.log(state.c); });当执行外层effect时activeEffect指向它。读取state.a后进入内层effectactiveEffect被覆盖为内层effect。内层执行完毕activeEffect被置为null。此时再读取state.c就无法正确收集到外层effect作为依赖了。解决方案使用一个effect栈。在effect函数执行时将当前的effectFn压入栈执行完毕后弹出并始终将栈顶的effect作为activeEffect。const effectStack []; function effect(fn) { const effectFn () { cleanup(effectFn); // 清理旧依赖见下文 effectStack.push(effectFn); activeEffect effectFn; try { fn(); } finally { effectStack.pop(); activeEffect effectStack[effectStack.length - 1]; // 恢复为上一个effect } }; effectFn.deps []; // 用于存储所有包含此effect的dep集合 effectFn(); return effectFn; }4.3 依赖清理与避免无限循环一个effect可能会在多次执行中依赖不同的属性。例如在条件分支中effect(() { console.log(state.flag ? state.a : state.b); });当state.flag为true时effect依赖state.a当flag变为false后effect应该依赖state.b而不再依赖state.a。如果不清理旧的依赖那么修改state.a仍然会触发这个effect这是不必要的甚至可能导致问题。此外如果一个effect中同时设置了它依赖的属性会导致set触发triggertrigger执行effecteffect中又触发set形成无限循环。解决方案在每次effect执行前清理掉它与所有dep的关联。我们在effectFn上添加一个deps数组用来存储所有包含此effect的depSet。在track时我们建立双向链接。function track(target, key) { if (!activeEffect) return; // ... 获取或创建dep的逻辑 dep.add(activeEffect); // 建立反向链接 activeEffect.deps.push(dep); } function cleanup(effectFn) { for (const dep of effectFn.deps) { dep.delete(effectFn); // 从所有依赖集合中移除此effect } effectFn.deps.length 0; // 清空deps数组 }在trigger时我们执行的是dep的副本new Set(dep)这样即使在执行effect的过程中调用了cleanup也不会影响当前正在遍历的集合从而避免了无限循环。4.4 计算属性computed与ref的简易实现基于我们已有的effect和响应式系统可以实现一个简化版的computed。function computed(getter) { let value; // 缓存的值 let dirty true; // 脏标记为true表示需要重新计算 const effectFn effect(getter, { lazy: true, // 我们需要一个不立即执行的effect scheduler() { // 调度器当依赖变化时不直接执行effectFn而是标记脏 dirty true; // 这里还应该触发依赖此computed的effect简化起见略过 } }); const obj { get value() { if (dirty) { value effectFn(); // 执行getter计算新值 dirty false; // 计算完毕标记为干净 } // 这里也需要track收集读取此computed.value的effect track(obj, value); return value; } }; return obj; }ref的实现则更简单它本质上是一个包装了原始值的响应式对象其.value属性是响应式的。function ref(rawValue) { const r { get value() { track(r, value); return rawValue; }, set value(newVal) { if (rawValue ! newVal) { rawValue newVal; trigger(r, value); } } }; return r; }5. 从模拟到实战理解Vue 3响应式的设计哲学通过以上步骤我们完成了一个具备核心功能的reactive模拟实现。回顾整个过程Vue 3响应式系统的设计哲学清晰可见基于Proxy的惰性拦截相比Vue 2的Object.defineProperty需要递归遍历并劫持所有属性Proxy是“按需拦截”性能更好且能原生支持数组和动态新增属性。显式的依赖关系通过effect函数显式地声明副作用依赖关系在运行时动态收集非常精确。良好的封装与组合reactive,ref,computed提供了不同粒度的响应式API适应不同场景。effect是响应式系统的底层基石而watch、watchEffect等都是在它之上的高级抽象。在实际项目中的启示避免解构响应式对象const { count } reactive({ count: 0 })解构出来的count是一个原始值失去了响应性。应使用toRefs。理解响应式数据流知道数据变化后哪些组件或计算会更新有助于编写更高效、更可预测的代码。谨慎处理循环引用与复杂对象深度响应式转换在极端情况下可能带来性能开销对于永不改变的大型对象可以考虑使用shallowRef或markRaw来优化。亲手实现一遍哪怕是最简化的版本也足以让你对Vue 3的响应式系统建立起深刻而直观的理解。下次当你使用watchEffect自动追踪依赖或是疑惑为什么某个更新没有触发时脑海中浮现的将是targetMap、track和trigger这些核心概念解决问题自然就有了方向。这就是深入原理的价值所在。
返回列表