
写代码的时候用ref、ref.value总觉得这玩意儿就是一个“会变的值”。但真要是被人问一句“它到底是怎么变的”很多人就卡住了。Vue 3 源码阅读笔记里ref.ts就是解答这个问题的最佳入口。这个文件不大两百行左右却一口气塞进了依赖收集、触发更新、懒代理、自动解包这些核心机制。这篇笔记我不会按文件行号从头念到尾而是按我自己的理解顺序来拆先讲它在响应式体系里的位置再逐段拆RefImpl类然后顺手把toRef、toRefs、proxyRefs这几个高频 API 的源码一起解决掉最后聊聊我是怎么读这份源码的、怎么调试验证。ref.ts值得反复读不仅因为ref是 Vue 3 里最常用的响应式 API更因为它浓缩了整个响应式体系的设计思路。读完它你对reactive、computed、watch的理解都会跟着上一个台阶。1. 先从整体看ref.ts 在 Vue 3 响应式体系里的坐标很多人啃源码失败是因为一上来就打开ref.ts从第一行读结果读完全文还是不知道它跟effect、跟渲染有什么关系。我建议先建立全局坐标再进文件。1.1 三个核心文件的分工reactive.ts、effect.ts、ref.tsVue 3 的响应式核心在packages/reactivity/src目录下你只需要盯住三个文件reactive.ts、effect.ts、ref.ts。reactive.ts负责“对象代理”它用Proxy拦截对象的get、set、has、deleteProperty等操作把对属性的读写变成可以追踪的动作。effect.ts负责“副作用调度”它维护了当前正在执行的副作用activeEffect以及每个依赖对应的dep集合track和trigger的具体实现都在这里。而ref.ts的角色稍微特殊一点它负责“单值包装”把一个基础类型值、或者一个对象塞进带value属性的Ref结构中。这三个文件不是孤立的。ref.ts里的trackRefValue最终会调用effect.ts里的trackEffectstriggerRefValue最终会调用triggerEffects。所以你可以把ref.ts想象成一套对外接口真正的“会计记账”工作由effect.ts完成。我见过的最大误区是读ref.ts时死磕RefImpl类的几十行代码却完全没看trackEffects和triggerEffects是怎么实现的最后得出“ref 是通过发布订阅实现的”这种含糊结论。没错但不够你要能说出来订阅者是谁、存在哪里、什么时候被取出执行。这三个问题的答案都在effect.ts里不在ref.ts里。1.2 ref 和 reactive 的本质差异一个包装值一个代理对象说说ref和reactive的选择问题这也是面试高频题。从源码角度看两者根本不是同一层级的 API。reactive只接受对象它返回的是原对象的Proxy。你操作obj.name实际上是在操作Proxy的get/set拦截器拦截器负责track和trigger。ref是什么都能装基础类型直接存值对象则会被转交给reactive处理。所以ref({})和reactive({})在对象场景下最终的响应式实现是同一条链路。我画过一张对比表自己用着很顺手维度refreactive输入类型任意值基础类型、对象仅对象Object、Array、Map、Set 等内部存储RefImpl实例值存在_value原对象的Proxy访问方式.value直接属性访问模板解包自动解包count而非count.value本身可展开无需解包解构安全解构后依然是响应式引用直接解构会丢失响应式推荐场景单值、组合式函数返回值、可空状态层级较深的对象、集合类型、表单数据核心区别就一句话ref是“盒子”你操作盒子里的东西reactive是“镜子”你操作镜子映射出来的对象原对象被替换了镜子也会失效。2. 逐行拆解 ref 入口函数与 RefImpl 类现在进入正题。我按“入口函数 → 构造函数 → get/set”这个顺序来讲这是理解ref最自然的路径。2.1 入口只有三行createRef 里的类型判断与递归保护打开ref.ts先是这个看着平平无奇的入口函数export function ref(value?: unknown) { return createRef(value, false) } function createRef(rawValue: unknown, shallow: boolean) { if (isRef(rawValue)) { return rawValue } return new RefImpl(rawValue, shallow) }ref函数本身只有一行逻辑但createRef里这行isRef(rawValue)判断非常关键。它的作用是幂等保护如果你调用ref(另一个Ref实例)不会生成一个“ref 套 ref”的嵌套结构而是直接把原来的 ref 返回。我实际开发中踩过相关坑写了一个接受参数的工具函数入参可能是number也可能是ref。如果不做判断直接ref(param)当param已经是 ref 时就会出现const outer ref(inner)然后每次访问都要写outer.value.value非常难受。源码里这层保护就是在告诉你ref对已存在的Ref对象是透明的。注意value?: unknown这个可选参数。ref()不传参数也能创建此时_value是undefined这种写法在声明“稍后赋值”的变量时很实用。2.2 构造函数里的 toReactive为什么 ref 也能代理对象紧接着看RefImpl构造函数class RefImplT { private _value: T private _rawValue: T public dep?: Dep undefined public readonly __v_isRef true constructor(value: T, public readonly __v_isShallow: boolean) { this._rawValue __v_isShallow ? value : toRaw(value) this._value __v_isShallow ? value : toReactive(value) } }这里有三个变量分工各不同。_value是对外读取时返回的值注意构造函数里用的是toReactive而不是直接赋值。toReactive的实现简单粗暴export const toReactive T extends unknown(value: T): T isObject(value) ? reactive(value) : value也就是说ref({ name: vue })内部其实是reactive({ name: vue })_value存的是一个Proxy。这也是为什么ref包对象后修改嵌套属性也能触发更新的原因——因为值本身就是 reactive 代理。_rawValue存的是“原始值”。toRaw(value)会把一个 reactive 代理拆回它背后的原始对象。为什么要存原始值因为后面set里做值变化比较时要用_rawValue来对照。如果你拿Proxy做比较基准不同代理实例、代理和原始对象之间会出现各种不一致直接比较原始对象最可靠。__v_isRef true是个标记常量isRef就是靠它来判断的。Vue 内部很多地方检查一个值是不是 ref比如模板自动解包、toRef返回值判断、proxyRefs的set拦截全部依赖这个标记。dep属性是懒创建的。public dep?: Dep声明了类型但初始值是undefined第一次被读取时才会通过trackRefValue创建。2.3 get/set 里的 track 与 trigger响应式更新的触发链路RefImpl的核心逻辑在get value和set valueget value() { trackRefValue(this) return this._value } set value(newVal) { const useDirectValue this.__v_isShallow || isShallow(newVal) || isReadonly(newVal) newVal useDirectValue ? newVal : toRaw(newVal) if (hasChanged(newVal, this._rawValue)) { this._rawValue newVal this._value useDirectValue ? newVal : toReactive(newVal) triggerRefValue(this, newVal) } }get的逻辑一句话就能说清先执行trackRefValue(this)把当前正在运行的副作用收集到dep里然后返回_value。顺序不能反。如果先返回_value再 track在极端情况下会出现“值读到了但依赖没注册”的竞态虽然普通业务代码里极难遇到但源码的严谨就在这里提现。trackRefValue的实现要重点看一下export function trackRefValue(ref: RefBaseany) { if (shouldTrack activeEffect) { ref toRaw(ref) if (__DEV__) { trackEffects(ref.dep || (ref.dep createDep())) } } }条件判断是shouldTrack activeEffect。activeEffect是当前正在执行的 effect比如组件的渲染 effect、watch内部创建的 effect、computed的 effect。如果没有 effect 在执行track 毫无意义直接跳过。ref toRaw(ref)这行值得注意。ref本身不是 ProxytoRaw对普通对象直接返回自身为什么还要走一遍这是一种防御性操作保证dep挂载在“最原始的 Ref 实例”上。因为存在const obj reactive({ r: ref(0) })这种用法访问obj.r时实际上会通过reactive代理的解包逻辑拿到 ref 实例这时 ref 本身也可能被 proxy 包过一层。toRaw在多层包装下确保你操作的是真身。set的逻辑稍微复杂一点但拆开看也就三步。第一步计算useDirectValue。三个条件满足任何一个就直接用新值不做转换this.__v_isShallow为 true当前是shallowRef连对象内部都不追踪更没必要转 reactive。isShallow(newVal)为 true新值本身是 shallow ref 的对象强制转换会破坏它的浅响应式语义。isReadonly(newVal)为 true新值是 readonly 对象reactive(readonlyObj)会报警告所以直接存。第二步newVal useDirectValue ? newVal : toRaw(newVal)。先拆掉代理拿到原始值然后跟_rawValue比较。第三步如果hasChanged为 true就更新_rawValue和_value再调用triggerRefValue(this, newVal)触发更新。这里有个细节_value更新用的是toReactive(newVal)。也就是说如果新值是对象_value会重新指向一个或复用同一个reactive 代理。triggerRefValue的实现也不复杂export function triggerRefValue(ref: RefBaseany, newVal?: any) { ref toRaw(ref) const dep ref.dep if (dep) { triggerEffects(dep) } }注意它拿到了ref.dep之后就直接调用triggerEffects把 dep 里所有副作用取出来执行并不关心这个副作用是渲染函数、watch 回调还是 computed 计算函数。这种“不关心订阅者身份”的设计让响应式体系变得非常灵活。3. 高频疑问一次说清toRef、toRefs 与 proxyRefs读ref.ts时连着出现的好几个导出函数会让人头晕。这里我按“为什么需要它”来串讲比按源码顺序更容易记住。3.1 toRef 是“建立通道”而不是“拷贝快照”toRef的源码很短export function toRefT extends object, K extends keyof T( object: T, key: K, defaultValue?: T[K] ): ToRefT, K { const val object[key] return isRef(val) ? (val as ToRefT, K) : new ObjectRefImpl(object, key, defaultValue) }核心类是ObjectRefImplclass ObjectRefImplT extends object, K extends keyof T { public readonly __v_isRef true constructor( public readonly _object: T, public readonly _key: K, public readonly _defaultValue?: T[K] ) {} get value() { const val this._object[this._key] return val undefined ? this._defaultValue! : val } set value(newVal) { this._object[this._key] newVal } }注意ObjectRefImpl没有dep属性也没有自己的 track/trigger 逻辑。它的get value()读取的是this._object[this._key]这个读取动作会触发源对象的响应式拦截。换句话说toRef建的不是一个独立响应式的“盒子”而是一条“通道”。你通过通道访问目标对象上的属性时目标对象自身的响应式系统会完成依赖收集。这就解释了为什么toRef(reactiveObj, name)是保持响应式的而toRef(plainObj, name)得到的 ref 改变量后页面纹丝不动——因为普通对象根本没有代理get和set都不会触发任何 track 和 trigger。__v_isShallow true这个标记也是有含义的。它告诉外部系统“这个 ref 是浅层的”因为ObjectRefImpl存的只是引用通道不负责深层转换。3.2 toRefs 的适用场景与开发警告toRefs的实现更直白export function toRefsT extends object(object: T): ToRefsT { if (__DEV__ !isProxy(object)) { console.warn(toRefs() expects a reactive object but received plain object.) } const ret: any isArray(object) ? new Array(object.length) : {} for (const key in object) { ret[key] toRef(object, key) } return ret }注意开发环境下的警告toRefs只接受 reactive 对象。我之前看到有些初学者拿着toRefs(plainObject)用还抱怨“为什么解构出来不是响应式”答案就在这行 warn 里。它还处理了数组的情况用isArray(object)判断然后创建对应长度的数组这样对数组执行toRefs时可以用下标toRef(arr, 0)生成索引通道。实际项目里的典型用法const state reactive({ count: 0, name: vue }) const { count, name } toRefs(state) // 现在 count.value 和 state.count 是双向同步的toRefs只转换对象已有的属性。如果你后来给state新增了一个属性这个属性不会自动出现在解构出来的count、name之外因为toRefs只遍历了一次。这也是跟ref的一个隐蔽区别。3.3 proxyRefs模板里 ref 自动解包的真身这个函数在ref.ts末尾但它的影响力无处不在export function proxyRefsT extends AnyObject(objectWithRefs: T): ShallowUnwrapRefT { return isReactive(objectWithRefs) ? objectWithRefs : new Proxy(objectWithRefs, shallowUnwrapHandlers) } const shallowUnwrapHandlers: ProxyHandlerany { get(target, key, receiver) { return unref(Reflect.get(target, key, receiver)) }, set(target, key, value, receiver) { const oldValue target[key] if (isRef(oldValue) !isRef(value)) { oldValue.value value return true } else { return Reflect.set(target, key, value, receiver) } } }get拦截器只有一行unref(Reflect.get(target, key, receiver))。unref的规则就是“如果是 ref 就返回.value否则原样返回”所以模板里count直接就是数字不需要.value。set拦截器是精华中的精华。当你要给这个被代理的对象赋值时会检查旧值如果旧值是 ref而新值不是 ref就直接把新值写入旧 ref 的.value然后返回 true不替换整个属性。这意味着模板里写count 2实际执行的是countRef.value 2而不是把countRef这个 ref 对象替换成数字 2。这就是“自动解包”不只是读的便利还包括写的安全。组件setup()返回的对象会被这个代理包一层defineComponent内部对setupState的处理就调用了proxyRefs。所以你在script setup里定义的ref模板里才能既不用.value读取又能直接赋值。注意proxyRefs的解包是“浅”的只对第一层属性生效。也就是说setup返回{ user: { age: ref(18) } }模板里user.age拿到的还是 ref 对象必须user.age.value。这一点经常坑到人。4. shallowRef 与 customRef从源码看“浅”与“自定义”的实现原理shallowRef和customRef是ref.ts里被问得最多的两个进阶 API。它们的源码都不长但背后设计思路很有嚼头。4.1 shallowRef 只改了一个参数行为差了一个层级看实现export function shallowRefT extends unknown(value: T): ShallowRefT { return createRef(value, true) }你没看错就是调createRef时第二个参数从false变成true。但这一个参数在RefImpl构造函数和 set 逻辑里牵动了三条分支构造函数里不再调用toReactive对象直接以原样存进_value不会包成 Proxy。set 逻辑里useDirectValue恒为 true任何形式的新值都直接使用不做toRaw和toReactive。嵌套属性访问不经过代理自然也不会被 track。所以shallowRef({ name: vue })你修改ref.value.name react不会触发更新。但如果你整体替换ref.value { name: react }会触发更新因为hasChanged比较的是引用地址新对象和旧对象引用不同。实际业务里shallowRef适合两类场景一是数据体量特别大、内部属性变更不频繁的场景二是你打算用triggerRef手动控制更新节奏的场景。4.2 triggerRef手动触发更新是特殊情况下的外挂triggerRef源码就一行调用export function triggerRef(ref: Ref) { triggerRefValue(ref, __DEV__ ? ref.value : void 0) }手动情况下你可能在一个shallowRef的内部改了某个字段但 Vue 不知道所以需要主动调用triggerRef告诉 Vue “这个 ref 变了跑一下依赖”。我做过一个数据表格组件全表数据放在一个shallowRef里内部某些行数据由后端推送更新前端只替换行对象。这时候一行的更新不需要重新检查整个大对象树的嵌套手动triggerRef就能让渲染副作用只跑一遍省掉深层响应式的开销。但注意triggerRef不会绕过hasChanged。shallowRef.value 同一个对象引用这个赋值本身就不会触发更新你再调triggerRef也没用。手动触发的前提是“值确实变了只是 Vue 感知不到”。4.3 customRef把依赖收集权交到业务代码手里customRef的核心实现export function customRefT(factory: CustomRefFactoryT): RefT { return new CustomRefImpl(factory) as any } class CustomRefImplT { public dep?: Dep private readonly _get: (() T) | undefined private readonly _set: ((newVal: T) void) | undefined public readonly __v_isRef true constructor(factory: CustomRefFactoryT) { const { get, set } factory( () trackRefValue(this), () triggerRefValue(this) ) this._get get this._set set } get value() { return this._get!() } set value(newVal) { this._set!(newVal) } }customRef的巧妙之处在于它把trackRefValue和triggerRefValue这两个底层函数通过工厂函数参数的形式暴露给了开发者。你可以在get里决定什么时候触发依赖收集在set里决定什么时候触发更新。网上最常见的customRef例子是防抖输入。比如封装一个防抖 refimport { customRef } from vue export function useDebouncedRefT(initialValue: T, delay 200) { let value initialValue let timer: ReturnTypetypeof setTimeout | null null return customRef((track, trigger) { return { get() { track() return value }, set(newVal) { value newVal if (timer) { clearTimeout(timer) } timer setTimeout(() { trigger() }, delay) } } }) }重点是get里调了一次track()set里通过定时器延迟调trigger()。这样连续输入时trigger被反复推迟直到用户停止输入delay毫秒后依赖才真正执行更新。很多刚接触customRef的人会犯一个错忘记在get里调track。结果就是页面读了这个 ref但依赖没收集后续trigger了也没人理你。这两个函数一定都要手动调用缺一不可。补充一个冷知识customRef产生的对象__v_isRef也是true所以它能被isRef识别也能享受模板自动解包。它本质上就是一个“行为自定义的真 ref”。5. 源码阅读路线与调试实操经验最后分享一些我自己的源码阅读方法以及实际调试ref.ts时用得上的经验这部分不太会出现在官方文档里但非常管用。5.1 我的源码阅读顺序从最小依赖到完整链路读 Vue 3 源码千万不要从index.ts开始那个文件全是重导出读完只知道“有哪些 API”不知道“这些 API 怎么工作”。我给一个适合大部分人的顺序先读effect.ts搞清楚track、trigger、trackEffects、triggerEffects这四个函数各自负责什么activeEffect是什么时候被赋值的。再读ref.ts里的trackRefValue和triggerRefValue你会发现它们只是对effect.ts的薄封装。接着读reactive.ts里的reactive函数和baseHandlers理解对象代理如何复用track/trigger。最后回到ref.ts剩余部分此时RefImpl、ObjectRefImpl的每个操作你都能自己推导演算了。这个顺序的核心逻辑是“先看被依赖的再看依赖别人的”。ref.ts依赖effect.ts所以effect.ts先行。很多人在第一步就放弃就是因为先啃reactive.ts被 Proxy 的各种陷阱搞晕了。如果你嫌源码仓库太大也可以用另一个方法直接打开node_modules/vue/dist/vue.global.js搜索const ref 虽然压缩程度不同但核心逻辑还在方便快速定位。5.2 用断点加最小用例看 track/trigger 的真实轨迹纸上得来终觉浅源码阅读一定要配合调试。我分享一个最小可跑的调试方案。新建一个 HTML 文件引入 CDN 版的 Vuescript srchttps://unpkg.com/vue3/dist/vue.global.js/script script const { ref, effect } Vue const count ref(0) effect(() { console.log(effect run:, count.value) }) setTimeout(() { count.value 1 }, 1000) /script然后打开 DevTools 的 Source 面板找到vue.global.js如果没有可以把脚本内容展开后搜索在trackRefValue和triggerRefValue两个函数内部打断点。再刷新页面观察调用栈第一次count.value读取时会进入trackRefValue此时activeEffect是非空的就是那个 effect 函数dep从无到有被创建。一秒钟后count.value 1会进入triggerRefValuedep里的副作用被取出来逐个执行。实际操作两次你对依赖收集和触发的理解会是看书的三倍以上。如果你用的是 Vite 项目也可以直接在源码里写import { ref } from vue然后通过node_modules/.vite/deps下找到编译后的模块打断点不过那路径比较深不如 HTML 调试来得快。5.3 读 ref.ts 前后最容易踩的三个坑第一个坑只盯着ref.ts不看toReactive的去向。ref({})内部会走reactive()如果你没看reactive的实现就不明白为什么ref对象也能深层响应式。一定要跟着调用链跳转不要停留在字面意思。第二个坑把ObjectRefImpl当成独立的响应式单元。toRef(reactiveObj, key)返回的 ref它的响应式完全依赖源对象。如果你ref.value new后发现页面更新了不是因为ObjectRefImpl自己建了 dep而是因为赋值动作走到了源对象的 set 拦截器触发了源对象的依赖。这个关系搞反了后面看什么都会别扭。第三个坑用console.log直接打印 ref 对象的数据结构来“研究源码”。在控制台里console.log(ref(0))会看到一个RefImpl实例但 Vue 在开发模式下给 ref 对象加了自定义的toJSON、Symbol.toStringTag等处理打印结果和有Proxy的reactive对象长得完全不一样。如果直接拿打印结果去对照源码容易误判。正确做法是打断点看运行时变量。我自己的习惯是对任何 API 的源码理解都以“最小复现 断点观察”为准而不是靠读代码脑补。因为 Vue 源码为了兼容 SSR、开发告警、类型推导写了很多条件分支肉眼看一遍很可能漏掉关键的那一条。关于 ref.ts 我最后想说的读完整份ref.ts我最大的收获不是记住了某个函数的实现而是理解了 Vue 响应式设计的一个核心分层reactive负责“怎么拦截对象操作”effect负责“怎么记录和执行副作用”ref负责“怎么包装单值并且衔接前两者”。这直接影响了我日常写代码的选择。以前我习惯一个页面一个reactive大对象包一切读源码之后我改成能用ref就用ref宁可多写几个.value也不愿在解构时丢掉响应式。数据需要从组合式函数里返回时优先返回ref而不是返回reactive因为消费方可以安全地把返回值直接丢进setup返回对象里而不用担心解构问题。如果你也是那种“API 用得很熟但心里总觉得悬”的开发者我建议你找个周末把这篇笔记对应的源码文件完整过一遍按我给的顺序再配合一次断点调试。相信我跑通一次之后你再看到ref、toRefs、customRef这些名字脑子里浮现的不再是一堆用法规则而是一条条清晰的执行链路。