Vue.js响应式系统核心原理
Vue 2 给每个属性装 getter/setter,数组靠换原型拦住七个方法,新增属性只能用 Vue.set 补一次。Vue 3 换成 Proxy 之后,哪些限制消失了,哪些还在。
三次修改,Vue 2 一次都不响应
下面这段在完整版 Vue 2 的控制台里直接能跑(DevTools 支持顶层 await):
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,cJohn-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」:
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 的构造函数分成两条路,走哪条只看值是不是数组:
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 响应式最核心的一个函数,一共装了两个访问器:
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 是怎么触发更新的?答案在一个只有三十行的文件里:
const arrayProto = Array.prototypeexport 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 则是真的
做不到,在控制台里一行就能验:
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)把这一步手动补上:
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) 上,读取那一支是这样:
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 这类方法是为了
在执行期间暂停依赖收集:
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:
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 里的结果:
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,cstate.list[0] = 'x'; // 30 / x,b,cstate.list.length = 1; // 30 / x三次全都触发了。Vue 2 里要写 Vue.set 才成立的两件事,这里是普通赋值的副产物。
ref 需要写 .value,因为 Proxy 代理不了一个数字
new Proxy(...) 的第一个参数必须是对象。想让一个数字、一个字符串也能被追踪,
只有一条路:把它装进一个对象,再拦这个对象的一个属性。这个对象就是 RefImpl,
那个属性就叫 value:
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 是另一种东西,它们靠对象身上的两个隐藏字段工作:
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 = 1count 拿到的是 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 依赖收集与追踪机制深度剖析。