从零实现Vue3__响应式核心篇

1,684 阅读12分钟

系列介绍

本系列将会从零开始实现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时,A0A1都应该被追踪 (track)。
  • 在运行effect函数时如果读取了某个被追踪的变量,则让该effect成为该变量的订阅者。例如:在update函数中读取了A0A1,所以update函数就是A0A1的订阅者。
  • 当变量被修改时,通知这个变量的所有订阅者重新运行。例如:当修改A0的值时,通知到A0订阅者,此时只有update函数,所以update函数重新运行。S

以上内容即为reactivity的核心。

reactive

既然我们需要监听值的,自然就想到了JS中的definePropertyproxy。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运行的整个流程:

  1. 用户创建了一个effect,传入一个函数fn
  2. 将当前effect记录下来,并且运行fn
  3. 运行fn时,如果内部触发了响应式变量的get操作,则触发了track
  4. track内部,将当前记录的effect和当前响应式变量关联起来,互相收集对方作为依赖
  5. 如果触发了响应式变量的set操作,则找到当前变量的effect依赖,运行它内部的函数

整体的流程就是这样,细节方面在编码阶段我们一一完善。

项目搭建

这里我使用antfu的模板搭建项目模板,如果你也要用,请记得修改配置文件中的个人信息。

模板已经集成了vitest,我们可以直接安装依赖npm install

接下来删除src/下的默认内容,就可以开始开发了。

测试驱动开发

本系列将会采用TDD的模式进行开发,也就是测试驱动开发

具体操作流程:

  1. 分析需求,编写测试用例
  2. 实现代码,通过测试用例
  3. 重构代码,回归测试

至于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,在其中的getset分别进行依赖收集和触发effect执行。

为了通过测试,现在我们可以先不用考虑getset中的操作,那么代码就很简单了。

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们)去运行那个函数。

既然tracktrigger都与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>()

整体关系用图来表示:

image-20220610161236633

搞清楚了结构,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){}

思路相信大家应该都能想到了:

  1. 拿到当前target也就是reactive变量对应的depsMap
  2. 拿到depsMap中当前的key对应的activeEffect集合
  3. 遍历集合,依次调用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功能,isReactiveReadonly等,以及附属于reactivity模块的功能,比如refcomputed等,但这些都比较简单,也不是核心内容,本文就没有介绍。

如果你对这些没有介绍的功能感兴趣,可以去我的代码仓库查看,如果觉得有收获,欢迎点个star!