这是笔者在阅读《Vue.js 设计与实现》第二篇“响应系统”过程中的心得体会 💗。
响应系统概述
首先我们需要明确一个概念:什么是响应式数据?
回忆曾经编写过的 Vue 项目代码,我们或许可以给出这样一个定义:当一个变量发生变化时,所有与之相关的地方也会随之变化。我们来看这样一个例子:
const obj = { text: "Hello world!" };
function effect() {
document.body.innerHTML = obj.text;
}
在这段代码中,effect
函数会设置 body
的文本内容。但是,除了 effect
函数,其他任何函数也可以读取或设置 body
的文本内容。也就是说,effect
函数的执行可能会影响其他函数的执行,产生副作用。在这种情况下,我们称 effect
为一个副作用函数。
此时我们可以对响应式数据进行略微专业一点的定义:当一个变量发生变化时,所有与该变量有关的副作用会自动重新执行。对于上述代码,当我们运行 obj.text = 'Hello javascript'
时,我希望副作用函数 effect
会重新执行。
那么为了实现响应式系统,我们需要解决以下几个问题:
- 🔗 哪些副作用函数与响应式数据有关?
- 🚄 当响应式数据变化时,如何重新执行所有相关的副作用函数?
Vue 给出的解决方案是:通过 Proxy 拦截一个对象的读取与设置操作:
- 当副作用函数试图读取对象的某个属性时,我们会将该副作用函数存储在一个【容器】中。
- 当对象的某个属性被设置时,我们又会从【容器】中取出相关的副作用函数并重新执行它们。
然而,仅仅通过 Proxy 对数据进行劫持还是不够的,虽然 Proxy 可以告诉我们一个数据被访问或修改了,但它不能告诉我们这个操作是在哪个副作用函数中发生的,我们需要一个额外的工具来告诉 Proxy 这个数据到底被哪个副作用函数所依赖。这个工具我们姑且称之为 effect
,它的作用是注册一个副作用函数。
✨ 分析至此,一个基本的响应系统,应该包含以下三个模块:
track
: 当对象的读取操作触发时,进行副作用函数依赖收集。trigger
: 当对象的设置操作触发时,重新执行所有相关的副作用函数。effect
: 用于注册副作用函数,在 Proxy 拦截读取操作时,标记当前活跃的副作用函数。
实现基本的响应式
副作用函数的容器结构
在上文的叙述中,我们可以注意到无论是拦截读取还是设置操作,都需要用到一个存放副作用函数的“容器”。那么这个容器具体的数据结构是怎样的呢?回顾上文提到的代码:
function effect() {
document.body.innerHTML = obj.text;
}
在这段代码中存在三个角色 👨:
- 被操作(读取)的代理对象 obj
- 被操作(读取)的字段名 text
- 副作用函数 effect
根据上述信息,我们可以设计出这样的容器结构 🍧 :WeakMap<object,Map<key,Set<effectFn>>>
它的含义是:
WeakMap
的键是被代理的对象,值是一个Map
。使用WeakMap
是为了防止内存泄漏,当对象不再被引用时,可以自动释放内存。Map
的键是对象的字段名,值是一个Set
。Set
中存储的是与特定对象的特定字段相关联的所有副作用函数。
这样设计的目的是为了在对象的某个字段发生变化时,能够迅速找到所有相关的副作用函数并重新执行它们。具体的查找过程是:首先在 WeakMap
中根据对象找到对应的Map
,然后在 Map
中根据字段名找到对应的 Set
,最后在 Set
中找到所有的副作用函数。
注册副作用函数
上文提到,我们需要一个额外的工具 effect
来跟踪哪个副作用函数正在访问哪个响应式数据,这个过程我们称之为“注册副作用函数”📝。事实上这个工具的实现非常简单,首先我们 effect
函数传入一个副作用函数,利用全局变量 activeEffect
将其标记为 “当前活跃的副作用函数”。然后,我们会执行传入的副作用函数,在这个副作用函数的执行过程中,每当访问到一个响应式数据,就会被 Proxy 所拦截,这时我们就可以将 activeEffect
加入到上文提到的容器之中。这样,我们就建立了副作用函数和响应式数据之间的联系 😃。
一个简单的 effect
实现如下:(真正的 effect
函数需要考虑更多的情况)
let activeEffect = null;
function effect(fn) {
activeEffect = effectFn;
fn();
}
依赖收集与触发更新
在 Vue 的响应系统中,依赖收集与触发更新是两个核心的步骤 🔥,虽然上文中已经提到过了,但不妨再过一遍整个流程。
首先我们通过 Proxy 拦截对象 obj
的读取与设置操作。
随后我们注册一个副作用函数,该副作用函数读取了 obj.text
,在这个过程中,Proxy 对象会拦截这个读取操作,并且调用 track
函数。track
函数的作用是将当前活跃的副作用函数(由全局的 activeEffect
变量表示)添加到该属性的依赖列表中(借助前文提到的容器)。这样,我们就实现了对于 obj.text
的依赖收集.
然后我们尝试修改 obj.text
的值 ⚙️, 在这个过程中,Proxy 对象会拦截这个设置操作,并且调用 trigger
函数。trigger 函数的作用是根据该对象与属性,在副作用函数的容器中找到所有依赖于该属性的副作用函数,并重新执行这些函数。这样,我们就实现了 obj.text
变化引发的更新。
一个简单的代理函数实现如下:
function reactive(obj){
return new Proxy(obj,{
get(target,key){
track(target,key); // 读取时收集依赖
return target[key];
}
set(target,key,newVal){
trigger(target,key); // 设置时触发更新
target[key] = newVal;
return true;
}
})
}
// 此时 person 就是一个响应式数据
const person = reactive({name: 'John', age:18});
拓展处理
effect 存在的问题
在注册副作用函数时,我们还需要考虑两个问题 🚧。
- 【嵌套副作用】
在一个副作用函数中又注册另一个副作用函数,就会导致 activeEffect
被覆盖,所以可以应当将 activeEffect
设置为栈结构。
- 【分支切换】
在副作用函数中,可能存在这样的代码: return flag?obj.foo:obj.bar
,当 flag
不同时,应该与不同的响应式数据建立联系,同时也应当删除已建立的联系。解决方法其实很简单,在每次执行副作用函数之前,先把之间建立的所有依赖字段都删除了,然后在执行函数的过程中,又会对响应式数据进行读取,这就建立了新的依赖关系。
然后在之前进行依赖收集的时候,我们收集的是对象字段 => 副作用的联系,我们并不知道当前副作用函数依赖于哪些字段,所以在 track
时我们需要反向收集一下依赖以便调用前清除。
响应系统的可调度性 🎨
所谓可调度,就是当 trigger
动作触发副作用函数执行时,我们有能力决定副作用函数执行的时机、次数以及方式。
在现有的 effect
函数中,我们会在注册副作用函数时立即执行一次。然而,有时我们可能不希望副作用函数立即执行,而是希望能够在适当的时机手动调用。此时我们可以引入 lazy
选项,在 effect
函数中将注册后的副作用函数返回,以便人为地决定其调用时机。
上面提到的其实是副作用第一次触发的时机,我们还希望实现 trigger
时能够自定义副作用的执行过程。我们可以引入 scheduler
选项来指定调度器。在 trigger
触发时,我们会执行调度器,并将当前的副作用函数作为参数传递给调度器。在调度器中,我们可以自定义代码来控制副作用函数的执行过程。
计算属性 computed
计算属性(computed
)的核心是一个延迟执行的副作用函数。通过引入懒加载标记,我们可以手动调用这个副作用函数来获取其返回值。
在使用 computed
时,我们传入一个 getter
函数作为参数。这个函数会被用来创建一个带有懒加载功能的 effectFn
。computed
函数会返回一个对象,其中的 value
是一个访问器属性。每当我们尝试读取 value
的值时,就会手动执行 effectFn
并返回其结果。
需要注意的是,如果在一个副作用函数中访问了计算属性的值,那么当计算属性的值发生变化时,这个副作用函数也应该重新执行。但是,由于 computed
返回的并不是一个响应式对象,访问计算属性的值并不会进行依赖收集,也就无法触发更新🚨。
为了解决这个问题,我们可以在读取 value
时,手动触发依赖追踪(track
),建立 computedObj.value
与副作用函数之间的关系。然后,当 computedObj.value
更新时,手动触发触发器(trigger
),通知所有依赖于该计算属性的副作用函数重新执行。
然而,这样的设计还缺少数据缓存📚 的功能。为了解决这个问题,我们可以引入一个 dirty
标记和一个 lastVal
。当我们尝试获取计算属性的值时,首先会检查 dirty
标记,如果数据已经被修改(即为脏数据),则会重新计算并返回计算属性的值。否则,我们将直接返回上一次计算的结果,即 lastVal
。我们可以在 effect
的第二个选项参数调度器(scheduler
) 中重置 dirty
标记。当响应式数据发生变化时,调度器将被执行,数据将被标记为 dirty
,这样在下一次读取时,数据将会被重新计算。
监听器 watch
监听器(watch
)其主要功能是观测 👀 一个响应式数据,当数据发生变化时通知并执行相应的回调函数 🏃,在回调函数中我们能够获取到数据变换前后的值。watch
的实现基于 effect
以及其 scheduler
选项参数。
首先,我们需要实现对响应式数据的监视🧐。用户传入的可能是对象,也可能是一个 getter
函数。如果传入的是对象,我们需要深度遍历该对象的所有属性,并追踪所有属性的副作用。通常,我们会编写一个 traverse
函数来递归实现这个过程。
其次,我们需要实现数据变化时触发回调函数🚄 的功能。实际上,我们可以将 effect
的 scheduler
选项视为回调函数。对于用户传入的响应式数据,我们将其封装为一个带有 lazy
标记的副作用函数,并将用户传入的回调函数放在 scheduler
中。这样,一旦数据发生变化,调度器就会被执行,进而触发我们的回调函数。
最后,我们需要解决获取旧值和新值🦄 的问题。我们可以在初始化 watch
时手动调用副作用函数以获取一个值,该值可以视为旧值。然后,在调度器触发时,我们再次调用该副作用函数以获取新值。
关于监听器,还有更多的扩展内容,例如 immediate / flush / onInvalidate
等,但这些内容在此不作详细讨论。🐷
对象 & 数组响应式
首先,我们需要明确一个概念,Vue 的响应式是通过 Proxy 拦截对象的读取与设置操作。这里的读取与设置操作,其实是一个很宽泛的概念。下面我们来具体看一下普通对象的读取和设置操作。
读取操作包括 🔥:
- 访问属性:例如
obj.text
,这会触发get
操作的拦截。 - 判断对象或原型上是否存在给定的
key
:例如key in obj
,这会触发has
操作的拦截。 - 使用
for...in
循环遍历对象:例如for(const key in obj){}
,这会触发ownKeys
操作的拦截。
设置操作包括 💧:
- 通过
obj.foo
可以设置或者添加属性,这会触发set
操作的拦截,我们需要区分是增加属性还是设置属性。 - 通过
delete obj.bar
可以删除属性,这会触发deleteProperty
操作的拦截。
需要注意的是,在拦截 ownKeys
操作时,我们会新建一个 symbol
变量来标记与其相关的副作用依赖。当增加或删除属性时,应该触发与 for...in
相关的副作用。
对于数组而言,情况会有所不同:
- ⚔️ 索引与
length
- 通过索引可以修改或增加元素,当索引值大于数组长度时,就是增加元素 假设当前对象为数组且为增加元素的操作,那么应该触发与
length
相关的副作用 - 通过
length
也会影响数组元素,当length
设置为小于原来长度时 相当于将index > newLength
的数据设置为undefined
,需要触发相关的副作用
- 🔮
for...in
与for...of
遍历
- 对于
for...in
我们使用proxy
的ownKeys
去拦截,普通对象我们是采用一个symbol
去收集相关副作用,对于数组来说,for...in
与长度或者说元素的个数息息相关,所以我们可以直接使用length
属性去收集相关副作用。 - 对于
for...of
,其本质上是去访问数组的内置属性Symbol(Symbol.iterator)
. 事实上我们什么也不用做,就能实现目的。然而因为我们读取了Symbol
类型的属性,追踪它们的副作用可能会产生意料之外的错误,所以我们需要在拦截读取的时候避免追踪Symbol
类型的副作用。
- 🎁 数组的查找方法:
当在数组中进行查找时,用户可能使用原始对象也可能使用代理对象,我们需要重写相关的方法(如 includes,indexOf,lastIndexOf
)以进行额外处理。当我们在数组中找不到代理对象时,就去数组中寻找原始对象。
- 💰 隐式改变数组长度的方法:
push,pop,shift
等
当调用数组的 push
方法时,既会读取 length
属性又会设置 length
属性,这会导致两个独立的副作用函数互相影响,进而导致栈溢出。解决方案是引入 shouldTrack
,在调用方法前令 shouldTrack
为假,同时在 track
时检查 shouldTrack
,如果为假则不追踪。这就实现了调用方法时屏蔽对 length
的追踪。当然,为了保证其响应式能力,我们还需要在执行副作用函数之前,将 shouldTrack
恢复成原来的状态。
原始值响应式方案 ref
ref
在 Vue 中的主要目的是解决两个关键问题:代理原始值的问题和响应式丢失的问题。
代理原始值的问题
在 JavaScript 中,
Proxy
无法直接代理原始值。为了解决这个问题,Vue 引入了ref
。ref
的实质是创建一个新的对象,将原始值包裹在这个对象中,然后将这个包裹对象传递给reactive
。这样,原始值就可以间接地实现响应式。然而,这样做的结果是ref
和普通对象在本质上没有什么区别,因此需要一个标志来区分ref
和普通对象。响应式丢失的问题
响应式丢失是指当我们通过展开运算符从响应式对象创建一个普通对象时,如果响应式对象发生修改,创建的普通对象并不能收到这个修改,也不能触发相关的副作用。
toRef
和toRefs
函数就是为了解决这个问题而设计的。toRef
可以对响应式对象的某个属性进行包裹,返回一个包装后的对象。这个对象有一个访问器属性value
,当读取value
的值时,实际上是读取的响应式对象的值;当设置value
的值时,实际上是在设置响应式对象的值。这样,我们就可以自由地使用展开运算符,而不会丢失响应能力。
然而,使用ref
时,我们需要通过.value
的形式去访问原始值,这对开发者来说是一种心智负担。为了解决这个问题,Vue 引入了proxyRefs
函数。proxyRefs
函数对传入的参数进行了代理,拦截读取和设置操作。如果传入的对象是ref
类型,读取和设置操作就直接对.value
进行。这样,开发者就可以无需访问value
属性,就能读取ref
的值。