本文已参与「新人创作礼」活动,一起开启掘金创作之路
本专栏会讲述如何实现一个mini-vue,让你了解vue的底层原理,如果直接阅读vue源码,那会是一件非常头疼的事情,因为许多的代码是用于处理一些边界情况的,这就导致我们很难找到核心内容,而mini-vue实现了vue的核心功能,忽略边界条件的判断,旨在让我们能够抓住核心,了解vue的底层原理,并通过TDD的思想进行开发,让你感受到TDD带来的好处!
本节是该专栏的第一节,reactivity是实现mini-vue的基本,后续功能会依赖于reactivity,因此我们从reactivity开始,阅读本节之前,请确保你已经使用过vue的reactivity相关功能,了解它的作用,本节不会介绍reactivity是什么,而是注重它的运行流程和实现原理
reactivity模块会分为几篇文章去讲解,本篇文章是reactivity模块的第一篇,主要讲解如何实现基本的reactive和effect
reactive用于创建响应式对象
effect用于包裹副作用函数,收集响应式对象和副作用函数之间的依赖关系以及触发依赖
1. 项目搭建
首先需要创建我们的项目,需要用到的依赖有jest、babel、typescript
- 安装
typescript
pnpm i typescript -D
npx tsc --init
修改tsconfig.json,将noImplicitAny为false,因为我们主要关注的是原理实现,而不关注类型,但又希望用到typescript的一些特性,所以要允许项目中使用any
- 安装
jest
pnpm i jest @types/jest -D
jest集成babel
pnpm i babel-jest @babel/core @babel/preset-env -D
创建babel.config.js
module.exports = {
presets: [['@babel/preset-env', {targets: {node: 'current'}}]],
};
jest集成typescript
pnpm i @babel/preset-typescript -D
修改babel.config.js
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
};
- 修改
package.json添加测试脚本
"scripts": {
"test": "jest"
},
创建项目源码目录src/reactivity,并写一个简单的测试用例看看环境是否搭建成功,src/reactivity/tests/index.spec.ts
describe('index', () => {
it('happy path', () => {
console.log('hello reactivity');
});
});
终端执行pnpm test,如果能够通过测试说明环境搭建完成
2. 实现简易版reactivity
首先我们实现一个简易版的reactivity,这也就意味着不考虑很多额外的功能,只考虑先把最基本的功能实现,那么reactivity最基本的功能有什么呢?
主要有两个模块:
reactive,用于创建响应式对象,通过Proxy实现effect,管理副作用函数,最基本的功能包括依赖收集和触发依赖
2.1 effect测试用例
基于TDD(Test-Driven Development)的思想,我们先写一个简单的测试用例描述一下使用场景
// src/reactivity/tests/effect.spec.ts
describe('effect', () => {
// it 和 test 是一样的
// it.skip 表示暂时跳过该测试项 因为目前需要 reactive 和 effect 而我们希望先去实现 reactive
// 但又不希望 effect.spec.ts 影响整个测试的进行 因此可以用 skip 暂时跳过 等 reactive 实现后再改回来
it.skip('happy path', () => {
const foo = reactive({
name: 'foo',
age: 20,
isMale: true,
friends: ['Mike', 'Tom', 'Bob'],
info: {
address: 'China',
phone: 11011011000,
},
});
let nextAge;
effect(() => (nextAge = foo.age + 1));
expect(nextAge).toBe(21);
// update
foo.age++;
expect(nextAge).toBe(22);
});
}
我们的需求很简单,就是利用reactive创建一个响应式对象,然后effect函数中会执行副作用函数fn,当fn所依赖的响应式对象的数据修改后,能够自动执行副作用函数fn去更新依赖
由于reactive和effect函数目前都还没有实现,这个单元测试自然是无法通过的,而它们又是两个大模块,因此实现这两个模块也是有它们对应的单元测试的,可是目前这个happy path的单元测试会妨碍我们之后编写具体某一个模块的单元测试的运行
比如我想先实现reactive,那么我就需要先编写相应的单元测试,然后去运行单元测试,但是由于effect模块还没实现,因此会被happy path的这个单元测试干扰,导致无法通过所有测试用例,因此我们可以先将其标记为skip,等我们实现完了两个模块的基本功能后再回来将标记删除,来测试happy path是否可以通过
下面来理一下这个测试用例的流程
2.2 reactive测试用例
我们先来实现reactive模块,仍然是先创建测试用例,根据测试用例去开发代码,这是TDD的核心思想
describe('reactive', () => {
it('happy path', () => {
const foo = { bar: 1 };
const observed = reactive(foo);
// observed 代理 foo 对象
expect(observed).not.toBe(foo);
expect(observed.bar).toBe(1);
});
});
接下来我们就需要去实现reacive函数
2.3 reactive基本实现
// src/reactivity/reactive.ts
export function reactive(raw) {
return new Proxy(raw, {
get(target, key) {
const res = Reflect.get(target, key);
// TODO 依赖收集
track(target, key);
return res;
},
set(target, key, value) {
const res = Reflect.set(target, key, value);
// TODO 触发依赖
trigger(target, key);
return res;
},
});
}
就是返回了一个Proxy,代理传入的对象,并且get和set也是基本的功能,没有做过多额外的处理
不过之后为了管理依赖,会在get中调用effect模块的track进行依赖收集,在set中调用effect模块的trigger触发依赖(目前还没实现,后面会讲),我们先看看能不能通过测试用例吧
测试用例通过,说明
reactive实现基本的代理功能是没问题了,那么接下来我们就要开始处理依赖的问题了!
2.4 effect基本实现
考虑到effect既要负责执行副作用函数,又要管理依赖,有多个功能,因此适合将他们封装到一个类中,我们首先封装一个ReactiveEffect类
// src/reactivity/effect.ts
class ReactiveEffect {
private _fn: any;
constructor(fn) {
this._fn = fn;
}
run() {
this._fn();
}
}
export function effect(fn) {
const _effect = new ReactiveEffect(fn);
_effect.run();
}
只要调用effect函数,就会创建ReactiveEffect对象,并执行它的run方法,这样我们就将运行副作用函数的逻辑从effect中转移到了ReactiveEffect的run方法中了
接下来要编写track函数,用于收集依赖,trigger函数,用于触发依赖
2.4.1 track依赖收集
根据前面的流程图,track就是一个映射寻找的过程,首先是以依赖的对象作为key去寻找它的属性和副作用函数之间的映射,找到这个映射后,再以依赖对象的属性作为key去寻找副作用函数的集合,将当前激活的effect对象加入到该集合中即可
targetMap用一个全局变量去存储,可以使用Map或者WeakMap,为了让垃圾回收机制能够正常运作,建议使用WeakMap作为targetMap的实现,具体原因可自行了解Map和WeakMap的区别
当前激活的effect对象用activeEffect全局变量存储,每当effct首次执行的时候,就会将activeEffect标记为当前在执行的函数
如果该函数内部有触发响应式对象的get拦截的话,就会执行track进行依赖收集,而track正是从actvieEffect中获取到需要收集的函数的,因此activeEffect算是建立起get拦截器和track之间沟通的桥梁
注意:加入到集合中的是**effect**对象,而不是副作用函数,因为我们是通过**effect**对象的**run**方法统一执行副作用函数的
/**
* @description 依赖收集
* @param target 依赖的对象
* @param key 对象的属性
*/
export function track(target, key) {
// target -> key -> deps
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let deps = depsMap.get(key);
if (!deps) {
deps = new Set();
depsMap.set(key, deps);
}
deps.add(activeEffect);
}
为了能够在track中通过activeEffect访问到正确的当前激活的effect对象,我们需要在effect对象执行run方法的时候修改一下activeEffect指向自己
class ReactiveEffect {
private _fn: any;
constructor(fn) {
this._fn = fn;
}
run() {
activeEffect = this;
this._fn();
}
}
2.4.2 trigger触发依赖
触发依赖也很简单,流程图中已经说明了,根据target拿到depsMap,再根据key拿到deps集合,遍历集合中每一个effect对象,调用它们的run方法即可将副作用函数执行
/**
* @description 触发依赖
* @param target 依赖的对象
* @param key 对象的属性
*/
export function trigger(target, key) {
const depsMap = targetMap.get(target);
const deps = depsMap.get(key);
for (const effect of deps) {
effect.run();
}
}
track和trigger都实现了以后,effect单元测试的happy path就可以通过了