10_第_6_章_原始值的响应式方案
约 9979 字大约 33 分钟
2026-10-05
开场白:原始值到底哪里麻烦了
第 5 章我们搞定了非原始值(对象、数组、Set、Map)的响应式方案。本章聊另一个话题:原始值怎么变响应式。
不过在动手写代码之前,先花两分钟想明白一件事:原始值到底哪里麻烦了? 这一章标题就是这个问题,把它想透,后面所有代码都会变得顺理成章。
先来一个生活场景。你在管理一个快递柜,柜子上的电子屏显示取件码:
- 屏上只写着一个数字
3。你要改它,只能把3擦掉,写上5。这个数字本身没有“外壳”,你往它身上装不了任何感应器。 - 或者,你给它配一个小卡片,卡片上印着“3”,卡片有外壳、有夹层、有挂钩 —— 你可以在卡片上装感应器,有人动卡片时你立刻知道。
第一种东西就是原始值,第二种就是非原始值。响应式系统要干的活儿是“有人改了 → 自动通知所有相关方”,可对第一种东西,你连钩子都挂不上去。这就是本章要解决的、也是唯一要解决的麻烦。
先认识两个名词
原始值(primitive value):
JS 里“最简单”的值类型,一共 7 种 —— boolean(真假)、number(数字)、bigint(大整数)、string(字符串)、symbol(唯一标记)、undefined(没有定义)、null(空指针)。
生活中的比喻:原始值就像写在白板上的字。你能擦掉重写,但你没法在白板背后藏一个感应器。
非原始值(object):
也就是我们口语里说的“对象”,包含普通对象、数组、Set、Map、Function 等。
生活中的比喻:非原始值就像一台带外壳的机器。外壳里装着可以替换的零件,所以你能把感应器、摄像头装在壳子上,盯着里面每一次改动。
小细节:原文用的是“非原始值”这个说法。注意在 ECMAScript 规范里 reference(引用)是个更专门的术语,跟这里的“对象”不是一回事,翻译成中文容易混淆,所以本书统一说“非原始值”。
它们之间的关键区别有两个。
区别一:数据是怎么“传”给别人的
原始值是按值传递的 —— 赋值时会复制一份。非原始值是按引用传递的 —— 赋值时复制的是“地址”。
let a = 1
let b = a // 按值传递:b 是 a 的副本
b = 2
console.log(a) // 还是 1,没变
const obj1 = { x: 1 }
const obj2 = obj1 // 按引用传递:obj2 和 obj1 指向同一个对象
obj2.x = 2
console.log(obj1.x) // 2,因为是同一个对象我们逐行看:
- let a = 1:把数字
1写到变量a上。 - let b = a:把
a那里写着的内容誊抄一份给b。此刻a和b是两份各自独立的“1”。就像你把一份合同复印了两份。 - b = 2:只改了复印件 B。原件 A 不受影响,所以下一行读
a拿到的还是1。 - const obj1 = { x: 1 }:造一个对象,里面
x字段的值是1。对象是“外壳 + 内层字段”两层结构,所以才有“引用”(地址)可谈。 - const obj2 = obj1:这里没有誊抄内容,只是把
obj1的“地址”抄给了obj2。此刻obj1和obj2是同一台机器的两个门牌号。 - obj2.x = 2:从
obj2这个门牌号走进机器,把里面的x改成2。因为两个门牌号指着同一台机器,所以从obj1那边看到的也是2。
这条规则在函数里体现得最直观:原始值作为参数传给函数时,形参与实参之间没有任何引用关系,它们是两个完全独立的值,对形参的修改不会影响实参。
区别二:能不能被 Proxy 接管
第 5 章说过,Vue 3 的响应式全靠 Proxy —— 给一个对象套一个“中间人”,所有对它的读写都先经过中间人,中间人就能顺手记一笔“谁读过”“谁改过”,从而建立“数据 ↔ 副作用”的联系。
那么问题来了:这个中间人能不能套在数字上?
这张图是整章的地基——原始值压根没法套 Proxy,所以响应联系无从建立。出路只有一个:先把裸值装进一个对象,再对这个对象套 Proxy。
装箱:把值装进一个壳里
包装 / 装箱(boxing):
既然 Proxy 只能代理对象,想让原始值也变响应式,就只能先把原始值装进一个对象里,让这个对象去当“壳”,然后对壳套 Proxy。这个“把裸着的值塞进盒子”的动作,就叫包装,也叫装箱。
生活中的比喻:你想给一枚光秃秃的戒指加防盗报警器,但报警器只能贴在盒子上。那你就得先做一个首饰盒,把戒指放进去,报警器贴在盒子上。Vue 里这个“盒子”就是 { value: 你传进去的值 }。
本章的主角 ref,本质就是一个专门用来装原始值的盒子,再加上“每次进出都登记”的那一套动作。
结论:原始值需要特殊处理,是因为 Proxy 的代理目标必须是非原始值,这是语言层面的硬规定;而 count 这种数字 / 字符串恰恰不是对象,钩子挂不上。 唯一的出路就是套一层盒子,把不能被代理的东西变成可以被代理的东西。
6.1 引入 ref 的概念
什么是 ref
ref:Vue 3 里用来包装原始值的响应式“容器”。名字里的 “ref” 是 reference(引用)的缩写,但它在这里不是 ECMAScript 规范里那个术语,而是“给原始值套一层壳,让它也能被 Proxy 接管”这件事的简称。使用上看起来像这样:
const count = ref(0)
console.log(count.value) // 0
count.value++ // 改值,自动触发依赖此数的 effect生活中的比喻:ref(0) 就像把一个裸奔的数字 0 关进一个保险箱,保险箱上装了一块登记面板。取值 = 开箱取物,顺手在面板上记一笔“某人来看过”;改值 = 往箱子里塞新东西,顺手记一笔“有人动过”,然后通知所有来看过的人。
结论:count 看着像个数字,其实是个对象;count.value 才是那个真正的数字。
为什么不能直接代理原始值
为啥不能直接代理原始值?我们想想,假如有一个原始字符串:
let str = 'vue'
str = 'vue3' // 直接赋值,怎么拦?- let str = 'vue':变量
str里现在躺着一个字符串'vue'。 - str = 'vue3':你只是把变量里躺的东西换掉了。注意这一行连“属性”都没写 ——
str自己就是一个原始值,你连一个可以挂夹子的字段都找不到。
那为什么拦不住?根本原因在于 Proxy 的硬性规定:代理目标必须是非原始值(也就是对象)。你写 new Proxy('vue', { set() {} }) 会直接抛 TypeError,因为字符串根本不能被代理。
⚠️ 常见的误解是“JS 引擎对原始值的赋值会走
[[Set]],只是 Proxy 没钩子拦”。其实不是 —— 是压根就不允许代理原始值,Proxy 连创建都创建不出来,谈不上拦不拦。
小细节:上一条 ⚠️ 里提到的 [[Set]],是 JS 规范书写内部方法的惯用记法。第 5 章出现的 [[Get]]、[[Set]]、[[Call]] 都属于这一类——它们是引擎内部的动作名,你在代码里调不到。而且原始值上压根就没有这类内部方法,因为它不是对象。
唯一能想到的办法是包一层对象:
const wrapper = { value: 'vue' }
const name = reactive(wrapper) // 用 reactive 包 wrapper,wrapper.value 就能拦截了
name.value // 'vue'
name.value = 'vue3' // 走 set 拦截器,能触发响应 ✅我们逐行看:
- const wrapper = { value: 'vue' }:造一个普通对象当壳,壳里有一个叫
value的字段,字段内容是字符串'vue'。原始值'vue'到此就被“装箱”进这个壳里了。 - const name = reactive(wrapper):调用第 5 章实现的
reactive,用 Proxy 把wrapper套上。从此以后,你接触不到真正的wrapper,手里拿的是它的代理name。 - name.value:读代理的
value字段,Proxy 的get夹子先被触发,最终返回'vue'。 - name.value = 'vue3':写代理的
value字段,Proxy 的set夹子先被触发,响应被触发。这就是套壳的全部意义 —— 原始值终于有了可以被拦截的地方。
但这样有两个问题:
- 用户得自己造包裹对象:本来只想存个数字,还得先想
{ value: x }这种结构,烦。 - 命名不统一:用户可能叫
wrapper.value,也可能叫wrapper.val,没人规范得了。
打个比方:让每个来存钱的人都自己带保险箱,那这个银行就乱套了 —— 有人带铁皮箱,有人带纸箱,柜台根本不知道该怎么登记。银行应该自己提供统一规格的保险箱。这就是 ref 要做的事。
修法:写一个 ref 函数,把“造包裹对象”这件事封装起来:
function ref(val) {
// 👇 在 ref 函数内部创建包裹对象
const wrapper = {
value: val
}
// 👇 把包裹对象变成响应式
return reactive(wrapper)
}我们逐行看:
- function ref(val):定义一个叫
ref的函数,接收一个参数val(你传进来的那个原始值)。它没有直接把val交出去,而是先把它装进壳里。 - const wrapper = { value: val }:创建一个普通对象,结构是
{ value: 你传进来的值 }。这一步就是“装箱” —— 原始值val从此不再是“裸的”,而是躺在一个对象的.value字段里。 - return reactive(wrapper):调用第 5 章实现的
reactive把wrapper包成 Proxy 代理,再把这个代理返回给用户。从今往后,用户拿到的refVal看着像原始值,其实是个对象;所有对它的读写都会被 Proxy 拦截,从而建立响应联系。
调用一下:
const refVal = ref(1)
effect(() => {
console.log(refVal.value)
})
refVal.value = 2 // 能触发 effect ✅我们逐行看:
- const refVal = ref(1):把数字
1装箱成 ref。此刻refVal的内部结构是{ value: 1 }的代理。 - effect(() => { ... }):注册一个副作用函数(第 4 章的
effect:里面读了什么数据,之后那个数据被改了,这个函数就重跑一遍)。 - console.log(refVal.value):在副作用函数里通过
value属性读取原始值。这一步会把refVal记进“我依赖它”的名单。 - refVal.value = 2:修改值。Proxy 的
set夹子发现名单里有人在等,于是触发副作用函数重新执行。
于是内存里大概长这样:
所以 ref(1) 拿到的从来就不是数字 1 本身。
用 __v_isRef 认出 ref
但是 —— 新问题来了:
const refVal1 = ref(1)
const refVal2 = reactive({ value: 1 })- const refVal1 = ref(1):走我们的
ref函数造出来的,它是 ref。 - const refVal2 = reactive({ value: 1 }):用户手写了一个
{ value: 1 }再丢给reactive,它不是 ref,就是个普通响应式对象。
refVal1 和 refVal2 这俩东西长得一模一样(都是 { value: 1 } 的代理),从我们的实现来看,它们没有任何区别。但语义上完全不是一回事:我们后面要讲的“自动脱 ref”能力,就需要分辨一个数据到底是不是 ref。
打个比方:两个快递包裹的面单上写着一模一样的字,可一个是“生鲜”、一个是“书籍”。柜员要按类别上架,就必须有办法一眼认出哪个是生鲜 —— 面单上得盖章。给 ref 加个“身份证”标识就是这个“盖章”动作:
function ref(val) {
const wrapper = {
value: val
}
// 👇 在 wrapper 上定义一个不可枚举且不可写的属性 __v_isRef,值为 true
Object.defineProperty(wrapper, '__v_isRef', {
value: true
})
return reactive(wrapper)
}- Object.defineProperty(wrapper, '__v_isRef', { value: true }):给 wrapper 增加一个叫
__v_isRef的属性,值是true。Object.defineProperty是 JS 用来精确描述一个属性的函数,比直接赋值更强大 —— 除了赋值,你还能规定这个属性能不能被遍历到、能不能被改写。 - 不可枚举:这样
JSON.stringify或for...in遍历 wrapper 时不会看到__v_isRef,不污染输出。打个比方,就像身份证盖在包裹的内侧,机器能读到,人肉眼扫一眼包装却看不到。 - 不可写:
Object.defineProperty只写了value: true、没写writable,所以这个属性默认就是只读的。从外面看,__v_isRef永远是true,谁也改不动它 —— 这样 Vue 判断“这是不是 ref”时不会被误改的标记骗到。 __v_isRef这个名字是约定俗成的:__v_前缀 +isRef,Vue 看到这个名字就知道“这是个 ref”。__v_isRef就是一个布尔值标志:为true表示“这是一个 ref”,跟普通对象区分开。
这样以后想判断一个数据是不是 ref,直接检查它有没有 __v_isRef 属性、值是否为 true 就行 —— 不用去看它是不是对象、里面有没有 value 字段。
6.2 响应丢失问题
ref 解决了“原始值怎么变响应式”,但它的本事不止于此 —— 它还能用来解决响应丢失问题。
什么是响应丢失
什么是响应丢失?Vue 组件里经常这么写:
export default {
setup() {
const obj = reactive({ foo: 1, bar: 2 })
// 把数据暴露到模板中
return {
...obj // 👈 用展开运算符把 obj 的属性展开
}
}
}我们逐行看:
- export default { ... }:把这个组件对象设为默认导出。组件(component)就是 Vue 里一块可以复用的界面单元(比如一个按钮、一个列表),它把“数据”和“用数据渲染出来的界面”绑在一起。
- setup() { ... }:组件的
setup函数,作用是“准备数据”。它跑完之后,返回的那个对象会被交给模板使用 —— 你能理解成“我在这个组件里准备了这些东西,模板随便用”。 - const obj = reactive({ foo: 1, bar: 2 }):造一个响应式数据对象(第 5 章的
reactive),里面foo是1、bar是2。 - return { ...obj }:把
obj里的属性展开,逐个放进一个新对象再返回。这个新对象就是模板能看到的东西。 - 👈 这里就是全章最关键的一个字:
...展开。它看起来只是“转手一下”,但这一步恰恰把响应弄丢了,下面就讲这件事。
然后模板里:
<template>
<p>{{ foo }} / {{ bar }}</p>
</template><p>{{ foo }} / {{ bar }}</p>:一个段落,段落里插了两个“取数据”的位置。{{ }}是 Vue 模板的插值语法(interpolation),意思是“这里要放一个数据,请你去组件里拿”。渲染时它会把foo、bar的值替换进页面。
看起来很正常对吧?但是改数据的时候,不会重新渲染!
export default {
setup() {
const obj = reactive({ foo: 1, bar: 2 })
setTimeout(() => {
obj.foo = 100 // 😱 改了,但模板不更新
}, 1000)
return {
...obj
}
}
}- setTimeout(() => { ... }, 1000):1 秒后执行一次回调函数。用来模拟“过了一会儿数据变了”的场景。
- obj.foo = 100:把响应式数据里的
foo改成100。数据确实改了,但页面纹丝不动。 - return { ...obj }:还是那个展开。问题就出在它身上。
⚠️ 注意这里最容易懵的地方:
obj.foo确实被改了,响应式系统确实触发了,只是没人被通知到。因为渲染函数当初读的是setup返回的那个普通对象上的foo——展开时抄下来的一个数字,不是通往obj.foo的那条路。数据改了,但没人在“看”它。
响应丢失(losing reactivity / reactivity loss):
一个本来有响应能力的数据,经过某次“搬运 / 拷贝”之后,落进了一个普通对象里,从此再改原始数据,它不会有任何反应。
生活中的比喻:你把一个装了摄像头的水箱接到一根普通水管上。摄像头还开着、水也还在流,但水管末端的水龙头(模板)压根不知道上游出了什么事 —— 监控线在中间被剪断了。
本章要解决的就是:怎么让“被搬出去的那份”也跟着响应。
为什么会这样:展开运算符与解构赋值
为什么会这样? 问题就出在 ...obj 这个展开运算符。
展开运算符(spread operator):
...obj 把 obj 的自身可枚举属性逐个拷出来,等价于:
return {
foo: 1, // 一个普通数字
bar: 2 // 一个普通数字
}看清楚了 —— 展开出来的 foo: 1 里那个 1,是当场读取到的一个普通数字,不是“通往 obj.foo 的通道”。展开后产生的是一个普通对象({foo: 1, bar: 2}),里面的 foo / bar 都是普通数字,根本不是响应式的。模板读它们的时候,没办法建立响应联系。所以后面改 obj.foo,模板根本不知道,也就不会重新渲染。
解构赋值(destructuring):
ES6 的语法,作用是从对象或数组里把需要的值“取出来”直接当地名用。
const { foo } = obj // 等价于 const foo = obj.foo
const [a, b] = arr // 等价于 const a = arr[0]; const b = arr[1]生活中的比喻:它就像从一沓资料里抽出你需要的几页,抽出之后这几页就独立了,跟原来那沓资料再无关系。
结论:return { ...obj } 和 const { foo } = obj 是同一类操作 —— 都是把属性值“取出来”放进一个新的普通容器。取出来的那一刻,值就变成了快照。本章的 toRefs 就是专门对付这一类“取出”动作的解药。
修法:把“取值的通道”搬过去
我们可以用一段独立的代码把“响应丢失”现象复现出来:
const obj = reactive({ foo: 1, bar: 2 })
const newObj = {
...obj // 普通对象
}
effect(() => {
console.log(newObj.foo)
})
obj.foo = 100 // 不会触发 effect ❌我们逐行看:
- const obj = reactive({ foo: 1, bar: 2 }):
obj是响应式数据,任何人读obj.foo都会被记录进“依赖名单”。 - const newObj = { ...obj }:用展开运算符把
obj的属性拷进一个全新的普通对象。newObj和obj从此是两个不相干的东西。 - effect(() => { console.log(newObj.foo) }):注册副作用函数,在副作用函数内通过新的对象
newObj读取foo属性值。请注意读的是newObj.foo—— 被登记进依赖名单的是newObj.foo,而不是obj.foo。 - obj.foo = 100:修改
obj.foo。obj的set夹子确实被触发了,它去翻obj.foo的依赖名单 —— 名单是空的(因为大家都记的是newObj.foo),所以什么也不会发生。
因为 effect 里读的是 newObj.foo,不是 obj.foo,所以它们俩根本不在同一个“通知名单”里。
怎么修? 核心思路:让 newObj 里的 foo 也能间接读到 obj.foo。也就是 —— 别再把值“抄”出来了,把“读取的通道”搬过去。
最直接的办法是给 newObj 的每个属性加个访问器(getter),让它“代理”回 obj:
const newObj = {
foo: {
get value() {
return obj.foo // 👈 读 newObj.foo.value 等价于读 obj.foo
}
},
bar: {
get value() {
return obj.bar
}
}
}
effect(() => {
console.log(newObj.foo.value) // 这里读的是 obj.foo(响应式),能建立响应联系 ✅
})
obj.foo = 100 // 现在能触发 effect 了 ✅我们逐行看(注意结构和上一段完全不同):
- const newObj = { foo: { get value() { ... } }, bar: { ... } }:
newObj.foo不再是一个普通数字了,而是一个只有 getter 的小对象。get value() { ... }叫访问器属性(getter):你什么时候读.value,它就什么时候执行get里的那句代码。 - get value() { return obj.foo }:getter 的作用就是每次读取都现去
obj那边取一次。所以newObj.foo里没有存任何数值快照,它只存了“要去哪儿取”这条线索。 - effect(() => { console.log(newObj.foo.value) }):在副作用函数内通过新的对象
newObj读取foo属性值。读的动作会一层层往下走:newObj.foo.value→ 执行 getter → 读到obj.foo→ 被obj的get夹子记录进依赖名单。 - obj.foo = 100:这时
set夹子去翻依赖名单,发现有人在等(刚才那次读取登记过了),触发副作用函数重新执行。
结论:坏做法是把值搬走 —— 搬走的那份成了快照,死掉了;好做法是把“取值的通道”搬走 —— 每次都回源头取,源头一动,通道里的人立刻知道。
观察一下,newObj.foo 和 newObj.bar 这俩对象的结构一模一样:都是 { get value() { return ... } }!这不就是 ref 吗?每个属性都被套上了一个“能自动回源头取值”的壳 —— 这正是 6.1 节 ref 的结构。
也就是说,如果我们能做一个“ref 工厂”,就能批量给响应式对象的每个属性生成一个 ref:
function toRef(obj, key) {
const wrapper = {
get value() {
return obj[key]
}
}
return wrapper
}- toRef(obj, key):把响应式对象 obj 的某个 key 单独包成一个 ref 风格的 wrapper。读
wrapper.value时,会去读obj[key],所以响应联系自然能建上。
toRef / toRefs:把响应式对象的属性单独(或批量)包装成 ref 的两个函数。toRef(obj, key) 管一个属性,toRefs(obj) 管一个对象的全部第一层属性。
命名解释:toRef 顾名思义 “to a ref” —— 把某个东西变成一个 ref。toRefs 就是它的“批量版”(多一个 s,s = plural,表示复数)。
生活中的比喻:原来的 obj 是一间带监控的总机房。toRef 的作用是给某个机柜单独接一根线到外面的显示器上 —— 线是从总机房实时接过去的,所以总机房里的机器一动,外面立刻能看到。toRefs 就是一次把整间机房的线全接好。
它们的共同点是:都不搬走数据,只搭一条“回源头的取货通道”。这正是修好响应丢失的关键动作。
用了 toRef 之后,新对象可以这么写:
const newObj = {
foo: toRef(obj, 'foo'),
bar: toRef(obj, 'bar')
}- foo: toRef(obj, 'foo'):调用
toRef,把obj的'foo'这个属性包成一个 wrapper,放到新对象的foo字段上。注意这里foo的值是个对象,不是数字1了。 - bar: toRef(obj, 'bar'):同理,管
bar。 - 对比一下篇幅:原来要手写 6 行
{ get value() { return obj.foo } },现在一行搞定一个字段。代码变得非常简洁。
但如果 obj 的字段特别多(一个几十个字段的对象),每个字段都写一遍太麻烦 —— 写 50 个字段就是 50 行,还容易拼错名字。我们再做一个 toRefs 来批量搞定:
function toRefs(obj) {
const ret = {}
// 用 for...in 遍历对象的所有键
for (const key in obj) {
// 逐个调用 toRef
ret[key] = toRef(obj, key)
}
return ret
}我们逐行看:
- const ret = {}:准备一个空对象
ret,装转换结果,最后返回。 - for (const key in obj):
for...in是 JS 遍历对象键名的循环,每次循环key就是当前这个键的名字。这里用它遍历obj的所有键(包括从原型上继承来的,但实际的响应式对象一般没有继承属性)。 - ret[key] = toRef(obj, key):对每个
key调用toRef(obj, key),逐个完成转换,把结果按原键名放进ret里。 - return ret:返回一个新对象,它的每个字段值都是 ref。
这样用起来就一句话:
const newObj = { ...toRefs(obj) }
console.log(newObj.foo.value) // 1
console.log(newObj.bar.value) // 2- const newObj = { ...toRefs(obj) }:
toRefs(obj)已经返回了一个普通对象,这一步再展开一次,等于把它的字段原样搬进newObj。从此obj改了,newObj跟着改。 - console.log(newObj.foo.value) // 1:通过 getter 回
obj取foo,拿到1。 - console.log(newObj.bar.value) // 2:同理,拿到
2。
结论:响应丢失问题被我们彻底解决了。 解决的思路是把响应式数据转换成“类似于 ref 结构”的数据 —— 不搬数值,只搭通道。
浅层响应(shallow reactivity):
只有对象的第一层属性是响应式的,更深层的嵌套属性不是。打个比方,它像只刷了一遍油漆的柜子 —— 外面那层是新的,里面裹着的旧柜子还是原样(第 5 章 5.5 节专门讲过深 / 浅响应)。
⚠️ 本章的
toRefs也是同样的“第一层”视角:它只把响应式数据的第一层属性值转换成 ref。所以第一层这三个字要记住,它既是第 5 章的关键词,也是理解 6.3 节的前提。
⚠️ 但为了概念上的统一,我们最好让 toRef 返回的结果也带上
__v_isRef标识(这样 vue 才能认为它是一个真正的 ref):
function toRef(obj, key) {
const wrapper = {
get value() {
return obj[key]
}
}
// 👇 加上 __v_isRef 标识
Object.defineProperty(wrapper, '__v_isRef', {
value: true
})
return wrapper
}- 改变的只有一处:多了
__v_isRef: true这个标识,getter一行没动。 - 这么做的意义:
toRef返回的 wrapper 和 6.1 节的ref结构完全一样了,所以我们可以把它们当成同一种东西对待,后面 6.3 节的“自动脱 ref”才有统一的标准可用。
小细节:回头看一眼,ref 本质上也是一个“盒子”。6.1 节的盒子装的是“一个原始值”,这里的盒子装的是“另一个对象的某个键”。结构相同,来路不同。
toRef 默认是只读的 —— 因为我们只定义了 getter,没写 setter。上面这段代码跑起来是这样的:
const obj = reactive({ foo: 1, bar: 2 })
const refFoo = toRef(obj, 'foo')
refFoo.value = 100 // 无效我们逐行看:
- const obj = reactive({ foo: 1, bar: 2 }):响应式数据,
obj.foo是1。 - const refFoo = toRef(obj, 'foo'):把
obj的'foo'属性包成 ref。读refFoo.value正常返回1。 - refFoo.value = 100 // 无效:这一行什么也不会发生。为什么?因为 wrapper 的
value属性只有getter、没有setter。你赋的值不知道该往哪儿写 —— 没有出口的货,送不进去。 - 比对一下:
ref(1)造出来的 ref 之所以能改,是因为它内部是reactive包出来的真对象,value是它的普通属性,写上去自然生效。而toRef造出来的是只读壳子,要改就得给壳子开个写入口。
但有时候我们也希望能改值,给 toRef 加个 setter:
function toRef(obj, key) {
const wrapper = {
get value() {
return obj[key]
},
// 👇 加 setter
set value(val) {
obj[key] = val
}
}
Object.defineProperty(wrapper, '__v_isRef', {
value: true
})
return wrapper
}- set value(val):当你给 ref.value 赋值时,会执行这个 setter,把 val 写回 obj[key]。这样原响应式对象就真的被改了,会触发响应。
6.3 自动脱 ref
上一节我们用 toRefs 解决了响应丢失问题,但同时也带来了新的“小麻烦”。
新的“小麻烦”:到处都得写 .value
看代码:
const obj = reactive({ foo: 1, bar: 2 })
obj.foo // 1
obj.bar // 2
const newObj = { ...toRefs(obj) }
// 必须使用 value 访问值
newObj.foo.value // 1
newObj.bar.value // 2我们逐行看:
- const obj = reactive({ foo: 1, bar: 2 }):还是那个响应式对象,读它不用写
.value。 - obj.foo // 1 / obj.bar // 2:
obj的字段值是原始值(数字),reactive的get夹子直接返回数字,所以没有壳可脱。 - const newObj = { ...toRefs(obj) }:
toRefs把obj的第一层属性值转换成了 ref(每个字段外面都套了个壳)。 - newObj.foo.value // 1:⚠️ 必须使用
value访问值。因为现在newObj.foo已经是一个 ref 了,得开箱才能拿到1。
注意对比:
- 在
obj上读foo,直接拿到1,不用写.value。 - 在
newObj上读foo,必须写.value才能拿到1,因为newObj.foo已经是个 ref 了。
这其实增加了用户的心智负担。因为通常我们在模板里访问数据:
<p>{{ foo }} / {{ bar }}</p>你肯定不会希望写:
<p>{{ foo.value }} / {{ bar.value }}</p>所以我们需要自动脱 ref 的能力。
自动脱 ref(unref):
属性的访问行为 —— 如果读到的属性是一个 ref,则直接将该 ref 对应的 value 属性值返回。所以读 newObj.foo 直接拿到 1,不用写 newObj.foo.value。
生活中的比喻:toRefs 给每个字段套了件外套,你从此得一件件脱;proxyRefs 就是自动帮你脱外套的机器 —— 你递进去,它把外套扒了再递给你。
⚠️ 注意范围:它只作用于你“读”的那一步。
newObj自己并没有被改造,里面的字段依然是 ref。所以要拿没脱壳的那个 ref 本身时(比如 6.3 节set夹子里要往value上写),还得绕开代理、从target上直接读。
要实现这个能力,我们得用上一节讲过的 __v_isRef 标识 + Proxy。
proxyRefs(proxy + refs):
“代理 + 一堆 ref” —— 它接收一个“字段值可能都是 ref”的对象,返回这个对象的一个 Proxy 代理。从今往后你从这个代理上读属性,代理就顺手帮你把 ref 脱掉。名字里的 proxy 就是第 5 章学过的 Proxy(代理),职责是“拦截并重新定义属性访问”。
它是 Vue 内部用来批量脱 ref 的一个函数:读属性时会先看看到底是不是 ref,是的话自动返回 value;写属性时也会先看是不是 ref,是的话自动写到 value 上。
生活中的比喻:就像机场的翻译服务 —— 你递进去的是一张写着中文的登机牌(ref),递出来的是印着英文的那张(value),使用者完全感觉不到中间做了一次翻译。
所以它的价值不在功能,而在“无感”:用户写代码时不必先弄清楚“这个值到底是不是 ref”,因为两种写法都给你正确的答案。
function proxyRefs(target) {
return new Proxy(target, {
get(target, key, receiver) {
const value = Reflect.get(target, key, receiver)
// 自动脱 ref 实现:如果读取的值是 ref,则返回它的 value 属性值
return value.__v_isRef ? value.value : value
}
})
}
// 调用 proxyRefs 函数创建代理
const newObj = proxyRefs({ ...toRefs(obj) })
console.log(newObj.foo) // 1
console.log(newObj.bar) // 2我们逐行看:
- function proxyRefs(target) { ... }:定义一个工具函数,接收一个普通对象 target,返回它的 Proxy 代理。
- new Proxy(target, { get(...) { ... } }):用 Proxy 拦截 target 的属性读取。
- get(target, key, receiver):
get夹子在代码读newObj.foo时自动触发。三个参数分别是:真身target、要读的键名key、以及读取发起者receiver(原封不动传给 Reflect 即可)。 - const value = Reflect.get(target, key, receiver):用 Reflect 的标准方式拿到原始值。如果
target[key]是个 ref,这里拿到的就是 ref 本身(还没脱)。 - return value.__v_isRef ? value.value : value:判断拿到的值是不是 ref(看
__v_isRef是不是true)。是 ref 就返回value.value(脱掉外壳),不是 ref 就原样返回。这就是“自动脱 ref”的全部魔法。 - const newObj = proxyRefs({ ...toRefs(obj) }):调用
proxyRefs函数创建代理。先把obj转成一堆 ref(toRefs),再给这个对象套一层代理(proxyRefs)。现在你手里的newObj是个代理,写代码的感觉和普通对象一模一样。 - console.log(newObj.foo) // 1:读的时候不需要写
.value了,代理帮你脱。 - console.log(newObj.bar) // 2:同理。
来一个对比,感受一下自动脱 ref 的威力:
| 写法 | 不用 proxyRefs | 用 proxyRefs |
|---|---|---|
| 读取 | newObj.foo.value | newObj.foo |
| 赋值 | newObj.foo.value = 100 | newObj.foo = 100 |
Vue.js 组件的 setup 函数返回的对象,其实就是被 proxyRefs 处理过的:
const MyComponent = {
setup() {
const count = ref(0)
// 返回的这个对象会传递给 proxyRefs
return { count }
}
}- const MyComponent = { setup() { ... } }:定义一个组件对象。组件返回的数据,会被 Vue 内部拿去交给
proxyRefs处理。 - const count = ref(0):用 6.1 节的
ref造一个 ref。此刻count内部是{ value: 0 }的代理。 - return { count }:
{ count }是对象字面量的简写(ES6 语法),等价于{ count: count },意思是“把这个叫 count 的东西放进返回对象里”。返回的这个对象会传递给proxyRefs。
这也是为什么我们在模板里直接写 {{ count }} 就能拿到 0,不用写 {{ count.value }}:
<p>{{ count }}</p>- :模板向组件要
count这个数据。组件那边返回的{ count }已经过proxyRefs处理,读的时候自动脱了 ref,所以这里不用写.value。这就是本章两大功能的合体:6.1 节的ref负责“有响应”,6.3 节的proxyRefs负责“用起来没负担”。
读有了自动脱 ref,写这边也得跟上来。我们给 proxyRefs 加个 set 拦截器:
newObj.foo = 100 // 应该生效- newObj.foo = 100:这一行看起来是“给 newObj 的 foo 赋个值 100”,但
newObj.foo其实是个 ref,所以我们真正想做的是“给这个 ref 里的值赋 100”。这两件事必须帮用户自动对上,否则“读的时候脱了、写的时候没脱”,就自相矛盾了。
实现:
function proxyRefs(target) {
return new Proxy(target, {
get(target, key, receiver) {
const value = Reflect.get(target, key, receiver)
return value.__v_isRef ? value.value : value
},
set(target, key, newValue, receiver) {
// 通过 target 读取真实值
const value = target[key]
// 如果值是 Ref,则设置其对应的 value 属性值
if (value.__v_isRef) {
value.value = newValue
return true
}
return Reflect.set(target, key, newValue, receiver)
}
})
}我们逐行看(get 夹子跟前面一模一样,新增的是 set 夹子):
- set(target, key, newValue, receiver):
set夹子在代码写newObj.foo = 100时自动触发。参数分别是:真身target、要写的键名key、要写的新值newValue、以及写入发起者receiver。 - const value = target[key]:通过
target读取真实值 —— 先看看这个键现在装的是什么。这里故意直接读target,不经过get夹子,因为get会把 ref 脱掉、脱掉之后就写不回去了(你得拿回 ref 本身才能写它的.value)。 - if (value.__v_isRef):如果值是 ref,进入这个分支。
- value.value = newValue:把新值间接写到 ref 的
value属性上。因为toRef的 setter 会把它转写回obj[key],而obj是响应式对象,所以响应就被正确触发了。 - return true:告诉引擎“这次赋值成功了”。Proxy 的
set夹子必须返回布尔值表示成功与否,返回true才不会报错。 - return Reflect.set(target, key, newValue, receiver):如果值不是 ref(是个普通值),就用
Reflect.set走标准赋值流程,保持普通对象的原有行为不变。
结论:为什么要先判断是不是 ref? 因为 proxyRefs 服务的对象里,可能 ref 和普通值混在一起。判断这一步保证了“是 ref 就脱,不是 ref 就放行” —— 两种字段都不出错,用户才真的可以“随便写”。
自动脱 ref 不止在 setup 返回的对象上生效。在 Vue 里,reactive 函数也有这个能力:
const count = ref(0)
const obj = reactive({ count })
obj.count // 0- const count = ref(0):造一个 ref,内部是
{ value: 0 }的代理。 - const obj = reactive({ count }):把这个 ref 本身塞进一个新对象,再交给
reactive。所以obj.count这个键对应的值,确实是一个 ref。 - obj.count // 0:在 Vue 真实源码里读出来是
0,不是那个 ref 对象 —— 因为 Vue 的reactive也有自动脱 ref 的能力(读到 ref 就自动返回它的 value)。⚠️ 提醒一句:本章第 5 章实现的那个迷你reactive并没有这个能力,所以在本章的代码上跑,obj.count拿到的会是那个 ref 本身,得写.value。这属于 Vue 源码层面的额外优化,本章不展开。
这里 obj.count 本来应该是个 ref(因为我们塞进去的就是 ref),但因为自动脱 ref 的存在,无须通过 .value 就能拿到值。
为什么这么设计?因为大部分场景下用户并不需要关心一个值到底是不是 ref。如果模板里写 {{ obj.count }},没人希望还得写 {{ obj.count.value }}。有了自动脱 ref,用户在模板里随便写,怎么都对。
结论:proxyRefs 是 setup 返回值的“翻译机”, 让 ref 自动脱掉 .value 外衣,用户完全无感。
四个函数放在一起看
| 函数 | 它处理什么 | 返回什么 | 读写特点 | 解决什么问题 |
|---|---|---|---|---|
ref(val) | 一个原始值 | { value: val } 经 reactive 包装后的代理,带 __v_isRef | 读和写都通过 .value | 让原始值有响应 |
toRef(obj, key) | 响应式对象的一个属性 | 一个带 getter 的 wrapper(也带 __v_isRef) | 读走 getter;写要另外加 setter | 修复 { ...obj } 带来的响应丢失 |
toRefs(obj) | 响应式对象的全部第一层属性 | 一个新对象,每个字段值都是 ref | 每个字段的行为跟它内部的 toRef 一样 | 批量修复响应丢失 |
proxyRefs(target) | 字段值可能都是 ref 的对象 | 这个对象的一个 Proxy 代理 | 读遇到 ref 返回 value;写遇到 ref 写到 value 上 | 让用户读、写都不用写 .value |
6.4 总结
这一章我们聊了原始值的响应式方案。回顾一下三件事:
- 6.1 ref:因为 Proxy 不能代理原始值,所以我们用
{ value: x }这种“包裹对象”间接实现原始值的响应式。__v_isRef是 ref 的“身份证”,用来区分 ref 和普通 reactive 对象。 - 6.2 响应丢失:
...obj展开后得到的是普通对象,丢掉了响应联系。修法是toRef/toRefs,给每个属性加个 getter(必要时加 setter),间接代理回原始响应式数据。 - 6.3 自动脱 ref:为了不让用户写
foo.value,我们用 Proxy +__v_isRef标识实现了proxyRefs,自动把 ref 翻译成它的 value。setup 返回的对象就是被它处理过的。
回看本章这一路,其实是四步走:
- Proxy 不能代理原始值 → 那就装箱:
ref把原始值装进{ value: x }这个盒子里。 - 盒子太多、名字太乱 → 那就统一盒子:造一个
ref函数替用户造盒子,并给盒子盖一个__v_isRef的章。 - 展开时响应丢了 → 那就不搬数值、只搭通道:
toRef/toRefs给每个属性接一条“回源头的取货通道”。 - 通道上要写
.value太麻烦 → 那就自动脱:用proxyRefs帮用户把.value翻译掉。
学完这一篇(第 4、5、6 章),你就掌握了 Vue 3 响应式系统的完整原理。后续如果想看 Vue 3 真实实现,记住我们这一篇始终贴近真实源码。Vue 3 响应式会有一些进一步的优化(缓存、Symbol 化的内部 key、scheduler 队列……),但原理都是这三章讲的那些。
本章小结
- 原始值(boolean / number / bigint / string / symbol / undefined / null)是 JS 里“最简单”的 7 种值,按值传递;而非原始值(对象 / 数组 / Set / Map / 函数)是按引用传递的。
- Proxy 的代理目标必须是非原始值 —— 这是语言层面的硬规定,也是原始值需要特殊处理的根本原因。钩子不是“不够用”,而是根本没地方装。
- 包装 / 装箱(boxing):原始值没法被代理,就把它装进一个对象当壳,再对壳套 Proxy。
ref里的{ value: x }就是这个壳。 ref={ value: 原始值 }这个包裹对象 +reactive包裹 +__v_isRef标识。ref(1)拿到的不是数字 1,而是一个装着 1 的对象,.value才是取值的门。__v_isRef是 ref 的“身份证”(一个值为true的不可枚举且不可写属性),让 Vue 能区分 ref 和普通 reactive 对象。- 响应丢失 = 用
...obj展开响应式对象后,得到的是普通值快照,跟原始数据断了联系。解构赋值(const { foo } = obj)这类“取出”操作是同一类问题。 toRef/toRefs给每个属性接上一个带getter的通道,不搬数值、只搭通道,从而修复响应丢失。它们也把第一层属性转成了 ref(浅层响应的视角)。toRef默认只读(只有 getter 没有 setter),要可写必须给 wrapper 加上 setter:set value(val) { obj[key] = val }。proxyRefs= 自动脱 ref 的工具函数,用__v_isRef标识判断:读属性遇到 ref 就返回value,写属性遇到 ref 就写到value。- setup 函数返回的对象会被
proxyRefs处理,所以模板里{{ count }}能直接拿到 ref 的值,不用.value。 reactive({ ref })在 Vue 真实源码里也有自动脱 ref 能力,obj.count不写.value也能拿到值。目的都是减轻用户的心智负担。
