JavaScript 语言精粹:异步模型

JavaScript 是门非阻塞单线程语言,因为在最初 JavaScript 就是为了和浏览器交互而诞生的。 JS 在执行的过程中会产生执行环境,这些执行环境会被顺序的加入到执行栈中。如果遇到异步的代码,会被挂起并加入到 Task(有多种 task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为。

基本概念

  • 主线程:Javascript 执行引擎,负责程序本身的运行

  • 同步任务:在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务

  • 异步任务:不进入主线程、而进入"任务队列"(task queue)的任务。

  • 回调函数(Callback):那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。

任务队列(Task Queue)

任务队列,是包含多个事件的队列(也可以理解成消息的队列),是一个先进先出(FIFO)的有序的数据结构。比如IO设备完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。

任务队列中包含以下任务:

  • Events:在事件目标上发送事件对象。注意,不是所有的事件都从任务队列中发送,有许多是在别的任务中发送的。
  • Parsing:HTML parser 标记化转换任务
  • Callbacks:回调由一个专用任务调度
  • Using a resource:获取网络资源
  • Reacting to DOM manipulation:DOM操作响应。比如为元素添加子节点

事件循环(Event Loop)

概念

JavaScript 执行引擎在主线程方法执行完毕,到达空闲状态时,会从任务队列中顺序获取任务来执行,这一过程是一个不断循环的过程。W3C规范

Wikipedia 定义:

In computer science, the event loop, message dispatcher, message loop, message pump, or run loop is a programming construct that waits for and dispatches events or messages in a program.

WHATWG 定义:

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. There are two kinds of event loops: those for browsing contexts, and those for workers.

小结:

  • 事件循环(Event Loop)是一个程序结构,用于等待和发送消息和事件。

  • 事件循环有 2 种:

    • Browsing contexts
    • Workers

处理模型

规范:

Event loop processing model - WHATWG

事件循环示意图:

JavaScript 事件循环模型

关键点:

  • 主线程运行的时候,产生堆(heap)和栈(stack)。栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)

  • 执行栈(stack)中的代码(同步任务),总是在读取"任务队列"(异步任务)之前执行

  • 每个浏览器环境,至多有一个事件循环(event loop)

  • 一个事件循环可以有1个或多个任务队列(task queue)

定时器:setTimeout

使用方法:

1
2
3
const fn = () => { console.log('timeout') }
const delay = 0
const timeoutID = setTimeout(fn, delay)

关键点:

  • 返回值 timeoutID 是一个正整数,表示定时器的编号,是计划执行代码的唯一标识符。这个值可以传递给 clearTimeout() 来取消该定时

  • 当在代码中调用 setTimeout() 方法时,注册的延时方法会交由浏览器内核其他模块(timer 模块,以 Webkit 为例,是 Webcore 模块提供的)处理。当延时方法到达触发条件,即到达设置的延时时间时,这一延时方法被添加至任务队列里。这一过程由浏览器内核其他模块处理,与执行引擎主线程独立

  • fn 可为字符串,但不推荐使用

  • 关于延时 delay:

    • 单位为 ms

    • 如果省略该参数,delay 取默认值 0

    • 取值 0 的含义是,指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行

    • 最小值:HTML5 标准规定了 setTimeout() 的第二个参数的最小值(最短间隔),不得低于 4 毫秒,如果低于这个值,就会使用最小值

    一直以来,不同浏览器中出现这种最小延迟的情况有所不同(例如Firefox) - 从其他地方调用了 setInterval(),或者在嵌套函数调用setTimeout() 时(嵌套级别达到特定深度时),都会出现超时延迟。 除了"最小延时"之外,定时器仍然有可能因为当前页面(或者操作系统/浏览器本身)被其他任务占用导致延时。

    • 最大值:浏览器包括 IE, Chrome, Safari, Firefox 以 32 个 bit 字节存储整数。这就会导致如果一个整数大于 2147483647 (2^31 - 1,大约24.8 天)时就会溢出,导致定时器将会被立即执行

    • 比 delay 为 0 更小的延时,采用 window.postMessage(),参考 setTimeout with a shorter delay

重排(Reflow)与重绘(Repaint)

重排和重绘其实和 Event loop 有关,在事件循环处理模型中的 “Update the rendering” 环节中,按照以下流程处理:

  1. 当 Event loop 执行完 Microtasks 后,会判断 document 是否需要更新。如果浏览器是 60Hz 的刷新率,约每 16ms 才会更新一次。
  2. 然后判断是否有 resize 或者 scroll ,有的话会去触发事件,所以 resize 和 scroll 事件也是至少 16ms 才会触发一次,并且自带节流功能。
  3. 判断是否触发了 media query
  4. 更新动画并且发送事件
  5. 判断是否有全屏操作事件
  6. 执行 requestAnimationFrame 回调
  7. 执行 IntersectionObserver 回调,该方法用于判断元素是否可见,可以用于懒加载上,但是兼容性不好
  8. 更新界面
  9. 以上就是一帧中可能会做的事情。如果在一帧中有空闲时间,就会去执行 requestIdleCallback 回调。

任务(Task)

概念

  • 宏任务(macro-task):代指任务(Task),包含:

    • script(整体代码)
    • setTimeout
    • setInterval
    • setImmediate
    • I/O
    • UI rendering
  • 微任务(micro-task):在 ES2015 规范中称为 Job,可理解为更小粒度的单项工作。包含:

    • Promises
    • Object.observe
    • MutationObserver
    • process.nextTick

[注:为了明显区分 macrotask 和 microtask,本文一般添加加短横线]

事件循环如何处理任务(tasks 和 microtasks)

  • 每个线程有自己的事件循环,所以每个 web worker 有自己的,所以它才可以独立执行。然而,所有同属一个 origin 的 windows 共享一个事件循环,所以它们可以同步交流

  • 事件循环不间断在跑,执行任何进入队列的 task

  • 一个事件循环可以有多个 task source,每个 task source 保证自己的任务列表的执行顺序,但由浏览器在(事件循环的)每轮中挑选某个 task source 的 task

  • 任务是被有序计划的(scheduled),所以浏览器可以从内部到JS/DOM,保证动作按序发生。在 tasks 之间,浏览器可能会 render updates

  • 微任务(micro-tasks)也是被有序计划好的(scheduled),经常是为需要直接在当前脚本执行完后立即发生的事,比如 async 某些动作但不必承担新开 task 的弊端。micro-task queue 在回调之后执行,只要没有其它JS在执行中,并且在每个 task 的结尾。micro-task 中添加(嵌套)的 micro-task 也被添加到 micro-task queue 的末尾并处理。micro-task 包括 Mutation Observer callbacks 和 Promise callbacks

  • click, ajax, setTimeout 的回调是都是 task, 同时,包裹在一个 script 标签中的js代码也是一个 task 确切说是 macro-task

执行顺序

  1. JavaScript 引擎首先从 macro-task queue 中取出第一个任务(同步任务),并执行;
  2. 执行完毕后,将 micro-task queue 中的所有任务取出,按顺序全部执行;
  3. 必要的话渲染 UI(UI rendering);
  4. 然后再从 macro-task queue 中取下一个,并执行;
  5. 执行完毕后,再次将 micro-task queue 中的全部取出;
  6. 循环往复,直到两个 queue 中的任务都取完。

在一个事件循环的周期(cycle)中一个 (macro)task 应该从 macrotask 队列开始执行。当这个 macrotask 结束后,所有的 microtasks 将在同一个 cycle 中执行。在 microtasks 执行时还可以加入更多的 microtask,然后一个一个的执行,直到 microtask 队列清空。

如何选择

基本来说,当我们想以同步的方式来处理异步任务时候就用 micro-task(比如我们需要直接在某段代码后就去执行某个任务,就像 Promise 一样)。

其他情况就直接用 macro-task

实战演练

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
console.log('task start')

const interval = setInterval(() => {
console.log('setInterval')
}, 0)

setTimeout(() => {
console.log('setTimeout 1')
Promise.resolve()
.then(() => { console.log('promise 3') })
.then(() => { console.log('promise 4') })
.then(() => {
setTimeout(() => {
console.log('setTimeout 2')
Promise.resolve()
.then(() => { console.log('promise 5') })
.then(() => { console.log('promise 6') })
.then(() => { clearInterval(interval) })
}, 0)
})
}, 0)

Promise.resolve()
.then(() => { console.log('promise 1') })
.then(() => { console.log('promise 2') })

console.log('task end')

题解:

将 macro-task 和 micro-task 看作是2个队列,不断的清空入栈执行:

  1. setInterval 和 setTimeout 1 被加入到 task,promise 1 和 promise 2 被加入到 micro-task
  2. 清空 micro-task,打印 promise 1 和 promise 2,执行 task 队列,打印 setInterval 和 setTimeout 1
  3. setInterval 被加入到 task,promise 3 和 promise 4 被加入到 micro-task,setTimeout 被加入到 task
  4. 清空 micro-task,打印 promise 3和 promise 4,执行 task 队列,打印 setInterval,setTimeout 2
  5. setInterval 被加入到 task,promise 5 和 promise 6 被加入到 micro-task
  6. 清空 micro-task,打印 promise 5 和promise 6,clearInterval

结果:

JavaScript 事件循环题解

注:实际在 Chrome 控制台执行时,期间同时执行 setInterval 代码 2次

参考资料

晓月风尘 wechat
扫描二维码与我相识
你我共同创造价值,记得支持一下哦~