项目采用迭代式方法循序渐进地添加内容,每篇文章都会对应一个主题,比如这篇文章就会添加响应式相关的基础内容。因此,将会产生许多版本的代码,我们使用 Git 来管理这些版本。
这里是我编写的已测试的 示例代码,你可以克隆仓库,切换到对应分支,在需要参考时使用。
准备工作
本项目尽量保证目录结构、模块划分与 Vue3 一致
创建并切换至分支 reactivity_base。
删除起初创建的 index.ts 和 index.spec.ts,定义目录结构如下:
corex-vue3
└── packages
├── reactivity
│ ├── __tests__
│ └── src
└── shared
└── src
简单介绍一下各个目录的意义:
packages:Vue3 采用 monorepo 的管理模式 对响应式等多个模块进行统一的依赖管理,它们都位于packages目录下,是项目的主要内容。- 本项目为了简单,不使用 monorepo,直接在一个项目中进行开发。
reactivity:包含响应式相关内容,如 reactive、ref 等。shared:包含各模块通用的常量、函数,如 isObject(val) 等。*/src:包含源码。*/__tests__:包含测试代码。
基本思想
什么是响应式?在 Vue3 中,响应式是针对变量而言的,只要为变量赋予响应式,使用响应式变量的函数就会在响应式变量变化时自动执行。考虑如下场景:
// 假设 a 为响应式变量
let a = 1
// b 依赖 a
let b = 0;
function setB() { b = a - 1 }
// 自动执行 setB()
a = 2
// 希望得到 b = 1
console.log(b)
响应式渲染的原理也是一样:
// 假设 msg 为响应式变量
let msg = ''
// 渲染依赖 msg
let innerHTML = '<div></div>'
function render() { innerHTML = `<div>${msg}</div>` }
// 自动执行 render()
msg = 'hello'
// 希望渲染出 <div>hello</div>
console.log(innerHTML)
Vue3 实现响应式系统的基本思想非常简单:在函数 fn 执行时,如果使用了响应式变量 var,则记录 var=>fn 的依赖关系;在 var 更新时,根据依赖关系找到所有依赖 var 的 fn 自动执行。
编写测试
项目采用 TDD 的方式开发,在编写源码之前,我们必须明确所需功能、编写测试
在 Vue3 中,我们常用的响应式 API 及其主要功能如下:
reactive(target):赋予对象响应式。- 赋予嵌套对象响应式。
ref(value):赋予原始类型响应式,通过 ref.value 使用。computed(getter):赋予计算属性响应式,通过 computed.value 使用。- 惰性更新:计算属性依赖的响应式变量更新时,计算属性并不会立即更新,而是在下次被使用时更新。
除此之外,还有一个隐性功能 effect(fn):它相当于一个“我要 fn 被响应式”的声明,只有通过 effect 执行的函数 fn 才会随着其依赖的响应式变量的变化而自动执行,而其它我们不关心的函数即使用了响应式变量,也不会为我们的响应式系统增加额外负担。
由于 Vue3 类型系统较为复杂,本项目中不会严格定义类型,仅为了加强代码提示
在 Vue3 中,这四个 API 恰好分属不同模块,为它们分别创建源文件、定义导出。
// reactivity/src/reactive.ts
export function reactive(target): any {}
// reactivity/src/ref.ts
export function ref(value): any {}
// reactivity/src/computed.ts
export function computed(getter): any {}
// reactivity/src/effect.ts
export function effect(fn): any {}
本章之后,测试代码不会贴出,你可以拷贝示例代码中的测试,也可以自行编写
根据以上功能定义,我们可以编写单元测试,这里以 reactive 为例。
在此再度强调本项目“源码瘦身”的核心理念,我们的测试包括后续的功能实现,只会包含主要的、最小可用的场景。因此,在编写测试时只需要囊括我们定义的主要功能,切勿面面俱到。
// reactivity/__tests__/reactive.spec.ts
import { effect } from '../src/effect';
import { reactive } from '../src/reactive';
// 对 reactive 模块的测试
describe('reactive', () => {
// 测试一:赋予对象响应式
test('shallow object', () => {
let original = { a: 1 };
let observed = reactive(original);
// 响应式变量并非直接替换原变量
expect(observed).not.toBe(original);
// 响应式
let b = 0;
function setB() {
b = observed.a - 1;
}
// 声明 setB 应被响应式
effect(setB);
// 更新 a,希望 setB 自动执行更新 b
observed.a = 2;
expect(b).toBe(1);
});
// 测试二:赋予嵌套对象响应式
test('nested object', () => {
let original = {
obj: {
a: 1
}
};
let observed = reactive(original);
// 嵌套变量也会被转为响应式变量
expect(observed.obj).not.toBe(original.obj);
// 嵌套变量响应式
let b = 0;
function setB() {
b = observed.obj.a - 1;
}
effect(setB);
observed.obj.a = 2;
expect(b).toBe(1);
});
});
编写源码
原理详解
在 Vue3 的模块划分中,effect 模块负责主要的响应式系统构建,其它模块都是与响应式系统的对接及使用。因此,我们第一步就要来实现 effect.ts。
现在,回忆一下响应式的基本思想。假设有响应式对象 obj = { a: 1 },函数 fn1、fn2 都依赖 obj.a,那么我们就需要在某个地方保存 obj.a => fn1 和 obj.a => fn2 这两个依赖关系。首先,对于这种“映射关系”,很容易想到通过 Map 来实现,令 key 为 obj.a、value 为 [fn1, fn2] 即可;其次,如何定位 obj.a?我们可以使用两层 Map,第一层 targetMap 负责定位对象 obj,第二层 depsMap 负责定位属性 a;最后,如果 fn1 === fn2,我们不能够保存 [fn1, fn1],因为一次 obj.a 的更新只应该引起一次 fn1 的自动执行,所以我们不用数组而用 Set 来保存这些被响应式的函数。
// 映射结构大致如下
targetMap: {
[obj1]: {
[attr1]: Set(fn1, fn2),
[attr2]: Set(fn4)
},
[obj2]: {
[attr3]: Set(fn1, fn5)
}
}
为了便于理解这个较为复杂的响应式系统,我们还需要定义一些词汇:
- 依赖集合(Dep):Vue3 中的命名,等价于上文存储 fn 的 Set。
- RE(ReactiveEffect):表示我们声明需要被响应式的函数。在 Dep 中不会直接保存 fn,而是保存 fn 包装后得到的 RE。
- 当前 RE(activeEffect):表示正在执行的 RE。可能是 undefined,代表是普通的 fn 而非被响应式的 RE 在执行。
- 收集(track):表示将当前 RE 保存到对应的 Dep 中。
- 触发(trigger):表示自动执行对应的 Dep 中的全部 RE。
effect(fn)
主要的实现逻辑参考代码和注释,代码段前的文字将会解释一些较难说明的问题
首先,定义全局变量 targetMap。
这里还有一个小技巧,targetMap 使用 WeakMap 而非 Map 实现,出于两点考虑:首先,WeakMap 的键值恰好只能是对象,与我们第一层定位对象的需求一致;其次,如果对象 obj 是响应式变量,而现在因我们的业务代码而被清除了,如果使用的是 Map,则 Map 会继续引用 obj,导致它无法真正被清除,而我们已经永远不会再使用 obj 了,这就产生了内存泄露,而 WeakMap 就不存在这样的问题。
// reactivity/src/effect.ts
const targetMap = new WeakMap();
下一步,实现 RE 类:
先再定义两个全局变量 activeEffect 和 shouldTrack。在 Vue3 中,需要处理很多不应该收集的情况,因此需要定义一个全局变量 shouldTrack 来灵活控制。在我们的瘦身版本中,只有像上文提到的 fn1 === fn2,即重复收集同一个 RE 时才会用到。
此外,依赖关系其实不止 obj.a => fn,从 fn => obj.a 的映射也应该被保存,这里我们就在 RE 中定义了一个 deps,用于保存所有包含此 RE 的 Dep(不过暂时不会使用)。
// reactivity/src/effect.ts
export type EffectScheduler = (...args: any[]) => any;
export let activeEffect: ReactiveEffect | undefined;
export let shouldTrack = true;
export class ReactiveEffect {
deps: Set<ReactiveEffect>[] = [];
// scheduler 允许用户更灵活地定义 RE 被触发时的执行逻辑
// 在 computed 中,我们将初次使用到它
constructor(public fn, public scheduler: EffectScheduler | null = null) {}
// 执行 fn 并收集 RE
run() {
try {
// 声明自己应被收集
activeEffect = this;
shouldTrack = true;
return this.fn();
} finally {
// 记得还原
activeEffect = undefined;
shouldTrack = false;
}
}
}
下一步,实现收集:
在 effect.ts 中,我们只实现收集和触发的功能,并将它们作为响应式系统的接口提供给外部,至于它们应该何时被调用是其它模块的事。
// reactivity/src/effect.ts
export function track(target, key) {
// 如果有 RE 正在运行,才会收集
// 否则说明使用响应式变量的函数无需响应式
if (shouldTrack && activeEffect) {
// 根据 target 和 key 定位 Dep
// 如果不存在则新建
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = new Set()));
}
// 向 Dep中收集当前 RE
trackEffects(dep);
}
}
export function trackEffects(dep) {
// v! 会向 TS 保证 v 不为 undefined/null
// 如果已经收集过当前 RE,无需重复收集
shouldTrack = !dep.has(activeEffect!);
if (shouldTrack) {
dep.add(activeEffect!);
activeEffect!.deps.push(dep);
}
}
下一步,实现触发:
这里我们将会用到一个判断是否是数组的工具函数,像这种与我们的核心逻辑无关,随便放到哪个电商项目、后台管理项目中都能复用的函数,我们就把它放在 packages/shared/src/index.ts 中。
// shared/src/index.ts
export const isArray = Array.isArray;
// reactivity/src/effect.ts
import { isArray } from '../../shared/src';
// 根据 target 和 key 定位 Dep并触发
export function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) {
return;
}
const dep = depsMap.get(key);
triggerEffects(dep);
}
export function triggerEffects(dep) {
// Vue3 原句:spread into array for stabilization
const effects: ReactiveEffect[] = isArray(dep) ? dep : [...dep];
// 触发所有 RE
for (const effect of effects) {
// 若定义了调度器则使用,而非直接执行
if (effect.scheduler) {
effect.scheduler();
} else {
effect.run();
}
}
}
最后,实现 effect(fn):
我们一开始定义功能的时候并没有考虑到 options.lazy 参数,但在实现中感觉有必要,所以加了上来,这并不会违反 TDD 的原则,只要补充好单元测试就行。
// reactivity/src/effect.ts
export function effect(fn, options?): any {
// 将 fn 包装成 RE
const _effect = new ReactiveEffect(fn);
// 如果设置 options.lazy = true 则不立即执行 RE
if (!options || !options.lazy) {
_effect.run();
}
// 返回一个能够执行 RE 的函数,同时保留 RE 的引用
const runner = _effect.run.bind(_effect) as ReactiveEffectRunner;
runner.effect = _effect;
return runner;
}
至此,我们已经完成了 effect(fn, options)。可惜的是,我们只是构建好了响应式系统,提供了收集和触发的 API,还没有真正调用它们,因此也无法进行测试。理论上来说,对 track/trigger 等功能也需要做单元测试,但我们一开始并不知道会有这些东西,只好偷懒等到完成 reactive 之后再进行测试了。
reactive(target)
reactive/ref/computed 等需要考虑的问题都是如何在合适的时机收集和触发依赖。
对于 reactive(target) 来说,它的目标是一个对象,我们应该在对象属性被使用时收集,在对象属性被更新时触发。恰好,ES6 为我们提供了 Proxy API,它允许我们基于原始对象创建一个“代理对象”。我们可以像使用原始对象一样访问代理对象的属性,而对属性的读取和修改会被创建时传入的第二个参数 handler 的 get/set 方法拦截。既然可以拦截读取和修改,那么我们就能在其中调用收集和触发,完成响应式的赋予。
在本文中,我们只考虑普通对象、只考虑 get & set,而不关心 Array, Map, Set、不关心 push, pop 等方法,因此 reactive.ts 的实现其实并不复杂。唯一需要注意的一点就是 Reflect.get/set 的使用,当对象的原型是一个代理对象时,使用 target[key] 取值可能会导致 一些问题 。
// shared/src/index.ts
export const isObject = (val: unknown) =>
val !== null && typeof val === 'object';
export const hasChanged = (value: any, oldValue: any): boolean =>
!Object.is(value, oldValue);
// reactivity/src/reactive.ts
import { hasChanged, isObject } from '../../shared/src';
import { track, trigger } from './effect';
const reactiveMap = new WeakMap();
export function reactive<T extends object>(target: T): T {
// 一个对象只有一个代理对象
// 如果已经创建,可以直接返回缓存中的代理对象
const existingProxy = reactiveMap.get(target);
if (existingProxy) {
return existingProxy;
}
// 创建代理对象
const proxy = new Proxy(target, {
get(target, key, receiver) {
// 如果不使用 Reflect.get,在取原型链属性
// 即 target !== receiver 时可能出现 BUG
const res = Reflect.get(target, key, receiver);
// 使用对象属性,收集依赖
track(target, key);
// 如果对象属性还是对象,也要赋予其响应式
if (isObject(res)) return reactive(res);
return res;
},
set(target, key, value, receiver) {
// 存储旧值
let oldValue = target[key];
// 和使用 Reflect.get 原因相同
const result = Reflect.set(target, key, value, receiver);
// 只有当值真正发生了变化才会触发依赖其的函数
if (hasChanged(value, oldValue)) {
trigger(target, key);
}
return result;
}
});
// 缓存代理对象
reactiveMap.set(target, proxy);
return proxy;
}
进行测试
完成了 reactive(target),我们终于拥有了第一个可用的响应式功能,是时候进行测试了!你可以直接执行 pnpm test,但显然它会给你带来大段飘红的 failed,因为 ref 和 computed 还没有完成。
这里建议使用 VSCode,安装 Jest Runner 插件,安装完成后打开 reactive.spce.ts,你会发现 describe(...) 上方出现了 Run|Debug 的字样,点击 Run,即可单独运行这一模块的测试。
如果你乖乖地只使用 reactive 来编写 effect 的测试,那么它在这个阶段也已经可以测试,否则……建议重写吧!不然之后再发现 effect 存在问题可有罪受的。如果测试全部通过,我们可以认为实现代码无误;否则请对照 示例 检查是测试还是实现的问题,直到测试通过为止。
ref(value)
对于 ref(value) 来说,它的目标是原始类型值,无法通过 Proxy 拦截读写,这样一来就无法适时地进行触发和收集,怎么办呢?考虑到 ref.value 的使用方式,我们很容易想到可以把原始类型值包装成只有一个 value 属性的简单对象,这样就和之前的响应式系统接轨了。
但是,我们并不会直接复用 reactive({ value: primitiveValue }),主要是因为:我们只是为了给原始类型提供 get/set 的拦截,才将其包装为对象,并不希望它真的被当成对象操作,比如在 value 之外添加别的属性等。此外,针对原始类型单独实现,逻辑较为简单,专人专车效率也更高。
本文中实现的 ref(value) 只允许传入原始类型值,不支持传入对象,也不支持传入响应式对象。
// reactivity/src/ref.ts
import { hasChanged } from '../../shared/src';
import {
activeEffect,
ReactiveEffect,
shouldTrack,
trackEffects,
triggerEffects
} from './effect';
export interface Ref<T = any> {
value: T;
}
type RefBase<T> = {
dep?: Set<ReactiveEffect>;
value: T;
};
// Ref 实现类
class RefImpl<T> {
// 用 _value 保存原始类型值
private _value: T;
// 无需 targetMap 两层定位,自己持有 Dep
public dep?: Set<ReactiveEffect> = undefined;
constructor(value: T) {
this._value = value;
}
// 没有 target&key,所以不复用 track,而是自己实现
get value() {
trackRefValue(this);
return this._value;
}
// 没有 target&key,所以不复用 trigger
set value(newVal) {
// 只有当值真正改变才会触发
if (hasChanged(newVal, this._value)) {
this._value = newVal;
triggerRefValue(this);
}
}
}
// 如果符合收集条件,使用自己的 Dep 进行收集
export function trackRefValue(ref: RefBase<any>) {
if (shouldTrack && activeEffect) {
// 第一次收集的时候才会创建 Dep
// 如果初始化时就创建可能导致过多的内存使用
trackEffects(ref.dep || (ref.dep = new Set()));
}
}
// 如果有 Dep,则将其触发
export function triggerRefValue(ref: RefBase<any>) {
if (ref.dep) {
triggerEffects(ref.dep);
}
}
// ref 接口就是简单地返回一个 Ref 对象
export function ref<T>(value: T): Ref<T> {
return new RefImpl(value);
}
至此,ref(value) 实现完毕,可以进行测试。
computed(getter)
对于 computed(getter) 来说,它的目标是计算属性,也就是一个返回值依赖其它响应式变量的函数。大部分情况下,计算属性也是一个原始类型值,因此我们对它做和 ref 类似的处理。需要注意的就是懒更新的实现,传入调度器的 RE 在被触发时执行 scheduler() 而非 run(),详见 triggerEffects 的实现。
本文中实现的 computed(getter) 只考虑返回原始类型值的 getter,不考虑 setter。
// reactivity/src/computed.ts
import { ReactiveEffect } from './effect';
import { trackRefValue, triggerRefValue } from './ref';
export interface ComputedRef<T = any> {
readonly value: T;
}
export type ComputedGetter<T> = (...args: any[]) => T;
// Computed 实现类
export class ComputedRefImpl<T> {
// 保存计算属性
private _value!: T;
// 自己持有 Dep
public dep?: Set<ReactiveEffect> = undefined;
// getter 将被包装为 RE
public readonly effect: ReactiveEffect;
// 脏位:标记计算属性是否应该更新
public _dirty = true;
constructor(getter: ComputedGetter<T>) {
this.effect = new ReactiveEffect(getter, () => {
// 在被触发时(计算属性依赖的响应式变量更新时)
// 不会立即执行 getter 更新 _value,只是设置脏位
if (!this._dirty) {
this._dirty = true;
// 当然,还是要进行触发,执行依赖计算属性的函数
triggerRefValue(this);
}
});
}
get value() {
// ComputedRef 与 Ref 相同,都持有 value 和 Dep
// 因此 track/trigger 可以复用
trackRefValue(this);
// 只有在读取计算属性时才会执行 getter 更新 value
if (this._dirty) {
this._value = this.effect.run();
// _value 已是最新,还原脏位
this._dirty = false;
}
return this._value;
}
/* 目前不支持 set */
}
// computed 接口就是简单返回一个 ComputedRef 对象
export function computed<T>(getter: ComputedGetter<T>): ComputedRef<T> {
return new ComputedRefImpl(getter);
}
至此,computed(getter) 实现完毕,可以进行测试。但在编写测试时,我们会碰到这样一个问题:如何验证 computed 的懒更新特性?如果想要验证计算属性的值没有变化,必须读取 computed.value,但这样又会触发 getter 执行……我们可以换个思路,计算属性值没有变化,等价于 getter 没有执行,那么我们只要观察 getter 执行次数,就可以在不读取 computed.value 的基础上进行验证了。
在不知道如何编写测试时,可以参考 Vue3 对应测试的写法
// reactivity/__tests__/computed.spec.ts
test('computed lazy update', () => {
let a = ref(1);
const getter = jest.fn(() => a.value - 1);
const b = computed(getter);
a.value = 2;
// 未使用 b,getter 未被调用
expect(getter).toHaveBeenCalledTimes(0);
// 使用 b,getter 被调用,b.value 更新
expect(b.value).toBe(1);
expect(getter).toHaveBeenCalledTimes(1);
});
总结
这一章节,我们尝试了 TDD 的开发过程、了解了 Vue3 响应式系统的基本原理,实现了 effect(fn, options)、reactive(target)、ref(value)、computed(getter) 等基础功能。