什么是响应式
响应式,就是指一个状态对另一个状态的变化做出对应的变更。
比如家里饮水机里的水已经被烧开了,烧水的指示灯就会从红灯变为黄灯。水的状态变化了,指示灯的状态也会响应其变化,做出对应的调整。
我们用实际的代码来看看在程序中的响应式是什么样的,如下面例子,每次 obj.a
、obj.b
变化的时候,如果你不手动执行 sum 函数,那 sumvalue 的值是永远不会变化的。
let obj = {
a: 10,
b: 20
}
let sumvalue = 0
function sum() {
sumvalue = obj.a + obj.b
}
sum()
console.log(sumvalue) // output: 30
obj.a = 30
console.log(sumvalue) // output: 30
从逻辑上来说,sumvalue 是和 a、b 有关联关系的,这两个值的变化,sumvalue 也会对应的做出响应,得到最新的计算值的。但从代码上来说,并没有直接建立这个关系,而是需要手动执行来更新,那有没有办法可以让其自动 “响应” 呢?
这里我们直接引入 @vue/reactivity
来改造一下试试。 你可以通过这个 codepen codepen.io/arronkler/p… 查看效果(打开左下角的 console 控制台,在控制台中查看)
import { effect, reactive } from '@vue/reactivity'
let obj = reactive({
a: 10,
b: 20
})
let sumvalue = 0
effect(function sum() {
sumvalue = obj.a + obj.b
})
console.log(sumvalue) // output: 30
obj.a = 30
console.log(sumvalue) // output: 50
我们分别将 obj 对象和 sum 函数用 reactive 和 effect 包裹了一下,使得前者成为 “响应式对象”,后者成为了 “副作用函数”。obj.a
变化后,我们并没有执行 sum,但是 sumvalue 的值自己就变化了,也就是说,sumvalue 响应了 obj.a
的变化。它是怎么做到的呢?
sum 这个副作用函数执行期间,访问了响应式对象 obj 的 a 和 b 属性,于是这两个属性就都与 sum 这个副作用函数自动关联起来了(注意这里一定是在副作用函数中访问响应式对象才行,普通函数或者普通对象都没用)。有了关联关系,在 a 或者 b 变动的时候,sum 这个副作用函数就会被自动触发重新执行,这样一来 sumvalue 就被计算更新了,sum 这个副作用函数就是针对 a、b 变动的时候的 “响应”。
响应是一个状态 A 对另一个状态 B 的响应。这里的响应是一个动词,它是一个行为,对应到代码中也就是我们所说的副作用函数。只不过这个函数将我们的状态 A 做出了改变,因此你也可以认为是状态 A 响应了状态 B 的变化。
一个响应式模块应该具备些什么
知道了什么是响应式,那想要实现响应式我们需要哪些东西呢?
在上面的例子中,我们引入了 reactive 和 effect,一个用于创造响应式对象,一个用于创造副作用函数,那你可能会说响应式的基本模块就是由副作用函数和响应式对象组成。这种说法对也不全对,响应式对象其实还可以更细的拆分开来,分成两个核心点,也就是 track (收集副作用),trigger(触发副作用)。至于为什么这么拆分,是因为在之后我们要实现的一些模块中,并不是直接使用 reactive,而是特定形式的 track 和 trigger 的组合,这一点,后面你会更加有所感触。
所以,一个响应式模块应该具备些什么,最基本的就是下面三个部分
- effect 副作用函数
- track 副作用搜集器(依赖收集)
- trigger 副作用触发器(派发更新)
响应式的主要核心实现,就是 副作用函数 / 副作用搜集器 / 副作用触发器 的相互配合。track 会将当前正在执行的副作用函数记录下来,trigger 是用来触发被记录下来的副作用的。所谓响应式对象,也就是在对象的属性被访问的时候进行 track 副作用,然后在属性变更的时候去执行 trigger,这样的一个对象配合副作用函数,也就形成了响应式。
所有在 Vue3 的 Reactivity 模块中出现的其它模块都是围绕这三个函数来的,包括我们熟知的 reactive / ref / computed。说到这里,其实我们的手写流程已经很明确了,先实现 effect 和 track / trigger,后面的就都是这三个函数的特定组合罢了。这样看来是不是感觉简单了一些呢!
多说无益,如果上面的内容你不能很好理解,跟着下面的步骤写一遍,就八九不离十了。
手写一个 reactivity 模块
准备
Get your hands dirty. 要想真正理解,还是得把手弄脏,下苦功夫
在写代码之前,先跟着我建立好项目的基本结构,
这里推荐直接在 codesandbox 中创建一个 Vanilla Typescript
的项目 ,我们的项目虽然会使用到 Typescript,但都是非常简单的语法特性,即使不是很熟悉 typescript 也完全可以跟上。来试试吧!
这是我在 codesandbox 上创建好项目后所设置的项目结构,你可以按照下面的样子先建好目录。
|- /src
|- /reactivity # 建立一个 reactivity 目录
|- index.ts
|- index.html
|- package.json
|- tsconfig.json
effect 函数:响应数据变更的副作用函数
先来说副作用函数。副作用函数实际就是响应式数据在发生变更的时候,要执行的函数。那我们是如何创建一个副作用函数的呢?
import { effect } from '@vue/reactivity'
let effectedFn = effect(() => {
// effect code will rerun when trigger
}, {
lazy: true
})
和前面的示例一样,这里我用 effect 函数传入了一个箭头函数,箭头函数经过 effect 执行之后就变成了一个副作用函数 (effectedFn) 了(也就是说这个 effect 函数的返回值是副作用函数)。第二个参数提供了一些选项,这里我们提供 lazy 为 false,表示这个箭头函数只是被包裹成了 effectedFn,但当下并不会执行(也即延迟执行,或者叫做懒执行),如果为 true 或者不填,那在创建副作用函数的时候就会先立即执行一次。(这一点对于之后的 computed 的来说很重要)
接下来我们来实现一下这个 effect,在 reactivity 目录下创建 effect.ts
文件,由于内容不多,这里就直接贴代码了。
// src/reactivity/effect.ts
// -------------------------
interface EffectFn<T = any> {
(): T;
}
// 副作用栈,栈顶为当前激活的副作用,会被 track 所跟踪
const effectStack: EffectFn<any>[] = [];
// 创建副作用函数
export function effect(fn: () => any, options?: any): EffectFn {
const effectFn: EffectFn = function () {
if (!effectStack.includes(effectFn)) {
effectStack.push(effectFn);
let res = fn();
effectStack.pop();
return res
}
};
if (!options?.lazy) { // 是否延迟执行
effectFn();
}
return effectFn;
}
从代码可以看出 effect 内部实际上只是新建了一个包裹函数 effectFn(它内部会去实际调用我们传入的函数)并返回。effectFn 在调用的时候,会先在副作用函数栈 effectStack 上将当前 effect 入栈,执行完函数后,又会将其弹出栈。
lazy 选项很容易明白,但 effectStack 是干嘛的?为什么需要一个 Stack 呢?
先说答案:effecStack 是在 effect 嵌套调用的时候,用来跟踪当前副作用函数变化的。
我们知道,栈是一种后进先出的线性的数据结构。普通的 javascript 的函数就是栈式进行调用的,当一个函数执行的时候,会为这个函数创建执行上下文,然后将其 push 到函数调用栈(或者叫做执行上下文栈)中。函数执行完成,要退回到上一个函数继续执行后续的代码,只需要将当前的执行上下文从函数调用栈中 pop 出去就恢复了上一层函数的执行状态。
这里的 effectStack 也是类似的,普通的函数调用是通过栈来跟踪函数的嵌套调用过程,effectStack 是通过栈的特性来跟踪 “副作用函数” 的嵌套调用关系。
也就是下面这种情况,在一个副作用函数执行中,又创建了一个副作用:
// 【0】
effect(function outer() {
track() //【1】 track outer()
effect(function inner() {
track() // 【2】 track inner()
})
track() // 【3】 track outer()
})
// 【4】
// 假设有个 track 函数跟踪副作用
function track() {
const currentEffect = effectStack[effecStack.length - 1]
// do track
}
在上面例子中,effectStack 的变化如下:
也就是说,在 track 函数执行的时候,【1】,【3】两个地方都将搜集到 outer
这个副作用函数,而在【2】这个地方搜集到的副作用是 inner
,与预期是符合的。
我们后面会说到的 computed / watch 在 Vue 项目里实际上都是基于这种嵌套的 effect 结构,所以这个 effectStack 是一个妙点。
track、trigger:副作用函数的搜集与触发
这两个函数是用来搜集和触发副作用函数的,前面我们的副作用函数执行期间都会将自己压入 effectStack 中,这个时候就该 track 出场了,仍然是在 effect.ts 文件中
// src/reactivity/effect.ts
// -------------------------
// .....
// 获取当前栈顶的副作用函数
function getCurrentEffect() {
return effectStack[effectStack.length - 1];
}
// 记录目标对象中某个键所依赖的副作用函数
const targetMap = new WeakMap();
export type ObjKeyType = string | number | symbol;
// 建立副作用跟踪关系
export function track(target: any, key: ObjKeyType) {
if (effectStack.length === 0) return;
// 当前对象的“键依赖表”
let depMap = targetMap.get(target);
if (!depMap) {
depMap = new Map();
targetMap.set(target, depMap);
}
// 当前对象,当前键,所对应的副作用集合
let deps = depMap.get(key);
if (!deps) {
deps = new Set<EffectFn>();
depMap.set(key, deps);
}
// 添加栈顶副作用作为依赖
deps.add(getCurrentEffect());
}
这里的 track 函数其实很好理解,它会记录下一个对象的某一个特定键所对应的副作用函数。这里重要的是理解 targetMap 这个数据结构,它是用来存储副作用函数的,track 和后面会说到的 trigger 都是基于它来运行的。
- targetMap 本身是一个 WeakMap 对象,其 key 是一个对象(要设置为响应式的对象),值是一个 Map(假设叫做 keyMap)
- keyMap 本身是个 Map 对象,其中的 key 是响应式对象的属性名,值是一个 Set 集合(假设叫做 depSet)
- depSet 是一个包含有副作用的集合,Set 的特性也使得其不会重复
在 Vue3 源码中的 TS 描述如下:
// {target -> key -> dep}
type Dep = Set<ReactiveEffect>
type KeyToDepMap = Map<any, Dep>
const targetMap = new WeakMap<any, KeyToDepMap>()
所以,在副作用函数中执行 track ,就能将某个对象的键和副作用函数建立联系,之后只是在恰当的时候取出这个副作用函数再次执行就 “响应” 了。咱先把执行 “响应” 的 trigger 写了,然后放在一起使用试试。
// src/reactivity/effect.ts
// -------------------------
// .....
// 触发副作用
export function trigger(target: any, key: ObjKeyType) {
const depsMap = targetMap.get(target);
if (!depsMap) {
// 没有被 track 过就跳过
return;
}
let deps = depsMap.get(key);
if (deps) {
deps.forEach((efn: EffectFn) => efn());
}
}
trigger 也很简单,就是将特定对象的特定键下面所关联的一切副作用函数都执行一下。
在 index.ts 文件中我们结合 effect、trigger 和 track 来测试一下
// src/index.ts
// ---------------
import { effect, track, trigger } from "./reactivity/effect";
// 创建一个普通对象
let o: any = {
_uid: 0
};
// 在其上定义一个 uid 属性,并在 getter 中做 track,在 setter 中去 trigger
Object.defineProperty(o, "uid", {
get() {
track(o, "uid");
return this._uid;
},
set(value) {
this._uid = value;
trigger(o, "uid");
}
});
effect(() => {
console.log("uid:" + o.uid);
});
o.uid = 20
o.uid = 30
/* console output
uid:0
uid:20
uid:30
*/
执行之后,我们可以在控制台中看到分别输出了 0, 20, 30 ,effect
中传入的函数被执行了三次。第一次是传入的时候执行的,我们在其中访问了 o.uid
,因而触发了它的 getter
,在其中 track
了这个副作用函数。于是我们后面再给 o.uid
赋值的时候通过其 setter
执行了 trigger,也就触发了刚才被 track
的副作用函数(这个运行 console.log
的函数)。这也就是最基本的响应式了。
给几道练习:
- 试试在
console.log
的地方,把o.uid
改成o._uid
,看看是否还能响应呢? - 给 effect 传入 lazy 选项,看看是否有效?如果没有效应该怎么让其运行起来呢?
reactive:让普通对象升级为响应式对象
前面我们通过 Object.defineProperty
试验了一下响应式的效果,其实,对于某个对象,我们只需要遍历一下其所有的 key 不就可以将整个对象变成响应式对象了么?是的,Vue2 就是这么做的。不过在 Vue3 中使用 Proxy
替换了这种遍历的方式,直接代理整个对象,方便又快捷。而且有更多的好处:
- 延迟部分代码执行。
Object.defineProperty
需要在一开始就深度遍历代码 - 增加的新属性也能响应。
那我们来定义一下 reactive 吧!
在 reactivity 文件夹下创建 reactive.ts 文件,然后写入下面代码
// src/reactivity/reactive.ts
// ---------------
import { track, trigger } from "./effect";
export function reactive(obj: any) {
const proxy = new Proxy(obj, {
// 访问属性
get(target, key, receiver) {
track(target, key);
let res = Reflect.get(target, key);
// 值为对象的,深度转换为响应式的对象
if (Object.prototype.toString.call(res) === "[object Object]") {
reactive(res);
}
return res;
},
// 修改属性
set: function (target, key, value, receiver) {
const res = Reflect.set(target, key, value, receiver);
trigger(target, key);
return res;
}
});
return proxy;
}
这个 reactive 是不是非常简单,我们只是用 Proxy 给传入对象做了一层代理,在访问其属性的时候执行了之前就定义好的 track 用来搜集副作用,然后在修改属性值的时候,trigger 触发已经搜集的副作用函数。这样一个普通对象就转换成了一个响应式的对象,我们将代理后的响应式对象返回,后续只要只用了这个响应式对象的地方,就具备了自动搜集副作用和触发副作用的能力。
正如我们前面所说,reactive 不过就是 track 和 trigger 的一个特定组合。
值的注意的是,proxy 的 get 定义中,如果得到的值也是一个对象,那我们会对这个对象递归调用 reactive,使其也是响应式的。(在 vue3 中的 shallowReactive 就会省略这一步)
在 index.ts
中试验一下
// src/index.ts
// ---------------
import { effect } from "./reactivity/effect";
import { reactive } from "./reactivity/reactive";
let sum = 0
let obj = reactive({
a: 0,
b: 20
});
effect(function summary() => {
sum = obj.a + obj.b
console.log(sum);
});
obj.a = 20;
obj.b = 40;
/* console output
30
40
60
*/
summary
函数会在定义为副作用函数的时候执行一次,输出 30,然后在 obj.a
和 obj.b
变化的时候自动执行,得到计算结果 40 和 60。
ref:原始值变为响应式对象的语法糖
想要让一个原始值变为响应式可就没有对象那么容易了,这是由于语言特性决定的,我们可以通个 Proxy / Reflect 对 JS 中的对象做 “元编程”(也就是改变对象的一些默认操作行为),但却没有办法可以改变原始值的默认行为。
因此你可能也联想到了,在 vue3 中使用 ref 虽然传入的是原始值,但返回的就是一个对象,你得通过 .value
才能访问到真实值。通过对象来操作,使其变成响应式的,那可就太容易了,毕竟我们上面已经实现过一个了。
不过我们注意到的是,我们返回的对象只有一个属性 value,也只需要存储一个值,因而通过 Proxy 的方式就大材小用了,我们可以直接通过 访问器属性 来作为 value,像下面这样:
// src/reactive/ref.ts
// ---------------
import { track, trigger } from "./effect";
export function ref(value?: any) {
let res = {
get value() {
track(res, "value");
return value;
},
set value(v: any) {
value = v;
trigger(res, "value");
}
};
return res;
}
让我们在 index.ts
文件中试验试验这个 ref 的效果如何,清除其中的代码,写入下面的试验代码:
// src/index.ts
// ---------------
import { effect } from "./reactivity/effect";
import { ref } from "./reactivity/ref";
let obj = ref(10);
effect(() => {
console.log(obj.value);
});
obj.value = 20;
obj.value = 30;
/* console output
10
20
30
*/
我们发现副作用函数被执行了三次,分别是初始值 10 和后面改变了 value 之后的值 20 和 30,和我们在 vue3 中使用同样的模块是一样的结果。
这样,就完成了将一个普通值转换成一个响应式的值。
computed:一个响应了另外的响应式对象的值
接下来看一看一个非常实用且常用的功能 —— computed,这个功能就是让一个值响应另一个或多个值的变化。我们前面提到的响应式主要是在响应式对象的属性变化的时候,执行了特定的副作用函数,并没有做到让一个值直接响应另外的值的变化。而 computed 就做到了,你可以先思考一下,如果让你来实现这个功能,应该是怎样来实现的呢?
这次我们倒着过来实现,先在 index.ts 中写好测试代码:
// src/index.ts
// ---------------
import { reactive } from "./reactivity/reactive";
import { computed } from "./reactivity/computed";
import { effect } from "./reactivity/effect";
let obj = reactive({
a: 10,
b: 20
});
let sum = computed(function summary() {
return obj.a + obj.b;
});
effect(() => {
console.log("sum is: ", sum.value);
});
obj.a = 20;
obj.b = 30;
/* expected console output
sum is: 30
sum is: 40
sum is: 50
*/
看着上面的代码,我们至少可以作出以下思考:
- summary 函数中可以做任意的计算,但最终需要返回一个值,这个值就是 sum 会得到的值
- sum 值是动态变化的,如果只是一个原始值,无论怎样都不会动态变化的,所以 computed 的返回值需要是一个对象值
- computed 的返回值不仅仅是一个普通对象,应该也是响应式的,否则使用了 computed 值的副作用函数无法在 computed 值变化的时候做出响应
- 传入的 summary 在响应式对象变化的时候要被自动执行,那传入之后肯定会被副作用函数包裹,否则无法被追踪和触发
看一下,依靠这几点思考,你是否能写出一个 computed 呢?
在 reactivity 目录下创建 computed.ts 文件先自己写一下,然后再看下面的代码吧!
// src/reactivity/computed.ts
// ---------------
import { effect, track, trigger } from "./effect";
export function computed(getter: any) {
let value: any = null;
let res: any = null;
effect(function inner() => {
value = getter();
trigger(res, "value");
});
res = {
get value() {
track(this, "value");
return value;
}
};
return res;
}
这里的实现其实就满足了我们上面所分析的特性,也符合我们在 index.ts 中写的测试代码,得到了期望的输出结果。
可以看出,这里实际上就是将传入的 getter 用 effect 包裹了一下,这样就能拿到 getter 执行后的返回值,且 getter 中的响应式对象变化的时候,就会触发这里的副作用函数 inner 重新运行,得到新的值,这就保持了 computed 值一直都会响应变化。
另外,我们构建了一个 res 的对象,只提供了 get value()
的方法,在其中返回我们上面的 value 值,并做了一次依赖搜集,将使用了这个 res.value
的副作用函数收集起来。然后在上面包裹 getter 的副作用函数 inner 中触发副作用,这样当前 computed 值的变化就会触发外部的副作用函数了。
总结
写到这里,vue3 的响应式模块的一个 mini 版本基本就算实现了。相信在看完本文跟着做了一遍之后,你已经对 vue3 的响应式模块是如何实现的有了一个基本的了解,这时候再去看 vue3 响应式模块的实际源码,问题也不大了。
本文整体源码: codesandbox.io/s/reactivit…
其它文章:
- 如何打造一套Vue组件库: arronkler.top/post/build_…
- 浏览器进程架构的演化: arronkler.top/post/browse…
- 网络请求资源优化: arronkler.top/post/networ…