从零实现Vue响应系统(一、概念与基础架构)
这篇文章是我系统学习《Vue设计与实现》一书的笔记开篇,主要介绍了响应式数据与副作用函数的概念,以及如何设计一个完善的响应式系统。后续我会就这一本书,逐章逐节进行阅读,并将阅读笔记整理成文章发布至此。
在 Vue.js 中,响应系统贯穿了这款框架的设计始终。如果说 React 是遵循 单向数据流 的框架,Vue 则通过 响应式数据绑定 实现视图与数据的自动同步。那么 Vue3 中的响应式系统究竟是怎么实现的呢?我很好奇!
注:本文是 Vue 设计与实现 一书的阅读笔记,包括原文的部分摘抄以及本人的总结,尽可能用自己的理解描述一遍,并附上完整的实现代码。 本文中的完整代码地址:https://github.com/nonhana/demo-vue/blob/main/src/js/responsiveData.js
响应式数据与副作用函数
副作用函数
当我们在接触 React 或者是一些函数式编程概念的时候,我们或多或少都有听过一个名词:副作用。而副作用函数也就是那些会产生副作用的函数。
那么什么是副作用?
对于这个函数而言,它的作用是 设置 body 的文本内容,但是 除了 effect 以外的任何函数都可以读取或设置 body 的文本内容。所以这个函数的执行很可能会 直接或间接地影响其他函数的执行。这个时候我们就会说 effect 函数产生了副作用。
因此,副作用函数的本质就是 这个函数内部进行的操作会影响到外部某些事物的变化,而这些外部的事物能够被其他的函数使用。
更直观的例子:
如果我们不执行 effect
,那么最后会打印 2;如果执行了 effect
,最后会打印 3。
而影响打印结果的并不是最终进行打印的 effect2
函数,那么我们就可以说 effect
函数产生了副作用,effect2
函数就是受到了副作用的影响。如果我们不关注具体代码中在哪里调用了 effect
函数而只关注 effect2
,那么我们就会发现输出的值是 不可预测的。
为了做对比,我们也来探讨一下在函数式编程领域最重要的一个概念:纯函数。
纯函数是指:
- 给定相同的输入,永远会得到相同的输出。这意味着函数的输出仅仅依赖于它的输入参数,而与外部的状态或变量无关。无论函数被调用多少次,只要输入相同,输出也一定相同。
- 没有副作用。副作用指的是函数内部改变了外部世界的状态,或者对外部环境(如全局变量、I/O 操作等)产生影响。纯函数不会修改外部的变量或状态,也不会与外部环境产生任何交互(例如,打印到控制台、写入文件、修改全局变量等)。
举个例子:
这就是一个标准的简单纯函数,它不修改任何外部状态,也不依赖任何外部的全局变量。
响应式数据
响应式数据简单来说,就是这个数据能够 响应 其发生的变化。当数据变了的时候,会 自动的触发某些操作。
我们假设这个 obj 已经是一个响应式数据,那么我们期望 obj 里面这个 text 属性变了的时候能够重新触发这个 effect
函数,然后重新设置这个 textContent
属性。
响应式数据的基本实现
在刚才的描述中,我们能够发现,响应式数据和副作用函数 是两个相互依赖的关系。
- 当副作用函数
effect
执行时,会触发obj.text
的 读取 操作; - 当修改
obj.text
的值时,会触发obj.text
的 设置 操作。
那么,我们只要能够拦截到一个对象的 读取 和 设置 操作,我们就能够在这些操作上面去做手脚了。
当对某个字段属性进行读取时,我们可以把对应的副作用函数存储到一个 bucket 里面,在设置字段属性时我们把副作用函数从 bucket 中取出并重新执行就可以了。
而在 Vue3 中,拦截一个对象属性的读取和设置操作我们都知道是通过 Proxy 来实现的。
可以简单的使用 JavaScript 来进行初步实现:
当然,上面这段代码只是最简单的根据固定的对象和函数进行的实现,这种 硬编码 的方式是我们平时在编写代码时应当全力避免的。
那么,如何设计一个完善的响应式系统呢?在 Vue.js
中,一个完善的响应式系统主要需要考虑以下几个因素:
设计一个完善的响应式系统
如何注册函数
我们都已经知道了,响应系统的一般工作流程如下:
- 发生 读取 操作时,将副作用函数收集到 bucket 里面
- 发生 设置 操作时,从 bucket 里面取出副作用函数,然后依次执行
在上面我们刚刚实现的响应式系统,我们的副作用函数直接就是 effect
命名的,注册也用的这个名字,这肯定不行,我们必须要把 所有依赖于某个属性的函数全部放到 bucket 里面才可以,不管他是不是叫 effect
还是匿名函数。
所以,我们必须要想办法去实现一个机制,这个机制 专门用来把依赖于某个对象的某个属性的副作用函数收集到 bucket 里面,我们也可以直接称其为 注册 机制。
我们用 JavaScript 来实现一下:
我们用这个 activeEffect
专门来存那些需要被收集的副作用函数,而需要被收集的副作用函数需要通过 effect
来进行注册。effect
直接接受一个函数,所以不管你取什么名字都可以。
而对应的,在执行 fn
的时候会触发 get,这个时候需要收集的不是 effect
而是 activeEffect
:
怎么使用?
这样子算是解决了 如何注册函数 的问题。
响应式系统的数据结构
但是,我们稍微测试一下就可以发现新问题。比如,我们在 obj 这个 Proxy 上面设置一个不存在的属性的时候:
可以看到,执行了两次这个匿名副作用函数,但是我们的 notExist
属性实际上是不存在的,所以这个属性不应该建立响应联系。
不过这个问题肯定一堆人都看出来了,因为 Proxy 本身就是拦截 某个对象 的操作,并没有细化到 某个对象的某个属性 的操作。所以当对 obj.notExist
进行操作时,肯定会触发 set 操作,然后重新执行,没啥好奇怪的。
所以我们需要 重新设计数据结构,将副作用函数的收集细化到某个对象的某个属性。
我们原本是使用 set,直接把函数往里面一扔一拿就完事了,但是这是不行的。
我们可以看一下我们现在注册副作用函数的代码:
这个函数主要包含了三个部分:
- 被操作 & 读取的代理对象 obj
- 被操作 & 读取的字段名 text
- 副作用函数
effectFn
我们可以根据这三层,来构建一个 树形结构。为什么是树形结构?因为一个代理对象可能有多个属性值,一个属性值又可能有多个副作用函数,所以很容易就联想到树形结构的枝桠。
回到上面的问题,我们只需要将副作用函数和代理对象的关系约束到属性层面,就可以解决问题了。为了适应我们的树形结构,我们将数据结构也改为两层。用 TypeScript 来描述一下:
最外层是 WeakMap
,其值为一个 Map,这个 Map 保存着对象属性和副作用函数的映射。副作用函数列表本身则保存在 Set 中,需要通过指定的对象和属性方可取出。
为什么是 WeakMap
而不是 Map 呢?
- WeakMap 本身的 key 只能存储 object 类型
- WeakMap 对于 key 是弱引用,一旦对应 object 的表达式执行完毕,就会将其从内存中移除,不影响垃圾回收器的回收行为。因此特别适合 想要临时在某个对象上挂载数据 的行为。
基于最新的数据结构,我们来改写一下 Proxy 部分的代码:
代码改写的条理还是很清晰的。
get 部分先根据 target 这个原始对象拿到这个对象内部的属性和副作用函数的映射 Map,如果没有就 new 一个。同理,再根据 key 来拿到副作用函数的 Set,没有就 new 一个,然后往这个 Set 里面把新函数塞进去。
set 部分也是分层拿,没拿到就直接返回,如果拿到了就一个个拿出来执行就行。
可以再进行一层封装:
这样子拆代码能够减少耦合性,使得各个部分更加的各司其职。
分支切换
这不是 git checkout
那个分支切换,主要指的是在表达式中 根据条件执行不同的代码。我们可以看一下下面:
这里用了个三元表达式,根据 obj.ok
的值来切换执行不同的代码分支。
**分支切换可能产生遗留的副作用函数。**在上面,需要首先读 obj.ok
才能确定接下来要读 obj.text
,然后再读 obj.text
,触发了 obj
两个属性的读取操作,所以相当于是给 obj 的两个属性都加上了副作用函数的依赖。
那么,我们把 obj.ok
设置为 false,此时 obj.text
就不会被读取了。理想情况下的依赖收集情况应该是这样:
但是现在很显然是做不到的,切换 obj.ok
后也只能保持第一种的收集方式,那么多出来的那个副作用收集,也就是 obj.text
对应的那个副作用函数收集就是 遗留的。
**遗留的副作用函数会导致不必要的更新。**那么怎么解决这个问题呢?
我们可以先想想副作用函数在执行的时候会发生什么?答案是 副作用函数执行的时候会读取某个对象的某个属性的内容。而读取的时候会发生什么?会把这个副作用函数给放到对应 obj 对应 key 的 Set 里面。也就是说,即便副作用函数被注册了,在对某个响应式对象的某个属性进行 set 操作的时候,还是会照样触发,照样走一遍注册的流程。而因为是 Set,Set 保留了这个函数的引用,因此塞不进去了。
那么很简单了,既然每次执行副作用函数都会进行一次注册的操作,那我在执行这个副作用函数之前,先把这个副作用函数从所有依赖的 Set 里面 filter 掉不就可以了?反正你还得重新注册,我全删了你重新来一遍就行。 这巧妙的解决了分支切换的问题,因为在重新执行之前,一些分支可能切换了,导致这个副作用函数读的属性可能发生了变化,那么我重新执行之前清理掉冗余的副作用函数,用全新的空依赖去注册,就行了。
为了实现这个东东,我们需要 明确的知道哪些依赖集合中包括这个副作用函数,所以得重新设计一下这个副作用函数,给这个副作用函数加一个 deps
属性来存所有包含这个副作用函数的依赖集合。相当于 Map 是存了属性和副作用函数 Set,而副作用函数也存一个 数组 来反向标记哪些依赖,是一个双向的过程。
好,我们开始改写:
很简单吧,把原来的逻辑单独拆到一个函数里面,函数也是个对象,我们可以直接挂一个 deps
的属性,然后执行这个函数就行了。
接下来我们着重处理这个 deps
数组的 依赖收集。依赖收集这个词,终于出现了!
在哪里收集呢?在 track
函数里面。
可以看到,在最后一步将这个 target 的这个 key 的所有副作用函数 Set 当作依赖项放入 deps 里面,建立了如下的关系:
明确了这个关系之后,我们可以在每次副作用函数执行之前,根据当前这个副作用函数的所有依赖项的副作用函数 Set 来移除它:
注意,为啥能这样做,因为 JS 中对于一个对象默认都是用 引用 的方式进行保存的。所以可以直接遍历 effectFn.deps
数组,取出 Set,然后删掉要执行的副作用函数。
这样子能够避免分支切换导致的副作用函数遗留。
不过还是有点问题,现在其实会无限循环运行。可以看一下我们目前的 trigger 函数:
现在的 trigger 函数遍历 effects 集合,集合中的每个副作用函数执行的时候会调用 cleanup 进行清除,实际上就是从 当前 effects 集合 中将当前执行的副作用函数剔除。但是 副作用函数的执行会导致其重新被收集到这个 effects 集合当中,而此时的 forEach 还没遍历完呢,所以会导致一直卡在同一个副作用函数的执行上走不动了。
所以,我们需要在执行的时候,用一个新的集合进行遍历,确保正在遍历的这个集合不是原本的集合,就行了。
这样子,我们的分支切换算是解决了。
嵌套的 effect 与 effects 栈
在实际的代码编写中,effect 是有可能会发生嵌套的,比如我们有时候会这样子写代码:
这个场景其实很常见。我们知道 Vue 是可以组件套组件的,Vue 的组件经过编译之后就是一个普通的渲染函数,而这个渲染函数中肯定有一些响应式变量,这个渲染函数就是副作用函数。那么,我们在一个父组件里面套一个子组件,这个时候的渲染函数执行情况就是类似于上面的情况。
所以 effect 必须要设计成可嵌套的。
而我们上面的实现其实并不支持 effect
嵌套。比如这样写:
如果这么写了,我们想要的结果应该是,修改 obj.name
的值之后,会触发外层函数的执行从而间接触发内层函数的执行;而修改 obj.age
的值之后,只会触发内层函数的执行。但是很明显,结果并没有按我们的期望输出:
前两行是初始化数据,但是到后面我们发现居然内层的执行了,外层的没执行。
不过我们稍微想想,其实能够找到原因。为什么 只 触发内层的副作用函数呢? 外层的哪里去了? 答案是 被内层的给覆盖了 。
我们上面的代码,只使用了一个 activeEffect
变量来存需要注册的副作用函数,而且它是一个全局变量,那也就意味着在同一时刻 只能存一个副作用函数。而嵌套 effect
的写法,相当于在第一次初始化的时候,activeEffect
先是被赋值为外层的副作用函数,然后里面又有个 effect
,这个 effect
在执行的时候又有个内层的副作用函数,这个时候又重新走了一遍注册副作用函数的流程,所以内层的就会直接把外层的副作用函数给覆盖了。
所以简单来说,我们目前的代码 只能够注册嵌套 effect
中最内层的副作用函数 。而根据我们改完后的依赖收集数据结构,Map 里面的 key 倒都是能够正常一个个注册的,但是由于副作用函数被最内层的覆盖了,所以 每个 key 对应的副作用函数 Set 都会是同一个。所以你无论改了什么属性的内容,都只会触发最内层的副作用函数。举一个更更更直观的例子:
所以怎么解决呢?核心问题是现在的方案会导致内层的把外层的副作用函数给 覆盖 并且 无法复原。为了能够保留以前的副作用函数,我们需要用一个 栈 来存这些副作用函数。
当副作用函数执行时,把当前的副作用函数压入栈中 ,待其执行完毕后从栈中弹出,而 activeEffect
始终指向栈顶的副作用函数。这样子能够实现 响应式数据只会收集直接读取它值的副作用函数 而不会相互影响。
所以我们可以改代码,加一个 effectFnStack
:
我们改完后可以重新模拟一下嵌套 effect
被调用的过程。拿这个例子来:
首先,传入的参数 effectFn
是这个函数:
activeEffectFn
就等于这个fn1
,然后将fn1
压入effectFnStack
,然后执行fn1
。
此时的effectFnStack
:索引 值 0 fn1 - 执行
fn1
的时候,遇到了第二个effect
。这个effect
包含的副作用函数是:
然后,activeEffectFn
就被 覆盖了。没错,还是会被覆盖的!不过我们之后利用栈能够拿回原来的!
然后将fn2
压入effectFnStack
,此时的栈为:索引 值 0 fn1 1 fn2
压完之后,执行副作用函数。它 读取了obj.age
,触发了我们的track
函数,实现了依赖追踪,把fn2
和obj.age
建立了联系。现在的 Map 里面,存的是age
和只包含fn2
的Set
。 fn2
执行完之后,栈把fn2
给弹出来,只剩下fn1
,然后把现在是栈顶的fn1
又重新赋值给activeEffect
。- 之后,我们才走到
temp1 = obj.name
这一行,读取obj.name
,然后建立起映射。
所以现在的 Map 映射如下:Key Value age fn2 name fn1
而我们知道,fn1
实际上是包含了fn2
的:
所以现在我们修改obj.name
的值,就会从上到下依次重新触发fn1
和fn2
的内部流程。
但是,fn1
重新触发的时候还是会执行effect
函数啊?这有没有问题啊?
没问题,即使重新执行了一遍,也只是把内部的嵌套函数注册流程又走了一遍,每次执行的时候都会cleanup
然后再 push 到 Set 里面。不过重复执行 effect,确实有点浪费性能啊!
至此,嵌套 effect
的问题我们算是解决了。
避免无限递归循环
我们看一个例子:
这个简单的副作用函数里面写了个自增操作。我们知道自增操作相当于是:
既会读取 obj.count
的值,又会设置 obj.foo
的值 。在执行这个代码的时候,首先是读 obj.count
触发 track
函数来收集依赖,把这个函数本身放到 Set 里面。
放好了之后,就是赋值语句,触发了 trigger
操作,会重新拿出 Set 里面的函数执行。但问题是这个 Set 刚刚被塞进来了你自己 ,所以拿出你自己执行,又会重新走一遍上面的流程,导致 Set 被无限的推进你自己,然后又无限的执行无数个你自己,导致了栈溢出。
这个问题的核心在于 读取和设置操作是在同一个副作用函数里面进行的。所以我们可以加一个条件,如果 trigger
触发执行的副作用函数和当前正在执行的副作用函数( activeEffect
)相同,就不触发执行 。
我们改一下代码:
这样就可以把 副作用函数数据源 和 副作用函数执行栈 给拆了开来,因此能够确保正常执行。