Batch Update 浅析

Batch Update 浅析

Virtual DOM 为主流前端 MV* 框架提供了高效的 view 更新机制。即使如此,Virtual DOM 整个 diff/patch 的过程仍然是一个昂贵的操作,在保证 view 及时更新的前提下如何尽可能减少 diff/patch 的次数?这就涉及到 Batch Update 机制。

什么是 Batch Update

Batch Update 即「批量更新」。在 MV* 框架中,Batch Update 可以理解为将一段时间内对 model 的修改批量更新到 view 的机制。以 React 为例,我们在 componentDidMount 生命周期连续调用 setState :

componentDidMount () {
  this.setState({ foo: 1 })
  this.setState({ foo: 2 })
  this.setState({ foo: 3 })
}

在不引入 Batch Update 的情况下,上面的操作会导致三次组件渲染,而实际运行上面的代码可以发现组件只渲染了一次。componentDidMount 中三次对 model 的操作被 Batch Update 优化为一次 view 的更新,不必要的 Virtual DOM 计算被省略,从而提高了框架的效率。

Batch Update 的实现

我们很容易想到使用一个 queue 来保存 update,并在合适的时候对这个 queue 进行 flush 操作。但在前端框架中实现 Batch Update 的关键在于两个问题:

  1. 何时开始一个 queue
  2. 何时 flush 这个 queue

主流的前端框架都有自己的 Batch Update 实现。以 React 和 Vue 为例,这两个框架用完全不同思路实现了 Batch Update。

首先是 React:React 中的 Batch Update 是通过「Transaction」实现的。在 React 源码关于 Transaction 的部分,用一大段文字及一幅字符画解释了 Transaction 的作用:

*                       wrappers (injected at creation time)
*                                      +        +
*                                      |        |
*                    +-----------------|--------|--------------+
*                    |                 v        |              |
*                    |      +---------------+   |              |
*                    |   +--|    wrapper1   |---|----+         |
*                    |   |  +---------------+   v    |         |
*                    |   |          +-------------+  |         |
*                    |   |     +----|   wrapper2  |--------+   |
*                    |   |     |    +-------------+  |     |   |
*                    |   |     |                     |     |   |
*                    |   v     v                     v     v   | wrapper
*                    | +---+ +---+   +---------+   +---+ +---+ | invariants
* perform(anyMethod) | |   | |   |   |         |   |   | |   | | maintained
* +----------------->|-|---|-|---|-->|anyMethod|---|---|-|---|-|-------->
*                    | |   | |   |   |         |   |   | |   | |
*                    | |   | |   |   |         |   |   | |   | |
*                    | |   | |   |   |         |   |   | |   | |
*                    | +---+ +---+   +---------+   +---+ +---+ |
*                    |  initialize                    close    |
*                    +-----------------------------------------+

Transaction 对一个函数进行包装,让 React 有机会在一个函数运行前后执行特定逻辑,从而完成整个 Batch Update 流程的控制。

简单来说,在 Transaction 的 initialize 阶段,一个 update queue 被创建。在 Transaction 中调用 setState 方法时,状态并不会立即应用,而是被推入到 update queue 中。函数执行结束进入 Transaction 的 close 阶段,update queue 会被 flush,这时新的状态会被应用到组件上并开始后续 Virtual DOM 更新等工作。

与 React 相比 Vue 实现 Batch Update 的方法就要简单很多:直接借助 JavaScript 的 Event Loop。Vue 中 Batch Update 的核心代码只有大约 20 行:

// https://github.com/vuejs/vue/blob/dev/src/core/observer/scheduler.js#L122-L148
/**
 * Push a watcher into the watcher queue.
 * Jobs with duplicate IDs will be skipped unless it's
 * pushed when the queue is being flushed.
 */
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

当 model 被修改时,对应的 watcher 会被推入 update queue,与此同时还会在异步队列中添加一个 task 用来 flush 当前 update queue。这样一来,当前 task 中的其他 watcher 会被推入同一个 update queue 中。当前 task 执行结束后,异步队列中的下一个 task 会开始执行,update queue 会被 flush,并进行后续的更新操作。

为了让 flush 动作能在当前 Task 结束后尽可能早的开始,Vue 会优先尝试将任务 micro-task 队列,具体来说,在浏览器环境中 Vue 会优先尝试使用 MutationObserver API 或 Promise,如果两者都不可用,则 fallback 到 setTimeout。

对比两个框架可以发现 React 基于 Transition 实现的 Batch Query 是一个不依赖语言特性的通用模式,因此有更稳定可控的表现,但缺点是无法完全覆盖所有情况,例如对于如下代码:

componentDidMount () {
  setTimeout(_ => {
    this.setState({ foo: 1 })
    this.setState({ foo: 2 })
    this.setState({ foo: 3 })
  }, 0)
}

由于 setTimeout 的回调函数「不受 React 控制」,其中的 setState 就无法得到优化,最终会导致 render 函数执行三次。

而 Vue 的实现则对语言特性乃至运行环境有很强的依赖,但可以更好的覆盖各种情况:只要是在同一个 task 中的修改都可以进行 Batch Update 优化。

总结

了解 Batch Update 的原理及实现目的是为了帮助我们避开平常代码中相关的「坑」,同时根据框架的特性来写出更加高效的代码。进一步来说,Batch Update 不是框架的专利,我们的许多业务场景也可以使用 Batch Update 的思想进行优化:比如在一些复杂的表单中用户连续操作之后再进行集中的保存/提交操作,避免频繁的保存/提交造成资源浪费。

篇幅有限,本文只对 Batch Update 的原理及主流框架中的实现进行了简单的分析,许多细节(如 update queue 的排重和合并,组件树的更新顺序等等)并没有一一涉及。希望能对大家的学习有所帮助,也欢迎兴趣的同学一起探讨。

发布于 2017-08-17

文章被以下专栏收录

    只看代码的话,上 https://github.com/ElemeFe 。这一群人,关心的不是「如何写前端」而是「如何很好地运行一个 ( web ) APP」;这一群人,会在监控屏上加上弹幕,会让实习生自主招聘,会设计、编写、监控整个 APP 的生命周期;这一群人,玩的时候... 更卖力,就像从来没来过那般卖力,卖力地热爱生活。所以这些创作大多基于 ❤️