本文已参与「新人创作礼」活动,一起开启掘金创作之路
本专栏会讲述如何实现一个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 -Dnpx 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.tsdescribe('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基本实现
pnpm i jest @types/jest -D0
就是返回了一个Proxy,代理传入的对象,并且get和set也是基本的功能,没有做过多额外的处理
不过之后为了管理依赖,会在get中调用effect模块的track进行依赖收集,在set中调用effect模块的trigger触发依赖(目前还没实现,后面会讲),我们先看看能不能通过测试用例吧 测试用例通过,说明reactive实现基本的代理功能是没问题了,那么接下来我们就要开始处理依赖的问题了!
2.4 effect基本实现
考虑到effect既要负责执行副作用函数,又要管理依赖,有多个功能,因此适合将他们封装到一个类中,我们首先封装一个ReactiveEffect类
pnpm i jest @types/jest -D1
只要调用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**方法统一执行副作用函数的
pnpm i jest @types/jest -D2
为了能够在track中通过activeEffect访问到正确的当前激活的effect对象,我们需要在effect对象执行run方法的时候修改一下activeEffect指向自己
pnpm i jest @types/jest -D3
2.4.2 trigger触发依赖
触发依赖也很简单,流程图中已经说明了,根据target拿到depsMap,再根据key拿到deps集合,遍历集合中每一个effect对象,调用它们的run方法即可将副作用函数执行
pnpm i jest @types/jest -D4
track和trigger都实现了以后,effect单元测试的happy path就可以通过了