Vue3 源码学习(2)-- 响应式系统

257 阅读13分钟

Vue3 源码学习(2)-- 响应式系统(1)

前言

响应式系统式Vue.js的重要组成部分,本章来实现一个简易的响应式系统。

在实现响应式系统之前,我们需要连明白两个概念响应式数据和副作用函数

1、 响应式数据与副作用函数

1.1、副作用函数

副作用函数指的是会产生副作用的函数,如下面代码所示:

 function effect(){
     document.body.innerText = "hello vue3"
 }

当effect函数执行的时候他会设置body的文本内容,但除了effect之外的其他函数仍然可以读取或者设置body的文本内容。也就是说:effect函数的执行会直接或者间接应影响其他函数的执行,这时我们说effect函数产生了副作用,effect函数就是副作用函数。

下面代码就是一个典型的副作用函数:

//全局变量 
let val = 1 

function effect(){
    val =2   //修改全局变量,产生副作用
    
 }   

effect()

1.2、什么是响应式数据

理解了什么副作用函数,再来说说什么是响应式数据。假设在一个副作用函数中读取某个对象的属性

    const obj = {
        text:"hello world "
   }
    function effect(){
       //effect函数的执行会读取obj.text
       document.body.innerText = obj.text
    }
    

上述代码所示,副作用函数effect会设置bodyinnerText属性,其值为obj.text当obj.text发生变化的时候,我们希望副作用函数effect会重新执行,如果能够实现这个目标,那么对象Obj对象就为响应式数据。

1.3、响应式数据的实现思路

如何让一个普通的obj对象变成响应式数据呢?通过观察发现两点线索

  • 副作用函数effect触发的时候,会读取obj.text,即触发getter(读取)操作
  • 当修改obj.text的值的时候,触发setter(设置)操作

如果我们能够拦截对象的getter和setter操作,并进行一下处理:

  • getter的时候将副作用函数effect收集到一个“桶”里面,
  • setter的时候重新将副作用函数effect从“桶”里取出并执行

image.png

实现上面需求,用到es6新增proxy代理对象,对数据的getter和setter进行代理

2、响应式系统的实现

由上文所述,响应式系统的工作流程如下:

  • 副作用函数执行,触发getter操作并且存储effect副作用函数
  • setter操作的时候取出依赖的effect副作用函数并执行

下面我们将利用TDD的形式来进行响应式系统的实现和完善

2.1、初始化项目和测试环境

  • 在项目文件夹下执行yarn init -y命令生成package.json文件
  • 执行yarn add typescript jest @types/jest -D安装typescriptjest以及@types/jest
  • 执行tsc --init命令生成tsconfig.json文件,并修改以下几项
"lib": ["DOM", "ES6"],     // 引入 DOM 和 ES6
"noImplicitAny": false,    // 允许在表达式和声明中不指定类型,使用隐含的 any
"types": ["jest"],         // 将 jest 的类型声明文件添加到编译中
"downlevelIteration": true // 开启 for-of 对可迭代对象的支持
  • package.json中配置:
"scripts":{
    test:"jest"
}
  • 根据 Jest 官网进行配置,以在 Jest 中使用 Babel 和 TypeScript:执行yarn add -D babel-jest @babel/core @babel/preset-env @babel/preset-typescript安装 babel-jest、@babel/core、@babel/preset-env 和 @babel/preset-typescript;创建babel.config.js文件,并写入以下内容:
module.exports = {
  presets: [
    ['@babel/preset-env', { targets: { node: 'current' } }],
    '@babel/preset-typescript'
  ]
}

2.2、设计一个相对完善的响应式系统

响应式系统的实现需要两部分组成:

  • 相应式数据(reactive、ref等)
  • 副作用函数

2.2.1、实现最基础的reactive

查看Vue3文档中响应式Api部分,找到有关reactive的介绍 Returns a reactive proxy of the object返回一个对象的响应代理 , 类型声明 `function reactive(target:T):UnwrapNestedRefs

在实现reactive之前,首先创建测试用例reactive.spec.ts

//reactive.spec.ts
describe("reactive",()=>{
    it("Object",()=>{
        //原始对象 original
        const original = { foo:1}
        // reactive返回对象的响应式副本
        const observed = reactive(original)
        // observed !== original
        expect(observed).not.toBe(original)
        //observed的property属性值与original值相等
        expect(observed.foo).toBe(1)
     })   
})

实现reactive

// reactive.ts

export function reactive(obj){
    // 返回proxy的实例
    return new Proxy(obj,{
        //对obj 进行getter和setter的代理
        get(target,key){
            //TODO 收集依赖(副作用函数收集)
            return Reflect.get(target,key)    
        },
        set(target,key,newVal){
            
            const res = Reflect.set(target,key,newVal)
            // TODO依赖触发(副作用函数触发)
            return  res
        }
    })
}

思考: get中为什么不直接返回return obj[key] set中为什么不直接赋值`obj[key] = newVal TODO 解决思考

执行yarn test reactive命令,可以看到测试通过,这样就完成了reactive最基础的实现。

2.2.2、实现最基础的effect

之前上文我们编写了副作用函数的名字为effect,但在实际生参数,不可能所有的副作用函数都为effect,我们希望的是副作用函数哪怕是一个匿名函数,也能够正确收集起来。为了实现这一点,我们抽离了一个ReactiveEffect类表示真实副作用函数,effect函数则用来注册副作用函数

在effect函数中立即执行一次副作用函数,用来触发响应式对象的getter操作,收集ReactiveEffect依赖

在实现effect之前,首先创建测试用例effect.spec.ts

describe("effect",()=>{
    it("should run the true effect function at once",()=>{
        //真实的effect函数
        const fnSpy = jest.fn(()=>{})
        effect(fnSpy)
        //当程序执行时,传入的副作用函数会被立即执行
        expect(fnSpy).toHaveBeenCalledTimes(1)
    })
    it("should observe basic properties",()=>{
        let dummy
        //创建响应式对象爱过你
        cosnt counter = reactive({num:0})
        
        effect(()=>{dummy = counter.num})
        expect(dummy).toBe(0)
        //响应式数据发送变化,会再次执行该函数
        counter.num = 2
        expect(dummy).toBe(2)
        couter.num ++
        expect(dummy).toBe(3)
    })
})
//effect.ts
/**
* @param  fn:真实的副作用函数
*/
class ReactiveEffect(fn){
   private _fn : any
    
   constructor(fn){
       // 将传入的副作用函数fn赋值给实例的私有属性 _fn
       this._fn = fn
   }
   
   //执行传入的函数
   run(){
           this._fn()
       
   }     
/**
* funtion effect  用来注册副作用函数
* fn : 副作用函数
*/
exprot function effect(fn) {
    //创建ReactiveEffect类的实例
    const _effect:ReactiveEffect = new ReactiveEffect(fn)
    
    //调用ReactiveEffect实例的run方法,执行纯如的函数
    _effect.run()
}

此时我们就实现了一个不完全的effect

之后就如上文所讲,在reactive函数返回的Proxy实例的get中收集副作用函数,在set中触发依赖

//reactive.ts
export function reactive(obj){
    // 返回proxy的实例
    return new Proxy(obj,{
        //对obj 进行getter和setter的代理
        get(obj,key){
 
            const res =  Reflect.get(target,key)    
            // 收集依赖(副作用函数收集)
            track(target,key)
            return res
        },
        set(obj,key,newVal){
            
            const res = Reflect.set(target,key,newVal)
            // TODO依赖触发(副作用函数触发)
            trigger(target,key)
            return  res
    })
}

接下来就是实现tracktrriger函数,在实现之前,我们先思考一下怎么设计用来存储ReactiveEffect依赖的这个

如何设计这个“桶”

在设计之前,我们先观察下面的代码:

effect(function effectFn(){
    document.body.innerText = obj.text
})

在这段代码中存在三个角色:

  • 被操作(读取)的代理对象obj
  • 被操作(读取)的key:text
  • 使用effect注册的副作用函数effectFn

如果使用target来表示一个代理对象所代理的原始原始对象,用key来表示被操作的字段名,用effetFn来表示被注册的副作用函数,那么额可以用着三个角色建立一下关系

image.png

如果两个副作用函数共同读取同一个对象的属性值:

image.png

如果在同一个副作用函数中读取了同一个对象的两哥不同属性:

image.png

如果不同的副作用读取不同的两个不同对象的不同属性:

image.png

明白了这个收集依赖的是一个树形结构,我们来实现这个。在实现过程中使用了一个全局的WeakMap类型的变量targetsMap,用于保存程序运行中的所有依赖,以及一个全局的变量activeEffect,用于保存正在执行的ReactiveEffect类的实例。

//effect.ts

const targetsMap = new WeakMap()  //存储所有副作用的桶

let activeEffect :ReactiveEffect  //用于保存当前执行的ReactiveEffect实例

class RectiveRffect {
    /*其他代码*/
    
    run(){
        //调用run方法时,用全局变量activeEffect保存当前实例
        activeEffect = this
        this_fn()
     }
}     

/**
*track 用于收集依赖
*@param target 
*@param key   
*/
exprot function track(target,key){
    if(!activeEffect) return
    //获取当前响应式对象依赖的“桶” ,若为undefined,则进行初始化并保存带targetsMap中
    /**
    * target为当前响应式对象
    * depsMap 为Map实例,用于保存与当前响应式对象相关的depsMap
    */
    let depsMap:Map<any, Set<ReactiveEffect>> | undefined = targetsMap.get(target)
    if(!depsMap){
        depsMap = new Map<any, Set<ReactiveEffect>>()
        targetsMap.set(target,depsMap)
    }
    
    //获取当前property依赖的“桶”,,若为undefined,则进行初始化并保存带depsMap中
    /**
    * key 为响应式对象的property
    * deps 为Set实例,用于存储与当前property相关的ReactiveEffect实例
    */
    let deps:Set<ReactiveEffect> | undefined = depsMap.get(key) 
    if(!deps){
        deps = new Set<ReactiveEffect>()
        depsMap.set(key,deps)
     }   
    
    //最后将当前激活的副作用函数实例activeEffect添加到“桶”里
    if(deps.has(activeEffect)){
        return
    }
    deps.add(activeEffect)
}   

/**
*trigger 用于触发依赖
*@param target 
*@param key   
*/

export function trigger(target,key){
    //获取当前响应式对象对应的Map实例
     let depsMap:Map<any, Set<ReactiveEffect>> | undefined = targetsMap.get(target)
    //获取当前key对相应的Set实例
     let deps:Set<ReactiveEffect> | undefined = depsMap.get(key) 
    
    for(const reactiveEffect of deps){
        reactiveEffect.run()
    }
}

image.png 执行yarn test effect命令,可以看到测试通过,这样就完成了响应式最基础的实现。

思考: 为什么使用WeakMap

2.2.3、完善effect

1、返回变量runner

effect执行会返回一个函数,用runner来接受这个函数,调用runner的时候会再次执行传入的副作用函数,同时返回该函数的返回值

在effect的测试文件effect.spec.ts添加测试代码:

//effect.spec.ts
describe("effect",()=>{
    
    /*其他测试代码*/
    
    it("should return runner when call effect",()=>{
        let foo = 0
        //用一个变量runner来接收effect执行后返回的函数
        const runner = effect(()=>{
            foo++ 
            return "foo"
        })
        expect(foo).toBe(1)
        //调用runner时,会再吃执行传入的副作用函数并返回值
        const res = runner()
        expect(foo).toBe(2)
        
        //runner执行返回该函数的返回值
        expect(res).toBe('foo')
        
    })


})

修改effect.ts

class ReactiveEffect {
    /*其他代码*/
    
    run(){
        activeEffect = this
        
        //返回传入的函数的执行结果
        return this._fn()
    }
}

export function effect(fn){
    const _effect:ReactiveEffect = new ReactiveEffect(fn)
    
    _effect.run()
    
    //返回 _effect.run 并将this的指向指定为_effect
    const runner = _effect.run.bind(_effect)
    
    return runner

执行yarn test effect命令运行effect的测试,可以看到测试通过,这样就进一步完善了effect的实现。

2、调度执行,接收参数scheduler

调度执行是响应式系统非常重要的特性。首先我们先明白什么是可调度性。所谓可调度性,就是当trigger动作触发的副作用函数重新执行时,有能力决定副作用函数的执行时机、次数以及方式

我们可以设计effect函数接收一个option对象作为第二个参数,允许用户指定调度器scheduler方法。用一个变量runner接收effect执行返回的函数,程序运行时会首先执行传入的副作用函数,而不会调用scheduler方法,之后当传入的函数依赖的响应式对象的property发生更行时,会调用scheduler方法而不会直接执行该函数,只有调用runner时才会重新执行该函数。

添加测试代码

describe("effect",()=>{

    /*其他代码*/
    it("scheduler",()=>{
        let dummy
        let run 
        const scheduler = jest.fn(()=>{
            run++
        })
        const obj = reactive({foo:1})
        const runner  = effect(
            ()=>{
                dummy = obj.foo
            },
            {
                scheduler
            }
        )
        
        //程序运行时会首先执行传入的函数,而不会调用scheduler方法
        expect(scheduler).not.toHaveBeenCalled()
        expect(dummy).toBe(1)
        // 当传入的函数依赖的响应式对象的 property 的值更新时,会调用 scheduler 方法而不会执行传入的函数 
        obj.foo++ 
        expect(scheduler).toHaveBeenCalledTimes(1) 
        expect(dummy).toBe(1)
        // 只有当调用 runner 时才会执行传入的函数
        runner()
        expect(scheduler).toHaveBeenCalledTimes(1)
        expect(dummy).toBe(2)
    })
})

为了通过以上测试,需要对effecttrigger函数进行完善

//effect.ts

class ReactiveEffect {
  scheduler?:()=>void
    /*其他代码*/
    
    constructor(fn,option){
        this._fn = fn
        this.scheduler = option?.scheduler
}

export funtion effect(fn,option={}){
    const _effect = new ReactiveEffect(fn,option)
    /*其他代码*/
 }
 
 
 export function trigger(traget,key){
     /*其他代码*/
     for(const reactiveEffect of deps){
         if(reactiveEffect.scheduler){
             reactiveEffect.scheduler()
          }else{
             reactiveEffect.run()
          }   
}     

执行yarn test effect命令运行effect的测试,可以看到测试通过,这样就进一步完善了effect的实现。

后面实现computed计算属性就会用到调度器这一属性,用来实现计算属性的缓存效果

3、stop函数

stop函数接收effect执行返回的函数(runner)作为参数。调用stop后,当传入的函数依赖的响应式对象的property发生值更新时不再执行该函数,只有当调用runner时才会恢复执行该函数

添加测试代码

//effect.spec.ts
describe('effect', () => {
  /* 其他测试代码 */

  it('stop', () => {
    let dummy
    const obj = reactive({ prop: 1 })
    const runner = effect(() => {
      dummy = obj.prop
    })
    obj.prop = 2
    expect(dummy).toBe(2)
    // 调用 stop 后,当传入的函数依赖的响应式对象的 property 的值更新时不会再执行该函数
    stop(runner)
    obj.prop = 3
    expect(dummy).toBe(2)
    obj.prop++
    expect(dummy).toBe(2)
    // 只有当调用`runner`时才会恢复执行该函数
    runner()
    expect(dummy).toBe(4)
  })
})

为了通过以上测试,需要对effect的实现进行完善,实现并导出stop

调用stop函数,响应式数据发生变化不在触发副作用函数,其根本就是stop函数触发会清空依赖的副作用函数,所以当响应式数据改变,触发trigger函数时,副作用函数不会执行,

//effect.ts

//用于记录是否应该收集依赖,防止调用stop后触发响应式对象的property的getter时重新收集
let shouldTrack:boolean = false
class ReactiveEffect {
    
    /*其他代码*/
    
    //用于存储与当前实例相关的响应式对象的property对应的Set实例
    deps:Array<Set<ReactiveEffect>>=[]
    
    //用于记录当前实例的状态,为ture时为调用stop方法,否则一调用,避免重复防止重复调用 stop 方法
    active:boolean = true
    
    run(){
         //若已调用stop方法则直接返回传入的函数的执行结果
         if(!this.active){
             return this._fn()
         }
         
         //应该收集依赖
         shouldTrack = true
         
         activeEffect = this
         
         const res = this._fn()
         
         //重置
         shouldTrack = false
         return res
     }    
         
    // 用于停止传入的函数的执行
    stop(){
        if(this.active){
            //清空依赖
            cleanupEffect(this)
            this.active = false
        }
     }    
}
function cleanupEffect(reactiveEffect:ReactiveEffect){
    reactiveEffect.deps.forEach(dep=>{
        //reactiveEffect.deps.delete(dep) 勘误
        dep.delete(reactiveEffect)
    })
 }   

  
        
export function effect(fn,option:any={}){

    /*其他代码*/
    
    // 用一个变量 runner 保存将 _effect.run 的 this 指向指定为 _effect 的结果 
    const runner: any = _effect.run.bind(_effect) 
    // 将 _effect 赋值给 runner 的 effect property 
    runner.effect = _effect
    
    return runner
}

export function track(target, key){
    //若不应该收集依赖直接返回
    if(!shouldTrack || activeEffect === undefined){
        return
     }
     /*其他代码*/
     
     dep.add(activeEffect) 
     // 将 dep 添加到当前正在执行的 ReactiveEffect 类的实例的 deps property 中 
     activeEffect?.deps.push(dep)
 }
 
export function stop(runner){
    // 调用 runner 的 effect property 的 stop 方法
    runner.effect.stop()
} 

执行yarn test effect命令运行effect的测试,可以看到测试通过,这样就实现了stop,进一步完善了effect的实现

4、接收onStop参数

effect接收的第二个参数对象中,还包括一个onStop方法。用一个变量runner接受effect执行返回的函数,调用stop并传入runner时,会执行onStop方法。

测试代码:

//effect.spec.ts
describe('effect', () => {
  /* 其他测试代码 */

  it('events: onStop', () => {
    const onStop = jest.fn()
    const runner = effect(() => {}, {
      onStop
    })

    // 调用 stop 时,会执行 onStop 方法
    stop(runner)
    expect(onStop).toHaveBeenCalled()
  })
})
//effect.ts
class ReactiveEffect {
    /*其他代码*/
    
    onStop?:()=>void
    
    constructor(fn,option){
        /*其他代码*/
         this.onStop = option?.onStop
    }
    
    stop(){
        if(this.active){
            cleanupEffect(this)
            if(this.onStop){
                this.onStop()
            }
            this.active =false
        }
        
 }   

执行yarn test effect命令运行effect的测试,可以看到测试通过,这样就进一步完善了effect的实现。

总结

本篇主要实现了一个最基本的reactiveeffect,基本实现了一个简易的响应式系统。

其中还有许多edge case的实现,《Vuejs的设计和实现》中的4.4分支切换和cleanup4.5嵌套effect与effect栈等还需努力

未完待续