系列介绍
本系列将会从零开始实现vue3核心模块reactivity, runtime-core,compiler-core。
本文知识来源于mini-vue,欢迎大家去star!
所有代码均为TypeScript编写(noImplicitAny),去除边界情况,只关注核心实现。
并采用TDD(测试驱动开发)的开发方式,测试框架为vitest(会在使用过程中讲解)。
Reactivity
如果不想看我废话,可以直接点此处查看官方介绍。
Reactivity,也就是我们常说的响应式系统,而在Vue3中,响应式系统被独立出来成为一个完善的模块。
什么是响应式系统呢?
举个栗子,这是一段原生的JS代码:
let A0 = 1
let A1 = 2
let A2 = A0 + A1
console.log(A2) //3
A0 = 2
console.log(A2) //还是3
A2 只是计算了值,它并不会随着A0和A1的变化而变化。
如果我们想实现这样的功能,可以说我们就实现了一个简单的响应式系统。
思考
那么如何在JS中实现这种功能呢?
我们自然可以想到,每次对A0或A1进行更改的时候,都对A2进行更新,这样就ok了。
那么,为了更新A2,我们需要一个更新函数:
function update(){
A2 = A0 + A1
}
我们可以这样想,update是一个副作用函数,它可以被称作effect,而A0和A1可以看作该effct的 依赖,也就是dependencies,而反过来,effect可以称作A0和A1的订阅者,subscriber,每次A0或者A1更新时,我们都通知effect去执行,这样就完成了响应式。
所以,我们需要实现的响应式系统,可以用一个函数进行抽象:
whenDepsChange(effect)
这个whenDepsChange函数应该做到以下事情:
- 挑选变量作为响应式变量,当这些变量被读取时,进行跟踪,例如:运行
A0 + A1时,A0和A1都应该被追踪 (track)。 - 在运行effect函数时如果读取了某个被追踪的变量,则让该effect成为该变量的订阅者。例如:在
update函数中读取了A0和A1,所以update函数就是A0和A1的订阅者。 - 当变量被修改时,通知这个变量的所有订阅者重新运行。例如:当修改
A0的值时,通知到A0的订阅者,此时只有update函数,所以update函数重新运行。S
以上内容即为reactivity的核心。
reactive
既然我们需要监听值的读和写,自然就想到了JS中的defineProperty和proxy。Vue3中选择了proxy来实现,(这里假设你已经明白了为什么选择proxy,如果不明白,建议先看相关文章或MDN文档,了解一下它们两个之间的区别)
但是,proxy有个问题,它不能监听到基本类型的变量,例如number,所以我们需要定义的响应式变量必须是个对象(object),先看看vue3中是如何使用的:
const obj = {
foo: 1
}
const ReactiveObj = reactive(obj)
所以,reactive就是一个函数,它接受一个对象,返回这个对象的代理(proxy)。在proxy的get中,对它进行track,也就是依赖收集,收集effect成为它的订阅者;在set中,通知它的订阅者重新运行。
那么如何监听基本类型变量呢?vue3的做法是用一个对象将它包裹起来,使用时调用.value:
const num = ref(1)
num.value // 1
具体实现和reactive类似,这里先略过。
effect
我们先看看Vue3一些API的使用:
const obj = reactive({ foo:1 })
const bar = computed( () => obj.foo + 1 )
watchEffect(()=>{
if(obj.foo === 5){
// ...
}
})
watch(()=> obj.foo, (count, prevCount)=>{
// ...
})
你应该能想到,在vue3中,watch, watchEffect, computed都和effect有关。
而在reactivity中,我们只需实现一个纯粹的effect:
const user = reactive({
age: 10
})
effect(()=>{
nextAge = use.age + 1
})
然后,确定effect运行的整个流程:
- 用户创建了一个
effect,传入一个函数fn - 将当前
effect记录下来,并且运行fn - 运行
fn时,如果内部触发了响应式变量的get操作,则触发了track - 在
track内部,将当前记录的effect和当前响应式变量关联起来,互相收集对方作为依赖 - 如果触发了响应式变量的
set操作,则找到当前变量的effect依赖,运行它内部的函数
整体的流程就是这样,细节方面在编码阶段我们一一完善。
项目搭建
这里我使用antfu的模板搭建项目模板,如果你也要用,请记得修改配置文件中的个人信息。
模板已经集成了vitest,我们可以直接安装依赖npm install。
接下来删除src/下的默认内容,就可以开始开发了。
测试驱动开发
本系列将会采用TDD的模式进行开发,也就是测试驱动开发。
具体操作流程:
- 分析需求,编写测试用例
- 实现代码,通过测试用例
- 重构代码,回归测试
至于TDD的好处这里就不再赘述,Vue官方仓库使用了Jest作为测试框架,这是个很流行的前端测试框架。
而我们的项目则采用**Vitest**来测试,它拥有更现代化的优势,比如智能文件监听模式、开箱即用的ts和jsx支持、ESM优先/顶部Await支持等等。最重要的是它兼容Jest的API,无需学习成本可以直接上手。感兴趣的同学可以在官网查看。
reactive实现
happy path 测试
在src下新建reactivity文件夹,同时在reactivity文件夹下新建tests文件夹,用于存放所有测试代码。
那么既然是TDD,我们首先来写一组测试,在tests下新建reactive.test.ts:
import { describe, expect, it } from 'vitest'
describe('reactive', () => {
it('happy path', () => {
const original = { foo: 1 }
const observed = reactive(original)
expect(observed).not.toBe(original)
expect(observed.foo).toBe(1)
})
})
简单解释一下这个测试,happy path test是软件开发里的概念,你可以把它理解为最简路径的测试,不考虑其他异常,如果代码没有通过happy path的测试,就意味着功能未实现。
这里给不熟悉测试的同学简单介绍一下:
我们首先定义一个original变量,然后定义一个它的reactive版本,那么首先,它们两个必然是不相同的,所以使用not.toBe组合进行一个基本判断,其次,reactive版本应该可以像它原本的对象一样正常获取内部属性,所以这里又进行了一个取值操作,判断observed.foo的值为1。
如果你对这些测试api还不熟悉,是时候去官方文档补充下知识了!
通过happy path
实现代码时,我们应该找准一个目标:通过测试。通过后再进行重构。
上文分析过,reactive实际上就是返回了一个proxy,在其中的get和set分别进行依赖收集和触发effect执行。
为了通过测试,现在我们可以先不用考虑get和set中的操作,那么代码就很简单了。
在reactivity下新建reactive.ts:
export function reactive(obj){
return new Proxy(obj, {
get(target, key){
// track(target, key) 依赖收集,先不实现
return target[key]
},
set(target, key, value){
// trigger(target, key) 触发更新,先不实现
target[key] = value
return true
}
})
}
ok,下一步我们运行测试,终端使用命令:vitest(如果是我的模板,可以npm run test)。
不出意外,你会看到1个测试已经pass了,这说明我们实现没问题,nice!
接下来简单重构一下代码,我们需要reactive变量仍保持原本的类型提示,故需要完善类型:
export function reactive<T>(obj: Record<any, T>): T{
return new Proxy(obj, {
get(target, key:string){
track(target, key) //依赖收集,先不实现
return target[key]
},
set(target, key:string, value){
trigger(target, key) //触发更新,先不实现
target[key] = value
return true
}
})
}
重构后,我们一定要再次运行测试:vitest。
现在应该可以看到测试仍然通过,说明我们的重构没问题!
接下来我们所有的实现都将采用TDD的模式,故关于这种写测试 -> 实现 -> 通过测试 -> 重构 -> 回归测试的流程就不再赘述。
effect实现
reactive只是构建了一个proxy,而响应式的重点在于effect。
我们知道,响应式其实就是让一个函数和reactive变量关联起来。
具体的实现就是我们创建一个effect函数,它接收一个普通函数作为参数,接着,如果这个普通函数里使用到了reactive变量,则会触发reactive变量的get中的track方法,track的作用就是将该effect收集为它使用的reactive变量的依赖。
那么,当reactive变量在某处被set的时候,它就会调用trigger方法,这是一个触发器,它会通知该reactve变量的所有依赖(也就是effect们)去运行那个函数。
既然track和trigger都与effect紧密相关,那么它们自然也应该是属于effect.ts模块下的。
以上就是核心流程。
首先,我们写happy path测试:/tests/effect.test.ts:
describe('effect',()=>{
it('happy path',()=>{
const user = reactive({ age:10 })
// track && trigger
let nextAge
effect(()=>{
nextAge = user.age + 1
})
expect(nextAge).toBe(11)
//update
user.age ++
expect(nextAge).toBe(12)
})
})
如果能通过happy path,则说明我们的响应式模块已经初步成型了!
effect实例
首先:effect出口的函数签名应该是这样的:
export function effect(fn){}
接下来我们思考一个问题,当effect被调用时,是需要先运行一遍fn的,这样才能触发track收集依赖。
同时,在track中,我们需要将这个effect作为依赖收集起来。
所以,在effect模块中,我们需要记录当前被调用的effect,在track时,将这个记录塞进依赖就行了。
但是如果要记录,单纯的一个effect函数是不行的,因为每个被调用的effect其实都是独立的实例。
说到实例,我们就能想到,我们需要创建一个类,这样才能每次调用effect时创建一个对应的实例。
//我们给这个类起个名字叫ReactiveEffect
class ReactiveEffet{
private _fn:Function // effect传入的函数
constructor(fn:Function){
this._fn = fn
}
run(){
this._fn()
}
}
在effect出口中:
export function effect(fn){
const _effect = new ReactiveEffect(fn) //创建实例
_effect.run() //运行函数触发track
}
这里,_effect就是我们记录的当前调用的effect实例了。在track的时候,我们需要把它塞进依赖里。
但是这里_effect是函数内部的变量,track肯定是拿不到的。
那我们再次梳理一下,_effect.run()之后,如果使用到了reactive变量,则会立马触发track,而track需要拿到这里的_effect,那既然这个流程是完整连续的,我们直接把_effect传到外部就行了。
effect.ts定义一个全局变量:
let activeEffect: ReactiveEffect
创建实例时把实例传给它:
export function effect(fn:Function){
const _effect = new ReactiveEffect(fn)
activeEffect = _effect
_effect.run()
}
接下来,track就能拿到当前effect实例了。
track
接下来我们来实现track。还记得track被调用的地方吗?
reactive.ts:
export function reactive<T>(obj: Record<any, T>): T{
return new Proxy(obj, {
get(target, key:string){
track(target, key) //依赖收集,先不实现
return target[key]
},
// 。。。
})
}
所以我们的函数签名已经有了:
export function track(target:Record<any,any>, key:any){}
接下来我们需要把activeEffect塞进依赖里,activeEffect已经准备好了,就差依赖了!
下面简单思考一下“依赖”的结构。
首先,在Vue项目中,我们基本会用到很多个reactive变量,而每个reactive变量又包含很多个键。
而effect则只和键有关:
const person = reactve({
name: 'John',
age: 18,
address: null
})
effect(()=>{
if(person.age === 18){
// do something...
}
})
我们不会在直接操作整个reactive变量,这也是新手常犯的错误:
let person = reactve({
name: 'John',
age: 18,
address: null
})
effect(()=>{
person = {
name: 'xxx',
age: 8,
address: 'xxx'
}
// 这样会让person失去响应性!因为它不再是一个reactive变量了
})
所以,每个键对应一个或多个RactiveEffect实例,可以用Map来表示:
type DepsMap = Map<any, Set<ReactiveEffect>> //这里用了Set而不是数组 是为了去重
而一个reactive变量则对应一个DepsMap:
const targetMap = new Map<Target, DepsMap>()
整体关系用图来表示:
搞清楚了结构,track也就很容易实现了:
export function track(target:Record<any,any>, key:any){
// 获取当前reactive变量的depsMap
let depsMap = targetMap.get(target)
if (!depsMap) {
// 如果这个reactive变量还没有depsMap,则初始化一个
depsMap = new Map()
targetMap.set(target, depsMap)
}
// 再获取需要track的键的ReactiveEffect集合
let dep = depsMap.get(key)
if (!dep) {
// 如果当前的键还没有依赖,则初始化
dep = new Set()
depsMap.set(key, dep)
}
// 最后, 将当前activeEffect塞进dep里
dep.add(activeEffect)
}
以上,我们的依赖收集就完成了!
trigger
最后要实现的就是触发更新函数trigger。
签名同track:
export function trigger(target:Record<any,any>, key:any){}
思路相信大家应该都能想到了:
- 拿到当前target也就是reactive变量对应的
depsMap - 拿到
depsMap中当前的key对应的activeEffect集合 - 遍历集合,依次调用
run方法
最终代码:
export function trigger(target:Record<any,any>, key:any){
const depsMap = targetMap.get(target)
// 如果没有依赖,就直接退出
if (!depsMap)
return
const dep = depsMap.get(key)
// 同上
if (!dep)
return
for (const effect of dep.values()) { //注意遍历的是Set,要加.values
effect.run()
}
}
搞定!
接下来运行测试:
vitest effect
在vitest后面加上名称,意思是只运行对应的测试,我们这里只运行effect的测试。
不出意外,测试完美通过!
最后
看了文章,相信你一定感觉Vue的核心响应式系统好像也不难!所以,一个流行的框架其实技术上不一定很难,但它一定有一个好的思想!
另外,reactivity模块还有很多其他的东西,比如effect的stop功能,isReactive、Readonly等,以及附属于reactivity模块的功能,比如ref、computed等,但这些都比较简单,也不是核心内容,本文就没有介绍。
如果你对这些没有介绍的功能感兴趣,可以去我的代码仓库查看,如果觉得有收获,欢迎点个star!