[vue源码笔记]vue2.x/3.0嵌套依赖收集以及订阅者异步更新策略

199 阅读5分钟

前言:本篇文章作为前面两篇文章[笔记]vue2.x-Object变化监听,[笔记]vue3.0-Proxy变化监听的一个小节,目的是说明解决前面两篇文章遗留下来的疑问

问题回顾

  1. 如果在一个订阅者的getter函数中嵌套另一个订阅者,此时的依赖收集过程是怎样的
  2. 在一轮变更中订阅者依赖的多个属性都发生了变更或者依赖的某个属性发生了多次变更怎么解决订阅者多次update的问题

订阅者嵌套情况下的依赖收集

以3.0的实现为例,(2.x大同小异)当订阅者方法执行前会将全局activeEffect置为当前订阅者的effect,在订阅者方法执行中触发依赖收集或者依赖更新就能通过全局activeEffect找到真正的订阅者 但是试想如果订阅者A方法中嵌套订阅者B的effect会发生什么:

  1. activeEffect置为effectA
  2. 执行订阅者A方法进行订阅者A的依赖收集
  3. 执行订阅者B的effectB
  4. activeEffect置为effectB
  5. 进行订阅者B的依赖收集
  6. effectB执行结束将activeEffect置为null
  7. 执行订阅者A方法的剩余逻辑 ... 很容看出问题:步骤7在执行订阅者A的剩余逻辑时activeEffect已被置为null,后续的依赖收集将找不到订阅者 解决方案也很明确:在步骤6effectB执行结束后只需要将activeEffect重新置为effectA即可 解决方案:
// 扩展一个全局activeEffect栈用于存放订阅者列表
const effectStack = []
window.activeEffect = null

// 改进effect函数,在每次执行订阅者方法之前将当前effect赋值给activeEffect时增加将当前effect推入effectStack列表
const effect = function reactiveEffect() {
    cleanup(effect) // 每次执行订阅者方法之前都先将所有的依赖关系清空,重新进行依赖收集
    effectStack.push(effect)
    activeEffect = effect
    try {
        fn() // 订阅者方法会引用数据,从而触发被引用数据的getter进行依赖收集
    }
    finally {
        // 完成本轮依赖收集后弹出当前effect
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1] // 完成依赖收集后将activeEffect置为栈顶的effect
    }
}

变更通知后的订阅者更新策略

先看一下出版数据响应存在的问题:
如果我们连续变更数据,则会连续触发变更通知导致订阅者频繁更新,这可能与我们的预期不符,而且也会造成严重的性能问题.
根本原因在于变更通知和订阅者更新是同步进行的,很多种情况下一个订阅者可能依赖多个数据,如果按照初版的实现就会造成数据a更新触发订阅者更新,数据b更新又一次触发订阅者更新.
而我们期望的是在一轮数据变更后订阅者进行一次更新即可
思路:我们应该很容易能想到变更通知同步,但是订阅者更新去异步进行,这样在一轮变更通知结束后将统一执行订阅者更新就解决了上面的问题
代码实现以3.0为例2.x雷同(以下代码并未经过测试,只是提供思路):

const queue = [] // 存放任务队列
let isFlushing = false, isFlushPending = false, currentFlushPromise = null
const scheduler = function(job) {
    // 避免任务重复推入
    if (!queue.includes(job)) {
        queue.push(job)
        queueFlush()
    }
}
function queueFlush() {
    if (!isFlushing && !isFlushPending) {
        isFlushPending = true
        currentFlushPromise = Promise.resolve().then(flushJobs)
    }
}
function flushJobs() {
    isFlushPending = false
    isflushing = true
    try {
        for (let i = 0; i < queue.length; i++) {
            queue[i]()
        }
    } finally {
        isflushing = false
        currentFlushPromise = null
        queue.length = 0
    }
}
// 通知方法
function trigger() {
    const effects = new Set()
    const depsMap = targetMap.get(target)
    if (!depsMap) return
    function add(effectsToAdd) {
        effectsToAdd.forEach(effect => {
            if (effect !== activeEffect) {
                effects.add(effect)
            }
        })
    }
    if (!depsMap.get(key)) return
    add(depsMap.get(key))
    function run(effect) {
        scheduler(effect) // 对订阅者effect做统一调度
    }
    effects.forEach(run)
}

vue3.0中利用了Promise.resolve()将订阅者更新effect当作一个一个job加入微任务队列.
也就是说变更通知作为同步任务同步进行通知,通过scheduler进行任务调度将effect加入队列,同时对相同任务做了过滤,当一轮变更通知下达完成后js引擎将自动检查微任务队列将scheduler调度加入的job队列依次进行同步执行,这样就实现了一个订阅者依赖多个数据,而在一轮数据变更中多个依赖的数据发生变更订阅者只进行一次更新的期望
扩展: 其实在vue中还存在更为复杂的情况, 如computed同时作为订阅者和依赖两种角色,当数据更新通知页面渲染render-effect执行的时候将引用computed触发computed重新计算,computed重新计算又将通知其订阅者,所以会出现queue中的任务执行过程中再次触发变更通知,由于变更通知是同步的所以会有两种情况:

  1. computed的订阅者已经存在任务队列queue中,则不产生实际影响
  2. computed的订阅者没有存在任务队列queue中,则会被推入队列,等待执行
    在2.x中实现异步的方法还包含使用(new MessageChannel()).postMessage的方式,但总体思路和3.x类似

后记

本篇文章主要讨论前两篇所遗留下来的两个疑问,当然在vue具体的实现上面还包含很多分支逻辑,以及对很多情况的兼容处理,具体可参看源代码
后续将介绍开发中最常用的和响应式有关的data,computed,watch.