手写 Vue 3 的 ref 实现:从零开始理解响应式核心

张开发
2026/4/17 22:29:56 15 分钟阅读

分享文章

手写 Vue 3 的 ref 实现:从零开始理解响应式核心
手写 Vue 3 的 ref 实现从零开始理解响应式核心在 Vue 3 的组合式 APIComposition 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”并通知它们重新执行。我们先手写这套核心的依赖管理系统// 全局变量存储当前正在执行的副作用函数letactiveEffectnull;// 依赖地图WeakMaptarget, Mapkey, SeteffectconsttargetMapnewWeakMap();// 依赖收集函数functiontrack(target,key){if(!activeEffect)return;// 没有活跃的 effect无需收集letdepsMaptargetMap.get(target);if(!depsMap){depsMapnewMap();targetMap.set(target,depsMap);}letdepdepsMap.get(key);if(!dep){depnewSet();depsMap.set(key,dep);}// 将当前 effect 添加到依赖集合中dep.add(activeEffect);}// 触发更新函数functiontrigger(target,key){constdepsMaptargetMap.get(target);if(!depsMap)return;constdepdepsMap.get(key);if(dep){// 复制一份依赖执行避免死循环consteffectsToRunnewSet(dep);effectsToRun.forEach(effecteffect());}}// 副作用函数包装器functioneffect(fn){const_effectfunction(){activeEffect_effect;// 设置为当前活跃 effectfn();// 执行函数触发依赖收集activeEffectnull;// 重置};_effect();return_effect;}有了这套机制我们就可以开始构建ref的实体了。二、 解剖 RefImplObject.defineProperty 的伪装ref的本质并不是魔法而是一个包装类。当你调用ref(0)时Vue 内部创建了一个RefImpl的实例。这个实例并不是直接暴露原始值而是将其包装在一个对象中并通过getter/setter拦截对.value的访问。核心逻辑构造函数接收初始值内部存储_value并通过toReactive将对象类型转换为响应式 Proxy。get value()读取时执行track收集依赖返回内部值。set value()修改时执行trigger触发更新。让我们手写这个核心类// 判断是否为对象functionisObject(val){returnval!nulltypeofvalobject;}// 模拟 reactive将对象转为 ProxyfunctiontoReactive(value){returnisObject(value)?reactive(value):value;}// 简化版 reactive 实现基于 Proxyfunctionreactive(target){returnnewProxy(target,{get(target,key,receiver){constresReflect.get(target,key,receiver);track(target,key);// 收集依赖returnisObject(res)?reactive(res):res;// 深层响应式},set(target,key,value,receiver){constoldValuetarget[key];constresReflect.set(target,key,value,receiver);if(oldValue!value){trigger(target,key);// 触发更新}returnres;}});}// RefImpl 类classRefImpl{constructor(value){this._valuetoReactive(value);// 对象转响应式原始值直接存储this.depnewSet();// 专属依赖集合}getvalue(){// 收集依赖到 RefImpl 自身的 dep 中if(activeEffect){this.dep.add(activeEffect);}returnthis._value;}setvalue(newVal){if(newVal!this._value){this._valuetoReactive(newVal);// 触发所有依赖此 ref 的 effectthis.dep.forEach(effecteffect());}}}// ref 工厂函数functionref(value){returnnewRefImpl(value);}关键点剖析为什么需要RefImpl因为 JavaScript 的基本类型number, string是按值传递的无法直接给它们添加属性或拦截操作。必须把它们装进一个对象盒子里。对象的特殊处理如果ref接收的是对象内部会调用toReactive即reactive函数将其转换为深层响应式的 Proxy。这就是为么ref({ count: 0 })也能工作的原因。依赖存储与reactive将依赖存在全局WeakMap不同ref的依赖通常存储在实例自身的dep属性中Vue 源码中更复杂涉及Dep类这使得查找更直接。三、 实战演练从零构建响应式计数器理论讲完了让我们用刚才手写的代码跑一个真实的案例。// 1. 定义响应式数据constcountref(0);conststateref({name:Vue3});// 2. 定义副作用函数模拟组件渲染effect((){console.log(Count is:${count.value});document.getElementById(count).innerTextcount.value;});effect((){console.log(Name is:${state.value.name});document.getElementById(name).innerTextstate.value.name;});// 3. 模拟用户操作document.getElementById(btn).onclick(){count.value;// 触发第一个 effectstate.value.nameVue 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的区别。这不仅是语法糖更是设计哲学的差异。维度refreactive适用类型通用基本类型 对象仅对象/数组/集合访问方式必须通过.value直接访问属性obj.prop底层实现包装类 getter/setterProxy 代理响应式替换可整体替换 (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((){constcountref(0);// 每次 effect 运行都会新建 ref依赖关系混乱console.log(count.value);});// 正确 ✅constcountref(0);effect((){console.log(count.value);});不要直接给 Ref 变量赋值letcountref(0);count1;// 错误 ❌切断了与响应式对象的联系count.value1;// 正确 ✅ShallowRef浅层 Ref对于大型对象如果只关心引用的变化整体替换不关心内部属性变化可以使用浅层 Ref。手写实现只需在 setter 中不进行toReactive转换即可。Vue 源码中的shallowRef就是通过标记位__v_isShallow来控制的。DOM 引用与组件实例ref的另一个用途是获取 DOM 元素或组件实例。在模板中使用refmyRefVue 会在挂载后将元素赋值给myRef.value。这利用了同一个RefImpl机制只是初始值为null且在mounted钩子后才赋值。结语通过手写ref我们不仅复现了 Vue 3 响应式的核心更看清了其设计的精妙之处用 Proxy 劫持对象用闭包包装原始值用依赖追踪实现精准更新。ref不仅仅是一个 API它是 Vue 3 组合式 API 的基石。理解了它你就理解了为什么 Vue 能在不需要this的情况下管理状态理解了computed和watch的运行机制甚至能在面试中自信地剖析 Vue 与 React 状态管理的本质区别。下次当你敲下const count ref(0)时请记住你手中握着的是一套精密运作的自动化引擎而不仅仅是一个变量。掌握底层原理才能在复杂的业务场景中驾驭自如写出高性能、高可维护性的代码。

更多文章