技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

【THE LAST TIME】彻底吃透 JavaScript 执行机制

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/67-【THE LAST TIME】彻底吃透 JavaScript 执行机制
本文目录10 个章节
  1. 一、执行和运行不是同一个概念
  2. 二、JavaScript 是单线程吗
  3. 三、栈、堆与队列:规范概念和实现概念
  4. 四、浏览器中的 Event Loop
  5. 五、Promise、async/await 与微任务
  6. 六、Node.js 的 Event Loop
  7. 七、浏览器和 Node.js 不要混为一谈
  8. 八、综合练习:Node.js 中 nextTick、Promise 和 timer
  9. 九、学习事件循环的正确方法
  10. 参考资料

【THE LAST TIME】彻底吃透 JavaScript 执行机制

The last time, I have learned

【THE LAST TIME】一直是我想写的一个系列,旨在厚积薄发,重温前端,也是给自己的查缺补漏和技术分享。本文保留原文关于浏览器、Node.js、定时器和 Promise 的学习路线,并将早期文章中的“Event Table”“宏任务队列优先”等简化说法更新为现代规范术语。原文配图来自 Juejin/ByteDance CDN,已下载到同目录 images 文件夹;图中文字属于当时的历史示意,具体结论以本文文字和官方资料为准。

本文讨论的是 JavaScript 代码与宿主环境的协作。浏览器和 Node.js 都运行 JavaScript,但它们的事件循环、定时器、I/O 和微任务细节并不完全相同。

原文配图:JavaScript 执行机制系列

一、执行和运行不是同一个概念

JavaScript 的核心语义由 ECMAScript 规范定义,解析和执行需要宿主环境参与:

  • 浏览器提供 DOM、网络、定时器、用户输入、渲染和 Web Workers 等 Web API;
  • Node.js 提供文件系统、网络、进程、Worker Threads 等服务器端 API,并使用 libuv 协调许多异步操作;
  • 不同宿主可以有不同的事件循环和调度规则。

因此,“JavaScript 的执行机制”不能简单等同于某一张浏览器流程图,也不能把 Node.js 的 Event Loop 直接套到浏览器上。

二、JavaScript 是单线程吗

原文用“JavaScript 是单线程语言”帮助入门,但这句话需要限定:

  • 在一个 JavaScript agent 中,同一时刻只能执行一个 JavaScript job,代码具有 run-to-completion(运行至完成)特征;
  • 浏览器可以通过 Web Worker、Shared Worker、Service Worker 等创建其他 agent;
  • Node.js 可以使用 worker_threads 创建其他 JavaScript 线程,也可以使用进程、线程池和操作系统异步 I/O;
  • 不同 agent 之间不能直接共享普通 JavaScript 对象,需要 postMessage 的结构化克隆,或在特定条件下通过 SharedArrayBuffer 共享内存。

所以更准确的表述是:同一个 JavaScript agent 的代码执行通常是单线程的,但宿主环境可以并行完成 I/O、渲染或运行其他 agent。

原文配图:单线程与宿主环境

单线程只说明 JavaScript 回调之间不会被另一个 JavaScript 回调从中间抢占,不代表整个浏览器或 Node 进程只有一个线程,也不代表异步操作本身在主线程上阻塞执行。

三、栈、堆与队列:规范概念和实现概念

1. 调用栈

调用栈(call stack)是后进先出(LIFO)的执行上下文记录结构。调用函数时通常压入一个新的执行上下文,函数返回后弹出:

function first() {
  second()
}

function second() {
  console.log('second')
}

first()

执行 first() 时,second() 的上下文位于栈顶;second 返回后才回到 first。递归过深或同步任务过长可能造成栈溢出或页面无响应。

2. JavaScript heap

MDN 将 heap 描述为引擎管理对象的区域,但 ECMAScript 不规定某个 primitive、变量或对象必须位于操作系统栈或 GC 堆。寄存器分配、栈槽、指针压缩、逃逸分析和对象优化都可能改变物理实现。

因此不要把“primitive 一定在栈、对象一定在堆、变量保存裸指针”当成语言规则。应从可观察语义理解:对象有身份,赋值后可能共享同一个对象;闭包会让仍需访问的词法绑定在函数返回后继续存活。

3. 任务队列

任务队列通常以先进先出作为直觉,但 HTML 标准允许一个事件循环拥有多个 task queue,并不把所有任务实现为一个可以由脚本直接观察的总队列。微任务队列也不是 task queue,它有单独的 microtask checkpoint 规则。

原文配图:调用栈与任务队列

四、浏览器中的 Event Loop

1. 一次任务的基本过程

浏览器事件循环可以用下面的简化流程理解:

  1. 从某个 task queue 选择一个合适的最老 task;
  2. 执行该 task,直到 JavaScript 栈清空(run-to-completion);
  3. 执行 microtask checkpoint,清空当前 microtask 队列;微任务中新加入的微任务也会继续执行;
  4. 浏览器在合适的时机更新渲染,不保证每个 task 后都恰好渲染一次;
  5. 继续选择下一个 task。

脚本中的两条同步语句不是两个独立的宏任务:经典 <script> 的整段脚本通常作为一个 task 执行。

原文配图:浏览器事件循环

2. 同步代码、异步操作和回调

同步代码在当前 task 中立即执行;定时器、网络请求和用户事件等宿主操作可以在后台等待,完成后把后续回调安排为未来的 task 或 job。原文使用“Event Table”描述这个过程,这是早期教程常用的教学模型,不是 HTML 标准要求的真实组件名称。

console.log('同步任务')

fetch('/api/data')
  .then(response => response.json())
  .then(data => {
    console.log('请求完成', data)
  })
  .catch(error => {
    console.error('请求失败', error)
  })

console.log('当前脚本继续执行')

通常会先输出两条同步日志;网络完成后,Promise reaction 才会在相应的 microtask checkpoint 中执行。网络请求的等待由宿主负责,并不是 JavaScript 引擎一直占用调用栈等待。

3. Task 与 microtask

常见的浏览器 task 来源包括:

  • 经典脚本或模块脚本的执行;
  • setTimeoutsetInterval 等计时器回调;
  • 用户输入、部分网络事件和消息事件;
  • 宿主定义的其他任务。

常见的 microtask 来源包括:

  • Promise 的 reaction(.then.catch.finally);
  • queueMicrotask()
  • 浏览器中的 MutationObserver 回调。

Promise 对象本身不是“微任务”;注册在 Promise 上的 reaction callback 才会以微任务形式调度。Node.js 的 process.nextTick() 是 Node 专有的 nextTick 队列,不应直接列为浏览器 microtask。

console.log('script start')

setTimeout(() => console.log('task: timeout'), 0)
queueMicrotask(() => console.log('microtask: queueMicrotask'))
Promise.resolve().then(() => console.log('microtask: promise'))

console.log('script end')

常见输出为:

script start
script end
microtask: queueMicrotask
microtask: promise
task: timeout

微任务会在当前 task 结束后先于下一个普通 task 执行;如果微任务不断递归加入新的微任务,就可能阻塞渲染和用户输入,形成 microtask starvation。

4. setTimeout 的延迟是阈值,不是承诺

const startTime = performance.now()

setTimeout(() => {
  console.log(`回调延迟:${performance.now() - startTime}ms`)
}, 1000)

for (let index = 0; index < 40_000; index += 1) {
  // 大量同步工作会阻塞当前 task
}

setTimeout(fn, 1000) 表示回调不会早于计时器允许的最小阈值(还受规范和嵌套限制影响),不表示恰好 1000ms 执行。只要当前 task 没有结束,计时器回调就不能抢占它;浏览器调度、后台页面限流和其他任务也可能增加延迟。

原文配图:长任务延迟定时器

原文配图:零延迟定时器仍需等待

原文配图:任务队列中的定时器回调

5. setInterval 不是精确节拍器

setInterval(fn, delay) 会按宿主的定时器规则尝试安排多次回调,但回调仍然要等待事件循环,并且不会与同一 JavaScript agent 中正在执行的代码并行。回调耗时超过间隔时,实际执行时间会发生延迟,不能用它作为精确的时钟或动画驱动器。

动画优先使用 requestAnimationFrame;定时轮询则要提供取消机制:

const timerId = setInterval(() => {
  console.log('poll')
}, 1000)

// 不再需要时
clearInterval(timerId)

五、Promise、async/await 与微任务

下面是原文的基础题:

console.log('script start')

setTimeout(() => {
  console.log('setTimeout')
}, 0)

Promise.resolve()
  .then(() => console.log('promise1'))
  .then(() => console.log('promise2'))

console.log('script end')

输出顺序是:

script start
script end
promise1
promise2
setTimeout

第一个 then reaction 在脚本 task 结束后执行;它完成后,第二个 then reaction 才被安排到 microtask 队列,因此两个 Promise 回调也会在同一次 microtask checkpoint 中依次执行。

async 函数调用会立即执行到第一个 await,并返回 Promise;await 后的恢复代码不会同步执行:

async function readValue() {
  const value = await Promise.resolve(42)
  console.log(value)
}

console.log('before')
readValue()
console.log('after')

输出是:

before
after
42

async/await 是 Promise 的语法糖”可以作为入门比喻,但现代规范语义由 Await、Promise reaction jobs 和异步函数定义共同决定;不要依据某个旧版 V8 曾经使用的“固定几个 tick”来推断今天所有引擎的时序。

六、Node.js 的 Event Loop

Node.js 使用 libuv 协调事件循环和许多异步 I/O。常见阶段可以概括为:

┌─────────────┐
│   timers    │  setTimeout / setInterval 到期回调
└──────┬──────┘
       ↓
┌─────────────┐
│ pending     │  部分延后的系统 I/O 回调
│ callbacks   │
└──────┬──────┘
       ↓
┌─────────────┐
│ idle,prepare│  libuv 内部使用
└──────┬──────┘
       ↓
┌─────────────┐
│    poll     │  I/O 回调和等待 I/O
└──────┬──────┘
       ↓
┌─────────────┐
│    check    │  setImmediate 回调
└──────┬──────┘
       ↓
┌─────────────┐
│ close       │  close 事件回调
│ callbacks   │
└─────────────┘

原文的六阶段图仍然有教学价值,但 Node 的实现和 libuv 版本会变化。特别是从 Node.js 20 使用的 libuv 1.45.0 起,timers 在每轮中主要位于 poll 之后运行;旧版和新版在边界时序上可能不同。不要把这张图当成所有版本、所有平台的精确指令序列。

timers 阶段与阈值

const fs = require('node:fs')
const scheduledAt = Date.now()

setTimeout(() => {
  console.log(`${Date.now() - scheduledAt}ms have passed`)
}, 100)

fs.readFile(__filename, () => {
  const start = Date.now()
  while (Date.now() - start < 10) {
    // 模拟一个耗时约 10ms 的回调
  }
})

100ms 是 timer threshold,不是精确执行时刻。I/O 回调、操作系统调度和其他 JavaScript 工作都可能让它晚于阈值执行;Node 官方示例在特定假设下可能观察到约 105ms,但实际机器不应追求这个固定数字。

poll 与 check

poll 负责处理大多数 I/O 回调,并在没有可执行任务时等待 I/O;当 poll 阶段空闲且已经安排了 setImmediate 时,会进入 check 阶段执行它。

const fs = require('node:fs')

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0)
  setImmediate(() => console.log('immediate'))
})

在 I/O 回调中安排这两个函数时,通常先输出 immediate,再输出 timeout,因为 setImmediate 进入当前轮次的 check 阶段;但不要把从主模块直接调用二者时的顺序写死:

setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))

从主模块执行时,顺序可能是 timeout -> immediate,也可能是 immediate -> timeout,取决于进程调度和具体时机。

process.nextTick()queueMicrotask()

process.nextTick() 不属于 libuv 的六个阶段。当前操作完成、JavaScript 调用栈展开后,Node 会优先处理 nextTick 队列,再继续事件循环;递归安排 nextTick 会阻塞 I/O。当前 Node 文档已将 process.nextTick() 标为 Legacy,并建议多数可移植的微任务场景使用 queueMicrotask();它仍可用于需要 Node 特有调度顺序的旧 API 兼容场景:

let count = 0

function schedule() {
  if (count++ < 3) {
    process.nextTick(schedule)
  }
}

schedule()
console.log('同步代码先执行')

Node 中通常可以观察到:process.nextTick 先于 Promise/queueMicrotask reaction;浏览器没有这个 API。新代码若只是需要一个跨环境的微任务,优先考虑 queueMicrotask();需要 Node 特有的“在当前操作之后、事件循环继续前”语义时再使用 process.nextTick(),并避免无界递归。

process.nextTick(() => console.log('nextTick'))
queueMicrotask(() => console.log('queueMicrotask'))
Promise.resolve().then(() => console.log('promise'))

Node 版本和嵌套位置会影响观察到的边界,但 nextTick 与 Promise microtask 是两种不同的队列,不能简单称为同一个微任务。

原文配图:Node.js 事件循环阶段

原文配图:Node timers 示例

原文配图:poll 与 check 阶段

七、浏览器和 Node.js 不要混为一谈

可以记住下面的边界:

内容浏览器Node.js
事件循环规范HTML Standard 与宿主集成libuv、Node 调度和 ECMAScript Promise jobs
定时器setTimeoutsetInterval同名 API,由 Node/libuv 调度
setImmediate非标准浏览器通用 API,不要依赖check 阶段 API
process.nextTick不存在独立 nextTick 队列,优先级高于常见 Promise microtask
渲染事件循环可能在合适时机更新渲染Node 没有浏览器页面渲染阶段
WorkerWeb Worker 拥有独立 agentworker_threads 拥有独立 JavaScript 线程

“浏览器有 GUI 线程、定时器线程、网络线程”可以帮助理解宿主可能并行工作,但线程划分是浏览器实现细节,不应当作为 HTML 标准或所有浏览器都相同的保证。

八、综合练习:Node.js 中 nextTick、Promise 和 timer

下面按 Node.js CommonJS 主模块运行;如果改成 ESM,顶层模块评估本身处于异步作业中,初始 nextTick 与 Promise reaction 的相对顺序可能不同。

console.log('1')

setTimeout(() => {
  console.log('2')
  process.nextTick(() => console.log('3'))
  new Promise(resolve => {
    console.log('4')
    resolve()
  }).then(() => console.log('5'))
}, 0)

process.nextTick(() => console.log('6'))

new Promise(resolve => {
  console.log('7')
  resolve()
}).then(() => console.log('8'))

setTimeout(() => {
  console.log('9')
  process.nextTick(() => console.log('10'))
  new Promise(resolve => {
    console.log('11')
    resolve()
  }).then(() => console.log('12'))
}, 0)

在当前 Node.js 中通常得到:

1
7
6
8
2
4
3
5
9
11
10
12

解释要点:

  1. 1、Promise 构造器中的 7 属于当前脚本同步执行;
  2. 当前操作结束后,nextTick 中的 6 先于 Promise reaction 8
  3. 第一个 timer 执行时同步打印 24,然后该回调产生的 nextTick 3 先于 Promise reaction 5
  4. 第二个 timer 同理产生 9111012

如果把这段代码放到浏览器,process.nextTick 本身不可用;如果使用不同 Node.js 大版本,timer 阶段边界仍可能有实现差异,因此答题时必须先说清运行环境。

原文配图:Node 与浏览器事件循环差异

原文配图:Node nextTick 队列

原文配图:综合题执行顺序

九、学习事件循环的正确方法

  • 先判断代码运行在浏览器、Node.js 主线程、Worker 还是其他宿主;
  • 把整个脚本看成当前 task,而不是把每一条语句都叫作一个宏任务;
  • 区分 task、microtask、Node nextTick queue 和宿主渲染机会;
  • 记住 run-to-completion:一个回调执行期间,同一 agent 的其他 JavaScript 回调不能插队;
  • 先写出同步日志,再按注册顺序排 Promise reaction、定时器、I/O 和事件回调;
  • 不把延迟参数当成精确时间,也不把旧版 Node/V8 的一次输出当成所有版本的规范;
  • 用真实目标环境运行最小示例验证,而不是只背流程图。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS