手写 Vue 3 的 ref 实现:从零开始理解响应式核心
在 Vue 3 的组合式 API(Composition API)中,ref无疑是最基础也是最核心的 API 之一。它不仅是原始类型数据(如number、string)实现响应式的唯一途径,更是连接模板与逻辑层的桥梁。然而,许多开发者只知其“然”——通过.value访问和修改,却不知其“所以然”。
要真正掌握 Vue 3 的响应式精髓,我们必须撕开 API 的表层,深入其基于 Proxy 和依赖追踪的底层逻辑。今天,我们将通过手写一个迷你版的ref,彻底解构 Vue 3 响应式系统的核心原理。
一、 响应式的基石:依赖收集与触发
在实现ref之前,我们必须先理解 Vue 响应式系统的两大支柱:依赖收集(Track)与触发更新(Trigger)。
Vue 3 利用 ES6 的WeakMap来建立一个精密的“依赖地图”。当组件渲染或副作用函数(effect)执行时,会发生以下过程:
- 读取数据(Get):访问响应式对象的属性时,系统会记录下“是谁(当前活跃的 effect)读取了哪个对象的哪个属性”。
- 修改数据(Set):修改响应式对象的属性时,系统会找到“所有读取过该属性的 effect”,并通知它们重新执行。
我们先手写这套核心的依赖管理系统:
// 全局变量:存储当前正在执行的副作用函数letactiveEffect=null;// 依赖地图:WeakMap<target, Map<key, Set<effect>>>consttargetMap=newWeakMap();// 依赖收集函数functiontrack(target,key){if(!activeEffect)return;// 没有活跃的 effect,无需收集letdepsMap=targetMap.get(target);if(!depsMap){depsMap=newMap();targetMap.set(target,depsMap);}letdep=depsMap.get(key);if(!dep){dep=newSet();depsMap.set(key,dep);}// 将当前 effect 添加到依赖集合中dep.add(activeEffect);}// 触发更新函数functiontrigger(target,key){constdepsMap=targetMap.get(target);if(!depsMap)return;constdep=depsMap.get(key);if(dep){// 复制一份依赖执行,避免死循环consteffectsToRun=newSet(dep);effectsToRun.forEach(effect=>effect());}}// 副作用函数包装器functioneffect(fn){const_effect=function(){activeEffect=_effect;// 设置为当前活跃 effectfn();// 执行函数,触发依赖收集activeEffect=null;// 重置};_effect();return_effect;}有了这套机制,我们就可以开始构建ref的实体了。
二、 解剖 RefImpl:Object.defineProperty 的伪装
ref的本质并不是魔法,而是一个包装类。当你调用ref(0)时,Vue 内部创建了一个RefImpl的实例。这个实例并不是直接暴露原始值,而是将其包装在一个对象中,并通过getter/setter拦截对.value的访问。
核心逻辑:
- 构造函数:接收初始值,内部存储
_value,并通过toReactive将对象类型转换为响应式 Proxy。 - get value():读取时,执行
track收集依赖,返回内部值。 - set value():修改时,执行
trigger触发更新。
让我们手写这个核心类:
// 判断是否为对象functionisObject(val){returnval!==null&&typeofval==='object';}// 模拟 reactive,将对象转为 ProxyfunctiontoReactive(value){returnisObject(value)?reactive(value):value;}// 简化版 reactive 实现(基于 Proxy)functionreactive(target){returnnewProxy(target,{get(target,key,receiver){constres=Reflect.get(target,key,receiver);track(target,key);// 收集依赖returnisObject(res)?reactive(res):res;// 深层响应式},set(target,key,value,receiver){constoldValue=target[key];constres=Reflect.set(target,key,value,receiver);if(oldValue!==value){trigger(target,key);// 触发更新}returnres;}});}// RefImpl 类classRefImpl{constructor(value){this._value=toReactive(value);// 对象转响应式,原始值直接存储this.dep=newSet();// 专属依赖集合}getvalue(){// 收集依赖到 RefImpl 自身的 dep 中if(activeEffect){this.dep.add(activeEffect);}returnthis._value;}setvalue(newVal){if(newVal!==this._value){this._value=toReactive(newVal);// 触发所有依赖此 ref 的 effectthis.dep.forEach(effect=>effect());}}}// ref 工厂函数functionref(value){returnnewRefImpl(value);}关键点剖析:
- 为什么需要
RefImpl?因为 JavaScript 的基本类型(number, string)是按值传递的,无法直接给它们添加属性或拦截操作。必须把它们装进一个对象盒子里。 - 对象的特殊处理:如果
ref接收的是对象,内部会调用toReactive(即reactive函数),将其转换为深层响应式的 Proxy。这就是为么ref({ count: 0 })也能工作的原因。 - 依赖存储:与
reactive将依赖存在全局WeakMap不同,ref的依赖通常存储在实例自身的dep属性中(Vue 源码中更复杂,涉及Dep类),这使得查找更直接。
三、 实战演练:从零构建响应式计数器
理论讲完了,让我们用刚才手写的代码跑一个真实的案例。
// 1. 定义响应式数据constcount=ref(0);conststate=ref({name:'Vue3'});// 2. 定义副作用函数(模拟组件渲染)effect(()=>{console.log(`Count is:${count.value}`);document.getElementById('count').innerText=count.value;});effect(()=>{console.log(`Name is:${state.value.name}`);document.getElementById('name').innerText=state.value.name;});// 3. 模拟用户操作document.getElementById('btn').onclick=()=>{count.value++;// 触发第一个 effectstate.value.name='Vue 3.5';// 触发第二个 effect};执行流程拆解:
- 初始化:
effect执行时,读取count.value,此时activeEffect指向渲染函数。RefImpl的get value被触发,将渲染函数收集到count.dep中。 - 点击按钮:执行
count.value++。 - Setter 拦截:
set value被触发,比较新旧值(0 vs 1),发现变化。 - 触发更新:遍历
count.dep,执行之前收集的渲染函数。 - 重新渲染:控制台打印 “Count is: 1”,DOM 更新。
对于对象类型state,当我们修改state.value.name时,实际上是触发了 Proxy 的set陷阱,进而调用trigger通知更新。
四、 进阶:Ref 与 Reactive 的爱恨情仇
在手写过程中,我们不可避免地要面对ref和reactive的区别。这不仅是语法糖,更是设计哲学的差异。
| 维度 | ref | reactive |
|---|---|---|
| 适用类型 | 通用(基本类型 + 对象) | 仅对象/数组/集合 |
| 访问方式 | 必须通过.value | 直接访问属性obj.prop |
| 底层实现 | 包装类 + getter/setter | Proxy 代理 |
| 响应式替换 | 可整体替换 (ref.value = {}) | 不可整体替换 (会失去响应性) |
| 解构问题 | 不会丢失响应性 (TS 编译时自动解包) | 必须使用toRefs保持响应性 |
为什么推荐基本类型用ref?
因为reactive无法处理number。如果你尝试reactive(0),Vue 会发出警告并返回原值。而ref通过包装类完美绕过了这个限制。
为什么对象推荐用reactive?
- 代码简洁:不需要到处写
.value。 - 性能优化:
ref对对象的处理是“包装一层 Proxy”,而reactive是直接代理原对象。虽然差异微小,但在极端性能场景下,少一层包装意味着更少的拦截开销。 - 不可重赋值:
reactive对象本身不能被重新赋值(否则响应性丢失),这在一定程度上强制了不可变数据流的最佳实践。
手写toRefs的逻辑:
为了解决reactive解构丢失响应性的问题,Vue 提供了toRefs。其原理非常简单:遍历对象的每个 key,创建一个对应的ref,其get指向原对象的属性,set时修改原对象。
functiontoRefs(proxyObj){constrefs={};for(letkeyinproxyObj){refs[key]={getvalue(){returnproxyObj[key];},setvalue(v){proxyObj[key]=v;}};}returnrefs;}五、 避坑指南与最佳实践
在理解了底层实现后,我们可以规避一些常见的“坑”:
不要在 Effect 内部定义 Ref
// 错误 ❌effect(()=>{constcount=ref(0);// 每次 effect 运行都会新建 ref,依赖关系混乱console.log(count.value);});// 正确 ✅constcount=ref(0);effect(()=>{console.log(count.value);});不要直接给 Ref 变量赋值
letcount=ref(0);count=1;// 错误 ❌!切断了与响应式对象的联系count.value=1;// 正确 ✅ShallowRef(浅层 Ref)
对于大型对象,如果只关心引用的变化(整体替换),不关心内部属性变化,可以使用浅层 Ref。手写实现只需在 setter 中不进行toReactive转换即可。Vue 源码中的shallowRef就是通过标记位__v_isShallow来控制的。DOM 引用与组件实例
ref的另一个用途是获取 DOM 元素或组件实例。在模板中使用ref="myRef",Vue 会在挂载后将元素赋值给myRef.value。这利用了同一个RefImpl机制,只是初始值为null,且在mounted钩子后才赋值。
结语
通过手写ref,我们不仅复现了 Vue 3 响应式的核心,更看清了其设计的精妙之处:用 Proxy 劫持对象,用闭包包装原始值,用依赖追踪实现精准更新。
ref不仅仅是一个 API,它是 Vue 3 组合式 API 的基石。理解了它,你就理解了为什么 Vue 能在不需要this的情况下管理状态,理解了computed和watch的运行机制,甚至能在面试中自信地剖析 Vue 与 React 状态管理的本质区别。
下次当你敲下const count = ref(0)时,请记住,你手中握着的,是一套精密运作的自动化引擎,而不仅仅是一个变量。掌握底层原理,才能在复杂的业务场景中驾驭自如,写出高性能、高可维护性的代码。