likes
comments
collection
share

🤯vue3核心源码剖析(二)

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

🚀reactive & effect 且利用Jest测试实现数据响应式(二)

山不在高,有仙则名。水不在深,有龙则灵。 —— 刘禹锡《陋室铭》

前言

还没看过上一篇的看这里🎉

🚀reactive & effect 且利用Jest测试实现数据响应式(一)

上一篇讲了reactive和effect数据响应的基本原理,这篇打算讲effect的其他用法,effect的第二个形参option的实现,内容包括 runnerschedulerstop(删除依赖)和onStop(依赖删除后的回调)。

🤣如有错漏,请多指教❤

🛠实现runner手动触发依赖

runner其实就是ReactiveEffect实例对象的run()函数的函数体,effect会返回一个runner函数,手动调用runner函数也可以实现触发依赖,不必等待reactive对象的set操作执行。如果effect的回调函数有返回值,那么runner函数被执行之后的返回值也应该是该effect回调函数的返回值。

先看以下测试用例

it('should return runner when effect was called', () => {
  let foo = 1
  let runner = effect(() => {
    foo++
    return 'foo'
  })
  expect(foo).toBe(2)
  let returnValue = runner()       // 手动触发依赖
  expect(foo).toBe(3)
  expect(returnValue).toBe('foo')  // 返回值是foo
})

不过这里runner()执行的时候就是执行的effect.run()方法,run方法里面的this会丢失对effect的指向,所以这里应该对run改变this指向,让他重新绑定为effect对象。

// 根据官方给出的介绍:effect会立即触发这个函数,同时响应式追踪其依赖
export function effect<T = any>(fn: () => T): EffectRunner {
  let _effect = new ReactiveEffect(fn)
  _effect.run()
  // 注意这里的this指向,return 出去的run方法,方法体里需要用到this,且this必须指向ReactiveEffect的实例对象
  // 不用bind重新绑定this,this会指向undefined
  let runner = _effect.run.bind(_effect) as EffectRunner
  return runner
}

还没完,还有一件事~~(成龙历险记老爹口吻),ReactiveEffect的run()方法必须返回effect回调函数的返回值,这样的话runner()执行的时候就能得到effect回调函数的返回值了。

class ReactiveEffect {
  constructor(public fn: Function, public scheduler?: EffectScheduler) {}
  run() {
    activeEffect = this
    let returnValue = this.fn() 
    return returnValue
  }
}

🛠实现scheduler

scheduler是effect第二个形参option的可选属性,它是一个函数

该功能描述:

  1. effect首次执行的时候不执行scheduler,直接执行effect的回调函数
  2. 之后每次触发trigger函数的时候都会执行scheduler函数,不执行effect回调函数
  3. 当调用run()的时候才会触发runner,也就是说调用effect的回调函数

在这里我直接拿vue源码的一个测试用例了

it('scheduler', () => {
  let dummy
  let run: any
  const scheduler = jest.fn(() => {
    run = runner
  })
  const obj = reactive({ foo: 1 })
  const runner = effect(
    () => {
      dummy = obj.foo
    },
    { scheduler }
  )
  expect(scheduler).not.toHaveBeenCalled()
  expect(dummy).toBe(1)
  // should be called on first trigger set操作的时候,也就是说在trigger被调用的时候
  obj.foo++
  expect(scheduler).toHaveBeenCalledTimes(1)
  // should not run yet
  expect(dummy).toBe(1)
  // manually run  会触发effect的回调函数
  run()
  // should have run
  expect(dummy).toBe(2)
})

实现起来也相对简单,effect函数新增第二个参数option对象并且里面有一个类型为function的scheduler属性,作为可选值的存在。

结合条件1:首次执行effect不会调用scheduler和条件2:每次触发trigger函数的时候都会执行scheduler函数可以推敲出scheduler()的调用应该在trigger里面,并且用if判断scheduler是否有值,要么执行effectrun()方法要么执行effect的scheduler()方法。

export function trigger(target: Record<EffectKey, any>, key: EffectKey) {

  const depsMap = targetMap.get(target)
  const deps = depsMap?.get(key)
  if (deps) {
    // deps是Set集合 里面的子元素是ReactiveEffect实例对象
    for (let dep of deps) {
      // if判断scheduler是否有值
      if (dep.scheduler) {
        dep.scheduler()
      } else {
        dep.run()
      }
    }
  }
}

effect()的改写也很简单,只需要作为可选配置传入一个option对象,通过extendoption里面的属性挂载到ReactiveEffect的实例对象即可,务必在ReactiveEffect上添加公有成员scheduler,这里说一下extend,其实它就是Object.assign,作用是把对象挂载到另一个对象上

export function effect<T = any>(fn: () => T, option?: EffectOption): EffectRunner {
  let _effect = new ReactiveEffect(fn)
  if(option){
    extend(_effect, option) // 等价于Object.assign(_effect, option)
  }
  _effect.run()
  let runner = _effect.run.bind(_effect) as EffectRunner
  return runner
}
class ReactiveEffect {
  public deps: Set<ReactiveEffect>[] = []
  constructor(public fn: Function, public scheduler?: EffectScheduler) {}
  run() {
    activeEffect = this
    let returnValue = this.fn()
    return returnValue
  }
}

🛠实现stop & onStop

我们有监听依赖的功能当然也少不了停止监听依赖的功能。

停止监听依赖本质上就是把effectdeps(Set集合)里面delete掉,那么当trigger被再次触发的时候就不会执行该effectrun()方法了。

如果我们想在停止监听依赖之后做点什么事情,那我们可以用onStop回调函数,它和scheduler一样也是在effect函数的第二个可选形参option定义,触发时机也很简单,就是当依赖被删除后执行onStop()就好了

以下为effect的option类型定义

export type EffectScheduler = (...args: any[]) => any
export interface EffectOption {
  scheduler?: EffectScheduler
  onStop?: () => void
}

🛠实现stop停止监听依赖

功能描述:

🧠通过stop可以停止监听依赖,怎么样停止监听依赖呢?

可以通过删除deps依赖,那么trigger被调用的时候就不会被循环调用这个依赖了

我们继续通过测试驱动我们的实现


it('stop', () => {
  let dummy
  const obj = reactive({ prop: 1 })
  const runner = effect(() => {
    dummy = obj.prop
  })
  obj.prop = 2
  expect(dummy).toBe(2)
  stop(runner)
  // 执行proxy的set操作,触发trigger()
  obj.prop = 3
  expect(dummy).toBe(2)
  // stopped effect should still be manually callable
  runner()
  expect(dummy).toBe(3)
})

定义stop()函数,并传入一个runner函数(effect.run()的函数体)

🧠这里有个问题,那就是我们要怎么拿到runner对应的effect呢?

runner并没有任何线索指向哪个effect,我们拿到effect再拿到effect所在的depsSet<ReactiveEffect>)才好 delete 掉啊。这里有个反向的思维且用到了js中函数的特性:

函数也是对象,函数对象上可以直接定义属性

让我们改写一下effect()函数:

// 里面存有一个匿名函数
export interface EffectRunner<T = any> {
  (): T
  effect: ReactiveEffect
}
export function effect<T = any>(fn: () => T, option?: EffectOption): EffectRunner {
  let _effect = new ReactiveEffect(fn)
  if(option){
    extend(_effect, option)
  }
  _effect.run()
  let runner = _effect.run.bind(_effect) as EffectRunner
  // 这里的effect挂载在了函数runner上,作为属性,这是利用了js中函数可以挂在属性的特性
  // 之后呢,实现stop的时候runner就能拿到ReactiveEffect实例对象了
  runner.effect = _effect
  return runner
}

🧠好了现在我们已经拿到了 runner 的 effect 了,上面我们提到一个反向的思维怎么解释呢?

即deps集合(Set<ReactiveEffect>)存储着相关key的effect,不妨反过来,ReactiveEffect类里面定义一个公有成员变量数组,用于存储deps集合,每个effect对象都有该数组存放deps集合的之后,就可以用循环的方式找出effect并从deps集合中删除就可以了。

因为停止监听依赖是属于依赖行为范畴的,所以我们要在ReactiveEffect类定义一个stop()方法,这样才符合面向对象的思想嘛。

class ReactiveEffect {
  // 此处deps就是数组,数组每个元素存储的Set集合
  public deps: Set<ReactiveEffect>[] = []
  constructor(public fn: Function, public scheduler?: EffectScheduler) {}
  ...
  stop() {
    // deps是Set集合
    for (let i = 0; i < this.deps.length; i++) {
      this.deps[i].delete(this)
    }
    this.deps.length = 0
  }
}

🧠好了新的问题出来了,那么什么时候effect应该push进effect.deps呢?

依赖被收集的时候就应该是最好的时机了,也就是说track被触发的时候应该activeEffect.deps.push(deps)

export function track(target: Record<EffectKey, any>, key: EffectKey) {
  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)
  // activeEffect的deps 接收 Set<ReactiveEffect>类型的deps
  // 供删除依赖的时候使用(停止监听依赖)
  activeEffect.deps.push(deps)
}

因为这里runner已经挂载了一个effect对象,所以我们可以直接调用effect.stop()方法用于停止监听依赖。

export function stop(runner: EffectRunner) {
  runner.effect.stop()
}

🛠实现onStop停止监听依赖后执行的回调函数

我们回顾一下:该onStop需要从effect()的option参数作为可选属性传递进来,因为我们实现了一个工具函数extend(),它可以将option挂载在ReactiveEffect类的实例对象effect上。

所以只需要在循环deps数组并在每个Set集合中找出要删除的effect然后delete这个操作之后,执行onStop即可,同样要在ReactiveEffect类中定义一个个公有成员变量onStop并置之为可选属性。

class ReactiveEffect {
  public deps: Set<ReactiveEffect>[] = []
  public onStop?: () => void  // 可选属性
  constructor(public fn: Function, public scheduler?: EffectScheduler) {}
  ...
  stop() {
    for (let i = 0; i < this.deps.length; i++) {
      this.deps[i].delete(this)
    }
    this.deps.length = 0
    // 当onStop有值的时候,执行onStop()
    this.onStop?.()
  }
}

📈代码优化

优化点:

  1. 如果用户调用多次stop()并且传入的都是相同的runner来停止监听依赖,那么代码将会执行不必要的循环操作(stop里面有循环来找出哪个应该被delete掉),降低代码性能。应该添加一个active作为成员变量到ReactiveEffect类,用于标识依赖是否已经被delete掉了,杀死掉了。
  • 同时stop循环的代码段也应该被提取出来作为单独的函数,我们命名为cleanupEffect好了。
class ReactiveEffect {
  public deps: Set<ReactiveEffect>[] = []
  public active = true  // 该effect是否存活
  public onStop?: () => void
  constructor(public fn: Function, public scheduler?: EffectScheduler) {}
  ...
  stop() {
    // 追加active 标识是为了性能优化,避免每次循环重复调用stop同一个依赖的时候
    if (this.active) {
      cleanupEffect(this)
      this.onStop?.()
      this.active = false
    }
  }
}
// 清除指定依赖
function cleanupEffect(effect: ReactiveEffect) {
  // 对effect解构,解出deps,减少对象在词法环境寻找属性的次数
  const {deps} = effect
  if (deps.length !== 0) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].delete(effect)
    }
    deps.length = 0
  }
}
  1. vue对stop功能的官方测试用例并不严谨,只检查了set操作,并没有遇到get操作的情况
 it('stop', () => {
    let dummy
    const obj = reactive({ prop: 1 })
    const runner = effect(() => {
      dummy = obj.prop
    })
    obj.prop = 2
    expect(dummy).toBe(2)
    stop(runner)
    // 单单只是检查set操作是不行的,还必须检查代码通过get操作之后,是否还能执行依赖
    // obj.prop = 3
    // 很明显如果换成obj.prop++,expect(dummy).toBe(2)就飘红了
    
    obj.prop++
    expect(dummy).toBe(2)     ❌
    runner()
    expect(dummy).toBe(3)
  })

🧠我们思考一下为什么把obj.prop = 3换成obj.prop++测试用例就不通过呢?

obj.prop++ ---拆解--> obj.prop = obj.prop + 1 很明显这里多了一个get操作。先get后set。

🤯解释:经过get操作之后,也就是说执行track函数之后原来被删除的effect又被add到deps上面去了,所以我们这里必须添加shouldTrack全局变量来表示应不应该被track 详细见effect.ts的 track 函数,控制shouldTrack开关在ReactiveEffect的run()方法中。shouldTrack的初始值为 false ,代表不可被track。当依赖正式可回收的时候(没有被删除),置shouldTrack为 true ,表示可被track。

让我们改写一下ReactiveEffect

// 初始化为false
let shouldTrack = false

class ReactiveEffect {
  public deps: Set<ReactiveEffect>[] = []
  public active = true  // 该effect是否存活
  public onStop?: () => void
  constructor(public fn: Function, public scheduler?: EffectScheduler) {}
  run() {
    // 如果effect已经被杀死了,被删除了(stop()函数相关)
    if (!this.active) {
      return this.fn()
    }
    activeEffect = this
    shouldTrack = true // 把开关打开让他可以收集依赖
    let returnValue = this.fn()
    // 之后把shouldTrack关闭,这样就没办法在track函数里面收集依赖了
    shouldTrack = false

    return returnValue
  }
  ...
}

当执行this.fn()的时候,fn里面会执行get操作,之后就会执行track收集依赖,因为shouldTrack是true,所以依赖收集能顺利完成。

开头判断this.active是否为空,如果为空,那么effect将会被执行而不会收集依赖,进入track就马上被return掉了,这也是为了如果用户单单调用runner()的时候能够执行effect回调函数(属于手动触发依赖)留的后路。

export function track(target: Record<EffectKey, any>, key: EffectKey) {
  // 这里为什么要多一层非空判断呢?
  // 我们查看reactive.spec.ts里面的测试用例
  // 测试用例里根本就没有调用effect(),所以没有执行ReactiveEffect的run()自然activeEffect也就是undefined了
  if (!activeEffect) return
  // 应不应该收集依赖,从而避免删了依赖又重新添加新的依赖
  if (!shouldTrack) return

  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)
  activeEffect.deps.push(deps)
}
  1. 避免无效执行deps.add(activeEffect)和track中跳出函数体的代码段抽离

    • 用Set实例的方法has来判断是否已经收集过依赖,如果没有收集过依赖,那么就收集依赖,如果已经收集过依赖,那么就不再收集依赖。
    • 定义一个函数isTracking表示当前是否在track的状态
function isTracking(){
  return activeEffect !== undefined && shouldTrack
}
export function track(target: Record<EffectKey, any>, key: EffectKey) {
  // 拦截不必要的track
  if (!isTracking()) return
  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)
  }
  // 避免不必要的add操作
  if (deps.has(activeEffect)) return

  deps.add(activeEffect)
  activeEffect.deps.push(deps)
}

总结

经过上面代码的一步步解析,我们已经实现了runner、scheduler、stop以及onStop功能,我们还处理了stop的一些边缘cases,优化了代码,让我们的代码更加优雅,更接近于vue源码的写法。相信大家已经了解了vue的reactive的数据响应式原理了。

最后@感谢阅读!

未来的路还很长,一起成长,一起进步!

完整代码

// In effect.ts

import {extend} from '../shared/index'
export type EffectScheduler = (...args: any[]) => any
class ReactiveEffect {
  public deps: Set<ReactiveEffect>[] = []
  public active = true  // 该effect是否存活
  public onStop?: () => void
  constructor(public fn: Function, public scheduler?: EffectScheduler) {}
  run() {
    // 如果effect已经被杀死了,被删除了(stop()函数相关)
    if (!this.active) {
      return this.fn()
    }
    // 为什么要在这里把this赋值给activeEffect呢?因为这里是fn执行之前,就是track依赖收集执行之前,又是effect开始执行之后,
    // this能捕捉到这个依赖,将这个依赖赋值给activeEffect是刚刚好的时机
    activeEffect = this
    shouldTrack = true // 把开关打开让他可以收集依赖
    let returnValue = this.fn() // 执行fn的时候,fn里面会执行get操作,之后就会执行track收集依赖,因为shouldTrack是true,所以依赖收集完成
    // 之后把shouldTrack关闭,这样就没办法在track函数里面收集依赖了
    shouldTrack = false

    return returnValue
  }
  stop() {
    // 追加active 标识是为了性能优化,避免每次循环重复调用stop同一个依赖的时候
    if (this.active) {
      cleanupEffect(this)
      this.onStop?.()
      this.active = false
    }
  }
}
// 清除指定依赖
function cleanupEffect(effect: ReactiveEffect) {
  // 对effect解构,解出deps,减少对象在词法环境寻找属性的次数
  const {deps} = effect
  if (deps.length !== 0) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].delete(effect)
    }
    deps.length = 0
  }
}
const targetMap = new Map<Record<EffectKey, any>, Map<EffectKey, Set<IDep>>>()
// 当前正在执行的effect
let activeEffect: ReactiveEffect
let shouldTrack = false
type EffectKey = string
type IDep = ReactiveEffect
// 这个track的实现逻辑很简单:添加依赖
export function track(target: Record<EffectKey, any>, key: EffectKey) {
  // 这里为什么要多一层非空判断呢?
  // 我们查看reactive.spec.ts里面的测试用例
  // 测试用例里根本就没有调用effect(),所以没有执行ReactiveEffect的run()自然activeEffect也就是undefined了
  // if (!activeEffect) return
  // 应不应该收集依赖,从而避免删了依赖又重新添加新的依赖
  // if (!shouldTrack) return
  if (!isTracking()) return
  // 寻找dep依赖的执行顺序
  // target -> key -> dep
  let depsMap = targetMap.get(target)
  /**
   * 这里有个疑问:target为{ num: 11 } 的时候我们能获取到depsMap,之后我们count.num++,为什么target为{ num: 12 } 的时候我们还能获取得到相同的depsMap呢?
   * 这里我的理解是 targetMap的key存的只是target的引用 存的字符串就不一样了
   */
  // 解决初始化没有depsMap的情况
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }
  // deps是一个Set对象,存放着这个key相对应的所有依赖
  let deps = depsMap.get(key)
  // 如果没有key相对应的Set 初始化Set
  if (!deps) {
    deps = new Set()
    depsMap.set(key, deps)
  }
  // 避免不必要的add操作
  if (deps.has(activeEffect)) return
  // 将activeEffect实例对象add给deps
  deps.add(activeEffect)
  // activeEffect的deps 接收 Set<ReactiveEffect>类型的deps
  // 供删除依赖的时候使用(停止监听依赖)
  activeEffect.deps.push(deps)
}
function isTracking(){
  
  return activeEffect !== undefined && shouldTrack
}
// 这个trigger的实现逻辑很简单:找出target的key对应的所有依赖,并依次执行
export function trigger(target: Record<EffectKey, any>, key: EffectKey) {

  const depsMap = targetMap.get(target)
  const deps = depsMap?.get(key)
  if (deps) {
    for (let dep of deps) {
      if (dep.scheduler) {
        dep.scheduler()
      } else {
        dep.run()
      }
    }
  }
}
export interface EffectOption {
  scheduler?: EffectScheduler
  onStop?: () => void
}
// 里面存有一个匿名函数
export interface EffectRunner<T = any> {
  (): T
  effect: ReactiveEffect
}
// 根据官方给出的介绍:effect会立即触发这个函数,同时响应式追踪其依赖
export function effect<T = any>(fn: () => T, option?: EffectOption): EffectRunner {
  let _effect = new ReactiveEffect(fn)
  if(option){
    extend(_effect, option)
  }
  _effect.run()
  // 注意这里的this指向,return 出去的run方法,方法体里需要用到this,且this必须指向ReactiveEffect的实例对象
  // 不用bind重新绑定this,this会指向undefined
  let runner = _effect.run.bind(_effect) as EffectRunner
  // 这里的effect挂载在了函数runner上,作为属性,这是利用了js中函数可以挂在属性的特性
  // 之后呢,stop的runner就能拿到ReactiveEffect实例对象了
  runner.effect = _effect
  return runner
}
export function stop(runner: EffectRunner) {
  runner.effect.stop()
}

// in shared/index.ts
export const extend = Object.assign

// All in effect.spec.ts
  // 实现effect返回runner函数 这个runner函数其实就是effect的回调函数
  it('should return runner when effect was called', () => {
    let foo = 1
    let runner = effect(() => {
      foo++
      return 'foo'
    })
    expect(foo).toBe(2)
    let returnValue = runner()
    expect(foo).toBe(3)
    expect(returnValue).toBe('foo')
  })
  // 实现effect的scheduler功能
  // 该功能描述:
  // 1. effect首次执行的时候不执行scheduler,直接执行回调函数
  // 2. 之后每次触发trigger函数的时候都会执行scheduler函数,不执行effect回调函数
  // 3. 当调用run的时候才会触发runner,也就是说调用effect的回调函数
  it('scheduler', () => {
    let dummy
    let run: any
    const scheduler = jest.fn(() => {
      run = runner
    })
    const obj = reactive({ foo: 1 })
    const runner = effect(
      () => {
        dummy = obj.foo
      },
      { scheduler }
    )
    expect(scheduler).not.toHaveBeenCalled()
    expect(dummy).toBe(1)
    // should be called on first trigger set操作的时候,也就是说在trigger被调用的时候
    obj.foo++
    expect(scheduler).toHaveBeenCalledTimes(1)
    // should not run yet
    expect(dummy).toBe(1)
    // manually run  会触发effect的回调函数
    run()
    // should have run
    expect(dummy).toBe(2)
  })
  // 实现effect的stop功能
  // 功能描述:
  // 通过stop可以停止监听依赖,怎么样停止监听依赖呢?可以通过删除deps依赖,那么trigger被调用的时候就不会被循环调用这个依赖了
  it('stop', () => {
    let dummy
    const obj = reactive({ prop: 1 })
    const runner = effect(() => {
      dummy = obj.prop
    })
    obj.prop = 2
    expect(dummy).toBe(2)
    stop(runner)
    // 单单只是检查set操作是不行的,还必须检查代码通过get操作之后,是否还能执行依赖
    // obj.prop = 3
    // 很明显如果换成obj.prop++,expect(dummy).toBe(2)就飘红了
    // 这是因为obj.prop还有一个get操作,经过get操作之后,经过track函数之后原来被删除的effect又被add到deps上面去了
    // 所以我们这里必须添加shouldtrack变量来表示应不应该被track 详细见effect.ts的track函数,控制shouldTrack开关在ReactiveEffect的run方法
    obj.prop++
    expect(dummy).toBe(2)

    // stopped effect should still be manually callable
    runner()
    expect(dummy).toBe(3)
  })
  // 实现onStop
  // 功能描述:
  // 1. 当stop对一个runner执行的时候,runner对应的依赖的onStop就会被执行,相当于事件触发
  it('onStop', () => {
    const obj = reactive({ foo: 1 })
    const onStop = jest.fn()
    let dummy
    const runner = effect(
      () => {
        dummy = obj.foo
      },
      {
        onStop,
      }
    )
    stop(runner)
    // 被调用1次
    expect(onStop).toBeCalledTimes(1)
  })
转载自:https://juejin.cn/post/7090165509735317534
评论
请登录