重读Vue3源码之reactivity
这是一篇流水账,边看代码边写,写到哪儿算哪儿。准备写完第一遍之后再总结重构。
Vue 的源码比较简洁,符合中国人的编码直觉,所以读起来会比较顺畅,比较有学习价值。
Vue 的代码是一个monorepo,大体分为下面几个部分:
reactivity核心中的核心,可以脱离Vue 独立使用,包含了响应式的核心逻辑,比如scope、effect、dep、link等。compiler-*这里其实是4个包,包含core、dom、sfc、ssr4个编译器实现,分别对应核心框架、DOM(template部分)处理、单文件组件、服务端渲染。runtime-*包含三个包,分别是core、dom、test。server-renderer服务端渲染的实现。shared多个仓库共用的一些代码,例如枚举定义等。vue嗯,这里是入口。vue-compat这个是兼容Vue2的代码实现。
这样一列就比较明确了,阅读顺序应该是reactivity → compiler-core → compiler-dom → compiler-sfc ,后面再说。
响应式核心 —— reactivity
核心概念:
reactivity最最核心的响应式变量封装对象,value必须是个Object对象。ref可以粗略的认为是reactive的上层封装,有了这层就能让基础数据类型,比如number实现响应式(其实就是包了一层)。depPubSub模式中的订阅源。link关联dep和effect的双向链表。effect响应式操作,简单来说就是订阅源变化之后要做什么操作。computed和effect是并列的,用于根据响应式变量计算出衍生的值。effectScope响应式生命周期的管理,说白了就是什么时候开始收集依赖,什么时候暂停、结束。
其他的还有watch 等等,按下不表。
响应式变量是如何封装的
开篇先看看,核心的响应式变量封装了个什么 —— Target。 显然是个Object,并且包含了几个特殊标记:
SKIP: 跳过响应式监听IS_REACTIVE: 是否是响应式的,比如上面SKIP了的话,IS_REACTIVE就是INVALID,后面也就不会再参与响应式过程。IS_READONLY是否只读、、IS_SHALLOW是否是浅层响应式,这两个标记功能类似。RAW: 这里就是响应式对象的原始值了,可以看到是个any类型。
1 | export interface Target { |
之后是4个全局变量 reactiveMap、shallowReactiveMap 、readonlyMap、shallowReadonlyMap,分别存储了所有的响应式变量Target 和用于封装这个Target的Proxy的映射关系。
从4个变量的名字可以看出来,一般响应式变量、浅层响应式变量、只读变量、浅层只读变量4个是分开存储的。
对应的也有4个方法来创建不同类型的响应式变量:reactive、shallow、readonly、shallowReadonly。
这4个方法都是对createReactiveObject 方法的封装。嘿嘿,主角登场。
核心方法: createReactiveObject
这个方法的主体逻辑非常清晰简洁。
- 如果Target是个Proxy,并且想在标记为响应式的Target上创建一个只读的响应式变量,就直接返回原响应式变量。
- 获取target的响应式类型,这个类型分为集合、一般变量和无法响应式的变量。这里的无法可能是标记为不需要响应式。这里集合类型包含map和set,一般变量类型包含一般对象和数组。嗯,就这几种,非常直观。 上面已经说过Target必须是Object,所以不用考虑其他情况。
- 先在全局map中查看下,是不是已经创建过Proxy,如果创建过则直接返回。
- 如果没有的话,根据响应式类型装配不同的
handlers。 - 在全局map中记录这个新创建的proxy,并返回。
1 | function createReactiveObject( |
Proxy封装了个啥: handlers
从上面代码可以看出,创建响应式变量中到底响应了个啥,再深层的逻辑都在handlers中。和上面逻辑对应,只读/可写、集合/一般对象、深层/浅层,叉乘下来貌似需要很多类型的Handler,但是实际上只有几个核心的类。
不同类型变量的封装逻辑
先从最简单的 BaseReactiveHandler 开始。 这个类只封装了一个get方法。 做的事情就是:
- 排除掉一些Vue内置的标记位,直接返回不参与响应式;
- 如果要访问的property就是Target的原始值呢? 有个边缘Case,就是用户的原始值就是一个Proxy,但是仿照了Vue的响应式Proxy,那直接返回这个用户定义的Proxy。(这里给我看郁闷了,有点绕)
- 如果目标对象是个数组,这次get其实是访问数组的方法,比如
forEach呢? 这里会从全局arrayInstrumentations取对应的方法,直接返回,原因后面再管。 - 用
Reflect获取具体的属性值。** 注意 ** ,直到这里,都还没有收集依赖哦。 - 如果当前Proxy是只读的,或者访问的是一些内定的不需要跟踪的属性,嘿嘿,就不用收集依赖了。
- 反之,收集依赖。 至于如何收集,后面再慢慢展开。 我粗看上去是一个
Link和Dep组成的双向十字链表(不是)的样子。 - 按照常规情况,还需要将返回的值封装成reactive对象。 也就是说Vue中多层嵌套响应式对象,不是在创建的时候就遍历属性值创建Proxy,而是在get阶段,get到哪儿创建到哪儿。
还是比较简单的。
MutableReactiveHandler
下面是MutableReactiveHandler,增加了set、deleteProperty、has和ownKeys方法。
set比较简单,因为get拿到的一定是reactive对象,所以先toRaw之后做了一些防御性编程。通过Reflect设置值之后,收集依赖。deleteProperty、has和ownKeys几乎就是单纯调用了Reflect,然后收集依赖。
有了这几个方法之后,Handler就能处理可写的对象了。
处理集合类型(Set/Map, 没有数组)的Handler比较特殊,并不是用类组装而成,而是调用createInstrumentationGetter方法生成的Handler.get,这个方法返回的类型是一个Instrumentation 对象。 乍一看很奇怪,为什么这么做呢?
首先,访问集合中的元素是不能直接按照property的方式访问的,而是调用集合的has,get等方法,所以收集依赖实际上就要劫持这些方法,劫持也是通过Proxy覆盖这些方法来实现的。
其次, createInstrumentationGetter 也只劫持了影响响应式的几个方法,比如forEach,clear等。
不过这几个方法封装的都比较简单,大体流程都是先转化成原始值、检测是否有变化、触发依赖收集、返回值/调用具体的方法。略有区别的就是对于是否只读做区分处理。
什么时候开始收集依赖?
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。
嗯嗯,就这些了。
可以看到,真实记录Dep 和 Effect 的关联关系,以及触发Effect 运行的逻辑都不在这里,在Scope中只有一个记录ReactiveEffect的数组,貌似有点关联。别急,继续看。
响应式关系具体如何记录的?
这里我遇到了一些困难。Dep、Link、ReactiveEffect 等这些类的关联关系错综复杂。感觉这几个类相互之间都存了一个双向链表。
先从最基础的来,响应式关系本质是一个订阅者模式,订阅的源在Vue里面的数据类型是 Dep 。 订阅者的核心数据结构是 Subscriber 。 Subscriber 分两种: ReactiveEffect 和 Computed 。
这样的订阅结构使用Link 数据结构来存储,Dep 和 Subscriber 是多对多结构,所以Link中存储的数据比较复杂。
1 | /** |
这段代码我必须把注释一起贴过来,可以看到里面存储Dep 用的是双向链表。存储Subscriber也是双向链表。用preActiveLink 来存储当前active的link链表,这里数据结构都飞起来了。 Link 是一个纯存储类,没有任何方法。
然后Dep 中也存了Link的双向链表,用于找到订阅者,再然后,Subscriber 中也存了Link的双向链表,用于找到订阅源。
响应式的大流程
单从数据结构看,会感觉逻辑非常破碎,但是将响应式的流程穿起来,就清晰一些。
- 神说,先要有
ReactiveScope,标记下现在开始收集依赖。 - 然后,有凡人创建了
Ref或者Reactive对象. - 这两类对象中,或者在创建阶段,或者在依赖收集阶段会创建
Dep. - 有了订阅源,后面凡人要创建
Subscriber, 可能是一个Effect对象,也可能是一个Computed对象。 - 例如
Effect创建的时候,其内部的方法一定被调用了。 - 在这个方法调用的时候,会访问到
Ref或者Reactive对象,这个时候触发了Track流程。 Track流程中会追加Link关系。就是在这一步,开始织网的。
关键是“网” 怎么织:
- 有个全局的activeSub,一定指向正在运行的 Subscriber 。 如果没有,说明触发响应式变量的操作不在 Effect 或者 Computed 对象里面,嘿嘿😄 ,忽略就好。
- 有这个activeSub,就可以对应创建一个Link,这个Link要放到activeSub的订阅源链表中。
- 同样的,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 的阅读到此告一段落。