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 字节存储整数。这就会导致如果一个整数大于 21474836472^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 ,有的话会去触发事件,所以 resizescroll 事件也是至少 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
扫描二维码与我相识
你我共同创造价值,记得支持一下哦~