likes
comments
collection
share

Vue3源码学习4(下) | 响应系统的作用与实现

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

Vue3源码学习4(下) | 响应系统的作用与实现

继上面对于 Vue3响应系统原理的学习,我们对这部分内容有了较深的理解,接下来让咱再次起航攻破这座大山吧!

4.6 避免无限递归循环

产生无限递归循环的原因

Vue发展至此,想要实现一个完善的 响应系统 需要考虑诸多细节。而现在要介绍的无限递归循环就是其中之一,咱们还是先看代码:

const data = { foo:1 }
const obj = new Proxy(data,{ /*...*/ })

effect(() => obj.foo++)

乍一看,上面的代码似乎并没有什么问题,就是在effect注册的副作用函数内有一个自增操作obj.foo++,该操作会引发 栈溢出

Uncaught RangeError:Maximum call stack size exceeded

是不是觉得很莫名其妙,就在前几节中我们说的都很"合情合理"。但是怎么到这就整出这个幺蛾子呢?接下来,就来一起来瞅瞅吧!

实际上,我们可以把obj.foo++这个自增操作分开来看,它相当于:

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

分析:为啥会 栈溢出

在这句语句中,既会读取obj.foo的值,又会设置obj.foo的值,而这就是导致问题的根本原因。我们可以尝试推理一下代码的执行流程:

  • 首先读取obj.foo的值,这会触发track操作,将当前副作用函数收集到一个变量中。
  • 接着将其加1后再赋值给obj.foo,此时会触发trigger操作,即把变量中的副作用函数取出并执行。
  • 问题所在:该副作用函数正在执行,还没执行完毕,就要开始下一次的执行。

这样会导致无限递归地调用自己,于是就产生了 栈溢出。

解决办法

解决办法并不难,通过上面的分析可以知道:读取和设置操作是在同一个副作用函数内进行的。

也就是说,此时无论是track时收集的副作用函数,还是trigger时要触发执行的副作用函数都是 activeEffect

基于此,我们可以在trigger动作发生时增加守卫条件如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行

代码如下:

function trigger(target,key) {
    const depsMap = bucket.get(target)
    if(!depsMap) retrun
    const effects = depsMap.get(key)
    
    const effectsToRun = new Set()
    effects && effects.forEach(effectFn => {
        // 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
        if(effectFn !== activeEffect) {
            effectsToRun.add(effectFn)
        }
    })
    effectsToRun.forEach(effectFn => effectFn())
}

加了这个"守卫者",咱就可以避免无限递归调用,从而避免栈溢出

4.7 调度执行

代码是我们写的,我们当然是它们的支配者

可调度性是响应系统非常重要的特性。首先我们需要知道一下这玩意是什么。

什么是可调度性

所谓的可调度性,指的是当trigger动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。

首先,看一下如何决定副作用函数的执行方式,下面代码所示:

const data = { foo:1 }
const obj = new Proxy(data,{ /*...*/ })

effect(() => {
    console.log(obj.foo++)
})
obj.foo++

console.log("结束了")

在副作用函数中,我们首先使用 console.log 语句打印obj.foo的值,接着对obj.foo执行自增操作,最后使用console.log语句打印出'结束了'。这段代码的输出结果如下:

1
2
'结束了'

现在假设需求有变,输出顺序需要调整为:

1
'结束了'
2

看到上面的打印结果,你是不是会想到只要调整一下代码的位置即可。

那么,有没有什么办法能够在不调整代码的情况下实现需求呢?这时就需要 响应系统支持调度

effect函数设计一个选项参数options,允许用户指定 调度器

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

如上面的代码所示,用户在调用effect函数注册副作用函数时,可以传递第二个参数options。它是一个对象,其中允许指定scheduler调度函数

调度器的作用

在effect函数内部需要把options选项挂载到对应的副作用函数上:

function effect(fn,options = {}) {
    const effectFn = () => {
         cleanup(effectFn)
         // 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
         activeEffect = effectFn
         // 在调用副作用函数之间将当前副作用函数压栈
         effectStack.push(effect)
         fn()
         // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
         effectStack.pop()
         activeEffect = effectStack[effectStack.length - 1]
    }
    // 将 options 挂载到 effectfn 上
    effectFn.options = options
    // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
    effectFn.deps = []
    // 执行副作用函数
    effectFn()
}

trigger函数中触发副作用函数重新执行时,就可以直接调用用户传递的调度器函数,从而把控制权交给用户:

function trigger(target,key) {
    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 => {
        // 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
        if(effectFn.options.scheduler) {
            effectFn.options.scheduler(effectFn)
        } else {
            // 否则直接执行副作用函数
            effectFn()
        }
    })
}

如上面的代码所示,在trigger动作触发副作用函数执行时,我们优先判断该副作用函数是否存在调度器,如果存在,则直接调用调度器函数,并把当前副作用函数作为参数传递过去,由用户自己控制如何执行;否则保留当前的行为,即直接执行副作用函数。

调度器的实操

有了这些基础设施后,咋现在就可以实现前文的需求,如下面的代码所示:

const data = { foo:1 }
const obj = new Proxy(data,{ /*...*/ })

effect(() => {
    console.log(obj.foo++)
},
// options
{
    // 调度器 scheduler 是一个函数
    scheduler(fn) {
        // 副作用函数放到宏任务队列中执行
        setTimeout(fn)
    }
}
)
obj.foo++

console.log("结束了")

我们使用setTimeout开启了一个宏任务来执行副作用函数fn,这样就能实现期望的打印顺序了:

(什么是宏任务?可以自行学习一下事件循环)
1
'结束了'
2

这是调度器控制副作用函数的执行顺序

通过调度器还可以做到控制它的执行次数,这一点也尤为重要。思考下面的例子:

const data = { foo:1 }
const obj = new proxy(data,{ /*...*/ })

effect(() => {
    console.log(obj.foo)
})

obj.foo++
onj.foo++

在没有指定调度器的情况下,它的输出如下:

1
2
3

但是2只是一个过渡阶段,我们只关心结果,不在意过程。换句话说,我们想要的结果是:

1
3

基于调度器我们可以很容易地实现此功能:

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

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

effect(() => {
    console.log(obj.foo)
},
{
    scheduler(fn) {
       // 每次调度时,将副作用函数添加到 jobQueue 队列中
       jobQueue.add(fn)
       // 调用 flushJob 刷新队列
       flushJob()
    }
}
)

obj.foo++
obj.foo++

观察上面的代码:

  • 首先,定义了一个任务队列jobQueue,它是一个Set数据结构,目的是利用Set数据结构的自动去重能力。
  • 接着,看调度器scheduler的实现,在每次调用执行时,先将当前副作用函数添加到jobQueue队列中,再调用flushJob函数刷新新队列
  • 然后,把目光转向flushJob函数,该函数通过isFlushing标致判断是否需要执行,只有当其为false时才需要执行,而一旦flushJob函数开始执行,isFlushing标志就会设置为true,意思是无论调用多少次flushJob函数,在一个周期内都只会执行一次
注意:在 flushJob 内通过 p.then 将一个函数添加到微任务队列,在微任务队列内完成对 jobQueue 的遍历执行。

上面代码的核心效果:

  • 连续对obj.foo执行两次自增操作,会同步且连续地两次执行scheduler调度函数,这意味着同一个副作用函数会被jobQueue.add(fn)语句添加两次,但由于Set数据结构的去重能力,最终jobQueue中只会有一项,即当前副作用函数

  • 类似地,flushJob也会同步且连续地执行两次,但由于isFlushing标志存在,实际上flushJob函数在一个事件循环内只会执行一次,即在微任务内执行一次。

  • 当微任务队列开始执行时,就会遍历 jobQueue 并执行里面存储的副作用函数。由于此时jobQueue队列内只有一个副作用函数,所以只会执行一次,并且当它执行时,字段obj.foo的值已经是3了,这样我们就实现了期望的输出:

1
2

可能你已经注意到了,这个功能有点类似于在 Vue.js 中连续多次修改响应式数据但只会触发一次更新,实际上 Vue.js 内部实现了一个更加完善的调度器,思路与上文介绍相同

待续

加油!各位!

转载自:https://juejin.cn/post/7159830145262419999
评论
请登录