从零开始实现 Vue3 响应式
Vue3 与 Vue2 的最大不同点之一是响应式的实现方式。众所周知,Vue2 使用的是 Object.defineProperty
,为每个对象设置 getter 与 setter,从而达到监听数据变化的目的。然而这种方式存在诸多限制,如对数组的支持不完善,无法监听到对象上的新增属性等。因此 Vue3 通过 Proxy API 对响应式系统进行了重写,并将这部分代码封装在了 @vue/reactivity
包中。
本文将参照 Vue3 的设计,从零开始实现一套响应式系统。注意本文引用的代码与实际的 Vue3 实现方式有所出入,Vue3 需要更多地考虑高效与兼容各种边界情况,但此处以易懂为主。 文中提到的大部分代码可以在 https://github.com/wxsms/learning-vue 找到。
什么是响应式
Evan 经常举的一个例子是电子表格(如:Excel)。当我们需要对某一列或行求和,将计算结果设置在某个单元格中,并且在该列(行)的数据发生变化时,求和单元格的数据实现实时更新。这就是响应式。
以代码来表达的话:
let col = [1, 2, 3, 4, 5] |
我们就可以的得到一个求和值 s
。
不同的是,以上代码是命令式的。也就是说,当 col
发生变化时,s
的值并不会随之改变。我们需要再次调用 s = sum(col)
才能得到新的值。
响应式就是要解决这个问题:当 col
发生变化时,我们可以自动地得到基于变化后的 col
计算而来的 s
。
我们的目标:
graph LR
A[依赖变更] -->|自动触发| B[响应函数]
B -->|自动监听| A
依赖与依赖监听
当要实现一个响应式系统的时候,我们实际需要的是什么?
答案是依赖与依赖的监听。
用上面的例子来说,col
是依赖,s=sum(col)
是监听依赖做出的反应。当依赖发生变化时,反应可以自动执行,这件事情就完成了。
依赖
那么我们先来实现依赖。 一个依赖:
- 代表了某个对象下面的某个值;
- 当值发生变化时,需要触发跟它有关的作用(effect)。
以下是实现代码:
// Dep -> Dependency 依赖 |
除此以外,我们还需要一个变量,用来存储所有的依赖:
// target (object) -> key (string) -> dep |
depsMap
是一个嵌套的 Map:
- 它的 key 是一个 Object,如
c = a.b + 1
中,key 是a
这个对象; - 它的 value 又是一个 Map:
- 它的 key 是一个键名,如
c = a.b + 1
中,key 是b
; - 它的 value 是一个 Dep 实例。
- 它的 key 是一个键名,如
mindmap
depsMap
A[Key: Object]
B[Value: Map]
C[Key: string]
D[Value: dep]
在实际的 Vue3 代码中,这里的第一层使用的是 WeakMap 而非 Map。原因是 WeakMap 对 key 是弱引用,当 key 在代码中的其它地方已经不存在应用时,它 (key) 以及对应的 value 都会被 GC。而如果使用 Map 的话,保有的是强引用,就会导致内存泄漏。
依赖监听
一个依赖监听模块大致需要以下内容:
import { Dep, depsMap } from './dep'; |
trigger
触发作用的代码非常简单,只需直接拿到对应的 dep,并调用它的 trigger
函数:
export function trigger (target, prop) { |
track
与 trigger
相反:trigger
是将 dep 取出来并触发里面的 effects,而 track
是将 effect 保存到 dep 中去。
需要注意的是,因为 depsMap
一开始是空的,所以取 dep 会包含一个初始化的过程:
function getDep (target, prop) { |
下面是 track
函数的具体实现:
export function track (target, prop) { |
effect
effect 作为一个工厂函数,只需完成 ReactiveEffect 实例的创建并立即运行:
export function effect (fn) { |
ReactiveEffect
最后来实现 ReactiveEffect 这个类。从上面的其它函数可以看出,这个类需要以下功能:
- 接收一个
fn
函数; - 包含一个
run
成员方法,可以运行一次该作用;
下面我们来分别实现它们。
1. 构造器
简单赋值即可:
constructor (fn) { |
2. run
run 函数的关键在于 currentEffect
的赋值:我们在这里默认在 fn
函数运行的过程中,会发起对相应依赖的 track()
,而 track
函数中会使用到 currentEffect
。这也是为什么它需要作为一个全局变量单独抽离出来,成为 track 与 effect 之间的纽带:
run () { |
仔细看的话会发现,这里每一次调用 run
都会给 currentEffect
赋值,可以理解为发起了依赖收集的流程。换而言之,每次执行这个作用都会收集依赖。为什么要这么做?举个例子:
effect(() => { |
如果依赖收集只执行一次,并且第一次执行的时候 a.b
是 falsely 的,那么第一次执行就只收集到了 a.b
这个依赖,而 a.b.c
没有收集到。那么后续当只有 a.b.c
发生变化时,d 将不会被重新赋值,这是不符合预期的。因此,目前来说依赖收集需要在每次作用函数运行时都进行。
小结
目前为止,我们定义了两个类以及一些工具函数:
Dep
表示一个“依赖”,它内部含有一个effects
集合,用来触发与它有关的作用;ReactiveEffect
表示一个“作用”;track
与trigger
函数,分别用来追踪依赖与触发作用。
它们可以实现如下效果:
let obj = { a: 1, b: { c: 2 } }; |
看起来好像是那么回事了,但还有点抽象,距离我们的最终目标还有一定距离。
响应式变量
现在我们有了依赖与依赖追踪,是时候来实现第二个关键组件 reactive
了。 它将帮我们完成“在作用函数内部自动调用 track()
”以及“依赖变化时自动调用 trigger()
”的工作。
众所周知,Vue3 使用了 Proxy 来实现响应式:
import { track, trigger } from './effect.js'; |
我们需要做的两件事:
- 实现 getter:当
get
触发时,追踪依赖 - 实现 setter:当
set
触发时,触发作用
graph LR
A[reactive] -->|发生读取| B[track]
A[reactive] -->|发生变更| C[trigger]
B --> D[ReactiveEffect]
C --> D[ReactiveEffect]
get
get
的第一版实现:
get (target, p) { |
Reflect 通常是与 Proxy 成对出现的 API,这里的 Reflect.get(...arguments)
约等于 target[p]
。
但是,这么做有个问题!因为 Proxy 代理的是浅层属性,举个例子,当我取 a.b.c
时,实际上分了两步:
- 先取
a.b
,这里a
是 reactive 对象,能够触发 getter,没问题; - 再取
b.c
,注意这里如果不做任何操作的话,b
将是一个普通对象,也就是说取值到这里响应性就丢失了。
为了解决这个问题,我们需要做一点小小的改造:
get (target, p, receiver) { |
set
实现 setter 需要注意的点是:
- 触发作用要在设置新值后进行;
- 需要判断新旧值是否相等以避免死循环。
set (target, p, value, receiver) { |
大功告成!
小结
我们现在可以:
- 定义响应式变量;
- 定义作用函数;
- 响应式变量发生变化时,函数将自动执行。
let a = reactive({ value: 1 }); |
实际上当进行到这里的时候,响应式的两大基石就已经完成了。因此下面其它的 API 实现我决定都通过 reactive
与 effect
来实现。当然实际上 Vue3 考虑的更多,做的也会更复杂一些,但是原理是类似的。
其它响应式 API
mindmap
Reactive & Effect
A)ref(
A)computed(
A)watch(
A)watchEffect(
A)...(
ref
上面的 reactive
API 可以对对象和数组这样的复杂类型完成监听,但对于字符串、数组或布尔值这样的基本类型,它是无能为力的。因为 Proxy 不能监听这种基本类型。因此,我们需要对它进行一层包裹:先将它包裹到一个对象中,然后通过 a.value
来访问实际的值(这实际上是 Vue3 目前仍在致力于解决的问题之一)。
下面,我们将以惊人的效率实现 ref
:
export function ref (value) { |
这种方式非常简单直接,并且能够完美地运行:
let a = ref(1); |
当然,实际上 Vue3 不是这么干的:它实现了一个 RefImpl
类,并且与 reactive
类似地,通过 getter 与 setter 完成对 value 的追踪。
computed
计算属性 (computed
) 是经典的 Vue.js API,它能够接受一个 getter 函数,并且返回一个实时更新的值。
仅 getter
我们先来实现一个最常见的版本:
export function computed (getter) { |
这是一个只包含 getter 函数的计算属性,它可以这么用:
let a = ref(1); |
getter & setter
复杂的计算属性可以同时拥有 getter 和 setter:
let a = ref(1); |
为了优雅起见,我们先对 computed 内部的函数做一下封装,首先是 getterEffect,它与上面的实现一样,接受一个 ref 与一个 effect 函数:
function getterEff (computedRef, eff) { |
然后是 setterEffect:
function setterEff (computedRef, eff) { |
与 getterEffect 不同的是,setter 是将 ref 值作为参数传入到 effect 函数内,而 getterEffect 是将 effect 函数的返回赋值给 ref。
最后,我们就可以得到完整的 computed
函数了:
export function computed (eff) { |
watch
除了经典的 watch
API 以外,Vue3 还带来了一个新的 watchEffect
API。与 watch
不同的是,它可以:
立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行。
也就是说,watchEffect
无需指定它监听的值,可以完成自动的追踪,且会立即执行。
然而在实现这两个 API 之前,需要先对 ReactiveEffect 做一点小小的扩展改造。
effect 改造 - 允许停止运行
Vue3 的 watch/watchEffect API 会返回一个 stop 函数,该函数可以将侦听器停止,停止后即不再自动触发。而我们目前的 ReactiveEffect 尚不支持停止。因此我们需要给它加一个 stop
函数。
想要停止一个 effect,我们需要做的事情就是把它从所有的 dep 中移除,这样一来 effect 就不能被 dep 触发了。比如:
for (let deps of depsMap) { |
但是,这样做有几个问题:
- 太过暴力,性能堪忧;
- 由于 Vue3 实际上在第一层使用了 WeakMap,而 WeakMap 是不支持遍历的。
因此,我们需要对 ReactiveEffect 做一些改造,将这些相关的 dep 存下来。
export class ReactiveEffect { |
同时,我们需要在追踪依赖时,将依赖添加到 effect 的 deps 中(双向追踪):
export function track (target, prop) { |
watchEffect
加入 stop 函数后,watchEffect 实现如下:
export const watchEffect = (cb) => { |
非常地“水到渠成”!
let a = ref(1); |
effect 改造 - 加入 scheduler
与 watchEffect 不同的是,watch 有更多特性:
- watch 方法接收两个参数:source 和 callback,分别代表监听的对象和 effect 函数;
- effect 函数接收两个参数,value 和 oldValue,分别代表新的值和变化后的值;
- 初次定义时,effect 函数不会运行;
- 只有 source 的改变才能触发 effect。
为了实现第 3&4 点,我们需要给 ReactiveEffect 加入一个 scheduler 的概念:它将决定 run
函数何时执行。
首先我们需要修改一下 Dep 类:
export class Dep { |
然后修改 effect:
export class ReactiveEffect { |
OK,完成了。实际上只是添加了一个可以自由更改 run 执行时机的选项。但 scheduler 非常强大,Vue 的另一个核心功能 nextTick
也是基于它实现的,此处先不展开。
watch
watch 的函数重载非常多,为了简单起见,我们只实现其中一种形式:
- getter:函数,返回监听的值;
- cb:回调函数
export function watch (getter, cb) { |
至此,watch 函数也实现完了。
let a = reactive({ value: 1 }); |
小结
在本节中,我们使用现成的 effect
与 reactive
API 实现了 ref
与 computed
,并且通过对 effect 扩展的两个功能(stop、scheduler)分别实现了 watchEffect
与 watch
。
至此,Vue3 响应式的核心功能已全部实现完!
响应式 UI
现在既然已经实现了响应式,那么我们回到最初的问题:
let col = [1, 2, 3, 4, 5] |
我们如何将这段代码变成响应式的,或者说,是否可以更进一步,直接将它变成响应式的 UI?
graph LR
A[数据变更] -->|自动触发| B[界面渲染]
B -->|自动监听| A
那么我们直接来定义一个(似曾相识的)组件:
const App = { |
然后,我们编写一个(似曾相识的) createApp 函数:
function createApp (Component) { |
最后,我们将组件挂载到 #app
上:
createApp(App).mount('#app') |
(当然我们还需要一个 HTML 文件):
|
完成了!虽然还非常简陋,但我们已经用与 Vue 类似的方式实现了一个响应式的前端页面:当 col
更新时,页面上将显示新的求和值。
再次强调,本文使用的实现思路与 Vue 大致相同,但简化了许多。对此感兴趣的同学,欢迎阅读 vuejs/core 源码。