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[视图更新]

图上这条链,这篇只负责最左边那一段:数据对象怎么被改造成能拦截读写的东西。 右边「谁在读」「变了通知谁」—— DepWatcherDep.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) 是最狠的一条。isPlainObjectObject.prototype.toString[object Object],所以 MapSetDate、 自定义 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」,Observerdep 记「谁读过 obj.list 里面的东西」。Vue.set 和数组的七个方法全靠后面这一个才能发出通知, 下面两节会把这条线接上。

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

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

protoAugmentcopyAugment 是同一件事的两种做法:把 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 是在判 NaNNaN === NaNfalse,光靠第一个条件的话,反复把一个 NaN 赋给同一个属性会每次都触发更新。 只有 NaN 自己不等于自己,这一句就是拿这个特性把它挑出来。

shallow 这个参数把 childOb 整条路关掉,只保留最外层的一个 getter/setter。Vue 2 内部 就在用它 —— initRender 里的 $attrs$listeners 都是 defineReactive(vm, ..., null, true) 装上去的。对外的 shallowReactiveshallowRef 则是 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(...) 查到的是这里的 mutatormutator 再用 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 处理的是另一件事:新塞进数组的元素还是生数据,得补一次 observeArraypushunshift 的参数全是新元素,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

数组的 lengthconfigurable: 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) 是在挡 toStringhasOwnProperty 这些从原型链上继承来 的名字:'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 绕过了代理, 读不到就不会被收集。传 receiverthis 保持在 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
}

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

MapSet 走的是另一组 handler。reactive 内部用 targetTypeMap 分流, Object / Array 用上面这套,Map / Set / WeakMap / WeakSetcollectionHandlers, 其余类型返回 INVALIDreactive() 原样把对象还给你。所以 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。 refreactive 的关系在这里:前者包着后者,多了一层能装原始值的壳。

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

模板里写 {{ count }} 不用写 .value,靠的是 setup() 返回值外面那层 proxyRefsreactive 对象里的 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 不收集依赖的理由很直接:改不了的东西不会变, 记下谁在读它没有意义。

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

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_REACTIVEIS_READONLYIS_SHALLOWIS_REF。这些 key 在 get 陷阱的 最前面被单独作答,永远走不到 Reflect.get

toRaw 的实现就是读一次 proxy.__v_raw —— 而这个 key 的答案是 get 陷阱现给的, 不是存在对象上的字段。递归那一句是为了穿透多层包装(readonly(reactive(x)))。 markRaw 反过来,把 __v_skipdef 写进对象自己身上,getTargetType 见到它就 判 INVALIDreactive() 原样返回。第三方类的实例、地图对象、大块的只读数据, 都是它的用处 —— 代价是这个标记写进了你的对象自己身上,之后再对它调 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 依赖收集与追踪机制深度剖析