我以为我用的是 Vue3,直到我发现我用的其实是"Vue3 的语法 + Vue2 的思维"。
引子:一个让我当场裂开的 Bug
那是一个风和日丽的下午,我写下这段代码:
constlist=ref([1,2,3])functionaddItem(){list.value.push(4)console.log(list.value)// [1, 2, 3, 4]}控制台里明明白白地打印出了四个数字,但页面上还是三个。
我盯着屏幕看了十秒,然后做了每个现代人都会做的事:把 .value 删掉试试。
结果当然更糟——页面直接白屏了。
那一瞬间我意识到一个残酷的事实:我天天在写 .value,但我根本不知道这个 .value 到底是个什么东西。
于是,我关掉编辑器,打开了 Vue3 的源码仓库。这趟旅程,比我想象的刺激得多。
第一章:Proxy,那个把 Vue2 拍在沙滩上的男人
如果你问 Vue3 相比 Vue2 最大的变化是什么,十个人里有九个会说"响应式用 Proxy 重写了"。
但"用 Proxy 重写"这句话,说了等于没说。真正的区别是:
Vue2 是"事先给每个属性装监控",Vue3 是"给整个对象装监控"。
打个比方:
- Vue2 的做法,像是给家里每个房间都装一个摄像头,你搬进来一个新房客(新增属性),摄像头拍不到,得手动补装(Vue.set)。
- Vue3 的做法,是给整栋楼装一个门禁系统,谁进谁出、谁改了名字,系统全都知道。
代码上,Vue3 的核心就是这一段(packages/reactivity/src/reactive.ts):
exportfunctionreactive(target){returncreateReactiveObject(target,false,mutableHandlers,mutableCollectionHandlers)}而真正干活的,是 baseHandlers.ts 里的 mutableHandlers:
exportconstmutableHandlers={get(target,key,receiver){if(key===ReactiveFlags.IS_REACTIVE)returntrueconstres=Reflect.get(target,key,receiver)track(target,TrackOpTypes.GET,key)// 收集依赖returnisObject(res)?reactive(res):res},set(target,key,value,receiver){constoldValue=target[key]constresult=Reflect.set(target,key,value,receiver)if(oldValue!==value){trigger(target,TriggerOpTypes.SET,key,value,oldValue)// 派发更新}returnresult}}看到那两个函数名了吗?track 和 trigger。
这两个词,就是 Vue3 响应式的全部秘密。一个负责"记住谁用了我",一个负责"通知用我的人"。
第二章:track 和 trigger,一场精密的"点名游戏"
我一开始以为track和trigger是什么高深莫测的算法,直到我看了它们的实现,才发现:
这不就是班级点名吗?
// 简化的依赖收集consttargetMap=newWeakMap()functiontrack(target,type,key){if(!activeEffect)returnletdepsMap=targetMap.get(target)if(!depsMap){targetMap.set(target,(depsMap=newMap()))}letdep=depsMap.get(key)if(!dep){depsMap.set(key,(dep=newSet()))}dep.add(activeEffect)// 把这个 effect 记到"用小本本"上}functiontrigger(target,type,key){constdepsMap=targetMap.get(target)if(!depsMap)returnconstdep=depsMap.get(key)if(dep){dep.forEach(effect=>effect())// 挨个通知}}数据结构是三层嵌套:WeakMap → Map → Set。
翻译成人话:
- 第一层(WeakMap):哪个对象?
- 第二层(Map):这个对象的哪个属性?
- 第三层(Set):哪些 effect 用到了这个属性?
当你读取obj.name时,track就记下:“当前正在执行的 effect,用到了 obj 的 name。”
当你修改obj.name时,trigger就翻出小本本:“哦,有三个 effect 用了 name,全都叫起来重新跑一遍。”
最妙的是,这个"小本本"是自动维护的。你不需要手动告诉 Vue"我这个组件依赖了哪些数据",Vue 会在你读取数据的那一刻,自动把你记下来。
这就是为什么 Vue3 的响应式比 Vue2 更"聪明"——它是按需收集的,而不是无差别劫持的。
第三章:ref 到底是个什么东西?
理解了track和trigger之后,我终于可以回答那个把我整破防的问题了:
ref 到底是什么?
答案有点反直觉:ref 就是一个带 .value getter/setter 的普通对象。
源码里(packages/reactivity/src/ref.ts)长这样:
classRefImpl{private_valuepublicdepconstructor(value){this._value=toReactive(value)this.dep=newSet()}getvalue(){trackRefValue(this)// 读取时收集依赖returnthis._value}setvalue(newVal){if(hasChanged(newVal,this._value)){this._value=toReactive(newVal)triggerRefValue(this)// 修改时派发更新}}}exportfunctionref(value){returnnewRefImpl(value)}所以 ref 的本质,就是一个手写的 getter/setter。
为什么要这么设计?因为 JavaScript 的原始类型(number、string、boolean)没法被 Proxy 代理,Proxy 只能代理对象。
所以 Vue3 的解决方案是:用一个对象把原始值包起来,在 .value 上做文章。
这也解释了为什么:
- ref(0) 要写 count.value++,因为你要触发 setter。
- reactive({}) 不用 .value,因为 Proxy 直接代理了对象本身。
- 在模板里不用写 .value,因为模板编译时会自动帮你解包(unref)。
我之前删掉 .value 导致白屏,是因为我把一个 RefImpl 对象直接扔给了模板,Vue 不知道该渲染什么。
第四章:effect,那个默默干活的幕后英雄
搞懂了ref之后,我又遇到了一个新概念:effect。
watch、watchEffect、computed,甚至组件的渲染,底层全都依赖effect。
effect的本质,就是一个"会被反复执行、并且能自动追踪依赖"的函数:
functioneffect(fn,options={}){consteffectFn=()=>{cleanup(effectFn)activeEffect=effectFnconstres=fn()activeEffect=nullreturnres}effectFn.deps=[]effectFn.options=optionsif(!options.lazy){effectFn()}returneffectFn}关键在这三行:
- activeEffect = effectFn:告诉 Vue"现在正在执行的是我"。
- fn():执行函数,里面读取的响应式数据会自动触发 track,把我记下来。
- activeEffect = null:执行完毕,清空。
这就是"依赖收集"的完整闭环:
- effect 执行 → 设置 activeEffect → 函数体内读取响应式数据 → 触发 get → track 把 activeEffect 记下 → 数据变化时 trigger 执行 effect → 回到第一步。
而computed更巧妙,它基于effect实现了一个带缓存的 effect:
classComputedRefImpl{private_valueprivate_dirty=trueconstructor(getter){this.effect=effect(getter,{lazy:true,scheduler:()=>{this._dirty=truetriggerRefValue(this)}})}getvalue(){if(this._dirty){this._value=this.effect()this._dirty=false}returnthis._value}}_dirty 这个标志位,就是 computed 缓存的全部秘密:
- 依赖没变时,_dirty = false,直接返回缓存值。
- 依赖变化时,scheduler 把 _dirty 置为 true,下次读取时重新计算。
这就是为什么 computed 比 watchEffect 更适合"派生状态"——因为它有缓存,不会每次都重新算。
第五章:Vue3 的 diff,一场"最长递增子序列"的炫技
如果说响应式部分还算好懂,那么 Vue3 的 diff 算法,就是真正让我怀疑智商的地方。
Vue2 的 diff 是"双端比较",四个指针从两端往中间逼近。Vue3 的做法是:
先掐头去尾,处理掉相同的前置和后置节点,然后对剩下的中间部分,用"最长递增子序列"来最小化 DOM 移动。
// 简化的中间部分处理constnewIndexToOldIndexMap=getSequence(newIndexToOldIndexMap)letj=newIndexToOldIndexMap.length-1for(leti=toBePatched-1;i>=0;i--){if(newIndexToOldIndexMap[i]===0){// 新节点,挂载}elseif(j<0||i!==newIndexToOldIndexMap[j]){// 需要移动}else{j--}}为什么用最长递增子序列?
因为移动 DOM 是最贵的操作。找到最长的"已经有序"的子序列,意味着这些节点不用动,其他节点移动过去就行。
打个比方:
- 你有一排书架,要把《A》《B》《C》《D》重新排成《B》《C》《D》《A》。
- 笨办法:全部拿下来,重新摆。
- 聪明的办法:发现《B》《C》《D》顺序没变,只用把《A》挪到最后。
Vue3 的 diff,就是在做这件"聪明的办法"。
而当我把这段算法画在纸上,一步步推导指针移动时,我忽然理解了为什么 Vue3 的 diff 比 Vue2 更快——它减少了节点移动次数,也就减少了浏览器重排重绘的开销。
尾声:读懂源码之后,我看代码的眼光变了
三十天的源码之旅结束后,我并没有变成什么"源码大师",但我确实变了:
- 我不再乱用 .value,因为我知道它是 getter/setter 的入口。
- 我不再随便用 reactive 包一切,因为我知道 Proxy 代理对象后,解构会丢失响应式。
- 我理解了 computed 和 watchEffect 的区别,知道前者有缓存,后者每次都跑。
- 我懂了 nextTick 为什么能拿到更新后的 DOM,因为它是把回调推到了微任务队列。
最重要的是,我不再觉得 Vue3 是"魔法"了。
它只是一群聪明人,用Proxy、WeakMap、Set、effect这些基础工具,搭起来的一座精密的机器。每一个看似奇怪的 API 设计,背后都有一个具体的理由。
写在最后
如果你也想读 Vue3 源码,我的建议是:
- 从 reactivity 模块开始,它是整个 Vue3 的地基。
- 别一上来就啃 runtime-core,那里是 diff 和调度,容易劝退。
- 多画图,targetMap → depsMap → dep 这三层结构,画出来就懂了。
- 允许自己看不懂,看不懂就跳过,过几天回来看,会突然开窍。
Vue3 源码不是什么高不可攀的东西。它只是一些问题,和一些解决问题的代码而已。
而你,也可以成为那个读懂它的人。