Vue.js响应式系统核心原理

Vue 2 给每个属性装 getter/setter,数组靠换原型拦住七个方法,新增属性只能用 Vue.set 补一次。Vue 3 换成 Proxy 之后,哪些限制消失了,哪些还在。

位置
第 03 篇 / 共 12 篇
预计
21 分钟

三次修改,Vue 2 一次都不响应

下面这段在完整版 Vue 2 的控制台里直接能跑(DevTools 支持顶层 await):

浏览器控制台 · 完整版 Vue 2
const vm = new Vue({
template: '<div>{{ user.name }}-{{ user.age }} / {{ list.join() }}</div>',
data: { user: { name: 'John' }, list: ['a', 'b', 'c'] }
}).$mount();
document.body.appendChild(vm.$el);
vm.user.age = 30; // 给对象加一个新属性
vm.list[0] = 'x'; // 按下标改一个元素
vm.list.length = 1; // 改数组长度
await Vue.nextTick();
console.log(vm.$el.textContent);
vm.list.push('d'); // 换一个方法改,别的什么都没动
await Vue.nextTick();
console.log(vm.$el.textContent);

两次输出:

控制台输出
John- / a,b,c
John-30 / x,d

三次修改全都改到了内存里的数据,界面一动不动。直到 push 那一次,界面才更新 —— 而它一更新,前面三次的结果跟着一起显示出来了:age 变成 30,数组变成 ['x', 'd']。

这说明数据从头到尾就是新的,缺的只是「有人通知」这一步。这篇讲的就是这一步装在哪儿、 哪些位置根本装不上,以及 Vue 3 把它挪到别处之后,上面这段代码的结果变成了什么。

graph LR
A[数据对象] --> B[数据劫持]
B --> C{操作类型}
C -->|读取| D[依赖收集]
C -->|设置| E[派发更新]
D --> F[Watcher队列]
E --> F
F --> G[视图更新]

图上这条链,这篇只负责最左边那一段:数据对象怎么被改造成能拦截读写的东西。 右边「谁在读」「变了通知谁」—— Dep、Watcher 和 Dep.target 那一套 —— 是下一篇的题目,这里只在必须交代的地方点一句。

Vue 2 的源码路径以 2.6.14 为准(2.7 把源码整个换成了 TypeScript,文件同名,后缀变 .ts), Vue 3 的部分标的是 3.5。

observe() 有五个条件,一个不满足就没有响应式

所有响应式的入口都是 observe()。它不是「把对象变成响应式」,而是「决定要不要给这个对象 配一个 Observer」:

Vue 2 · observe(src/core/observer/index.js)
export function observe (value: any, asRootData: ?boolean): Observer | void {
if (!isObject(value) || value instanceof VNode) {
return
}
let ob: Observer | void
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else if (
shouldObserve &&
!isServerRendering() &&
(Array.isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value._isVue
) {
ob = new Observer(value)
}
if (asRootData && ob) {
ob.vmCount++
}
return ob
}

高亮的五行是全部门槛,每一条都对应一类「为什么我的数据不响应」:

  • shouldObserve 是个模块级开关,toggleObserving(false) 能把它关掉。子组件初始化 props 时就关着它 —— 父组件传下来的对象已经是响应式的了,没必要再走一遍。
  • !isServerRendering() :服务端渲染只渲染一次,装了 setter 也没人会去改。
  • Array.isArray(value) || isPlainObject(value) 是最狠的一条。isPlainObject 用 Object.prototype.toString 判 [object Object],所以 Map、Set、Date、 自定义 class 的实例全部落选,进了 data 也只是个普通值。Vue 2 里 new Map() 放进 data 从来就不响应,不是用法问题,是这一行拦掉的。
  • Object.isExtensible(value) :Object.freeze 过的对象在这里被挡下,整棵遍历都省了。 这条被当成性能手段用得最多,第 10 篇专门讲它。
  • !value._isVue :Vue 实例本身不当数据观测。

前面那个 hasOwn(value, '__ob__') 分支同样重要:一个对象只会被观测一次,第二次拿到的是它 身上挂着的那个 __ob__。同一个对象被两个组件放进 data,它们共用一份响应式改造 —— 也共用同一批依赖。

observe() 返回的是 Observer 实例还是 undefined,这个区别后面处处都要用到。

对象逐个 key 装 getter,数组只换原型

Observer 的构造函数分成两条路,走哪条只看值是不是数组:

Vue 2 · Observer(节选)
export class Observer {
constructor (value: any) {
this.value = value
this.dep = new Dep() // 挂在对象/数组本身,不属于任何一个 key
this.vmCount = 0
def(value, '__ob__', this) // def 走 defineProperty,enumerable 为 false
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value)
} else {
this.walk(value)
}
}
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i]) // 只递归元素,从不给下标装 getter
}
}
}

三处值得停一下。

this.dep 是第二个 Dep。 后面 defineReactive 里还会给每个 key 各建一个 Dep, 两者不是一回事:key 的 dep 记「谁读过 obj.list」,Observer 的 dep 记「谁读过 obj.list 里面的东西」。Vue.set 和数组的七个方法全靠后面这一个才能发出通知, 下面两节会把这条线接上。

__ob__ 是用 def 装上去的,不是直接赋值。 def 是 defineProperty 的一行封装, enumerable 默认为 false。这一步不做的话,walk 里的 Object.keys 会把 __ob__ 也 当成一个数据字段遍历进去,JSON.stringify 也会把它印出来,而且它指回自己,直接就是循环引用。

数组这条路完全绕开了 walk。 observeArray 只是对每个元素递归调 observe, 从头到尾没有一次 defineReactive。所以数组的下标上根本没有 getter/setter, 开头那句 vm.list[0] = 'x' 走的是原生的属性赋值,中间没有任何 Vue 的代码。

protoAugment 和 copyAugment 是同一件事的两种做法:把 arrayMethods 塞到数组和 Array.prototype 中间。前者一句 target.__proto__ = src,后者在不支持 __proto__ 的环境里逐个 def 到实例上。arrayMethods 是什么,第五节讲。

getter 收两份依赖,setter 只通知一份

defineReactive 是整个 Vue 2 响应式最核心的一个函数,一共装了两个访问器:

Vue 2 · defineReactive(src/core/observer/index.js)
export function defineReactive (obj, key, val, customSetter, shallow) {
const dep = new Dep()
// 省略:取出 obj 上原有的 get / set 存成 getter / setter;configurable 为 false 直接返回
let childOb = !shallow && observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
if (Array.isArray(value)) {
dependArray(value)
}
}
}
return value
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
// 省略:开发环境下调 customSetter;原本就有 setter 的改走 setter.call
val = newVal
childOb = !shallow && observe(newVal)
dep.notify()
}
})
}

childOb 是这个函数里最容易被读漏的一行。observe(val) 对值本身再走一遍前面那五个条件: 值是对象或数组就返回它的 Observer,是数字、字符串就返回 undefined。整棵对象树的响应式 就是这么递归铺开的 —— 初始化时一层不落地走到底,不管你后来读不读得到。

于是 getter 里出现了两次收集。读一次 vm.user,当前的 watcher 同时被记进两个地方: user 这个 key 的 dep,和 user 那个对象自己的 ob.dep。前者管「vm.user 被整个换掉」, 后者管「user 里面发生了什么」。dependArray 是同一件事在数组上的延伸:读到一个数组时, 数组里每个元素身上的 ob.dep 也各收一份,嵌套数组还要递归下去。

setter 反过来只调了一次 dep.notify(),ob.dep 在这里是沉默的。理由很直白:给 vm.user 整个赋一个新对象,变的是这个 key,跟旧对象内部无关。ob.dep 那一份留着给别人用 —— 用它的正是 Vue.set 和数组的那七个方法。

setter 开头那个判断条件写得有点绕:newVal !== newVal && value !== value 是在判 NaN。 NaN === NaN 为 false,光靠第一个条件的话,反复把一个 NaN 赋给同一个属性会每次都触发更新。 只有 NaN 自己不等于自己,这一句就是拿这个特性把它挑出来。

shallow 这个参数把 childOb 整条路关掉,只保留最外层的一个 getter/setter。Vue 2 内部 就在用它 —— initRender 里的 $attrs 和 $listeners 都是 defineReactive(vm, ..., null, true) 装上去的。对外的 shallowReactive 和 shallowRef 则是 2.7 跟着组合式 API 一起 backport 回来的,这两个 API 不是 Vue 3 独有的。

被换掉的是七个方法,下标和 length 不在里面

数组没有 getter,那 push 是怎么触发更新的?答案在一个只有三十行的文件里:

Vue 2 · src/core/observer/array.js
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
const methodsToPatch = [
'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'
]
methodsToPatch.forEach(function (method) {
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) {
const result = original.apply(this, args)
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
if (inserted) ob.observeArray(inserted)
// notify change
ob.dep.notify()
return result
})
})

arrayMethods 是一个以 Array.prototype 为原型的空对象,上面挂着七个同名函数。 protoAugment 把它插进响应式数组的原型链,于是 list.push(...) 查到的是这里的 mutator,mutator 再用 original.apply 把活交回原生实现。原型链上多垫了一层, 数组本身一个字节都没改。

const ob = this.__ob__ 这一行说明了为什么 __ob__ 非挂在数组身上不可:mutator 里 除了 this,没有任何别的东西能指回那个 Observer。而它最后调的 ob.dep.notify(), 通知的正是上一节 getter 里 childOb.dep.depend() 收进去的那批 watcher。 读数组的时候记在 ob.dep 上,改数组的时候从 ob.dep 发出去 —— 这条路和 key 上那个 dep 完全平行,互不相干。

inserted 那个 switch 处理的是另一件事:新塞进数组的元素还是生数据,得补一次 observeArray。push 和 unshift 的参数全是新元素,splice 从第三个参数起才是, pop / shift / sort / reverse 不新增元素,一个都不用管。

七个之外的写法拦不住。下标赋值前面说过了 —— 那些位置上压根没装访问器。length 则是真的 做不到,在控制台里一行就能验:

浏览器控制台 · length 为什么拦不住
Object.getOwnPropertyDescriptor([], 'length')
// { value: 0, writable: true, enumerable: false, configurable: false }
Object.defineProperty([], 'length', { get () { return 0 } })
// TypeError: Cannot redefine property: length

数组的 length 是 configurable: false 的,defineProperty 改不成访问器,任何基于 defineProperty 的方案都到此为止。下标那一条则是选择的结果:给一个十万项的数组的每个下标 都装一对 getter/setter,代价摆在那里,而且装完还是拦不住 length,两个漏洞只堵得住一个。 Vue 2 的官方文档把这两条一起归给「JavaScript 的限制」,写清楚了它们不会被侦测到。

Vue.set 干的就是补装 getter,然后敲响 ob.dep

新增属性没有 setter,谁来发通知?Vue.set(实例上叫 this.$set)把这一步手动补上:

Vue 2 · set(src/core/observer/index.js)
export function set (target: Array<any> | Object, key: any, val: any): any {
// 省略:target 是 undefined 或原始值时的开发环境警告
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key)
target.splice(key, 1, val)
return val
}
if (key in target && !(key in Object.prototype)) {
target[key] = val
return val
}
const ob = (target: any).__ob__
if (target._isVue || (ob && ob.vmCount)) {
// 省略:开发环境警告「不要往实例或根 $data 上加属性」
return val
}
if (!ob) {
target[key] = val
return val
}
defineReactive(ob.value, key, val)
ob.dep.notify()
return val
}

四条分支,从上到下:

数组走 splice。 Vue.set(arr, 0, 'x') 并没有什么特别的数组魔术,它就是调了上一节那七个 被换掉的方法里的一个。target.length = Math.max(...) 那句是给「下标超出当前长度」兜底 —— 原生 splice 在越界的下标上会把元素插到末尾,先把长度撑开才能插到正确的位置。

已有的 key 直接赋值。 已经有 key 就说明它已经有 setter 了,赋值本身就会触发。 后半句 !(key in Object.prototype) 是在挡 toString、hasOwnProperty 这些从原型链上继承来 的名字:'toString' in obj 为真,但它并不是这个对象自己的属性,当成「已有 key」处理就错了。

target._isVue || (ob && ob.vmCount) 直接拒绝。 vmCount 就是第二节 observe() 末尾那个 asRootData 累加上去的计数,大于零意味着这个对象是某个组件的根 $data。 拒绝的理由不只是「应该在 data 选项里声明完」:根 $data 上的每个 key 在 initData 时 被 proxy(vm, '_data', key) 转发到了实例上,运行时新加的那个 key 没人给它补这层转发, 就算真的加成了响应式,this.x 依然读不到,只有 this.$data.x 能读到。

最后两行才是这个 API 存在的全部理由。 defineReactive 给新 key 补一个 getter/setter, ob.dep.notify() 用值本身那个 dep 把消息发出去。收到消息的,正是当初读 vm.user 时 被 childOb.dep.depend() 记下来的那些 watcher。三节前埋的线在这里接上了:getter 里 多收的那一份依赖,就是为了让「加一个新属性」这件事有地方可通知。

if (!ob) 那一支说的是:目标压根不是响应式对象,那就退化成普通赋值,一声不吭。所以 Vue.set 用在一个不在 data 里的普通对象上不会报错,也什么都不会发生。

Vue.delete 是对称的另一半,删属性同样绕不过去:delete obj.key 触发不了任何 setter。 它的实现里数组那一支同样是 splice,普通对象那一支是 delete target[key] 加一句 ob.dep.notify()。这两个 API 在 Vue 3 里都不存在了,迁移指南给的理由只有一句: 基于 proxy 的变更侦测不再需要它们。

Vue 3 把拦截从属性挪到了整个对象

graph TB
subgraph "Vue 2.x - Object.defineProperty"
A1[数据对象] --> B1[遍历所有属性]
B1 --> C1[defineProperty劫持]
C1 --> D1[Dep依赖收集]
D1 --> E1[Watcher更新]
F1[限制]
F1 --> G1[无法监听新增属性]
F1 --> H1[无法监听数组索引]
F1 --> I1[深层对象需要递归]
end
subgraph "Vue 3.x - Proxy"
A2[数据对象] --> B2[Proxy代理]
B2 --> C2[自动拦截所有操作]
C2 --> D2[WeakMap存储依赖]
D2 --> E2[Effect副作用更新]
F2[优势]
F2 --> G2[可以监听新增属性]
F2 --> H2[可以监听数组变化]
F2 --> I2[惰性深层响应式]
end

图上左右两栏的分界只有一句话:Object.defineProperty 拦的是一个已经存在的属性, Proxy 拦的是对一个对象的操作。属性还不存在的时候,前者无处下手,后者照样收得到。

reactive() 最后落在 new Proxy(target, baseHandlers) 上,读取那一支是这样:

Vue 3.5 · packages/reactivity/src/baseHandlers.ts · get(节选)
get(target: Target, key: string | symbol, receiver: object): any {
// 省略:ReactiveFlags 的几个内部 key(IS_REACTIVE / IS_READONLY / RAW …)直接作答
const targetIsArray = isArray(target)
if (!isReadonly) {
let fn: Function | undefined
if (targetIsArray && (fn = arrayInstrumentations[key])) {
return fn
}
if (key === 'hasOwnProperty') {
return hasOwnProperty
}
}
const res = Reflect.get(target, key, isRef(target) ? target : receiver)
if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
return res
}
if (!isReadonly) {
track(target, TrackOpTypes.GET, key)
}
if (isShallow) {
return res
}
if (isRef(res)) {
return targetIsArray && isIntegerKey(key) ? res : res.value
}
if (isObject(res)) {
return isReadonly ? readonly(res) : reactive(res)
}
return res
}

五个高亮行是五件事。

Reflect.get(target, key, receiver) 而不是 target[key]。 差别在 receiver: 对象上有 get fullName () { return this.first + this.last } 这种访问器时, target[key] 会让访问器里的 this 指向原始对象,于是 this.first 绕过了代理, 读不到就不会被收集。传 receiver 把 this 保持在 proxy 上,访问器内部读的每一个字段 都照常经过 get 陷阱。Reflect 在这里不是「更现代的写法」,是这一个参数。

track 在返回值之后才调。 收集的动作和 Vue 2 的 dep.depend() 位置一样, 只是依赖表从「每个 key 一个 Dep 对象」换成了模块级的一张 WeakMap: target → key → 依赖集合。用 WeakMap 是因为 key 是原始对象,对象被回收时这一条也跟着消失。

深层响应式发生在读取的那一刻。 isObject(res) 成立才 reactive(res),没读到的层级 永远不会被转换。Vue 2 是初始化时递归到底,Vue 3 是访问到哪一层转哪一层 —— 同一份大对象, 开销落在不同时刻,而且没访问到的部分根本不付这笔钱。转出来的 proxy 存在 reactiveMap 里, 同一个对象读两次拿到的是同一个 proxy。

isRef(res) 那两行就是 ref 的自动解包。 把 ref 放进 reactive 对象里, 读出来的直接是值而不是 Ref,这一行就是全部实现。后半句是个例外:数组的整数下标不解包, arr[0] 拿到的还是 Ref 本身。

数组仍然有一套 instrumentation。 但它和 Vue 2 那七个方法的目的完全不同。 Vue 3 的 Proxy 本来就拦得住下标和 length,这里改写 push / pop 这类方法是为了 在执行期间暂停依赖收集:

Vue 3.5 · arrayInstrumentations.ts · noTracking
function noTracking (self, method, args = []) {
pauseTracking()
startBatch()
const res = (toRaw(self) as any)[method].apply(self, args)
endBatch()
resetTracking()
return res
}

push 内部会先读 length 再写 length。在一个 effect 里 arr.push(x),不暂停的话 这个 effect 会把 length 收成自己的依赖,紧接着自己写 length 又把自己触发一遍 —— 死循环。includes / indexOf 那几个则走另一条路,因为数组里存的可能是 proxy 而参数是原始对象, 得两边都找一次。3.5 把这张表从七八个扩到了二十多个,map / filter / forEach 和迭代器 都进来了,为的是让回调里拿到的元素类型一致。

写入那一侧短得多,关键是 hadKey:

Vue 3.5 · baseHandlers.ts · set(节选)
set (target, key, value, receiver): boolean {
let oldValue = target[key]
// 省略:非 shallow 时把新旧值拆到 raw;旧值是 ref 而新值不是时,改写 ref.value
const hadKey = isArray(target) && isIntegerKey(key)
? Number(key) < target.length
: hasOwn(target, key)
const result = Reflect.set(target, key, value, isRef(target) ? target : receiver)
if (target === toRaw(receiver)) {
if (!hadKey) {
trigger(target, TriggerOpTypes.ADD, key, value)
} else if (hasChanged(value, oldValue)) {
trigger(target, TriggerOpTypes.SET, key, value, oldValue)
}
}
return result
}

ADD 和 SET 分开,是 Vue.set 消失的直接原因:新增属性在这里是一个有名有姓的操作类型, 不需要外部 API 来代劳。deleteProperty 陷阱对应 DELETE,Vue.delete 也就跟着不需要了。 has 陷阱让 key in obj 也能收集依赖,ownKeys 陷阱让 for...in 和 Object.keys 能收集 —— 这两件事 Vue 2 一件都做不到,因为它拦的是具体的 key,而 in 和 for...in 不针对任何具体的 key。

Map 和 Set 走的是另一组 handler。reactive 内部用 targetTypeMap 分流, Object / Array 用上面这套,Map / Set / WeakMap / WeakSet 用 collectionHandlers, 其余类型返回 INVALID,reactive() 原样把对象还给你。所以 reactive(new Date()) 拿回来的还是那个 Date,不是 proxy。

回到开头那段代码,同样三次修改在 Vue 3 里的结果:

浏览器控制台 · Vue 3 全局构建(vue.global.js)
const { reactive, effect } = Vue;
const state = reactive({ user: { name: 'John' }, list: ['a', 'b', 'c'] });
effect(() => console.log(`${state.user.age} / ${state.list.join()}`));
// undefined / a,b,c
state.user.age = 30; // 30 / a,b,c
state.list[0] = 'x'; // 30 / x,b,c
state.list.length = 1; // 30 / x

三次全都触发了。Vue 2 里要写 Vue.set 才成立的两件事,这里是普通赋值的副产物。

ref 需要写 .value,因为 Proxy 代理不了一个数字

new Proxy(...) 的第一个参数必须是对象。想让一个数字、一个字符串也能被追踪, 只有一条路:把它装进一个对象,再拦这个对象的一个属性。这个对象就是 RefImpl, 那个属性就叫 value:

Vue 3.5 · packages/reactivity/src/ref.ts · RefImpl(节选)
class RefImpl<T = any> {
_value: T
private _rawValue: T
dep: Dep = new Dep()
public readonly [ReactiveFlags.IS_REF] = true
constructor (value: T, isShallow: boolean) {
this._rawValue = isShallow ? value : toRaw(value)
this._value = isShallow ? value : toReactive(value)
this[ReactiveFlags.IS_SHALLOW] = isShallow
}
get value () {
this.dep.track()
return this._value
}
set value (newValue) {
const oldValue = this._rawValue
const useDirectValue =
this[ReactiveFlags.IS_SHALLOW] || isShallow(newValue) || isReadonly(newValue)
newValue = useDirectValue ? newValue : toRaw(newValue)
if (hasChanged(newValue, oldValue)) {
this._rawValue = newValue
this._value = useDirectValue ? newValue : toReactive(newValue)
this.dep.trigger()
}
}
}

拆开看,ref 用的还是访问器,和 Vue 2 的 defineReactive 是同一种手法 —— 只不过它只需要拦一个固定的名字,不必遍历,也不会漏掉新增的 key(ref 根本没有别的 key)。 .value 不是设计上的取舍,是 Proxy 用不上时唯一剩下的办法。

toReactive 是「是对象就 reactive(),不是就原样返回」的一行工具。所以 ref({ a: 1 }) 的 .value 拿到的是一个 proxy,ref(1) 的 .value 就是数字 1。 ref 和 reactive 的关系在这里:前者包着后者,多了一层能装原始值的壳。

_rawValue 和 _value 分开存,是为了让 hasChanged 比的是原始对象。 拿新的原始对象和存着的 proxy 比,永远不相等,每次赋值都会白白触发一轮更新。

模板里写 {{ count }} 不用写 .value,靠的是 setup() 返回值外面那层 proxyRefs; reactive 对象里的 ref 不用写 .value,靠的是上一节 get 陷阱里 isRef(res) 那两行。 两处解包是两套不同的机制,都不属于 RefImpl 自己。

shallow / readonly / markRaw 各拦哪一层

reactive 之外那几个 API,区别只在「拦到哪一层为止」和「拦下来之后做什么」:

API get 里收集依赖 set 里派发更新 深层是否继续代理
reactive 收 发 读到哪层转哪层
shallowReactive 只收最外层 只发最外层 不转,里面是原始对象
readonly 不收 不写,开发环境下警告 深层继续包 readonly
shallowReadonly 不收 不写 不包
markRaw 标记过的 —— —— reactive() 原样返回它

前四行在源码里就是同一个 createReactiveObject 换四组 handler,_isReadonly 和 _isShallow 两个布尔量组合出四种行为 —— 上一节 get 陷阱里那几个 if (!isReadonly) 和 if (isShallow) 就是它们的落点。readonly 不收集依赖的理由很直接:改不了的东西不会变, 记下谁在读它没有意义。

markRaw 和 toRaw 是另一种东西,它们靠对象身上的两个隐藏字段工作:

Vue 3.5 · packages/reactivity/src/reactive.ts
export function toRaw<T> (observed: T): T {
const raw = observed && (observed as Target)[ReactiveFlags.RAW]
return raw ? toRaw(raw) : observed
}
export function markRaw<T extends object> (value: T): Raw<T> {
if (!hasOwn(value, ReactiveFlags.SKIP) && Object.isExtensible(value)) {
def(value, ReactiveFlags.SKIP, true)
}
return value
}

ReactiveFlags 是一组字符串常量:RAW 是 '__v_raw',SKIP 是 '__v_skip', 另外还有 IS_REACTIVE、IS_READONLY、IS_SHALLOW、IS_REF。这些 key 在 get 陷阱的 最前面被单独作答,永远走不到 Reflect.get。

toRaw 的实现就是读一次 proxy.__v_raw —— 而这个 key 的答案是 get 陷阱现给的, 不是存在对象上的字段。递归那一句是为了穿透多层包装(readonly(reactive(x)))。 markRaw 反过来,把 __v_skip 用 def 写进对象自己身上,getTargetType 见到它就 判 INVALID,reactive() 原样返回。第三方类的实例、地图对象、大块的只读数据, 都是它的用处 —— 代价是这个标记写进了你的对象自己身上,之后再对它调 reactive() 也只会原样返回。

markRaw 那个 Object.isExtensible 的判断顺带说明了一件事:冻结过的对象连标记都不用打, getTargetType 那一句里就已经把它判成 INVALID 了。

Proxy 没有解决的那件事:解构

Vue 3 的响应式建在 proxy 上,而 proxy 只在「有人对着它做操作」的时候才起作用。 一旦某个值离开了 proxy,链条就断了:

浏览器控制台 · 解构之后就断了
const { reactive, effect, toRefs } = Vue;
const state = reactive({ count: 0, user: { name: 'John' } });
effect(() => console.log(`count = ${state.count}`));
// count = 0
let { count } = state; // count 是一个数字副本,和 state 没有任何关系了
count++; // effect 不重跑,state.count 还是 0
const { count: countRef } = toRefs(state);
countRef.value++; // count = 1

count 拿到的是 Reflect.get 返回的那个数字,复制出去之后再没有任何东西连着 proxy。 toRefs 的做法是给每个 key 造一个 ObjectRefImpl,它的 get value() 每次都回头读 object[key] —— 解构出来的是一堆带着来源的对象,而不是一堆值。

这条限制两代都有,而且性质不同。 Vue 2 的那些限制(新增属性、数组下标、Map)是 Object.defineProperty 的能力边界,换掉拦截手段就消失了;解构这一条是 JavaScript 值语义 本身决定的,Proxy 也拦不住一次赋值把值复制走。所以 props 要用 toRefs,组合式函数返回 一组 ref 而不是一个响应式对象 —— 这些写法都是在绕同一个东西。

state.user 那一支倒是例外:解构出来的是 get 陷阱里 reactive(res) 造的 proxy, 改它的字段照样触发。同一段解构,值类型的 key 断了,对象类型的 key 没断 —— 这种不对称正是它比 Vue 2 那些限制更难查的原因,没有警告,没有报错,只有半边不动。

到这里,数据这一侧已经全部铺完:哪些对象会被拦截、拦在哪个位置、改动怎么发出通知。 剩下的另一半是收通知的那一头 —— dep.depend() 到底把什么记了下来、Dep.target 在什么时刻被设上、一个组件的渲染函数怎么变成一个 watcher。那是下一篇的题目。

这篇是 Vue.js 内部机制深度解析的第 3 篇。前一篇是 Vue.js 整体架构与设计理念,后一篇是 Vue.js 依赖收集与追踪机制深度剖析。