重读Vue3源码之reactivity

这是一篇流水账,边看代码边写,写到哪儿算哪儿。准备写完第一遍之后再总结重构。

Vue 的源码比较简洁,符合中国人的编码直觉,所以读起来会比较顺畅,比较有学习价值。

Vue 的代码是一个monorepo,大体分为下面几个部分:

  • reactivity 核心中的核心,可以脱离Vue 独立使用,包含了响应式的核心逻辑,比如scopeeffectdeplink 等。
  • compiler-* 这里其实是4个包,包含coredomsfcssr 4个编译器实现,分别对应核心框架、DOM(template部分)处理、单文件组件、服务端渲染。
  • runtime-* 包含三个包,分别是coredomtest
  • server-renderer 服务端渲染的实现。
  • shared 多个仓库共用的一些代码,例如枚举定义等。
  • vue 嗯,这里是入口。
  • vue-compat 这个是兼容Vue2 的代码实现。

这样一列就比较明确了,阅读顺序应该是reactivitycompiler-corecompiler-domcompiler-sfc ,后面再说。

响应式核心 —— reactivity

核心概念:

  • reactivity 最最核心的响应式变量封装对象,value必须是个Object对象。
  • ref 可以粗略的认为是reactive的上层封装,有了这层就能让基础数据类型,比如number实现响应式(其实就是包了一层)。
  • dep PubSub 模式中的订阅源。
  • link 关联 depeffect 的双向链表。
  • effect 响应式操作,简单来说就是订阅源变化之后要做什么操作。
  • computedeffect 是并列的,用于根据响应式变量计算出衍生的值。
  • effectScope 响应式生命周期的管理,说白了就是什么时候开始收集依赖,什么时候暂停、结束。

其他的还有watch 等等,按下不表。

响应式变量是如何封装的

开篇先看看,核心的响应式变量封装了个什么 —— Target。 显然是个Object,并且包含了几个特殊标记:

  • SKIP : 跳过响应式监听
  • IS_REACTIVE: 是否是响应式的,比如上面SKIP了的话,IS_REACTIVE 就是INVALID,后面也就不会再参与响应式过程。 IS_READONLY 是否只读、、IS_SHALLOW 是否是浅层响应式,这两个标记功能类似。
  • RAW: 这里就是响应式对象的原始值了,可以看到是个any类型。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export interface Target {
[ReactiveFlags.SKIP]?: boolean
[ReactiveFlags.IS_REACTIVE]?: boolean
[ReactiveFlags.IS_READONLY]?: boolean
[ReactiveFlags.IS_SHALLOW]?: boolean
[ReactiveFlags.RAW]?: any
}

export const reactiveMap: WeakMap<Target, any> = new WeakMap<Target, any>()
export const shallowReactiveMap: WeakMap<Target, any> = new WeakMap<
Target,
any
>()
export const readonlyMap: WeakMap<Target, any> = new WeakMap<Target, any>()
export const shallowReadonlyMap: WeakMap<Target, any> = new WeakMap<
Target,
any
>()

之后是4个全局变量 reactiveMapshallowReactiveMapreadonlyMapshallowReadonlyMap,分别存储了所有的响应式变量Target 和用于封装这个Target的Proxy的映射关系。

从4个变量的名字可以看出来,一般响应式变量、浅层响应式变量、只读变量、浅层只读变量4个是分开存储的。

对应的也有4个方法来创建不同类型的响应式变量:reactiveshallowreadonlyshallowReadonly

这4个方法都是对createReactiveObject 方法的封装。嘿嘿,主角登场。

核心方法: createReactiveObject

这个方法的主体逻辑非常清晰简洁。

  1. 如果Target是个Proxy,并且想在标记为响应式的Target上创建一个只读的响应式变量,就直接返回原响应式变量。
  2. 获取target的响应式类型,这个类型分为集合、一般变量和无法响应式的变量。这里的无法可能是标记为不需要响应式。这里集合类型包含map和set,一般变量类型包含一般对象和数组。嗯,就这几种,非常直观。 上面已经说过Target必须是Object,所以不用考虑其他情况。
  3. 先在全局map中查看下,是不是已经创建过Proxy,如果创建过则直接返回。
  4. 如果没有的话,根据响应式类型装配不同的handlers
  5. 在全局map中记录这个新创建的proxy,并返回。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function createReactiveObject(
target: Target,
isReadonly: boolean,
baseHandlers: ProxyHandler<any>,
collectionHandlers: ProxyHandler<any>,
proxyMap: WeakMap<Target, any>,
) {
// DEV 模式下的类型检验,省略。
// target is already a Proxy, return it.
// exception: calling readonly() on a reactive object
if (
target[ReactiveFlags.RAW] &&
!(isReadonly && target[ReactiveFlags.IS_REACTIVE])
) {
return target
}
// only specific value types can be observed.
const targetType = getTargetType(target)
if (targetType === TargetType.INVALID) {
return target
}
// target already has corresponding Proxy
const existingProxy = proxyMap.get(target)
if (existingProxy) {
return existingProxy
}
const proxy = new Proxy(
target,
targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers,
)
proxyMap.set(target, proxy)
return proxy
}

Proxy封装了个啥: handlers

从上面代码可以看出,创建响应式变量中到底响应了个啥,再深层的逻辑都在handlers中。和上面逻辑对应,只读/可写、集合/一般对象、深层/浅层,叉乘下来貌似需要很多类型的Handler,但是实际上只有几个核心的类。

Handlers的类结构

不同类型变量的封装逻辑

先从最简单的 BaseReactiveHandler 开始。 这个类只封装了一个get方法。 做的事情就是:

  1. 排除掉一些Vue内置的标记位,直接返回不参与响应式;
  2. 如果要访问的property就是Target的原始值呢? 有个边缘Case,就是用户的原始值就是一个Proxy,但是仿照了Vue的响应式Proxy,那直接返回这个用户定义的Proxy。(这里给我看郁闷了,有点绕)
  3. 如果目标对象是个数组,这次get其实是访问数组的方法,比如forEach呢? 这里会从全局arrayInstrumentations取对应的方法,直接返回,原因后面再管。
  4. Reflect获取具体的属性值。** 注意 ** ,直到这里,都还没有收集依赖哦。
  5. 如果当前Proxy是只读的,或者访问的是一些内定的不需要跟踪的属性,嘿嘿,就不用收集依赖了。
  6. 反之,收集依赖。 至于如何收集,后面再慢慢展开。 我粗看上去是一个LinkDep 组成的双向十字链表(不是)的样子。
  7. 按照常规情况,还需要将返回的值封装成reactive对象。 也就是说Vue中多层嵌套响应式对象,不是在创建的时候就遍历属性值创建Proxy,而是在get阶段,get到哪儿创建到哪儿。

还是比较简单的。

MutableReactiveHandler

下面是MutableReactiveHandler,增加了setdeletePropertyhasownKeys方法。

  • set 比较简单,因为get拿到的一定是reactive对象,所以先toRaw之后做了一些防御性编程。通过Reflect设置值之后,收集依赖。
  • deletePropertyhasownKeys 几乎就是单纯调用了Reflect,然后收集依赖。

有了这几个方法之后,Handler就能处理可写的对象了。

处理集合类型(Set/Map, 没有数组)的Handler比较特殊,并不是用类组装而成,而是调用createInstrumentationGetter方法生成的Handler.get,这个方法返回的类型是一个Instrumentation 对象。 乍一看很奇怪,为什么这么做呢?

首先,访问集合中的元素是不能直接按照property的方式访问的,而是调用集合的hasget等方法,所以收集依赖实际上就要劫持这些方法,劫持也是通过Proxy覆盖这些方法来实现的。

其次, createInstrumentationGetter 也只劫持了影响响应式的几个方法,比如forEachclear等。

不过这几个方法封装的都比较简单,大体流程都是先转化成原始值、检测是否有变化、触发依赖收集、返回值/调用具体的方法。略有区别的就是对于是否只读做区分处理。

什么时候开始收集依赖?

EffectScope 管理了什么时候开始、暂停、结束依赖收集。从构造函数看,这个scope类是一个树状结构,允许嵌套的scope。一个scope中存储了下属的若干个子scope。

得益于JavaScript的单线程模型,在全局定义一个activeEffectScope 就可以记录当前在哪个scope内做依赖收集。

这个类中存储了所有的ReactiveEffect,也就是响应式变量变化的时候应该做什么。

这个类也极致简洁,只有200多行。从使用上看,Vue在全局创建了一个detached的scope,在每个组件实例中创建了一个专属的scope。

EffectScope的构造函数可以看出来,在创建组件的scope的时候,这个新创建的scope会自动存储在activeEffectScope.scopes中。这样Scope就天然的组装成了一个树状结构。

EffectScope 包含的方法包括暂停、恢复、运行某个方法(猜测是Effect)、on(成为当前activeEffectScope)、off(恢复之前的activeEffectScope)、彻底结束scope。

嗯嗯,就这些了。

可以看到,真实记录DepEffect 的关联关系,以及触发Effect 运行的逻辑都不在这里,在Scope中只有一个记录ReactiveEffect的数组,貌似有点关联。别急,继续看。

响应式关系具体如何记录的?

这里我遇到了一些困难。Dep、Link、ReactiveEffect 等这些类的关联关系错综复杂。感觉这几个类相互之间都存了一个双向链表。

先从最基础的来,响应式关系本质是一个订阅者模式,订阅的源在Vue里面的数据类型是 Dep 。 订阅者的核心数据结构是 SubscriberSubscriber 分两种: ReactiveEffectComputed

这样的订阅结构使用Link 数据结构来存储,DepSubscriber 是多对多结构,所以Link中存储的数据比较复杂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* Represents a link between a source (Dep) and a subscriber (Effect or Computed).
* Deps and subs have a many-to-many relationship - each link between a
* dep and a sub is represented by a Link instance.
*
* A Link is also a node in two doubly-linked lists - one for the associated
* sub to track all its deps, and one for the associated dep to track all its
* subs.
*
* @internal
*/
export class Link {
/**
* - Before each effect run, all previous dep links' version are reset to -1
* - During the run, a link's version is synced with the source dep on access
* - After the run, links with version -1 (that were never used) are cleaned
* up
*/
version: number

/**
* Pointers for doubly-linked lists
*/
nextDep?: Link
prevDep?: Link
nextSub?: Link
prevSub?: Link
prevActiveLink?: Link

constructor(
public sub: Subscriber,
public dep: Dep,
) {
this.version = dep.version
this.nextDep =
this.prevDep =
this.nextSub =
this.prevSub =
this.prevActiveLink =
undefined
}

这段代码我必须把注释一起贴过来,可以看到里面存储Dep 用的是双向链表。存储Subscriber也是双向链表。用preActiveLink 来存储当前active的link链表,这里数据结构都飞起来了。 Link 是一个纯存储类,没有任何方法。

然后Dep 中也存了Link的双向链表,用于找到订阅者,再然后,Subscriber 中也存了Link的双向链表,用于找到订阅源。

响应式的大流程

单从数据结构看,会感觉逻辑非常破碎,但是将响应式的流程穿起来,就清晰一些。

  1. 神说,先要有 ReactiveScope ,标记下现在开始收集依赖。
  2. 然后,有凡人创建了 Ref 或者 Reactive 对象.
  3. 这两类对象中,或者在创建阶段,或者在依赖收集阶段会创建Dep .
  4. 有了订阅源,后面凡人要创建 Subscriber , 可能是一个 Effect 对象,也可能是一个 Computed 对象。
  5. 例如 Effect 创建的时候,其内部的方法一定被调用了。
  6. 在这个方法调用的时候,会访问到 Ref 或者 Reactive 对象,这个时候触发了Track流程。
  7. Track 流程中会追加 Link 关系。就是在这一步,开始织网的。

关键是“网” 怎么织:

  1. 有个全局的activeSub,一定指向正在运行的 Subscriber 。 如果没有,说明触发响应式变量的操作不在 Effect 或者 Computed 对象里面,嘿嘿😄 ,忽略就好。
  2. 有这个activeSub,就可以对应创建一个Link,这个Link要放到activeSub的订阅源链表中。
  3. 同样的,Link也要追加到当前 Dep 的 订阅者列表里面。
    • 如果订阅源是一个computed对象的话,那computed本身既是订阅源,也是订阅者,那这个computed对象的deps,也同时是 activeSub的订阅源,activeSub的订阅源列表中要递归加入这个computed对象的订阅源。 这里是一个小逻辑。

自然而然的,在调用Ref或者Reactive对象的写操作的时候,就会触发这些对象对应Dep的trigger 操作。

这里的trigger 分为 notify 阶段 和 Subscriber的trigger阶段。原因是一次effect中,可能在一条指令中修改了多个响应式变量,每当触发变量的trigger,实际上启动了一次 startBatch —— 批操作。但是在endBatch的时候,未必会真正的执行effect,代码内部通过一个计数器的方式,保证要batch的操作都完成了之后,才会真正触发effect的trigger。

notify 阶段,实际上是把Subscriber的flag增加上 NOTIFIED标记,同时加入到 batchSub 链表中,用于之后真正执行响应式动作。

如果Subscriber是一个Computed 的话,流程类似,就是加到 batchedComputed 中。

剩下的一些源码就是computed 如何实现、Watch如何实现。其实到这里的时候,这部分代码已经没有什么引起我好奇的事情了,大体可以猜到这两个部分如何实现的。所以reactivity 的阅读到此告一段落。