原文作者:Bertalan Miklos
原文地址:https://blog.risingstack.com/writing-a-javascript-framework-execution-timing-beyond-settimeout/
中文翻译:文蔺
译文地址:http://www.wemlion.com/2016/execution-timing/
本文由 @文蔺 翻译,转载请保留此声明。
著作权属于原作者,本译文仅用于学习、研究和交流目的,请勿用于商业目的。

本文是“编写 JavaScript 框架”系列的第二章。在本章中,我将介绍 JavaScript 中异步执行代码的几种不同方式。你会读到关于事件循环相关的内容,以及像 setTimeout 和 Promise 等时间调度(timing)技术之间的差异。

本系列主要是如何开发一个开源的客户端框架,框架名为 NX。我将在本系列中分享框架编写过程中如何克服遇到的主要困难。对 NX 感兴趣的朋友可以点击 NX 项目主页查看。

本系列章节如下:

  1. 项目结构(Project structuring)
  2. 执行调度(Execution timing)(本章)
  3. 沙箱求值(Sandboxed code evaluation)
  4. 数据绑定简介
  5. ES6 Proxy 实现数据绑定
  6. 自定义元素
  7. 客户端路由

异步执行代码

说到异步执行代码,恐怕大部分人都很熟悉 Promiseprocess.nextTick()setTimeout() 以及 requestAnimationFrame() 等方式吧。它们在内部都使用了事件循环(Event Loop),但就时间精确度而言,它们的表现却截然不同。

本章将解释它们之间的差异,并介绍如何实现像 NX 这样的现代框架所需要的时间调度系统。不必重造轮子,使用原生的事件循环就可以达到目的。

事件循环

所谓事件循环,实际 ES6 标准 完全没有提到。JavaScript 自身只有任务、任务队列。更复杂的事件循环,分别由 NodeJS 和 HTML5 标准 各自说明。因为本系列是关于前端的,我将在此阐释后者。

事件循环之所以称为循环,是由原因的。它是一个寻找新任务并执行任务的无限循环。一次循环被称为一个 tick。单个 tick 内执行的代码称作任务(task)。

while (eventLoop.waitForTask()) {
eventLoop.processNextTask()
}

所谓任务,是指那些可能在循环中安排其他任务的同步的代码片段。一种安排新任务的简单方式是使用 setTimeout(taskFn)。不过,任务也可能来自其他地方,如用户事件、网络请求或 DOM 操作。

Execution timing: Event loop with tasks

任务队列

来点更复杂的。事件循环中可以有多个任务队列,但有两个限制:来源相同的事件必须归属于同一队列;每个队列中的任务按照插入顺序执行。除此之外,浏览器是完全自由的。比如说,它可以自己决定接下来执行哪一个任务队列。

while (eventLoop.waitForTask()) {
const taskQueue = eventLoop.selectTaskQueue()
if (taskQueue.hasNextTask()) {
taskQueue.processNextTask()
}
}

这个模型放松了对时间的精确控制。浏览器在执行我们用 setTimeout() 设置的任务之前,可能决定先处理完其他队列。

Execution timing: Event loop with task queues

Microtask 队列

幸运的是,事件循环中还有一个单线队列。每个 tick 内,当前任务完成后,microtask 队列被完全清空。

while (eventLoop.waitForTask()) {
const taskQueue = eventLoop.selectTaskQueue()
if (taskQueue.hasNextTask()) {
taskQueue.processNextTask()
}

const microtaskQueue = eventLoop.microTaskQueue
while (microtaskQueue.hasNextMicrotask()) {
microtaskQueue.processNextMicrotask()
}
}

设置 microtask 最简单的方式是 Promise.resolve().then(microtaskFn)。microtask 按照插入顺序执行,因为只有一个 microtask 队列,故不会造成混乱。

此外,在一个 microtask 中还能设置新的 microtask,它们会被插在同一个队列中,在同一 tick 中执行。

Execution timing: Event loop with microtask queue

渲染

还有一件事是渲染进度(rendering schedule)。不同于事件处理和解析,渲染不是由单独的背景任务完成的,而是由算法决定,可能会在每次 tick 末尾执行。

在这方面,浏览器自由度很大:可能在每个任务之后渲染,但也可能一直执行数百个任务而不进行渲染。

还是很幸运,我们有 requestAnimationFrame(),它会在下一次渲染之前执行传入的函数。最终我们的事件循环模型如下所示:

while (eventLoop.waitForTask()) {  
const taskQueue = eventLoop.selectTaskQueue()
if (taskQueue.hasNextTask()) {
taskQueue.processNextTask()
}

const microtaskQueue = eventLoop.microTaskQueue
while (microtaskQueue.hasNextMicrotask()) {
microtaskQueue.processNextMicrotask()
}

if (shouldRender()) {
applyScrollResizeAndCSS()
runAnimationFrames()
render()
}
}

Execution timing: Event loop with rendering

接下来使用上面的这些知识,构建一个时间调度系统吧!

使用事件循环

和大多现代框架一样,NX 专注于处理幕后 DOM 操作和数据绑定。它将操作分批异步执行,以提高性能。为正确调度这些任务,它依赖于 PromisesMutationObserversrequestAnimationFrame()

最佳的时间安排是这样的:

  1. 开发者编写的代码
  2. NX 进行数据绑定、响应 DOM 操作
  3. 开发者定义的钩子
  4. 浏览器渲染

Step 1

NX 使用 ES6 Proxy 同步注册对象变动,使用 MutationObserver 同步注册 DOM 操作(下一章会谈更多)。为优化性能,NX 将推迟响应(reaction),将其作为 mircotask 放到第二步。延迟响应对象变化由 Promise.resolve().then(reaction) 实现的,而 MutationObserver 会自动处理,因为其内部就使用了 microtask 。

Step 2

来自开发者的代码(任务)运行完成。NX 注册的 microtask 响应开始执行。因为是 microtask,所以它们会按顺序执行。请注意,目前还是在同一个 tick 中。

Step 3

NX 使用 requestAnimationFrame(hook) 运行开发者传过来的钩子。这可能发生在之后一次 tick 中。重点还是在于,这些钩子在下次渲染之前,所有数据、DOM、CSS 变动之后运行。

Step 4

浏览器渲染下一视图。也可能发生在稍后的 tick 中,但绝不会在上一步之前。

注意事项

基于原生事件循环,我们实现了一个简单而有效率的时间调度系统。理论上工作起来会很不错,不过时间调度是一件很微妙的事,小小的错误都可能导致一些奇怪的 bug。

在复杂系统中,很有必要设置一些关于时间调度的规则并在开发中遵守它们。以 NX 为例,我遵循了以下规则:

  1. 内部操作中绝对不要使用 setTimeout(fn, 0)
  2. 使用同一种方式注册 microtask
  3. 仅将 microtask 只于内部操作
  4. 不要将开发者钩子执行的时间窗口与其他东西混在一起

Rule 1 and 2

对数据操作和 DOM 操作的响应,应当按照操作发生的顺序执行。只要不将顺序搞混,延迟它们都是可以的。搞混执行顺序会让事情变得难以预测,也难以寻找问题原因。

setTimeout(fn, 0) 完全无法预测。使用几种不同方法注册 microtask 也会导致执行顺序混乱。比如下面的例子中,microtask2 会错误地先于 microtask1 执行:

Promise.resolve().then().then(microtask1) 
Promise.resolve().then(microtask2)

Execution timing: Microtask registration method

Rule 3 and 4

将开发者代码执行的时间窗口与内部操作隔离开非常重要。将两者混在一起,会导致一些看似无法预测的行为,并最终迫使开发者学习框架内部工作机制。想必很多开发者都有类似的经历。

写在最后

如果对 NX 框架感兴趣,请访问 主页。胆大的读者还可以在Github 上查看 NX 源码nx-observe 源码

希望你喜欢这篇文章。下一章我们将讨论沙箱求值。

原文作者:Bertalan Miklos
原文地址:https://blog.risingstack.com/writing-a-javascript-framework-execution-timing-beyond-settimeout/
本译文仅用于学习、研究和交流目的,转载请保留原文出处