Vue-next Reactivity 响应式系统探秘

本贴最后更新于 1910 天前,其中的信息可能已经时过境迁

Vue-next Reactivity 模块探秘

Vue-next 中的 Reactivity 是一个可独立使用的模块,预计会发布为 @vue/reacitivity,含有诸如 reactiverefeffectcomputed 等新的 Composition API。

它主要负责的是 Vue 的响应式数据系统,用于替代 Vue 2.x 中 Object.defineProperty 方案,改用 ES6 的 Proxy,并且采用了新的依赖收集方式。

具体有什么不一样呢?让我们一起来看看吧!

这篇文章的源代码仓库请戳这里呀!基本是按照 vue-next/packages/reactivity 中的结构。

Reactive 篇

既然我们要 「单测驱动」来阅读源码,那么看看我从源码中选取的这部分相应的几则单测

// 让简单的对象 变为 响应式 test('reacitive for plain value: ', () => { const original = { a: 123, b: 'this is just a string', c: false } const observed = reactive(original) expect(observed).not.toBe(original) expect(isReactive(original)).toBe(false) expect(isReactive(observed)).toBe(true) // get expect(observed.a).toBe(123) // has expect('b' in observed).toBe(true) // ownKeys expect(Object.keys(observed)).toEqual(['a', 'b', 'c']) })

要可以使得一个 JavaScript 的简单对象被转为「响应式」对象,可以看到源码当中还开放了一个 isReactive 供给我们来断言某对象是否为响应式。

那么我们来看看 reactive() 是怎么实现的:

export function reactive<T extends object>(target: T): UnwrapNestedRefs<T> export function reactive(target: object) { // 如果只读表中已经有了他,就直接返回该 只读型Proxy if (readonlyToRaw.has(target)) { return target } // 目标对象已经被显示标记为了只读 if (readonlyValues.has(target)) { return readonly(target) } return createReactivityObject( target, rawToReactive, reactiveToRaw, mutableHandlers ) }

这里使用了两个 WeakMap,分别是 readonlyToRawreadonlyValues,主要涉及 「只读型」 值的管理,和转换成为响应式的主要逻辑不相关,故可暂时跳过。

主要逻辑还是在这个 createReactivityObject 中,来看看他函数签名中的参数类型:

export function createReactivityObject( target: any, toProxy: WeakMap<any, any>, toRaw: WeakMap<any, any>, baseHandlers: ProxyHandler<any>, collectionHandlers: ProxyHandler<any> )
  • target 直译过来就是「目标」,意为挂载响应式监听的目标,也就是「源对象」
  • toProxytoRaw 是两个 WeakMap,分别对应的是 <源对象, 代理对象><代理对象, 源对象>
  • baseHandlers 即 我们平常创建 Proxy 时配置的 handler,如果你对它还不熟悉,我给你指路 MDN
  • collectionHandlers 其专门对应那些对 「集合类型」值创建 Proxy 代理时的 Handler

这就保证了将创建「可变型」与「只读型」响应式对象的两种逻辑抽象成为一个。

{ if (!isObject(target)) { console.warn(`not an 'Object' type value, can not be observered: ${String(target)}`) return target } // 如果 target 已经转过 Proxy 了(怎么判断?就是靠我们的 toProxy K-V 中的 V) let observed = toProxy.get(target) if (observed !== void 0) { return observed } // target 本身就是个 Proxy(怎么判断?就是靠我们的 toRaw K-V 中的 K) if (toRaw.has(target)) { return target } // 检测 target 是否是可以观察(监听)的 if (!canObserve(target)) { return target } const handlers = collectionTypes.has(target.constructor) ? collectionHandlers : baseHandlers observed = new Proxy(target, baseHandlers) toProxy.set(target, observed) toRaw.set(observed, target) return observed }

首先 target 如果不是对象这样的「引用类型」,而是简单的 number | string | boolean ... 这些「值类型」,那么我们就不挂载响应式了。

shared 这个文件夹中存放了很多项目中的「公用方法」,都是一些 JavaScript 或 Typescript 中的精妙技巧,很多甚至值得我们背会、熟用,运用到自己的项目中。

// 判断某值是否为 Object 类型 export const isObject = (val: unknown): val is Record<any, any> => val !== null && typeof val === 'object'

另外,如果一个对象是「不可观察」 的,我们也只返回源对象 target

// 判断是否是可观察的对象 const canObserve = (value: any): boolean => { return ( !value._isAux && !value._isANode && isObservableType(toRawType(value)) && !nonReactiveValues.has(value) ) }

如果所有的记录中都找不到,预检查也都通过了,那么我们就对此 target 新建一个 Proxy,并在两个 WeakMap 中记录下对应关系再返回此代理对象。

所以所谓的「响应式对象」其实就是 Handlers 中编写了所需操作的 Proxy 对象。

还记得 Vue 2.x 中是使用 Object.defineProperty 来覆写属性的 get / set 方法的么?曾经想要做劫持需要这么写:

let apple = {} let val = 3000 Object.defineProperty(apple, 'price', { enumerable: true, configurable: true, get(){ console.log('apple 属性被读取') return val }, set(newVal){ console.log('apple 属性被修改') val = newVal } })

但不足就是对诸如 Array 这类的对象劫持效果并不理想,所以 Vue 2.x 选择重写数组类原型上的一些常用方法,实现依赖收集、追踪和触发。

然而 Proxy 的 Handlers 比单单这两个 get / set 强大得多,可以劫持 13 种不同类型的操作,在第二则单测中,我们测试了「对数组使用 reactive 是否有效果」:

// 让数组变为响应式 test('reacitive for Array: ', () => { const original = [{ foo: 1 }] const observed = reactive(original) expect(observed).not.toBe(original) expect(isReactive(observed)).toBe(true) expect(isReactive(original)).toBe(false) expect(isReactive(observed[0])).toBe(true) // get expect(observed[0].foo).toBe(1) // has expect(0 in observed).toBe(true) // ownKeys expect(Object.keys(observed)).toEqual(['0']) })

那么要探究根据,我们必须看看在 reactive()readonly() 中传入的 mutableHandlersreadonlyHandlers 中都是怎么拦截的!

BaseHandler · 基本句柄

Get · 劫持对象的属性获取活动:

function createGetter(isReadonly: boolean = false) { // receiver 即是被创建出来的代理对象 return function get(target: object, key: string | symbol, receiver: object) { // 用 Reflect 获取原始数据的相应值 let res = Reflect.get(target, key, receiver) // 如果是js的内置方法,不做依赖收集 if (isSymbol(key) && builtInSymbols.has(key)) { return res } // 如果是 Ref 型的值,返回 .value if (isRef(res)) { return res.value } // 收集依赖 track(target, TrackOpTypes.GET, key) return isObject(res) ? isReadonly ? // 懒挂载依赖,避免出现循环依赖,同时提高性能 readonly(res) : reactive(res) : res } }

如果你还不太清楚 Reflect 我继续指路 MDN 相关部分 ,总之一般 Reflect 就是和 Proxy 想对应的一个只有方法的类,一般 Reflect 就是做一些对源对象的操作。

各个部分注释都很详细了,但是最后一段的那个「懒挂载」我还是要重点拿出来讲一下。我们都知道 Vue 3 的核心性能真的提高很多,懒加载是两个功臣之一。

如果你没有读过 2.x 的响应式原理解析,我建议你还是[在这里面读一下](https://nlrx-wjc.github.io/Learn-Vue-Source-Code/reactive/object.html#_2-%E4%BD%BFobject%E6%95%B0%E6%8D%AE%E5%8F%98%E5%BE%97%E2%80%9C%E5%8F%AF%E8%A7%82%E6%B5%8B%E2%80%9D(这些不是我写的,如果可以还是请支持人家博主,给个 Star 吧。)

如果你读过了这些 Vue 2.x 的源码教程,使用 Option API 在 data() 中定义某个对象时:

export default { data() { return { example: { a: 188, b: 'Someone Like You', c: false } } } }

要知道 vue-loader 在加载单文件组件(就 .vue 文件中这个导出的对象),是会执行一次 Vue 的构造函数,对 data() 函数返回的这个对象执行 walk

在经过 defineReactive 之后, example 的三个属性都会挂上依赖,其实这样的依赖收集是比较冗余的,Vue 的项目当中不乏一些不需要在视图中展现,甚至不需要响应式的数据,所以对此做优化是很有必要的!

track(target, TrackOpTypes.GET, key)

track 是之后要讲的 effect.ts 中导出的 API,在这里你现在只需要理解它执行后的结果就是将 target[key] 推进了一个依赖栈,有点像原来 2.x 的 Dep

2.x 源码中对于 nested object 即复杂嵌套对象的情况,解决方式是递归 defineReactive ,这里也是一样的思路,如果 target[key] 仍是 object 类型,那么继续使用 reactive 转换为响应式。

Set · 劫持属性的赋值:

function createSetter(isReadonly: boolean = false) { return function set( target: any, key: string | symbol, value: any, receiver: any ): boolean { if (isReadonly && LOCKED) { if (__DEV__) { console.warn( `Set operation on key "${String(key)}" failed: target is readonly.`, target ) } return true } // 获取旧值 const oldValue = (target as any)[key] // 如果value是响应式数据,则返回其映射的源数据 value = toRaw(value) // 如果旧值是 Ref 而新的值不是 Ref,那么更新 old 的 .value 并直接结束 set 过程 if (isRef(oldValue) && !isRef(value)) { oldValue.value = value return true } // 代理对象中,是否真的有这个key,没有说明操作是新增 const hadKey = hasOwn(target, key) // 将本次设置行为,反射到原始对象上 const result = Reflect.set(target, key, value, receiver) // 如果是原始数据原型链上的数据操作,不做任何触发监听函数的行为。 if (target === toRaw(receiver)) { if (__DEV__) { // 开发环境下,会传给trigger一个扩展数据,包含了新旧值,便于开发环境下做一些调试。 const extraInfo = { oldValue, newValue: value } // 如果不存在key时,说明是新增属性,操作类型为 ADD,否则就是更新,即 SET // 存在key,则说明为更新操作,当新值与旧值不相等时,才是真正的更新,进而触发trigger if (!hadKey) { trigger(target, TriggerOpTypes.ADD, key, extraInfo) } else if (value !== oldValue) { trigger(target, TriggerOpTypes.SET, key, extraInfo) } } else { // 同上述逻辑,只是少了供给 debug 用的 extraInfo if (!hadKey) { trigger(target, TriggerOpTypes.ADD, key) } else if (value !== oldValue) { trigger(target, TriggerOpTypes.SET, key) } } } return result } }

这段我 Anx 中的实现暂时剔除了 vue-next 中的 shallow,源码当中留下的注释翻译过来是:在浅层模式中,对象按原样设置,而不考虑是否响应。

  1. 为什么要 value = toRaw(value), 所赋的值为什么要转为源对象?
    所以说要谨记我们之前说的「懒挂载」原则呀!我们并不知道 所属对象是否需要这个新值变得响应式,所以我们默认最好是就保存源对象,以节省内存开销,只有至少需要过一次才挂上 Proxy 代理监听:

    const a_original = { x: 33 } let a_reactive = reactive(a_original, mutableHandlers) const b_original = { k: a_reactive } let b_reactive = reactive(b_original, mutableHandlers) /* b_reactive 的结果是 { k: a_original } */

    等到某个地方需要 b_reactive.k,触发了 get 之后发现 a_original 非响应式,一查 WeakMap 发现保存过与 a_reactive 响应式对象的关系,「懒挂载」此时不懒惰了,也触发了。

  2. 为什么最后一段要包在一个 if (target === toRaw(reciever)) 之中呢?
    刚看完 MDN 时,我觉得 receiver 有点儿像是 this 一样的存在,指代着被 Proxy 执行后的代理对象。那代理对象用 toRaw 转化,也就是转为原始对象,自然跟 target 是全等的。
    但是这里还有一个很偏门的知识点:

    Receiver:最初被调用的对象。通常是 proxy 本身,但 handler 的 set 方法也有可能在原型链上或以其他方式被间接地调用,因此不一定是 proxy 本身。

    const child = new Proxy( {}, { get(target, key, receiver) { return Reflect.get(target, key, receiver) }, set(target, key, value, receiver) { Reflect.set(target, key, value, receiver) console.log('child', receiver) return true } } ) const parent = new Proxy( { x: 888 }, { get(target, key, receiver) { return Reflect.get(target, key, receiver) }, set(target, key, value, receiver) { Reflect.set(target, key, value, receiver) console.log('parent', receiver) return true } } ) Object.setPrototypeOf(child, parent) child.x = 4 // 打印结果 // parent {x: 4} // child {a: 4}

    本来我们想要的结果是只触发 childset 句柄函数,没想到 parent 竟然也跟着被触发了,在这种情况下,parent 其实并没有变更,按道理来说,它确实不应该触发它的监听函数。
    所以为了避免我们的响应式系统中出现这样的冗余操作,方案就是 "通过用 WeakMap 保存的「代理与原生的对应关系」对触发的起因对象进行断言。"

Other Traps · 其他劫持句柄

// 劫持属性删除 function deleteProperty(target: any, key: string | symbol): boolean { const hadKey = hasOwn(target, key) const oldValue = target[key] const result = Reflect.deleteProperty(target, key) if (result && hadKey) { if (__DEV__) { trigger(target, TriggerOpTypes.DELETE, key, { oldValue }) } else { trigger(target, TriggerOpTypes.DELETE, key) } } return result } // 劫持 in 操作符 function has(target: any, key: string | symbol): boolean { const result = Reflect.has(target, key) track(target, TrackOpTypes.HAS, key) return result } // 劫持 Object.keys function ownKeys(target: any): (string | number | symbol)[] { track(target, TrackOpTypes.ITERATE, ITERATE_KEY) return Reflect.ownKeys(target) }

这些句柄在「可变型」与「只读型」中都是比较通用的,逻辑也比较简单,就不再赘述了,大家自行阅读吧。

CollectionHandler · 集合类型专用句柄

为什么它们需要特别对待?

因为 Proxy 也是有缺陷的啦!对于 Map、Set、WeakMap 这些内置集合类型,当我们使用它们的方法(例如 entriesdelete 等)时,这些方法的 this 指向的本应该是源对象,但通过代理对象去操作时,this 指向的是 代理对象。所以就会报如下错:

Uncaught TypeError: Method Set.prototype.add called on incompatible receiver [object Object]

如何劫持

那么源码当中给出了怎样的解决方案呢?简单来说,就是 “移花接木”!

也就是说,当访问的是一个方法时,我们需要给他重新绑定 thisInstrumentation 的意思就是 “插桩”,下面这个函数的意思是:创造一个特殊的 getter,如果是 get 集合类型的方法,那么我给你换一些做了劫持的方法,意思基本等同于原来 Vue 2.x 时代给数组重写方法。

function createInstrumentationGetter( instrumentations: Record<string, Function> ) { return ( target: CollectionTypes, key: string | symbol, receiver: CollectionTypes ) => Reflect.get( hasOwn(instrumentations, key) && key in target ? instrumentations : target, key, receiver ) }

那么这些所谓的 「桩」:instrumentation 到底是什么呢?它是一个对象,具有和集合类型一样的方法名,但其实这些方法其实是已经被我们改造过的,我们看一个例子:

const mutableInstrumentations: Record<string, Function> = { get(this: MapTypes, key: unknown) { return get(this, key, toReactive) }, get size(this: IterableCollections) { return size(this) }, has, add, set, delete: deleteEntry, clear, forEach: createForEach(false) }

上面这些方法传入时,都是在源码中都重写了的,比如下面的 get

function get( target: MapTypes, key: unknown, wrap: typeof toReactive | typeof toReadonly ) { // 获取原始数据 target = toRaw(target) // 由于 Map可以用对象做 key,所以 key也有可能是个响应式数据,先转为原始数据 key = toRaw(key) // 收集依赖 track(target, TrackOpTypes.GET, key) // 使用原型方法,通过原始数据去获得该key的值。 // wrap 即传入的 toReceive 或 toReadonly 方法,将获取的 value值转为响应式数据 return wrap(getProto(target).get.call(target, key)) }

注意!在 get 方法中,第一个入参 target 不能跟 Proxy 构造函数的第一个入参混淆。

  • Proxy 函数的第一个入参 target 指的原始数据
  • 在这个 get 方法中,这个 target 其实是被代理后的数据。

再看看其他几个例子:

// 集合类型的 .has() 拦截 function has(this: CollectionTypes, key: unknown): boolean { const target = toRaw(this) key = toRaw(key) track(target, TrackOpTypes.HAS, key) return getProto(target).has.call(target, key) } // 集合类型的 .size() 拦截 function size(target: IterableCollections) { target = toRaw(target) track(target, TrackOpTypes.ITERATE, ITERATE_KEY) return Reflect.get(getProto(target), 'size', target) }

特别注意:size 是一个属性而不是方法哟 ~

如果你对 函数参数中传 this 有疑问,敬请前往「TypeScript 文档此处」阅读相关解释。

「迭代器方法」劫持

在许多语言中都有迭代器这个 API,基本的思想就是 「不断的 next() 遍历元素,最后来到最后一个元素 end()」,JS 也有对应的实现,具体请查看 MDN 文档相关内容

这里让每次通过 next() 获取到的数据都变成响应式:

function createIterableMethod(method: string | symbol, isReadonly: boolean) { return function(this: IterableCollections, ...args: unknown[]) { // 获取原始数据 const target = toRaw(this) // 如果是entries方法,或者是 map的迭代方法的话,isPair为true // 这种情况下,迭代器方法的返回的是一个[key, value]的结构 const isPair = method === 'entries' || (method === Symbol.iterator && target instanceof Map) // 获取原型 调用原型链上的相应迭代器方法 const innerIterator = getProto(target)[method].apply(target, args) const wrap = isReadonly ? toReadonly : toReactive // 收集依赖 track(target, TrackOpTypes.ITERATE, ITERATE_KEY) // 返回一个包装的迭代器,它返回从实际迭代器发出的值的响应式版本 return { // 迭代器的实现协议 next() { const { value, done } = innerIterator.next() return done ? { value, done } : { value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value), done } }, [Symbol.iterator]() { return this } } } }

「for-Each」 劫持

function createForEach(isReadonly: boolean) { return function forEach( this: IterableCollections, callback: Function, thisArg?: unknown ) { const observed = this const target = toRaw(observed) const wrap = isReadonly ? toReadonly : toReactive track(target, TrackOpTypes.ITERATE, ITERATE_KEY) // 将传递进来的 callback函数插桩,让传入 callback的数据,转为响应式数据 function wrappedCallback(value: unknown, key: unknown) { // 注意下方 绑定的 this 是响应式对象 return callback.call(observed, wrap(value), wrap(key), observed) } return getProto(target).forEach.call(target, wrappedCallback, thisArg) } }

forEach 的用法是一个「参数表示每一个可迭代对象每个元素」的回调函数,callback.call(observed, wrap(value), wrap(key), observed),使这个参数变成了响应式数据。

「写」相关操作

// 集合类型的 .add() 拦截 function add(this: SetTypes, value: unknown) { // 获取原始数据 value = toRaw(value) const target = toRaw(this) // 获取原型 const proto = getProto(target) // 用原型的 has 判断是否有这个key const hadKey = proto.has.call(target, value) // 通过原型方法,增加这个 key const result = proto.add.call(target, value) // 没有这个 key的话 说明真的是新增,触发 ADD 的监听逻辑 if (!hadKey) { /* istanbul ignore else */ if (__DEV__) { trigger(target, TriggerOpTypes.ADD, value, { newValue: value }) } else { trigger(target, TriggerOpTypes.ADD, value) } } return result } // 集合类型的 .set() 拦截 function set(this: MapTypes, key: unknown, value: unknown) { value = toRaw(value) key = toRaw(key) const target = toRaw(this) const proto = getProto(target) const hadKey = proto.has.call(target, key) const oldValue = proto.get.call(target, key) const result = proto.set.call(target, key, value) /* istanbul ignore else */ if (__DEV__) { const extraInfo = { oldValue, newValue: value } if (!hadKey) { trigger(target, TriggerOpTypes.ADD, key, extraInfo) } else if (hasChanged(value, oldValue)) { trigger(target, TriggerOpTypes.SET, key, extraInfo) } } else { if (!hadKey) { trigger(target, TriggerOpTypes.ADD, key) } else if (hasChanged(value, oldValue)) { trigger(target, TriggerOpTypes.SET, key) } } return result }

写相关的操作其实比较容易,相比于基本类型的数据可以通过 Reflect 很方便地拿到,这里集合类型需要手动获取原型链并绑定 this 而已。

Ref 篇

有的时候,我们也希望对某些「非对象型」的值做监听,那么根据 Reactive 篇的经验,不难想到方案,即:使其变为对象,值存放在 value 字段中,然后挂上 Proxy 代理?

不要急着得出结论,还是一起走进源码耐心看看吧!

Unwrap · 包裹解套

最应该先看的就是这部分,用到了 TS 的 infer,如果你不了解建议先看 TS 这部分的官方文档。其实简单点说就是:能推断出某个带泛型的类型带的到底是什么类型。

// 基础类型无需解套 type BaseTypes = string | number | boolean // 递归地打开嵌套的值绑定。 export type UnwrapRef<T> = { cRef: T extends ComputedRef<infer V> ? UnwrapRef<V> : T // T 如果是 Ref<V> 类型,继续解套 V 直到终点 ref: T extends Ref<infer V> ? UnwrapRef<V> : T // T 如果是 Array<V> 类型,继续解套 V 直到终点 array: T extends Array<infer V> ? Array<UnwrapRef<V>> & UnwrapArray<T> : T // T 如果是 object 类型,它的 key 是 K 类型,继续解套 T[K] 直到终点 object: { [K in keyof T]: UnwrapRef<T[K]> } }[ T extends ComputedRef<any> ? 'cRef' : T extends Ref ? 'ref' : T extends Array<any> ? 'array' : T extends Function | CollectionTypes | BaseTypes ? 'ref' // : T extends object ? 'object' : 'ref' ]

最先我在开始从 vue-next 中抽取源码时,这部分我没有第一时间取出查看,对需要使用它或与其相关的内容时,我都暂时用其包裹的泛型 T 来表示,但在以下这个单测中就出了问题:

// 如果某对象为数组类型,且数组内元素长度、类型固定,视为 多元组类型: // ref() 它的结果应该保留 其中各个元素类型信息 it('should keep tuple types', () => { const tuple: [number, string, { a: number }, () => number, Ref<number>] = [ 0, '1', { a: 1 }, () => 0, ref(0) ] const tupleRef = ref(tuple) tupleRef.value[0]++ expect(tupleRef.value[0]).toBe(1) tupleRef.value[1] += '1' expect(tupleRef.value[1]).toBe('11') tupleRef.value[2].a++ expect(tupleRef.value[2].a).toBe(2) expect(tupleRef.value[3]()).toBe(0) tupleRef.value[4]++ // 在这一行 报错了! expect(tupleRef.value[4]).toBe(1) })

由于我对 tuple 的定义,tupleRef.value[4] 会是 Ref<number> 类型,所以 TS 编译器报错说 ++ 这样的自增运算符不可用于此类型。

这个看起来好长好长的 type 定义的语义,就是 UnwrapRef 可以帮助我们对包裹的值进行 「解套」,使得 Ref 类型的 value 值能像原来的类型一样正常使用。

Definition · 接口定义

export interface Ref<T = any> { // * 这个字段是必要的,它允许 TS 将一个 Ref与一个恰好有 “value” 字段的普通对象区分开来。 // 但是,在任意对象上检查符号要比检查普通属性慢得多, // 因此我们在实际实现中为 isRef()检查使用 _isRef普通属性。 // * 而不在接口中声明 _isRef的原因是,我们不希望这个内部字段泄漏到用户空间的自动完成 // 一个私有符号正好实现了这一点。 [isRefSymbol]: true value: UnwrapRef<T> }

接下来是 ref() 这个 Composition API 中主要使用到的 API 的定义:

// 主要 API:定义 Ref 型的值 export function ref<T extends Ref>(raw: T): T export function ref<T>(raw: T): Ref<T> export function ref<T = any>(): Ref<T> export function ref(raw?: unknown) { if (isRef(raw)) { return raw } raw = convert(raw) const r = { _isRef: true, get value() { track(r, TrackOpTypes.GET, 'value') return raw }, set value(newVal) { raw = convert(newVal) trigger( r, TriggerOpTypes.SET, 'value', __DEV__ ? { newValue: newVal } : void 0 ) } } return r }

果然,我们在 ref() 中并没有看到 Proxy API 的操作痕迹,因为这本身就是我们自己创造的对象,其实并不需要 Proxy 这么重的方案,基础值类型的数据本来也没有太多操作,仅仅是 getset 需要被监听就好了。

Deconstruction Optimization · 解构优化

现在似乎在咱们的响应式系统中,即使是基础值也可以是响应式的了!但 ... 我们还差点忘了这种情况:

const p = reactive({ x: 123, y: 0 }) const { x, y } = p

这样的情况下,xy 都是 number 类型,被解构出来后,就失去了响应式特性,所以我们需要为此情况提供一种解决方案:「若要解构,且要保证响应式,那么请全体挂 Ref,写法如下:

const p = reactive({ x: 123, y: 0 }) const { x, y } = toRefs(p)

但你阅读源码到 toRefs 的实现时你可能会感到困惑:「为什么它给每个属性对应的值挂 Ref 不是用 ref() API 而是用 toProxyRef,且这个 toPrxoyRef 中也没有 tracktrigger 来做跟踪和触发?」

这是因为 p 本身就已经是响应式对象,我们挂 Ref 的目的是让 xy 基本值类型变为对象类型,只是套一个壳子而已!

// 将一个普通对象中某属性对应的值转化为 Ref 型 // 返回该 Ref 型值 function toProxyRef<T extends object, K extends keyof T>( object: T, key: K ): Ref<T[K]> { return { _isRef: true, get value(): any { return object[key] }, set value(newVal) { object[key] = newVal } } as any }

至于什么 track 还有 trigger 的事儿,在运行到这里的 getset 时就会触发原来响应式对象 handler 里的句柄啦!

顾名思义嘛,为什么要叫 toProxyRef?即 一个去触发 Proxy Handler 的 Ref 壳子

Effect 篇

Effect 直译过来是 "影响,效果,达到目的",是一个由用户自定义的函数,会在依赖被 trigger 触发时执行。

在 2.x 的源码中,依赖收集是通过 DepWatcher 两个类完成的,通过在 defineReactive 当中对目标对象每一个属性都新建一个 Dep,用于保存所有依赖此属性的 订阅者 Watcher

那么在 3.0 中是怎么做的呢?还是从单测看起吧:

// 在 响应式值 更新时 触发effect it('should trigger effect when updating a reactive value', () => { const original = { a: 0, b: 'Hello' } let observed = reactive(original) let dummy; effect(() => { dummy = observed.a }) expect(dummy).toBe(0) observed.a = 1 expect(dummy).toBe(1) })

仔细读完之后,应该会发现第 11 行:我们本没有初始化 dummy,它应该为 undefined,但经过了 effect() 的定义后,我们却期待它为 0,这是为何?

唯一的解释就是我们传入的这个自定义「效果函数」被执行了一次,使 observed.a 把值 0 赋给了 dummy

“欸!你读取了一次 a 属性!应该触发 observedget !”

嘿嘿 😏️!恭喜你发现了重要的知识点:「定义了效果函数后,会立即执行一次!」由此获得一次 track 跟踪。

effect · 定义效果函数的方法

export function effect<T = any>( // 原始函数 fn: () => T, // 配置项 options: ReactiveEffectOptions = EMPTY_OBJ ): ReactiveEffect<T> { // 如果该函数已经是监听函数了,那赋值fn为该函数的原始函数 if (isEffect(fn)) { fn = fn.raw } // 创建一个监听函数 const effect = createReactiveEffect(fn, options) // 如果不是延迟执行的话,立即执行一次 if (!options.lazy) { effect() } // 返回该监听函数 return effect }
// 创建监听函数的方法 function createReactiveEffect<T = any>( fn: () => T, options: ReactiveEffectOptions ): ReactiveEffect<T> { // 创建监听函数,通过run来包裹原始函数,做额外操作 const effect = function reactiveEffect(...args: unknown[]): unknown { return run(effect, fn, args) } as ReactiveEffect // 省略其他配置代码... return effect }
// 监听函数执行器 function run(effect: ReactiveEffect, fn: Function, args: unknown[]): unknown { // 如果这个 active 开关是关上的,那就执行原始方法,并返回 if (!effect.active) { return fn(...args) } // 如果监听函数栈中并没有此监听函数,则: if (!effectStack.includes(effect)) { // 先预先清除它可能存在的依赖引用,准备重新添加进依赖栈 cleanup(effect) try { // 将本 effect推到 effect栈中 effectStack.push(effect) activeEffect = effect // 执行原始函数并返回 return fn(...args) } finally { // 执行完以后将 effect从栈中推出 effectStack.pop() activeEffect = effectStack[effectStack.length - 1] } } }

嚯!这三段代码看得人真头大 🤦🏻,梳理一下:

执行 createReactiveEffect() 来创建依赖

  • 创建时通过执行 run() 方法使得 TS 确认行内创建的 reactiveEffect 返回值为 fn 的返回值 T 类型
    • run() 方法中除了执行用户定义的「效果函数」,还有对 依赖栈 的操作:首先先将 effect 推入一个全局栈中,执行原始函数返回结果后将刚才推进来的 effect 推出。
      Q: 那照这么说,执行前进来,执行后出去,为啥还需要判断 !effectStack.includes(effect) 呢?
      A: 如果 fn() 函数中有对依赖数据的更改,就会导致递归,反复触发 effect 啦!所以我们需要此判断。

那么 ... 我们说了好多次这些个什么 tracktrigger 了,它们的实现又是怎么样的呢?

// 这个主要的 WeakMap 用来存储 {target -> {key -> dep} } 目标对象某个 key 属性的依赖. // 从概念上讲, 把 Dep 依赖作为要一个类更好理解 // 它是一个包含着订阅者们的集合, 但是我们简单存储它为原生 Set 以节省内存开销 type Dep = Set<ReactiveEffect> type KeyToDepMap = Map<any, Dep> const targetMap = new WeakMap<any, KeyToDepMap>()

track · 追踪与依赖收集

首先要想让 track 收集到依赖,那么得要有容器保存依赖,👆🏻️ 上面的注释已经写的很清楚了,保存的就是 目标对象与属性的关系属性和它的依赖集合

因为上面的 run() 函数中记录了当前活跃的 activeEffect,那么在执行了用户定义的「效果函数」 fn() 时触发 track 跟踪,将 activeEffect 推进 target[key] 的依赖集合中。

// 收集依赖的函数 export function track( // 原始数据 target: object, // 操作行为 type: TrackOpTypes, key: unknown ) { // 如果 shouldTrack开关关闭,或 effectStack中不存在监听函数,则无需要收集 if (!shouldTrack || activeEffect === undefined) { return } // 获取 target 的 {key -> deps},如果无,则初始化 let depsMap = targetMap.get(target) if (depsMap === void 0) { targetMap.set(target, (depsMap = new Map())) } // 获取 effect集合,无则初始化 let dep = depsMap.get(key) if (dep === void 0) { depsMap.set(key, (dep = new Set())) } // 如果集合中没有刚刚获取的最后一个 effect,则将其 add到集合 dep中 // 并在 effect的 deps中也 push这个 effects集合 dep if (!dep.has(activeEffect)) { dep.add(activeEffect) activeEffect.deps.push(dep) if (__DEV__ && activeEffect.options.onTrack) { activeEffect.options.onTrack({ effect: activeEffect, target, type, key }) } } }

trigger · 句柄事件触发

这部分的逻辑简单得令人惊讶 😂️,当依赖收集表的三维关系建立完成后,执行 setdelete 这些方法时就会触发、执行用户在 effect 处定义的句柄函数。

// 触发监听函数的方法 export function trigger( target: object, // 原始数据 type: TriggerOpTypes, // 写操作类型 key?: unknown, // 属性key extraInfo?: DebuggerEventExtraInfo // 拓展信息 ) { // 获取原始数据的响应依赖映射,没有的话,说明没被监听,直接返回 const depsMap = targetMap.get(target) if (depsMap === void 0) { // 从未被跟踪过 return } }

这里有个 TypeScript 赋予的特性,为避免环境中 undefined 值被污染,可以使用 void 0 来表达「空」。

最后我们小结一下,一图流让你看明白整个过程:

响应式一图流

Computed 篇

我们还是从主要的 API 的单测看起:

it('should return updated value', () => { const value = reactive<{ foo?: number }>({}) const cValue = computed(() => value.foo) expect(cValue.value).toBe(undefined) value.foo = 1 expect(cValue.value).toBe(1) })

Vue 3 中「计算属性」的写法被 Hooks 化了。我们就像给 effect 传句柄函数那样, 给 computed 传入一个计算函数即可。

接下来我们看看具体实现,之后我们还将讲到对实现中的一些重点难点的几个比较重要、比较有代表性的单测:

// !! 主要 API // computed 泛的这个 T 是 getter 返回值的类型 export function computed<T>(getter: ComputedGetter<T>): ComputedRef<T> export function computed<T>( options: WritableComputedOptions<T> ): WritableComputedRef<T> export function computed<T>( getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T> ) { let getter: ComputedGetter<T> let setter: ComputedSetter<T> // getterOrOptions 可能有两种情况: // 1. 就只是一个平常的 计算函数 返回结果值 // 2. 一个含有 get 和 set 的对象 if (isFunction(getterOrOptions)) { getter = getterOrOptions setter = __DEV__ ? () => { console.warn('Write operation failed: computed value is readonly') } : NOOP // NOOP = () => {} 表示无操作 } else { getter = getterOrOptions.get setter = getterOrOptions.set } let dirty = true let value: T // 一个专门用来跑 计算属性 getter 句柄函数的执行器 // 将 做计算的函数 传给 effect,并给予一些特殊配置! const runner = effect(getter, { lazy: true, // 标记 effect 为计算属性,以便在触发期间获得优先级 // 为什么会有优先级?参见 effect.ts 254 至 258 行左右 computed: true, scheduler: () => { // 这个行内函数作为 scheduler 通过闭包缓存了 dirty 的引用 // 每次 数据有变化时 这里的 dirty 先变化 // ⬇️️ 进入下方的 get 的 if 分支执行一次 runner dirty = true } }) return { // 计算属性值 算作 Ref _isRef: true, // 暴露 effect 使计算属性停止计算 effect: runner, get value() { if (dirty) { value = runner() dirty = false } // TCR: 当计算属性的 effect 涉及触发另一个 父级 effect,这个响应因子 // 应当跟踪这个计算属性的所有因子. // 对涉及触发 另一个计算属性 也是同样的。 trackChildRun(runner) return value }, set value(newValue: T) { setter(newValue) } } as any }

「因上面的代码注释比较详尽我就不一一讲述了。」

我们都知道计算属性一般来说是只读的,不过也可以自定义它的 gettersetter,体现在这里的 getterOrOptions

计算属性发生变化,一定是其计算因子发生了变化,所以一定有一个与定义此计算属性同时定义好的 effect 来触发这些变化,即我们的 runner

TCR 即 trackChildRun 是非常有必要的,我们看到下面这个单测:

it('should trigger effect when chained', () => { const value = reactive({ foo: 0 }) const getter1 = jest.fn(() => value.foo) const getter2 = jest.fn(() => { return c1.value + 1 }) const c1 = computed(getter1) const c2 = computed(getter2) let dummy effect(() => { dummy = c2.value }) expect(dummy).toBe(1) expect(getter1).toHaveBeenCalledTimes(1) expect(getter2).toHaveBeenCalledTimes(1) value.foo++ expect(dummy).toBe(2) // should not result in duplicate calls expect(getter1).toHaveBeenCalledTimes(2) expect(getter2).toHaveBeenCalledTimes(2) })

c2 是依赖于 c1 的,c1 依赖于 value,当 value 发生变化,应该链式触发使得 c2 也更新。

实现的原理就是将子级 runner 所有保存的依赖(即当前 effect 涉及的引用)拷贝到当前 effect 中即可:

// 见上方 TCR 注释 ... function trackChildRun(childRunner: ReactiveEffect) { if (activeEffect === undefined) { return } for (let i = 0; i < childRunner.deps.length; i++) { const dep = childRunner.deps[i] if (!dep.has(activeEffect)) { dep.add(activeEffect) activeEffect.deps.push(dep) } } }

心得体会: 从 2019 年 10 月份 vue-next 代码公开,到 2020 年此刻我整理完本模块的解析,已经过去了近 3 个月的时间,其实在这段过程中 Vue 3 还在不断地进行着优化和收尾工作。

总算完成了自己这么久以来欠的文章,感到非常高兴!如果你对本模块的代码解析中任何一处说法有疑问或有纠错,非常欢迎你联系我,我会尽快改正!不如来 Anx 仓库发一个 Issue 吧!

目前国内对 Vue 3 源代码的解读大多还只是 reactivity 模块,希望有更多大神来参加,我其实对有尤大在 Vue Conf 上提到的那个 "Block Tree" 的 Vitrual DOM 优化思路很感兴趣,希望之后会有更多解密!学到就是赚到!

参考资料:

  • Vue.js

    Vue.js(读音 /vju ː/,类似于 view)是一个构建数据驱动的 Web 界面库。Vue.js 的目标是通过尽可能简单的 API 实现响应的数据绑定和组合的视图组件。

    267 引用 • 666 回帖
  • 响应式
    3 引用 • 5 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...