浅曦Vue源码-43-patch 阶段-异步队列更新&性能优化

1,051 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第2天,点击查看活动详情

一、前情回顾 & 背景

上一篇篇小作文讲述了 Vue 如何组织队列更新的,主要依托于下面几个方法:

  1. Watcher.prototype.update,当响应式数据发生变化,其对应的 dep.notify 执行,watcher.update 会调用 queueWatcher
  2. queueWatcher 负责把 watcher 实例加入到待求值的 watcher 队列 queue 中,添加到队列需要根据当前队列是否处于刷新状态做不同的处理;
  3. queueWatcher 还会调用 nextTick 方法,传入消耗 queue 队列的 flushSchedulerQueue 方法;
  4. nextTick 会把 flushSchedulerQueue 包装然后放到 callbacks 队列,nextTick 另一个重要任务就是把消耗 callbacks 队列的 flushCallback 放入到下一个事件循环(或者下一个事件循环的开头,即微任务);

总结起来就两件事:

  1. 响应式数据发生变化,将依赖它的 watcher 放到 queue 队列;
  2. nextTick 把消耗 queueflushSchedulerQueue 放到 callbacks 队列,同时把消耗 callbacks 队列的 flushCallbacks 方法放到下个事件循环(或事件环的开头)

听完这些感觉已经很明白了,但是现在有两个具体的问题需要分析一番:

  1. 如果在一个用户 watcher 中修改某一个 渲染 watcher 依赖的响应式数据,这个渲染 watcher 会被多次添加到 queue 吗?

  2. 在一个 tick 中多次修改同一个被渲染 watcher 依赖的响应式数据(或者修改多个不同的响应式数据)那么渲染 watcher 会被多次添加到 queue 队列中吗?

很多人在看 Vue 面试题的时候都看到过一句话:Vue 会合并当前事件循环中的所有更新,只触发一次依赖它的 watcher;官网上也这个场景有一段描述,## 异步更新队列

所以答案很显然:是不会多次添加的,今天我们就来掰扯掰扯为什么不会?

二、用户 watcher 修改响应式数据

先来看一段示例代码:

这个示例代码是想表达:

  1. 渲染 watcher 依赖了 forProp.a 以及条件渲染的 imgFlag,即<div v-if="imgFlag">{{froProp.a}}</div>
  2. 当点击 button 按钮时,更新响应式数据 forProp.a 属性,使之++
  3. forProp.a 的变化就会触发用户 watcherforProp.a(nv, ov) {....},用户 watcher 会在触发时更新 imgFlag

首先 forProp.a 变化,渲染 watcher 肯定会被 pushqueue 队列,那么用户 watcher 执行时会不会再次把渲染 watcher pushqueue 队列,即 queue 中有两个渲染 watcher

<!DOCTYPE html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <title>Vue</title>
</head>
<body>
<div id="app">
 <button @click="goForPatch">使 forProp.a++</button>
 <div v-if="imgFlag"> ===> {{forProp.a}}</div>
 <img v-else
    :src="imgSrc"
    onload="console.log('img onload')">
</div>
<script src="./dist1/vue.js"></script>
<script>
 const src = 'https://gift-static.hongyibo.com.cn/static/ad_oss/image-1004-294/6139ce3ed297e/16311783028626.jpg'
 debugger
 new Vue({
    el: '#app',
    forProp: {
      a: 100
    },
   imgFlag: false,
   imgSrc: src
  },  
  methods: {
    goForPatch () {
      this.forProp.a++
    }
  },
  
  watch: {
    // 这个 watch 选项就是 用户 watcher,
    // 有别于 Vue 自己创建的渲染 watcher、计算属性对应的 lazy watcher
    'forProp.a' (nv, ov) {
      this.imgFlag = !this.imgFlag
     }
   }
 })
</script>
</body>
</html>

2.1 queueWatcher 的 has[id]、waiting

2.1.1 watch.id

每个 watcher 被创建时,都会获取一个唯一自增的 id,这个值是唯一的,无论是用户 watcher 还是 渲染 watcher 都有;

2.1.2 has[id]

前面的 forProp.a++ 使得 forProp.asetter 被触发,最终调用 dep.notity -> watcher.update -> queueWatcher(this);

queueWatcherthiswatcher 实例)添加到 queue,在添加之前会判断缓存对象 has 中是否已经存在该 watcher.id,如果判断出 has[id] 不存在,再 pushqueue,并且 has[id] = watcher.id;

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id

  // 如果 watcher 已经存在,则跳过,不会重复进入 queue
  if (has[id] == null) {
    // 缓存 watcher.id,用于判断 watcher 是否已经进入队列
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    }
  }
}

2.2 用户 watcher 和 渲染 watcher 的顺序

从上一篇讲述 消耗 queue 队列的 flushSchedulerQueue 方法中的得知,在触发 watcher 重新求值前会有一个给 queue 中的 watcher 按照 id 进行升序排序,所以 id 小的 watcher 将会被先执行;

所以现在问题变成了 用户 watcher渲染 watcherid 谁更小的问题。这个问题答案很显然,是用户 watcher id 更小。

Vue watcherid 是个自增的值,先被创建的 watcherid 会更小; 用户 watcher 是在初始化时初期进行响应式数据初始化的过程中创建的,而渲染 watcher 是在挂载阶段创建的,所以用户 watcher id 更小;

这里我们假设用户 watcher id4渲染 watcherid5

此时缓存 watcher idhas 对象:{ 4: true, 5: true }

2.3 消耗 queue 队列

综上,当 flushSchedulerQueue 方法执行时,开始遍历排序后的 queue 队列执行 queue 中每一项 watcher.run() 方法,因为用户 watcher id 较小,所以就会先执行用户 watcher 的回调: forProp.a(nv, ov) { this.imgFlag = !this.imgFlag }

imgFlag 被重新赋值,就会触发 imgFlag 这个响应式数据的 setter,进而触发 dep.notify()notify() 执行会触发 watcher.update(),调用流程如下:

this.imgFlag = !this.imgFlag;
  -> imgFlag setter () 
    -> dep.notify()
      -> watcher.update()
        -> queueWatcher(this) this 是渲染 watcher,其 id 为 5
          -> if (has[id] == null) 不成立,因为 has = { 4: true, 5: true }
export function queueWatcher (watcher: Watcher) {
  // 此时 watcher 是渲染 watcher,id 为 5
  const id = watcher.id

  // 因为 has = { 4: true, 5: true },
  // 由于 imgFlag 变更时,渲染 watcher 已经在 queue 了,
  // 所以不会重复将渲染 watcher 放入 queue
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    }
  }
}

2.4 总结

因为 watcher 被放入到 queue 前经过了判重处理,同时因为用户 watcher 的执行时机早于渲染 watcher,所以在用户 watcher 中修改渲染 watcher 依赖的数据时,不会多次将渲染 watcher 放入到 queue

这么做的好处显而易见了,这就能够避免用户 watcher 中修改响应式数据导致页面刷新多次,这就减少了非常大的性能开销。

这里还有一个隐藏条件:当渲染 watcher 执行时,就能拿到用户 watcher 更新后的响应式数据最新值,这是为啥?因为用户 watcher渲染 watcher 是同步串行的。

三、合并一个 tick 多次修改

3.1 一个 tick 多次修改同一个数据

先看一个例子:

这个例子很简单,当点击 button 按钮时,对 this.forProp++ 两次,此时分析一下会不会queue 中添加两次同一个渲染 watcher,同样我们假设渲染 watcherwatcher.id5

<!DOCTYPE html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <title>Vue</title>
</head>
<body>
<div id="app">
 <button @click="goForPatch">使 forProp.a++</button>
 <div> ===> {{forProp.a}}</div>
</div>
<script src="./dist1/vue.js"></script>
<script>
  debugger
  new Vue({
    el: '#app',
    data: {
      forProp: {
        a: 100
      },
    },

    methods: {
      goForPatch () {
        this.forProp.a++
        this.forProp.a++
      }
    }
  })
</script>
</body>
</html>

点击事件触发时,this.forProp.a 第一次被 ++ 时,

this.forProp.a++
  -> forProp.a 的 setter()
   -> dep.notify()
    -> 渲染 watcher.update()
     -> queueWatcher(this)
      -> if (has[id] == null) 成立
      ->  has[5] = true

第二次 this.forProp.a++ 时,还会走一变和上面类似的步骤,但是 has[5] == null 不成立了:

this.forProp.a++
  -> forProp.a 的 setter()
   -> dep.notify()
    -> 渲染 watcher.update()
     -> queueWatcher(this)
      -> if (has[id] == null) 不成立,has[5] = true

虽然 this.forProps.a 在同一个 tick 中被 ++ 两次,但是最终 queue 中只有一个渲染 watcher;这个也就是常说的 Vue 性能优化的一个重要手段:合并同一个 tick 中对同一个响应式数据的多次更新。

为啥称之为合并呢?当渲染 watcher 真正触发重新求值的时候,已经是在多次更新响应式数据的 tick 之后的下一个 tick 了,此时渲染 watcher 重新求值,获取到的就是上一个 tick 中响应式数据的最新值,至于在最新值之前的值通通被渲染 watcher 忽略掉了,因为渲染 watcher 从来就不知道这个响应式数据有这么多的前任

3.2 一个 tick 修改多个不同数据

这个原理同样被应用到在一个 tick 中一次性修改多个响应式数据,比如 this.forProp.a++ 然后 this.imgFlag = !this.imgFlag,这两个步骤都触发了各自的 setter,但是因为渲染 watcher 已经存在 queue 的原因,不会被重复添加,渲染 watcher 最后还是只有一个;

四、总结

本文是继 nextTick 的姊妹篇,同时又是一个深入理解 nextTick 精妙设计所在的过程。另外,也解答了何为合并多次修改的性能优化,其核心实现如下:

  1. watcherpushqueue 之前有一步判断重复处理,即 has[id] == null
  2. watcher 被成功推入 queue 之后 has[id] = true,可保证下次 push 这个 watcher 时不会通过上一步的判重,因而不会重复加入 queue
  3. queue 中的 watcher 被重新求值前,会按 id 进行升序,用户 wacherid 小于 渲染 watcherid,所以用户 watcher 放心大胆的改响应式数据,同样是由于 has[渲染watcher id] = true 故而不会将渲染 watcher 多次加入 queue
  4. 最后,每个 watcherqueue 取出并重新求值后 has[被取出 watcher id] 被置为 null,这就保证下个 tick 中修改响应式数据时,这些 watcher 又可以被重新添加到 queue