likes
comments
collection
share

从0到1实现自己的Mini-Vue3(1)

作者站长头像
站长
· 阅读数 21

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模块中的内容的,后续在实现这些模块时,我们也需要遵循这些规则来进行。 从0到1实现自己的Mini-Vue3(1)

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就可以跑我们的第一个测试用例了

从0到1实现自己的Mini-Vue3(1)

看到上面的绿色的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之后,发现控制台报了这样的错误

从0到1实现自己的Mini-Vue3(1)

这个错误是什么意思呢?

他告诉我们,现在我们还不能在测试用例中使用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,发现测试成功。

从0到1实现自己的Mini-Vue3(1)

紧接着,添加第二个用例,对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标志,说明当前两个用例中的功能都已经实现完毕。

至此,本期内容到这里介绍完了,更多内容敬请关注这个系列,更新会持续。也欢迎大家点赞、评论、转发,点赞多多、更新多多。大家下一期再见~