从浏览器组成到 JS 运行时环境

◆ 浏览器组成相关内容整理到了这篇浏览器中的进程与线程
◆ 浏览器内核实例中的以下几项内容被称为“JS 运行时环境(JS Runtime Environment)”:
(1)JS 引擎。
(2)事件循环(Event Loop)。
(3)任务队列(Task Queue)。
(4)Web/DOM API,包括如 setTimeout 等定时器函数、Ajax 等网络请求、DOM 操作与事件监听,这些 API 对运行时环境可见,具体的线程级实现由浏览器提供。

◆ 事件循环相关规范不属于 ECMA 范畴,它们约定于HTML Living Standard
◆ JS 运行时环境举例:Chrome 和 Node.js 使用了相同的 JS 引擎 V8,但具有不同的 JS 运行时环境。

JS 引擎

○ JS 引擎中除了执行代码的解释器(Interpreter)之外,主要包含上图左侧黑框中的两部分:内存堆(Memory Heap)、调用栈(Call Stack)。
○ V8 引擎中还会有个即时编译器(Just-In-Time, JIT compiler)作为解释器的辅助,解释器单纯按行解释和执行源代码,JIT 编译器则会发现并编译优化被频繁执行的代码块。
○ 内存堆用于存储代码执行期间的变量和函数。
○ 调用栈用于记录代码执行位置及相关状态信息,每个栈帧对应一个执行上下文,执行上下文的前置知识整理在这篇
○ 使用浏览器开发工具处理异常抛出的栈追踪(stack trace)正是异常发生时的调用栈状态。
○ JS 引擎执行 JS 代码的过程:

  1. 解释器创建全局执行上下文并作为栈帧添加到调用栈,然后在这个上下文环境执行代码。
  2. 执行期间如果遇到函数调用或 eval 表达式就创建一个新的执行上下文作为栈帧添加到调用栈栈顶并切换到新的栈帧执行其中代码。
  3. 执行期间如果再遇到函数调用或 eval 表达式重复步骤 2。
  4. 在当前栈帧执行完代码后,解释器将当前栈帧从调用栈弹出和销毁,然后切换到前一个栈帧继续执行。
  5. 重复步骤 2, 3, 4 直到调用栈清空。

○ 以下面代码段为例,随着代码执行,调用栈变化过程如下图。

1
2
3
4
5
6
7
8
function multiply(x, y) {
return x * y;
}
function printSquare(x) {
var s = multiply(x, x);
console.log(s);
}
printSquare(5);

○ JS 引擎运行在 Render 线程,单线程优势是不需要处理死锁之类的多线程问题,缺陷是耗时的 JS 执行会阻塞 Render 线程引起页面更新渲染与交互事件得不到及时处理,导致页面卡顿甚至卡死,Chrome 浏览器当阻塞持续超过一定时间会出现如下弹窗。

事件循环与任务队列

○ 浏览器内核中维护了多个任务队列,Render 线程要执行的任务(其中包括 JS 引擎要执行的代码段及 Event、Timer、网络请求、数据访问等 JS 回调)会先被存入任务队列,之后按照类型优先级等因素在最合适的时机被执行,维护任务队列与决定任务执行时机的主体称为 Event Loop。
○ HTML Standard 中定义了多种 Event Loop,此处的 Event Loop 是其中的“window event loop”(web worker 环境对应的是 worker event loop)。
○ 每个 Event Loop 对应一个或多个任务队列(task queue)与一个微任务队列(microtask queue),不同种类任务源的任务进入相应的任务队列,不同任务源任务有不同的优先级权重,属于同一个任务源的多个任务不会乱序执行。
○ task 在 V8 中被称为宏任务(macrotask),而 microtask 在 ECMAScript 中被称为 job。
○ 微任务队列中的微任务在当前调用栈为空而还未将控制权还给 Event Loop 的时候被执行,可用于在当前一轮 Event Loop 之内做任务的推迟执行。
○ 执行微任务阶段会将当前微任务队列内的所有微任务都执行完,从而保证这些微任务执行期间所处的环境状态是基本一致的,没有“经过了一次更新渲染”、“处理过定时器回调或事件回调”或“接收到了网络数据更新”等方面的差异。
○ 微任务的任务源如下

1
2
3
Promise的then|catch|finally处理函数以及与其等价的await的返回值处理
MutationObserver的处理函数
queueMicrotask注册的函数

○ 其他方式如 setTimeout、requestAnimationFrame、requestIdleCallback、MessageChannel 等注册的回调函数都属于宏任务。
○ 目前(20210104)不同类型任务优先级策略未在 HTML Living Standard 中指定,不同浏览器中表现可能不一致,如下例 MessageChannel 与 setTimeout 在不同浏览器的优先级差异导致的表现不一致。

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
setTimeout(function() {
console.log('setTimeout1');
}, 0);
setTimeout(function() {
console.log('setTimeout2');
}, 1);
var channel = new MessageChannel();
channel.port1.onmessage = function(event) {
console.log('postMessage' + event.data);
};
channel.port2.postMessage(0);
channel.port2.postMessage(1);
channel.port2.postMessage(2);
setTimeout(function() {
console.log('setTimeout3');
}, 0);
setTimeout(function() {
console.log('setTimeout4');
}, 0);
setTimeout(function() {
console.log('setTimeout5');
}, 0);

// Chrome输出:postMessage0 setTimeout1 setTimeout3 setTimeout4 setTimeout5 setTimeout2 postMessage1 postMessage2,由于setTimeout的最小4ms节流限制所以postMessage0在setTimeout1之前,但从优先级上setTimeout高于MessageChannel。
// Firefox输出:postMessage0 postMessage1 postMessage2 setTimeout1 setTimeout3 setTimeout4 setTimeout5 setTimeout2,可见Firefox中MessageChannel优先级高于setTimeout。

○ Event Loop 不断按照以下处理模型(event loop processing model)执行任务:

  1. 从某个(具体选择策略由浏览器决定)宏任务队列取出最老的 runnable task 交给 JS 引擎执行至调用栈为空(关于 runnable:同一个 Event Loop 可以被多个 Document 共用,某些 Document 可能当前并不处于 active 状态,从属于这种 Document 的任务就不是 runnable 的)。
  2. 不断从微任务队列获取最老的 microtask 交给 JS 引擎执行,直至微任务队列清空。
  3. 处理更新渲染,浏览器收集当前 Event Loop 关联的所有 Document,并排除据其“当前刷新帧率”还未到达下一帧时间的 Document,然后进一步排除“肯定没必要生成新的页面帧”的 Document,然后对筛选后的每个 Document 依次执行如下类型任务。
    (1)判断与标识 autofocus 候选元素
    (2)判断与触发 resize 事件
    (3)判断与触发 scroll 事件
    (4)判断与触发 media query 类事件
    (5)判断与触发 css animation 类事件
    (6)判断与触发 fullscreen 类事件
    (7)执行 animation callbacks (也就是通过 requestAnimationFrame 注册的回调函数)
    (8)更新 intersection observations
    (9)渲染生成新的页面帧(详细过程整理在这篇网页渲染过程)
  4. 如果以上三步都没有要执行的任务则进入 idle 阶段,将当前注册于 idle 阶段(通过 requestIdleCallback 注册)的所有回调函数添加到任务源为 idle-task 的宏任务队列。

JS 运行时环境的动态运行过程

♂ JS 引擎运行的代码均来自于任务队列(包括最初的 html 文档中<script>标签内的全局作用域代码),队列中任务的执行顺序由 Event Loop 控制。
♂ JS 执行代码期间遇到的事件监听、Ajax 请求、定时器等 Web/DOM API 调用由 API 实现层执行。
♂ Web API 完成时的回调函数被作为新任务添加到相应任务队列,不会立即影响当前正在执行的代码,也无须等待。
♂ Event Loop 会在合适时机( JS 引擎的调用栈当前为空是其必要条件)将回调函数从任务队列取出并交给 JS 引擎执行。

同步与异步、阻塞与非阻塞

♀ 结合上述 JS 运行时环境运行 JS 代码的两个特性:同步与异步、阻塞与非阻塞
(1)同步和阻塞指的是 JS 引擎是单线程执行代码的并且运行在 Render 线程,在 JS 引擎执行代码至其调用栈清空之前, Render 线程负责的更新渲染交互事件处理等其他任务是被阻塞的。
(2)异步和非阻塞指的是 JS 代码执行期间调用的浏览器所提供 Web/DOM API 不在当前线程同步执行,而是继续向下执行后面的代码,Web/DOM API 的执行结果是通过之后回调函数得到的,Event Loop 与任务队列是实现非阻塞特性的关键机制。

知识应用

■ 关于 requestAnimationFrame 的理解
Q: 在当前轮 Event Loop 调用 requestAnimationFrame 注册的回调函数执行时机是在下一轮 Event Loop 之前吗?
A: 不一定,根据以上 Event Loop 处理模型第 3 条,下一轮 Event Loop 达到当前刷新帧率的下一帧时间才会被执行,否则就是在之后满足时间条件的那一轮 Event Loop 中执行。
Q: 调用 requestAnimationFrame 及执行其回调函数所处的 Event Loop 中如果没有新 Frame 生成,requestAnimationFrame 的回调函数还会执行吗?
A: 会执行,根据以上 Event Loop 处理模型第 3 条第(7)(9)小条,执行回调函数与更新渲染是两个独立的处理阶段,回调函数仍然将在满足帧率时间的那一轮 Event Loop 执行,此行为与 MDN 中对 requestAnimationFrame 函数的说明(告诉浏览器接下来将执行一个引起更新渲染的操作,而在更新渲染新帧之前要先执行所注册的回调函数)不冲突。
■ 实现页面内可见的数字快速增长,由于更新渲染只可能发生在 task 执行完毕,所以将增长作为 task 添加到任务队列,代码示例与运行效果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<p style="will-change: contents" id="counter">0</p>
<button onclick="startCount();">start</button>
<script>
var pElem = document.querySelector('#counter');
function startCount() {
var curNum = 0;
function count() {
pElem.innerHTML = ++curNum;
if (curNum < 200) {
setTimeout(count, 0);
}
}
count();
}
</script>

0

参考文献

How JavaScript Works 系列
requestAnimationFrame Scheduling For Nerds
Task scheduling in Blink
HTML Living Standard
The JavaScript Call Stack - What It Is and Why It’s Necessary
Using microtasks in JavaScript with queueMicrotask()
In depth: Microtasks and the JavaScript runtime environment
Tasks, microtasks, queues and schedules
When will requestAnimationFrame be executed
Understanding Event Loop
Is there a faster way to yield to Javascript event loop than setTimeout(0)
Concurrency model and the event loop
Event loop: microtasks and macrotasks