Vue.js设计与实现读书笔记--第四章、响应系统的作用与实现—---副作用函数

80 阅读14分钟

1、副作用函数

#1.1 副作用函数

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

复制代码

effect函数设置了body的内容,但除了effect函数外的任何函数都可以读取或设置body的文本内容,所以,effect函数的执行会直接或间接的影响其他函数的执行,这时我们就会说effect函数产生了副作用。

4.2响应式数据

假设我们在一个副作用函数中读取了某个对象的属性

 const obj={text:"hello word"}
  function effect() {
    // effect函数的执行会读取obj.text
    document.body.innerText=obj.text
  }
  obj.text='hello vue3' //修改obj.text的值,同时希望副作用函数会重新执行

复制代码

当我们修改了obj.text的值后,我们希望当值变化话,effect函数会自动重新执行,如果能实现这个目标,那么对象obj就是响应式数据。

#1.2响应式数据的基本实现

  • effect函数执行时,会触发obj.text的读取操作
  • 修改obj.text的值时,会触发字段obj.text的设置操作
  • 那么当我们读取obj.tetx时,我们把副作用函数effect函数存储起来
  • 当我们设置obj.text时,再把副作用函数effect函数取出来执行,这样便完成了响应式

vue3.js采用代理对象proxy来实现响应式数据

  // 副作用函数
  function effect () {
    // effect函数的执行会读取obj.text
    document.body.innerText = obj.text
  }
  // 存储副作用函数
  const bucket = new Set()
  const data = { text: 'hello word' }
  const obj = new Proxy(data, {
    get (target, key){
      bucket.add(effect)
      return target[key]
    },
    set (target, key,newVal){
      bucket.forEach(fn => fn());
      return true
    }
  })
  // 执行副作用函数
  effect()
  // 一秒后修改响应式数据
  setTimeout(() =>{
    obj.text = '悟能'
  }, 1000)

复制代码

首先上面代码中,我们创建了一个存储副作用函数的存储痛bucket,他是set类型。接着我们定义了原始数据data,obj是原属数据的代理对象。我们分别对他设置了get和set拦截函数。从而读取和设置操作


#1.3 完善的响应式系统

4.2中我们写死了副作用函数的名字为effect,无法修改,修改会导致函数不能正确的执行,而我们希望的是函数哪怕是一个匿名函数,也可以正确的被执行。那么我来修改上面的代码

 // 用一个全局变量存储被注册的副作用函数
  let activeEffect
  // effect函数用于注册副作用函数
  function effect (fn) {
    // 当我们调用effect注册副作用函数时,将副作用函数fn复制给activeEffect
    activeEffect = fn
    fn()
  }
  // 存储副作用函数
  const bucket = new Set()
  const data = { text: 'hello word' }
  const obj = new Proxy(data, {
    get (target, key){
      // 将activeEffect 中存储的副作用函数收集到桶中
   if(activeEffect){
    bucket.add(activeEffect)
   }
      return target[key]
    },
    set (target, key, newVal){
      console.log(target,key,newVal);
      target[key]=newVal
      bucket.forEach(fn => fn());
      return true
    }
  })
  // 使用effect函数
  effect(() => { document.body.innerText = obj.text })
  // 一秒后修改响应式数据
  setTimeout(() =>
  {
    obj.text = '悟能'
  }, 1000)

复制代码

如上代码展示我们已经解决了副作用函数名称的问题,但是我们还没有在副作用函数与被操作的目标字段之间建立联系,也就是说即使修改了 obj 上面的其他字段,副作用函数也会被执行,需要将 obj.text字段和调用过它的副作用函数联系起来。


#1.4 将副作用函数与被操作的目标字段之间建立联系


  // 用一个全局变量存储被注册的副作用函数
  let activeEffect
  // effect函数用于注册副作用函数
  function effect (fn) {
    // 当我们调用effect注册副作用函数时,将副作用函数fn复制给activeEffect
    activeEffect = fn
    fn()
  }
  const data = { text: 'hello word' }
  // 存储副作用函数
  const bucket = new WeakMap()
  const obj = new Proxy(data, {
    get (target, key){
      // 没有activeEffect 直接return
      if(!activeEffect) return target[key]
      // 根据target从“桶”中取得depsMap 它也是一个map类型: key=>effects
      let depsMap=bucket.get(target)
      console.log(depsMap,'1');
      if(!depsMap){
        bucket.set(target, (depsMap = new Map()))
      }
      console.log(depsMap,'2');
      // 在根据key从depsMap中取得deps 他是一个set类型
      // 里面存储着所有与当前key 相关联的副作用函数:effects
      let deps = depsMap.get(key) 
      console.log(deps);
      // 如果deps不存在 同样我们要新建立一个Set并与key关联
      if (!deps) {
      depsMap.set(key, (deps = new Set()))//这样我们就与目标字段建立了联系
     }
    //  最后我们将当前激活的副作用函数添加到存储痛里
    deps.add(activeEffect)
    // 返回属性值
    return target[key]
  
    },
    set (target, key, newVal){
      target[key]=newVal
      // 根据target 从桶中取得 depsMap 他是key-->effects
      const depsMap=bucket.get(target)
      if(!depsMap) return
      const effects=depsMap.get(key)
      console.log(effects);
      effects && effects.forEach(fn => fn() )
    }
  })
  // 使用effect函数
  effect(() => { document.body.innerText = obj.text })
  // 一秒后修改响应式数据
  setTimeout(() =>{
    console.log(1);
    obj.text = '悟能'
     // 修改不相关的值副作用函数不会执行
    // obj.asd = 'hello vue3'
  }, 1000)

复制代码

#1.5 WeakMap和Map的区别

  const map = new Map()
  const weakMap = new WeakMap()
    (function ()  {
      const foo = { foo: 1 }
      const bar = { bar: 2 }
      map.set(foo, 1)
      weakMap.set(bar, 2)
    })()
  

复制代码

由于WeakMap的key是弱引用,他不影响垃圾回收器的工作,所以一旦表达式执行完毕,垃圾回收器就回吧对象bar从内存中移除,我们也就无法获取weakMap的key值,也就无法通过weakMap取得对象bar

简单来说WeakMap的key是弱引用,不影响垃圾回收机器的工作,根据这个特性,如果我们使用Map,那么即使用户侧的代码对target没有任何引用,但target也不会被回收,最终会导致内存溢出。

根据WeakMap的特性,我们对上面代码进行封装处理,提取一下 get 和 set 操作中操作副作用函数的逻辑,提高灵活性。




  // 用一个全局变量存储被注册的副作用函数
  let activeEffect
  // effect函数用于注册副作用函数
  function effect (fn) {
    // 当我们调用effect注册副作用函数时,将副作用函数fn复制给activeEffect
    activeEffect = fn
    fn()
  }
  const data = { text: 'hello word' }
  // 存储副作用函数
  const bucket = new WeakMap()
  const obj = new Proxy(data, {
    get (target, key){
      track(target, key)
      return target[key]

    },
    set (target, key, newVal){
      target[key] = newVal
      trigger(target,key)
    }
  })

  function track (target, key){
    // 没有activeEffect 直接return
    if (!activeEffect) return target[key]
    // 根据target从“桶”中取得depsMap 它也是一个map类型: key=>effects
    let depsMap = bucket.get(target)
    if (!depsMap) {
      bucket.set(target, (depsMap = new Map()))
    }
    // 在根据key从depsMap中取得deps 他是一个set类型
    // 里面存储着所有与当前key 相关联的副作用函数:effects
    let deps = depsMap.get(key)
    console.log(deps);
    // 如果deps不存在 同样我们要新建立一个Set并与key关联
    if (!deps) {
      depsMap.set(key, (deps = new Set()))//这样我们就与目标字段建立了联系
    }
    //  最后我们将当前激活的副作用函数添加到存储痛里
    deps.add(activeEffect)
  }
  function trigger(target,key) {
     // 根据target 从桶中取得 depsMap 他是key-->effects
     const depsMap = bucket.get(target)
      if (!depsMap) return
      const effects = depsMap.get(key)
      effects && effects.forEach(fn => fn())
  }

  // 使用effect函数
  effect(() => { document.body.innerText = obj.text })
  // 一秒后修改响应式数据
  setTimeout(() =>{
    console.log(1);
    obj.text = '悟能'
  }, 1000)

复制代码

#1.6 分支切换与celeanup

日常工作,读取代码可能会出现不同分支的情况,如下面的代码所示:

effect(() => {
  // 当obj.ok为false时,并不会触发obj.text的读取,也就不需要对副作用函数做收集。
  document.body.innerText = obj.ok ? obj.text : 'not'
})

// 修改obj.ok为false
obj.ok = false
// 再修改obj.text的值,理论上副作用函数不需要执行,但是现在依然会执行
obj.text = 'hello vue3'

复制代码

原因:分支切换可能会产生遗留的副作用函数

当读取字段值时,会触发副作用函数,此时副作用函数与响应式数据之间建立的联系如下图:

副作用函数与响应式数据之间的联系描述图(opens new window)

如上图我们可以很清楚的明白,副作用函数分别被字段data.ok和data.tex所对应的依赖所收集。当obj.ok字段被修改为false时,会触发副作用函数并重新执行。但obj.text字段不会被读取,只会触发obj.ok字段的读取。所以这个时候副作用函数不应该被字段obj.text对应的依赖手机

解决思路:副作用的每次执行,先把它从所有与之关联的依赖集合中删除,如下图所示:

断开副作用函数与响应式数据之间的联系(opens new window)

这里,我们要重新设计副作用函数,在effect内部定义一个effectFn函数,并为其添加effectFn.deps属性。用来存储所有包含当前副作用函数的依赖集合

 // 用一个全局变量存储被注册的副作用函数
  let activeEffect
  // effect函数用于注册副作用函数
  function effect (fn) {
    const effectFn=()=>{
    //当effectFn执行时,将其设置为当前激活的副作用函数
    activeEffect = effectFn
    fn()
    }
    // activeEffect.deps用来存书所有与该副作用函数相关联的依赖集合
    effectFn.deps=[]
    effectFn()
  }

复制代码

effectFn.deps数组中的依赖集合在tarck函数中如何收集?

œfunction track (target, key){
    // 没有activeEffect 直接return
    if (!activeEffect) return target[key]
    // 根据target从“桶”中取得depsMap 它也是一个map类型: key=>effects
    let depsMap = bucket.get(target)
    if (!depsMap) {
      bucket.set(target, (depsMap = new Map()))
    }
    // 在根据key从depsMap中取得deps 他是一个set类型
    // 里面存储着所有与当前key 相关联的副作用函数:effects
    let deps = depsMap.get(key)
    // 如果deps不存在 同样我们要新建立一个Set并与key关联
    if (!deps) {
      depsMap.set(key, (deps = new Set()))//这样我们就与目标字段建立了联系
    }
    //  最后我们将当前激活的副作用函数添加到存储桶里
    deps.add(activeEffect)
    // 将deps添加到activeEffect.deps数组中。 
    activeEffect.deps.push(deps)
  }
  function trigger(target,key) {
     // 根据target 从桶中取得 depsMap 他是key-->effects
     const depsMap = bucket.get(target)
      if (!depsMap) return
      const effects = depsMap.get(key)
      effects && effects.forEach(fn => fn())
  }

复制代码

关系图如下:

对依赖集合的收集(opens new window)

有了以上的联系后,我们就可以在副作用函数每次执行的时候,获取所有相关依赖,然后从依赖集合中删除

// 用一个全局变量存储被注册的副作用函数
  let activeEffect
  // effect函数用于注册副作用函数
  function effect (fn) {
    const effectFn=()=>{
      cleanUp(effectFn)
    //当effectFn执行时,将其设置为当前激活的副作用函数
    activeEffect = effectFn
    fn()
    }
    // activeEffect.deps用来存书所有与该副作用函数相关联的依赖集合
    effectFn.deps=[]
    effectFn()
  }
  //cleanUp函数的实现
 //cleanUp函数的实现
 function cleanUp(effectFn){
   // 首先便利effectFn.deps数组
   for(let i=0;i<effectFn.deps.length;i++){
    //deps是依赖集合
   const deps=effectFn.deps(i)
   //将effectFn从依赖集合中删除
   deps.delete(effectFn)
   }
   //最后重置deps数组
   effectFn.deps.length = 0
  }

复制代码

cleanUp会遍历effectFn.deps数组,从而将副作用函数从依赖集合中删除。最后重置effectFn.deps数组

那么现在,我们已经可以避免副作用函数产生遗留了,但是现在我们尝试运行代码。发现会导致无限循环执行,问题出现在了trigger函数中

原因:因为trigger函数内部会遍历effects集合,如果遍历集合时,一个值已经被访问过,然后该值被删除并重新添加到集合,此时如果遍历未结束,那么该值会重新被访问,就会导致无限循环

解决办法如下:我们构造一个新的Set集合effectsToRun,代替直接遍历effects集合

 function trigger(target,key) {
     // 根据target 从桶中取得 depsMap 他是key-->effects
     const depsMap = bucket.get(target)
      if (!depsMap) return
      const effects = depsMap.get(key)
     const effectsToRun=new Set(effects)
     effectsToRun.forEach(effectFn=>effectFn())
  }

复制代码

#1.7 嵌套的effect与effect栈

effect嵌套。


let temp1,temp2
// effectFn1 嵌套effectFn2
effect(function effectFn1() {
  console.log('effectFn1执行了')
  effect(function effectFn2() {
  console.log('effectFn2执行了')
  // 在effectFn2读取bar的值
  temp2=obj.effectFn2
  
})
temp1=obj.foo
  console.log(temp1,temp2)
  
})
  // // 一秒后修改响应式数据
  setTimeout(() =>{
obj.foo='帅涛'
// console.log('des',des)
  }, 1000)

复制代码

在上面这段代码中 ,effectFn1嵌套了effectFn2,当我们一秒后修改obj.foo值时,会发现输出跟我想像的不太一样:

effectFn1执行了
effectFn2执行了
effectFn2执行了

复制代码

我们会发现effectFn1函数并没有重新执行,这显然跟我们想的不太一样的。问题其实出现在了我们的effect函数与全局变量activeEffect

原因:我们使用全局变量activeEffect存储effect注册过的副作用函数,所以也就意味着activeEffect只能存储一个副作用函数,当发生嵌套时,内部的副作用函数执行会覆盖activeEffect的值。且不会恢复。这时如果我们在此修改响应式数据,即使数据实在外层副作用,但是因为activeEffect还是原来的内部副作用函数,所以此时收集到的还会是内部副作用函数。

解决办法:我们可以定义一个effectStack副作用函数栈,副作用函数执行时压入函数栈。执行完毕再从栈中弹出,并始终让activeEffect指向最顶层的副作用函数。

  // 用一个全局变量存储被注册的副作用函数
  let activeEffect
  //副作用函数栈
let effectStack=[]
  // effect函数用于注册副作用函数
  function effect (fn) {
    const effectFn=()=>{
      cleanUp(effectFn)
    //当effectFn执行时,将其设置为当前激活的副作用函数
    activeEffect = effectFn  
    // 调用副作用函数之前压入栈
    effectStack.push(effectFn)
    fn()
    effectStack.pop()  
    activeEffect=effectStack[effectStack.length-1]
    }
    // activeEffect.deps用来存书所有与该副作用函数相关联的依赖集合
    effectFn.deps=[]  
    effectFn()
  }

复制代码

#1.8 如何避免无限递归循环

我们假设在effect副作用函数中增加一个自增操作obj.foo++ 该操作就会引起栈溢出

const data = {foo:1}
const obj= new Proxy(data,{/*...*/})
 // 使用effect函数
  effect(() => {  obj.foo++ })

复制代码

运行后,改操作就会引起爆栈

Uncaught RangeError: Maximum call stack size exceeded

复制代码

原因:首先 我们把上述代码拆开,那么相当于如下代码:

 effect(()=>{
    obj.foo = obj.foo+1
  })

复制代码

那么当运行effect副作用函数的时候,既会读取obj.foo的值,又会设置obj.foo的值。当读取值的时候会触发track函数,将副作用函数添加到桶中,然后设置值会触发trigger函数,会将副作用函数取出执行,那么此时此刻,当前副作用函数还未执行完毕的时候,就要开始下一次的执行,从而形成了无限循环递归,导致了爆栈

解决办法:在上述问题中,我们会发现读取和设置都是在同一个副作用函数内执行的。那么我们则可以在trigger函数内增加条件判断。如果trigger函数执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。

 function trigger(target,key) {
     // 根据target 从桶中取得 depsMap 他是key-->effects
     const depsMap = bucket.get(target)
      if (!depsMap) return
      const effects = depsMap.get(key)
     const effectsToRun=new Set()
     effects && effects.forEach(effectFn=>{
       if(effectFn !== activeEffect){
        effectsToRun.add(effectFn)
       }
     })
     effectsToRun.forEach(effectFn=>effectFn())
  }

复制代码

我们修改完trigger函数后,这样就可以避免无限递归调用。从而避免爆栈

#1.8 调度执行

所谓调度,则是在trigger执行时,我们可以决定副作用函数的执行时间,所谓执行顺序。我门还是举列来说明:

const data = {foo:1}
const obj= new Proxy(data,{/*...*/})
 // 使用effect函数
  effect(() => { 
    console.log(obj.foo)
  
  })
 obj.foo++
 
 console.log('结束了')

复制代码

那么以上代码执行后,输出结果如下:

 1
 2
'结束了'

复制代码

那么如果现在我们想把输出顺序调整为如下,该怎么做呢

 1
 '结束了'
 2

复制代码

那么按照我们正常的编码思路,我们可以为effect函数增加一个选项参数options,用参数来控制。

// 使用effect函数
  effect(() =>
   { 
    console.log(obj.foo)
  },
  {
    // 调度器 scheduler 是一个函数
    scheduler(fn){

    }
  }
  )

复制代码

然后我们把options选项参数挂载到对应的副作用函数上

  // effect函数用于注册副作用函数
  function effect (fn,options={}) {
    const effectFn=()=>{
      cleanUp(effectFn)
    //当effectFn执行时,将其设置为当前激活的副作用函数
    activeEffect = effectFn  
    // 调用副作用函数之前压入栈
    effectStack.push(effectFn)
    fn()
    effectStack.pop()   
  
    activeEffect=effectStack[effectStack.length-1]
    }
    // 将options挂载到effect上
    effectFn.options=options
    // activeEffect.deps用来存书所有与该副作用函数相关联的依赖集合
    effectFn.deps=[]  
    effectFn()
  }

复制代码

最后,我们在trigger函数触发副作用函数时,既可以直接调用用户传递的调度器函数,从而实现调度执行

 function trigger(target,key) {
     // 根据target 从桶中取得 depsMap 他是key-->effects
     const depsMap = bucket.get(target)
      if (!depsMap) return
      const effects = depsMap.get(key)
     const effectsToRun=new Set()
     effects && effects.forEach(effectFn=>{  
       if(effectFn !== activeEffect){
        effectsToRun.add(effectFn)
       }
     })
    //  effectsToRun.forEach(effectFn=>effectFn())
    // 对effectsToRun集合进行循环
    effectsToRun.forEach(effectFn=>{
      // 如果调度器存在,则调用该调度器 并将副作用函数当作参数传递
      if(effectFn.options.scheduler){
        effectFn.options.scheduler(effectFn)
       }else{
        //  如果调度器不存在 则直接执行副作用函数
        effectFn()
       }
    })
  }

复制代码

现在我们将副作用函数放到宏任务队列

// 使用effect函数
  effect(() =>
   { 
    console.log(obj.foo)
  },
  {
    // 调度器 scheduler 是一个函数
    scheduler(fn){
       setTimeout(fn);
    }
  }
  )

复制代码

在看打印,可以发现我们的需求已经实现了。如上,我们已经实现了执行顺序的控制,我们还可以控制执行的次数。假如我们有如下需求:

// 使用effect函数
  effect(() =>
   { 
    console.log(obj.foo)
  })
 obj.foo++
 obj.foo++

复制代码

上述代码执行后,在我们没用调度器控制的情况下,输出肯定是1,2,3

1
2
3

复制代码

但是其实我们值2 只是自增重的过渡状态,我们并不关心这个过程,所以执行三次有点多余,我们所期望的是1,3,那么我可以通过调度器来实现此功能

// 我们定义一个任务队列
  const jobQueue = new Set()
  // 我们可以使用promise.resolve() 创建一个promise实列,我们用这个实列将一个任务添加到微任务队列
 const p =Promise.resolve()

// 一个标志代码是否正在刷新队列
let isFlushJob = false
function FlushJob() {
  // 首先如果队列在刷新 则什么都不做
  if(isFlushJob) return
  // 设置为true,代表正在刷新
  isFlushJob = true
  // 在微任务队列中刷新 jobQueue 毒烈
  p.then(()=>{
    jobQueue.forEach(job=>job())
  }).finally(()=>{
    // 结束后重置 isFlushJob
    isFlushJob = false
  })
}

  // 使用effect函数
  effect(() =>
   { 
    console.log(obj.foo)
  },
  {
    // 调度器 scheduler 是一个函数
    scheduler(fn){
      // 每次调度时,我们将副作用函数添加到任务队列jobQueue中
      jobQueue.add(fn)
      // 调用FlushJob刷新队列
      FlushJob()
    // setTimeout(fn);
    }
  })
 obj.foo++
 obj.foo++

复制代码

再次打印我们会发现,只打印出了1,2

个人博客地址