ARTICLE DETAIL

资讯详情

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

手写Vue 3.4响应式系统:深入ref与computed核心实现

手写Vue 3.4响应式系统:深入ref与computed核心实现 【手写 vue3.4 响应式系统】实现 ref 和 computed好久没碰源码了最近在 uni-app 里遇到一个和 ref 有关的怪问题——明明把响应式变量传给了子组件子组件也正常渲染了但一改值页面就是纹丝不动。折腾一晚上最后干脆把 vue3.4 的响应式系统从头梳理了一遍手写了一个极简版本。写完那一刻很多之前用得很顺但说不清原理的细节一下子全通了。这篇文章就是那晚的成果。我会带你从零手写一个支持 ref 和 computed 的极简响应式系统不直接抄 vue 源码而是把核心逻辑抽出来讲清楚。读完你会真正理解ref 为什么要包一层 .value、computed 的缓存是怎么实现的、依赖收集到底在收集些什么。适合已经用过 vue3 但想深挖原理的开发者也适合准备面试时被问到响应式原理想有话说的人。1. 整体架构思路从三个核心问题开始1.1 响应式系统到底在解决什么问题先说个最朴素的场景。我有一个变量let a 1一个函数let b a * 2。在普通 JavaScript 里修改a之后b不会自动更新你必须手动再调一次b a * 2。如果这个函数只有一两处手动调也没什么但如果函数散布在模板渲染、计算属性、watch 回调里手动同步就会变成一个噩梦——漏一处、错一处整个状态就崩了。响应式系统解决的正是这个数据变化后自动通知依赖方更新的问题。它本质上是一个观察者模式数据是被观察的目标target依赖数据的函数是观察者effect。当目标发生变化时系统自动通知所有观察者重新执行。怎么做到这一点答案是 Proxy 代理和依赖收集。用 Proxy 拦截对象的读取和写入操作在读取时记录谁在读取这个数据在写入时找到哪些人依赖这个数据并通知它们更新。这就是 vue3 响应式的全部骨架。1.2 代码先行整个系统的三个核心模块手写之前先看清楚 vue3 官方设计的三个核心模块我们后面会逐一实现// 1. 依赖收集的入口 export function track(target: object, key: string) { // 找到当前正在执行的副作用函数 // 把 (target, key) 存入对应的依赖集合 } // 2. 触发更新的入口 export function trigger(target: object, key: string) { // 找到依赖 target.key 的所有副作用函数 // 逐个重新执行或标记为待执行 } // 3. 副作用函数容器 export class ReactiveEffect { // 包装用户的业务函数 // 执行时把自己注册为当前正在执行的副作用 // 支持依赖收集、停止监听、调度执行 }这三个部分的关系可以这样理解track负责在读数据时记录当下的依赖关系trigger负责在改数据时通知对应依赖重新跑一遍ReactiveEffect则是连接两者的核心载体。ref 和 computed 都是在这套骨架上添加不同行为的壳子。下面我们先把这三个模块写出来再往上盖 ref 和 computed 这两层楼。2. 核心细节解析ref 的完整实现过程2.1 从最简单的 ref 开始为什么需要 .value先看一下官方 ref 的使用形态const count ref(0) console.log(count.value) // 0 count.value为什么不直接const count 0一句话解释基础类型string、number、boolean在 JavaScript 里是按值传递的Proxy 只能代理对象没法代理一个数字本身。所以必须用一个对象把基础类型包起来再让这个对象具备被代理的能力。这就像你要给一个散装物品贴追踪标签不能直接贴在空气上得先装进一个盒子里再贴。.value就是盒子上的开口你要拿东西、放东西都得通过这个开口。不过如果你以为 ref 内部只是{ value: 目标数据 }这么简单那就踩坑了。看一下 vue 源码里 ref 的真正的结构简化的定义class RefImplT { private _value: T public readonly __v_isRef true constructor(value: T) { this._value value } get value() { // 读取时收集依赖 return this._value } set value(newVal: T) { // 写入时触发更新 this._value newVal } }关键点在于 ref 是个类而且同时控制着 getter 和 setter。这样既能在get value()时收集依赖又能在set value()时触发更新把读取和写入的行为都牢牢攥在自己手里。如果用普通的对象字面量存数据就没有办法在读写之间插入收集和触发的逻辑。2.2 加入依赖收集track 函数的实现写完壳子现在要往 getter 里塞依赖收集逻辑。这里有一个明星选手ReactiveEffect。它的核心作用是在执行副作用函数之前把自己标记为当前活跃的 effect执行完之后再恢复原状。看下面的实现这是响应式系统的心脏let activeEffect: ReactiveEffect | null null class ReactiveEffect { fn: () void deps: SetSetReactiveEffect new Set() // 该 effect 被哪些依赖集合收集 scheduler?: () void parent: ReactiveEffect | null null constructor(fn, scheduler?) { this.fn fn this.scheduler scheduler } run() { // 嵌套 effect 的场景需要保存父级 effect this.parent activeEffect activeEffect this try { return this.fn() } finally { activeEffect this.parent } } }注意parent这个字段。它处理的是嵌套副作用场景一个 effect 里又创建了另一个 effect比如 computed 内部依赖了另一个 computed。如果不保存和恢复父级 effect内层执行完之后activeEffect就变成 null 了外层的依赖收集就全断了。依赖收集的track函数长这样const targetMap new WeakMapobject, Mapstring, SetReactiveEffect() export function track(target: object, key: string) { if (!activeEffect) return // 1. 先找到 target 对应的 Map let depsMap targetMap.get(target) if (!depsMap) { depsMap new Map() targetMap.set(target, depsMap) } // 2. 再找到 key 对应的 Set let dep depsMap.get(key) if (!dep) { dep new Set() depsMap.set(key, dep) } // 3. 把当前 activeEffect 加进依赖集合 if (!dep.has(activeEffect)) { dep.add(activeEffect) activeEffect.deps.push(dep) } }我用WeakMap做外层容器是为了避免内存泄漏如果某个响应式对象不再被业务代码引用WeakMap的垃圾回收机制会自动把它连同其所有依赖一起回收。内层用Map存 key 对应的依赖集合再用Set存 effect因为一个 effect 同一个 key 只会被收集一次Set天然去重。2.3 触发更新trigger 函数的实现有收集就一定有触发。trigger的逻辑是找到某个 key 对应的依赖集合把里面所有 effect 都拿出来执行或者交给调度器。export function trigger(target: object, key: string) { const depsMap targetMap.get(target) if (!depsMap) return const dep depsMap.get(key) if (!dep) return const effectsToRun new SetReactiveEffect() dep.forEach(effect { if (effect ! activeEffect) { effectsToRun.add(effect) } }) effectsToRun.forEach(effect { if (effect.scheduler) { effect.scheduler() } else { effect.run() } }) }这里有一个细节容易被忽略if (effect ! activeEffect)这一行。它的作用是防止无限循环——比如count.value本身发生在某个 effect 内部这个 effect 正在执行时又被 trigger 了一次如果我们不排除它同一个函数会在执行过程中反复触发自己栈直接爆掉。然后是scheduler字段。它让 effect 不立即重新执行而是交给一个自定义的调度逻辑。computed 正是利用这个字段实现惰性缓存的后面会详细展开。2.4 完整 Ref 实现与终极边界问题把上面两块拼起来一个能用的 ref 就出来了class RefImplT { private _value: T public readonly __v_isRef true constructor(value: T) { this._value value } get value() { track(this, value) return this._value } set value(newVal: T) { // 值没变就别触发更新 if (hasChanged(newVal, this._value)) { this._value newVal trigger(this, value) } } } function hasChanged(a, b) { return !Object.is(a, b) } export function refT(value: T) { return new RefImpl(value) }看到get value()里的track(this, value)了吗ref 的对象就是 targetkey 就是字符串 value。所以一个 ref 本质上是一个被「模拟代理」的对象——它的 getter/setter 充当了 Proxy 的 get/set 钩子。那问题来了ref 如果传入的是一个对象呢比如const obj ref({ a: 1 })然后把obj.value.a作为模板依赖。如果obj.value直接返回原始对象内层的a就完全没有响应能力。vue 源码里的处理方式是把初始值做一次响应式转换constructor(value: T) { this._value toReactive(value) } function toReactive(value) { return isObject(value) ? reactive(value) : value }所以嵌套对象的响应式是在 ref 构造时递归完成精确说是惰性递归的。这里需要注意的是手写时可以简化为不管什么类型都做判断但真实 vue 源码里有toRaw、isReactive等一堆边界判断建议第一版先处理最核心的对象走 reactive这个路径。2.5 为什么用类而不是直接 return 一个函数这个设计问题值得单独说。网上很多简化版的 ref 是return { get value() {...}, set value() {...} }这种对象字面量写法。它确实能用但有一个致命缺陷无法区分一个普通对象和一个 ref。vue 源码里给 ref 加了一个__v_isRef的标记字段就是为了在isRef()判断时识别它。如果你用一个普通对象字面量模拟这个标记字段很容易和用户的业务数据产生冲突。用类定义则可以挂载原型方法、控制非枚举属性隔离性更好。另外类还能方便地挂载调试工具比如__v_isShallow标记浅层 ref、子类扩展比如ComputedRefImpl就是RefImpl的近亲但它自己实现了缓存逻辑。从面向对象设计的角度来说类的语义比裸对象更清晰。3. 实现 computed从缓存到依赖链3.1 为什么说 computed 的核心是惰性很多介绍响应式的文章会把 computed 描述成带缓存的响应式值这是不够的。带缓存只是结果真正的灵魂是惰性求值。看这段使用方式const count ref(0) const double computed(() count.value * 2) console.log(double.value) // 0 count.value console.log(double.value) // 2如果 computed 不采用惰性策略那么修改count.value会立刻触发() count.value * 2重新执行这是最朴素的理解。但 vue 的 computed 不会这样做——它会在trigger时通过scheduler把 computed 标记为dirty true并不立即重新计算。只有下次有人读取double.value时才检查到 dirty 标记并重新执行 getter。这样设计的好处有两层第一层是性能。computed 可能依赖多个 ref 或响应式属性如果每次依赖变化都立刻重算而实际上并没有人读取计算结果那这些计算就是白费的。惰性求值保证了一次依赖变化只更新一个标记真正的计算推迟到读取时。第二层是顺序一致性。如果 computed 被立即重算它内部的副作用会提前执行可能导致依赖链上其他 effect 的执行顺序错乱。惰性求值把所有计算推迟到读取时间点这个读取发生在同步代码流的确定性位置顺序天然稳定。下面用生活化类比理解ref 像冰箱里的食材computed 像一道现做的菜。你不需要每买一次食材就立刻把菜做完只需要在准备上菜时判断一下食材是否新鲜、需不需要重新做。如果食材没变直接端上桌就行。3.2 computed 的依赖收集一个特殊 effect看了 2.2 节的ReactiveEffect实现你可能会想computed 不就是new ReactiveEffect(getter, scheduler)然后把effect.run()的结果存下来吗方向是对的但有一个关键差异computed 的执行时机不是由 trigger 决定的而是由读取 .value决定。vue 的ComputedRefImpl的核心代码简化是这样的class ComputedRefImplT { private _value: T private _dirty true public readonly __v_isRef true public dep?: SetReactiveEffect undefined private _effect: ReactiveEffect constructor(getter: () T) { this._effect new ReactiveEffect(getter, () { // scheduler依赖变化时只标记 dirty不立即重算 if (!this._dirty) { this._dirty true // 同时触发依赖了该 computed 的其他 effect triggerRefValue(this) } }) } get value() { // 手动收集依赖 trackRefValue(this) // 只有 dirty 时才重新计算 if (this._dirty) { this._dirty false this._value this._effect.run() } return this._value } }仔细读这个 getter它干了三件事调用trackRefValue(this)收集谁在读 computed.value检查_dirty如果为 true 就调用_effect.run()重新计算计算结束后立即把_dirty置为 false为什么要手动 track因为 computed 的 getter 在首次执行时会把activeEffect设置成this._effect此时如果 getter 内部读取了其他 ref那些 ref 的依赖会收集到这个_effect上。但读取 computed.value 的普通 effect并没有在这个时候执行它只是路过按键的读者依赖收集并不会自动发生在普通 effect 和 computed 之间。所以必须由trackRefValue(this)明确把当前活跃的 effect 和 computed 的 dep 集合关联起来。这就像 computed 是一扇门。门外的路人普通 effect看了一眼门内的东西他不会自动登记为门的上游依赖——他其实是下游消费者但 Vue 需要记录这个消费关系等门内内容变化时通知他。3.3 手动实现 trackRefValue 与 triggerRefValue既然上面用了trackRefValue和triggerRefValue两个函数那它们长什么样其实就是复用我们之前的 track/trigger 逻辑只是把 target 换成 computed 自身export function trackRefValue(ref) { if (activeEffect) { ref.dep ref.dep || new Set() ref.dep.add(activeEffect) activeEffect.deps.push(ref.dep) } } export function triggerRefValue(ref) { if (ref.dep) { const effects new Set(ref.dep) effects.forEach(effect { effect.scheduler ? effect.scheduler() : effect.run() }) } }注意ref.dep可能一开始是 undefined需要在第一次收集时初始化。vue 源码里这部分的实现会再优化一点用一个特殊的emptySet常量避免空集合重复创建但手写阶段用 undefined 判断就够了。这里有个坑为什么 computed 的 dep 是一个 Set而不是每个 key 一个 Map因为 computed 只有一个 key就是value不需要像 reactive 那样按 key 分桶。一个 Set 足够。3.4 computed 中的依赖链computed 依赖 computed怎么办现在到了最容易翻车的部分computed 依赖另一个 computed。const a ref(1) const b computed(() a.value * 2) const c computed(() b.value 1)分析一下依赖关系b 的_effect读取了 a所以 a 的 dep 里有 b 的_effectc 的_effect读取了 b此时 b 的 getter 执行trackRefValue(b)把 b 的 dep 收集为 c 的_effect现在修改a.valuetrigger(a, value)触发 a 的 dep即 b 的_effect的 scheduler 被调用b 的 scheduler 把b._dirty置为 true并调用triggerRefValue(b)triggerRefValue(b)遍历 b 的 dep找到 c 的_effect执行它的 schedulerc 的 scheduler 把c._dirty置为 true并调用triggerRefValue(c)整个过程没有任何一次真正的 getter 重新执行只是逐层把 dirty 标记向下传播。等到你读取c.value时c 是 dirty 的于是执行 c 的 gettergetter 里读b.value发现 b 也是 dirty 的于是执行 b 的 getterb 的 getter 读a.value发现 a 是普通 ref直接取值。最终计算链一次性跑完没有冗余计算。依赖链传播的原理就在这个逐层调度设计里。scheduler中if (!this._dirty)的判断可以防止同一个依赖链上的重复调度保证每个 computed 在同一个同步周期内最多只被标记一次。3.5 computed 的完整代码合并把上面的散块组合起来一个能用的 computed 就成型了function computedT(getter: () T) { return new ComputedRefImpl(getter) } class ComputedRefImplT { private _value!: T private _dirty true public readonly __v_isRef true public dep?: SetReactiveEffect private _effect: ReactiveEffect constructor(getter: () T) { this._effect new ReactiveEffect(getter, () { if (!this._dirty) { this._dirty true triggerRefValue(this) } }) } get value() { trackRefValue(this) if (this._dirty) { this._dirty false this._value this._effect.run() } return this._value } }这个类最精妙的地方在于getter 执行时_effect.run()会把activeEffect设置为this._effect所以 getter 内部的响应式依赖会被正确收集。而 computed 自身的依赖则由trackRefValue在每次读value时手动收集。一条完整的依赖链就此闭环。4. vue3.4 里响应式系统的关键优化与手写差异4.1 Vue 3.4 在响应式上改了什么Vue 3.4 是 2023 年底发布的版本代号小瓶。这一版在响应式层面有两大改动第一是重构了 reactive 的底层依赖收集实现。3.4 把track和trigger从两个大函数改成了通过Dep类管理依赖的结构每个 key 对应一个Dep实例由Dep统一管理 effect 的添加、删除和通知。这比之前直接用Set要更清晰也方便后续版本如 3.5增加 WeakMap 相关优化。第二是解析器大幅提速。3.4 重写了模板解析器解析速度提升了约 44%这部分主要影响编译层面。对运行时响应式系统而言3.4 的 Dep 重构让依赖收集的路径更短、内存分配更少。你可以把 3.4 的Dep理解成一个中间管理员以前track是直接去 Map 里找到 Set 再往里塞 effect现在Dep自己知道如何管理它的订阅者target 和 key 之间的关联变得真正解耦。4.2 手写版与官方版的核心差异在哪下面这张表是我梳理的手写版和官方版的主要差异点维度官方 vue3.4手写极简版依赖容器target Map Dep 类WeakMap Map Setcomputed 缓存_dirty scheduler同样思路响应式包装toReactivetoRaw递归处理省略了 reactive 深层处理细节停止监听stop()清理所有 deps未做清理逻辑嵌套 refunref、toValue等API支持手动简化性能优化位运算标记DIRTY状态、batch批处理核心逻辑为主手写版的优势是逻辑通顺、好理解官方版则在这些基础之上增加了大量边界情况的处理。比如官方 ref 的 setter 里有if (this._value ! newVal)的判断但真正实现时用了isRef、toRaw等多层守卫避免代理对象之间相互污染。这些边界优化可以等核心逻辑吃透后再逐步补上。4.3 3.4 特有的 Dep 类和手写版等价替换如果你想在 3.4 的手写复刻版达到官方风格可以把 Map 里的SetReactiveEffect换成Dep类class Dep { deps: SetReactiveEffect new Set() // 3.4 里还加入了 computed 的关联追踪 computedEffectsSet: SetReactiveEffect new Set() } const targetMap new WeakMapobject, Mapstring, Dep()引入Dep类之后track就变成export function track(target: object, key: string) { let depsMap targetMap.get(target) if (!depsMap) { depsMap new Map() targetMap.set(target, depsMap) } let dep depsMap.get(key) if (!dep) { dep new Dep() depsMap.set(key, dep) } dep.add(activeEffect) }这个改动最大的收益是语义化Dep把自己的管理职责收拢在一起后续要加清理、批处理都直接在类里扩展不用动 track/trigger。这也是 vue 官方从自由的数据结构走向面向对象封装的一个缩影。5. 实战在 uni-app 和 Vue3 环境中的常见问题排查5.1 computed 报错为什么返回了一个对象模板里却拿不到这是使用 computed 时最常见的问题忘记加.value。const fullName computed(() { return ${firstName.value} ${lastName.value} }) // 错误模板里直接用了 fullName // 正确fullName.value在模板里vue 会自动解包 ref所以fullName在模板里可以直接使用。但在 script 里不会自动解包——这是两个不同的机制。理解这个区别就能很快定位到底层报错。实际开发中还有一个隐蔽场景computed 内部返回了另一个 ref。比如const double computed(() inputRef.value)。此时double.value是一个 ref 对象而不是数值。要拿到数值得写成double.value.value。这是一个需要特别留意的陷阱。排查方法很简单在计算属性里加一行console.log看看返回值的类型。如果是个 object 且里面有__v_isRef: true标记那就要多剥一层。5.2 ref 绑定风险data ref editor 场景下的万能对象问题热词里提到一个现象叫data ref editor其实说的是在低代码/可视化编辑器里把响应式变量绑定到对象某个字段的场景。典型写法// 编辑器里把 data 的某个字段绑定到一个 ref const state reactive({ count: 0 }) const countRef ref(state.count) // 这可能不是你想要的效果问题出在ref(state.count)这一行。state.count是一个数字传入 ref 后会被包成一个独立的 ref它和 state.count 之间没有任何联动。你改countRef.valuestate.count 不会变改 state.countcountRef 也不会变。正确做法是绑定整个响应式对象const state reactive({ count: 0 }) // 需要绑定的是 state.count 这个属性的响应式访问而非初始值这其实是新手最容易踩的响应式陷阱之一拿到了初始值丢掉了响应式连接。在低代码编辑器动态绑定 ref 时务必确认绑定的是响应式来源而不是瞬时值。还有一个和 uni-app 相关的坑data ref editor场景里经常需要把 ref 赋值给页面的 data 字段。在 Vue3 中你直接写data.value xxx但在 uni-app 的页面里data 是页面的配置对象不是响应式代理对象往里面塞 ref 并不会自动获得响应式能力。正确做法是用reactive包裹数据层或者干脆用setup()返回的 ref。5.3 一个高频调试技巧让 computed 和 ref 的更新可追踪手写响应式系统之后我最大的收获是明白了如何主动排查依赖收集的失效。比如你发现 computed 不更新不要直接怀疑 Vue 的 bug先按这个顺序排查getter 里到底有没有读到对应的响应式数据如果 computed 的 getter 里存的是某个变量的初始值而不是响应式引用那它根本不会收集到依赖。读取的路径是否完整const a ref(obj.a)只能追踪到obj.a的读取如果之后你修改的是obj.bcomputed 自然不该更新。是否有异步读取computed 的 getter 应该是同步纯函数。如果在 getter 里 setTimeout 后再读 ref依赖收集发生在 setTimeout 之后的异步上下文里此时activeEffect很可能已经指向了别的 effect。排查时可以加一个临时的方法在 computed 的 getter 里打印依赖收集情况。const computedWithLog computed(() { console.trace(computed getter 执行) return a.value b.value })如果控制台里 getter 完全没有执行问题往往出在依赖收集方向如果执行了但结果不对问题就在取值逻辑本身。6. 扩展思考这套系统还能玩出什么花活6.1 从 ref 到 asyncComputed手写了一个异步计算属性理解的 ref 和 computed 的核心机制后完全可以造一个带异步支持的asyncComputedfunction asyncComputedT(getter: () PromiseT, initialValue: T) { const result ref(initialValue) let cancelled false const runner new ReactiveEffect(() { getter().then(value { if (!cancelled) { result.value value } }) }, () { // 依赖变化时重新执行并允许取消上一次请求 cancelled true runner.run() }) return readonly(result) }这个例子展示了当你知道scheduler的作用之后完全可以在依赖变化时注入自己的逻辑——比如取消旧的异步请求、再发起新的请求。这就是响应式系统的可扩展性也是深入理解源码的附加福利。6.2 从 ref 到 VueUse理解refDebounced、refThrottle背后的原理VueUse 里的refDebounced防抖版 ref看起来很高端核心原理其实就是给 ref 的 setter 包一层防抖function refDebounced(value, delay 200) { const rawRef ref(value) const debounced customRef((track, trigger) { let timer return { get() { track() return rawRef.value }, set(newVal) { clearTimeout(timer) timer setTimeout(() { rawRef.value newVal trigger() }, delay) } } }) return debounced }这里的关键是customRef。Vue 提供了这个 API 让开发者自定义 ref 的 get 和 set 行为它的内部实现就是 RefeEffect 的调度器思路——通过延迟触发让依赖更新变得更可控。理解了本文核心内容这一层就完全没有秘密了。6.3 手写版本的意义复现 vs 理解写到这里想聊聊手写框架这件事的价值。很多人觉得直接看源码不就行了何必手写一遍我的体验是源码是答案但手写是解法。读源码像是在看答案册你知道了哦这里调用了 scheduler但不清楚为什么非要 scheduler手写一遍你会从没有 scheduler 会怎么样开始一步步推导出它的必要性。这个从零推导的过程才是真正的理解。比如 computed 的 scheduler 参数如果只读源码你只知道它在依赖变化时会走() { if (!this._dirty) ... }这段手写时你先把 scheduler 留空跑测试发现 computed 每次都执行了、缓存失效了然后你才理解惰性求值依赖的正是脏标记调度器这对组合。所以这篇文章的目的不是让你背代码而是希望你看完、写完、跑通之后对 Vue 的响应式世界有原来如此的感觉。深夜调试那个 uni-app 问题的经历告诉我排查响应式 bug 的九成精力都花在理解依赖如何形成、何时失效上把本文这套核心机制吃透后面遇到任何响应式怪问题你都会有自己的排查路径。最后再分享一个实战小技巧在你手写的响应式系统里加一个log函数把它挂到全局跑测试的时候把依赖收集和触发过程都打出来。这比直接读官方源码里的调试代码更直观你会看到track在收集、trigger在通知就像在你面前展开了一张数据流动的地图。亲身试过一次之后你就会明白为什么我说手写一次胜过背诵十遍。
返回列表