从0到1实现自己的Mini-Vue3(1)
1.Vue3源码中的各个模块的关系
当我们提到Vue3源码时,就不得不先从其中的几个包(模块)说起,也就是@vue/compiler-dom、@vue/compiler-core、@vue/compiler-sfc、@vue/runtime-dom、@vue/runtime-core、@vue/reactivity,那么他们之间的功能和关系是怎样的呢?
功能
功能部分,其中有的包是单独负责完整功能的,例如@vue/reactivity,负责处理响应式相关;一些包是相互配合进行的,例如@vue/runtime-dom、@vue/runtime-core,负责处理运行时相关逻辑
- @vue/compiler-sfc 负责将.vue单文件组件转化成JS代码
- @vue/compiler-dom、@vue/compiler-core 负责将template编译成render函数
- @vue/runtime-dom、@vue/runtime-core 负责运行时相关逻辑
- @vue/reactivity 数据响应式
关系
如下图所示,例如@vue/compiler-sfc在处理单文件组件转化时,会使用到@vue/compiler-dom、@vue/compiler-core这两个模块中的功能;@vue/runtime-core会使用到@vue/reactivity模块中的功能。但是,由于这些模块关系的存在@vue/runtime-dom模块是不可以直接使用@vue/reactivity模块中的内容的,后续在实现这些模块时,我们也需要遵循这些规则来进行。
2.环境准备
首先,新建目录mini-vue,进入目录使用yarn初始化一个空的工程
yarn init -y
然后,创建目录src和src/reactivity
mkdir src && mkdir src/reactivity
接下来,在reactivity目录创建index.ts用于将来模块的出口
touch src/reactivity/index.ts
由于项目使用TDD测试驱动开发,所以先创建一些测试用例作为将来的功能验证
在src目录下创建tests目录存放测试用例,使用jest进行单测
mkdir src/tests && touch src/tests/index.spec.ts
在index.spec.ts文件写我们的第一个测试用例
it("init",() =>{
expect(true).toBe(true);
})
项目使用Typescript开发,所以还需要把他集成进来
yarn typescript -D
初始化typescript
npx tsc --init
集成jest和jest中的TS类型,这样在使用时,会有jest中API的提示
yarn add jest @types/jest -D
然后,在package.json中添加测试命令行脚本test
"scripts":{
"test":"jest"
},
之后,使用yarn test
就可以跑我们的第一个测试用例了
看到上面的绿色的1 passed标志,就表示测试用例通过了
但是,事情还没有结束。
我们可以在src/reactivity/index.ts中导出一个简单函数
export function add(a, b) {
return a + b
}
然后,对他进行测试,修改src/reactivity/tests/index.spec.ts内容如下
import { add } from '..';
it("init",() =>{
expect(add(1,2)).toBe(3);
})
重新yarn test
之后,发现控制台报了这样的错误
这个错误是什么意思呢?
他告诉我们,现在我们还不能在测试用例中使用import进行导入
原因是:jest现在是运行在nodejs环境,nodejs默认的模块规范是commonjs,现在我们使用ESM模块规范,他不认识了。那么需要怎样解决呢?
这就要请Babel出场了!!
Babel是一个JavsScript的转译器,可以一个版本/平台的JS代码转换成另外一个版本/平台的JS代码。
这里可以借助Babel在编译时将ESM规范的TS代码转换成commonjs规范的TS代码,然后再交由jest处理,就不会有这个问题了。
安装Babel核心包、预设、TS预设,Babel中用于处理jest相关的babel-jest包
yarn add babel-jest @babel/core @babel/preset-env @babel/preset-typescript -D
然后,创建babel.config.js,引入这些包
mkdir src/babel.config.js
module.exports={
presets:[
["@babel/preset-env",{targets:{node:"current"}}],
"@babel/preset-typescript"
]
}
最后重新yarn test
执行测试,当再次看到绿色1 passed标志,就代表到目前为止你的环境配置和我这边保持一致,在下一步就可以开始代码编写了😁。
3.实现effect、reactive、track、trigger
从第一节中,我们已经知道@vue/reactivity也就是响应式的这个包被核心运行时所依赖,所以我们要从这个包开始着手。
响应式的核心从头讲起时,就不得不提到effect、reactive函数,和track、trigger函数,他们的作用如下:
- effect函数 注册一个副作用函数fn,当其中的状态发生变化时,fn会被自动调用
- reactive函数 将一个普通的JS对象转换成一个响应式对象,使得他的属性的获取和变更可被追踪
- track函数 追踪对象属性的变化
- trigger函数 当对象属性发生变更时,触发他所依赖的副作用函数的执行
基于他们的功能,我们可以先创建测试用例如下
删除src/reactivity/tests/index.spec.ts,创建src/reactivity/tests/reactivity.spec.ts,写入如下用例
import { reactive } from '../reactive';
describe('reactive',() => {
it("happy path",() => {
const original = {foo:1}
const obsered = reactive(original)
expect(obsered).not.toBe(original)
expect(obsered.foo).toBe(1)
})
})
在这个用例中,我们测试了处理前后对象引用是否相同,属性值是否相同
创建src/reactivity/reactive.ts文件,内容如下
export const reactive = (raw: any) => {
return new Proxy(raw, {
get(target, key) {
return Reflect.get(target, key)
},
set(target, key, value) {
let res = Reflect.set(target, key, value)
return res
}
})
}
其中,reactive函数将一个原始对象raw包装成一个代理对象Proxy,并返回。属性访问和设置使用Reflect.get Reflect.set保证和原始对象有着一样的操作效果
然后,执行yarn test
,发现测试成功。
紧接着,添加第二个用例,对effect进行测试
创建src/reactivity/tests/effect.spec.ts,内容如下
import { reactive } from '../reactive'
import { effect } from '../effect'
describe("effect", () => {
it("happy path", () => {
const user = reactive({
age: 10
})
let next
effect(() => {
next = user.age + 1
})
expect(next).toBe(11)
user.age++
expect(next).toBe(12)
})
})
这里同时测试了reactive和effect两个函数的基础功能。
初始化变量next,注册一个函数fn到effect中,函数中引用到使用reactive函数处理过的响应式对象user的属性age,并且修改了next的值为user.age+1。
然后,测试next==11? 这个测试其实想知道,fn在被注册之后,有没有被执行过。我们知道结果当然是,执行过。
接下来,对user.age进行自增之后,重新测试next==12? 这里的测试想知道,fn所依赖的属性age变化后,fn有没有重新执行。结果当然也是肯定的。
这个功能稍复杂一些,我们可以如何做呢?
想象这样一个结构
user -> {
age -> [fn, fn2]
}
user是一个响应式对象,age是其中一个属性,[fn,fn2]是那些依赖age的副作用函数
我们可以在响应式对象属性key触发getter时,将对应的副作用函数加入到key对应的集合中
在key触发setter时,将对应的所有副作用函数拿出来,依次执行。这样就可以做到属性发生变更时,那些依赖这个属性的副作用函数中需要改变的值都重新计算。
话不多说,上代码。
创建src/reactivity/effect.ts,内容如下
// 全局变量,表示当前访问的属性对应的副作用函数
let activeEffect: any
// 用于存放副作用函数的信息
class ReactiveEffect {
private _fn
constructor(fn: any) {
this._fn = fn
}
run() {
activeEffect = this
this._fn()
}
}
// 响应式对象对应的依赖表
let targetMap = new WeakMap()
export function track(target: any, key: any) {
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)
}
export function trigger(target: any, key: any) {
let depsMap = targetMap.get(target)
let deps = depsMap.get(key)
for (const effect of deps) {
effect.run()
}
}
export function effect(fn: any) {
const _effect = new ReactiveEffect(fn)
_effect.run()
}
注意到上面使用到了WeakMap,他和Map有什么区别呢?下面的例子直观的展示了他们的不同
const map = new Map()
const weakmap = new WeakMap();
(() =>{
let foo = {foo:1}
let bar = {bar:1}
map.set(foo,1)
weakmap.set(bar,1)
})()
console.log(map,weakmap) // weakmap的key为空
可以看到,Map和WeakMap虽然都可以用对象作为key,但当对象的内存释放后,两者的表现发生了不同。
Map中仍然可以获取到释放掉内存的对象的属性信息,而WeakMap则拿不到,这样的差异导致了如果在存入大量键值数据时,Map可能会产生内存泄漏,而WeakMap则不会,故这里使用了WeakMap。
紧接着,修改src/reactivity/reactive/.ts
+ import { track, trigger } from './effect'
export const reactive = (raw: any) => {
return new Proxy(raw, {
get(target, key) {
+ track(target, key)
return Reflect.get(target, key)
},
set(target, key, value) {
let res = Reflect.set(target, key, value)
+ trigger(target, key)
return res
}
})
}
重新yarn test
执行测试,可以再次看到绿色2 passed标志,说明当前两个用例中的功能都已经实现完毕。
至此,本期内容到这里介绍完了,更多内容敬请关注这个系列,更新会持续。也欢迎大家点赞、评论、转发,点赞多多、更新多多。大家下一期再见~
转载自:https://juejin.cn/post/7198073902882586682