跳转至

01 运行时与事件循环:同一段代码的四种答案

这一章讲什么

六行代码,五个「待会儿再执行」的小任务,拼出一个 Hello。同一份代码不改一个字, 在 Node.js 里打 Hello,把文件扩展名从 .js 换成 .mjs 变成 elHo\nl, 拿到 Deno 里变成 elHlo,拿到 Bun 里变成 Helo\nl——四种结果,全是"正确"的1。

这一章是全书的地基,它做两件事:一是用上面这个谜题逼出一个总框架—— JavaScript 的任何行为都要分语言规范、引擎实现、宿主运行时三层归因; 二是把「事件循环」和「任务队列」这两个天天听到、却几乎没人讲清归属的机制拆开。 后面所有章节(字符串、数字、对象、错误)都会重复同一个动作: 怪现象 → 这是哪一层的选择 → 挖到实现。

顶层全景:一台引擎,无数个老板

先给一张总图,后面每一节都在填它的细节:

  你的代码
     │
     ▼
  ┌─────────────┐   解析、编译、执行;只管语言本身
  │  JavaScript 引擎 │   (V8、SpiderMonkey、JavaScriptCore)
  └──────┬───────┘
         │ 被嵌在里面、被它使唤
         ▼
  ┌─────────────┐   管网络、文件、定时器;决定"什么时候跑哪段代码"
  │   宿主运行时    │   (Node.js、浏览器、Deno、Bun、Cloudflare Workers)
  └─────────────┘

  图说:引擎负责"怎么执行",宿主负责"何时执行、能用什么"。语言规范管着引擎。

两个容易混的词,先就地分清。

引擎(JavaScript engine)是把你的源码变成可执行指令、并运行它的那坨软件: 解析(把源码拆成引擎认识的结构)、编译、解释、执行,全归它2。Google 的 V8 是最大的一个, Node.js、Deno、Chrome 都把它嵌在自己体内;Firefox 用自己的 SpiderMonkey, Safari 用自己的 JavaScriptCore3。

宿主运行时(host runtime)是把引擎嵌进去、使唤它的应用程序: Node.js、浏览器、Deno、Bun 都是4。它负责所有「跟外面世界打交道」的事—— 网络、文件系统、定时器;而其中最关键的一项职权是:调度—— 决定哪段 JavaScript 在什么时候、以什么顺序执行5。

记住这个分工:引擎管「怎么执行」,宿主管「何时执行、能用什 么」。 你写的每个 setTimeout、每个按钮点击回调(你留给宿主、让它回头调用的函数),都是宿主在调度,不是语言。

核心原理

3.1 单线程:一次只能干一件事,而且不干完不撒手

JavaScript 的执行有个铁律:同一时刻只能跑一段代码,而且这段代码必须一口气跑完, 中间不能被打断6。书里用了一个快递员的比方:快递小哥推着一整车包裹挨家送, 但每个包裹都要站在门口看你把它完全拆开,才肯去送下一个7。 这个「看拆包裹」就是 JavaScript 在运行你的函数——只要函数没返回, 谁来敲门都听不见。

这个比方马上要用,但先把它的机制名字给了:宿主运行时用一个叫事件循环 (event loop)的内部循环来排班。所谓事件循环,就是一个不停打转的循环, 转一圈就检查一遍「有没有事发生」:定时器到点了没有?网络数据到了没有? 用户点按钮了没有?有,就把对应的代码取出来执行;执行期间,整个系统都在等它8。

「为什么一次只派一件事?」——因为语言把并发(concurrency,同一时段处理多件事) 的责任整个推给了宿主:宿主可以开多个线程、每个线程各转一个事件循环, 但单条线程上的 JavaScript 永远是一件一件来9。你感受到的「同时下载十个文件」, 其实是宿主把十个任务排队,挨个送到同一条流水线上。

3.2 主线例子:Hello 谜题的全程走查

现在把第 1 章那个谜题从头走一遍。代码是这样的(缩写到本质):

function print(a) { process.stdout.write(a); }
queueMicrotask(() => print('e'));     // 排进「微任务队列」
process.nextTick(() => print('H'));   // 排进「nextTick 队列」
setTimeout(() => print('l'), 0);      // 排进「定时器」
Promise.resolve().then(() => print('l')); // 也排进「微任务队列」
setImmediate(() => print('o\n'));     // 排进「immediates 队列」

五行代码,五行之间没有任何依赖,纯按书写的先后,输出应该是 elHlol? 实际在 Node.js(按 CommonJS 方式运行)里,输出是 Hello10。 我们把每一步的队列状态写出来——这就是本章每个机制在其中占的一步。

第 0 步:登记。 代码从上往下执行,五行语句做的事全部一样: 把一个「待会儿执行的函数」登记进某个队列。此刻各队列状态:

队列 里面排了什么
nextTick 队列 print('H')
微任务(microtask)队列 print('e')、then 里那个 print('l')
定时器 0 毫秒后触发 print('l')
immediates 队列 print('o\n')

第 1 步:脚本跑完,先清 nextTick。 主脚本返回,Node.js 做的第一件事不是开事件循环, 而是把 nextTick 队列整个清空:H 打出。输出:H。

第 2 步:再清微任务队列。 nextTick 清空后,Node.js 接着清空微任务队列, 按登记的先后顺序:e、然后 l。输出:Hel。

第 3 步:事件循环转第一圈,处理定时器。 现在才轮到事件循环登场。 它转一圈有好几个阶段,第一个阶段查定时器:0 毫秒的计时早已到期,print('l') 执行。 输出:Hell。

第 4 步:同一圈稍后,处理 immediates。 事件循环继续往下走,有一个阶段专门处理 immediates 队列,print('o\n') 执行,顺带换行。输出:Hello\n。

第 5 步:无事可做,进程退出。 四个队列全空,事件循环不再转圈,进程结束11。

走查里藏着一个反直觉的事实:登记的顺序(e H l l o)和执行的顺序(H e l l o) 毫无关系——执行顺序完全由「哪个队列先被清空」决定,而清空的优先级是 Node.js 私定的: nextTick 先于微任务,微任务先于事件循环本身,定时器又先于 immediates12。 背代码顺序没用,背队列优先级才有用。

3.3 队列不只有一个,而且大多数不在语言手里

上面出现了四种队列。它们各自的「管辖权」是本章最重要的知识点:

队列 谁定义的 谁决定何时清空
微任务(microtask)队列 语言规范定义,Promise 就靠它 宿主决定清空的时机13
nextTick 队列 纯 Node.js 私有,规范里根本没有 Node.js14
immediates 队列(setImmediate) 纯 Node.js 私有,规范里根本没有 Node.js
定时器(setTimeout) Web 平台 API,不是语言 各宿主自己的实现

作者把话说得很硬:process.nextTick() 和它的队列,你在 ECMAScript 语言规范里、 在 V8 的源码里都找不到任何一个字;Deno 和 Bun 为了兼容各自模仿了一个, 但因为没有任何规范描述它该怎么工作,各家模仿得都不一样15。 这就是「同一谜题四种答案」的第一层原因:谜题里至少有两行代码, 在语言层面是没有标准答案的。

顺带纠正一个流行误解:很多资料说「微任务队列挂在事件循环上」, 作者明确说不对——微任务队列和事件循环根本不直接相连, 每个宿主想什么时候清它就什么时候清,转一圈清几次都行16。

3.4 Node 的事件循环:一圈分几个阶段

Node.js 的事件循环比浏览器的复杂,它转一圈要依次经过好几个阶段, 每个阶段干一类活17:

   ┌─→ 定时器阶段:到期的 setTimeout/setInterval 在这里触发
   │      │
   │      ▼
   │   待处理回调阶段:系统级回调(如 TCP 错误)
   │      │
   │      ▼
   │   轮询(poll)阶段:问操作系统有没有网络数据、文件读完了没 ← 主要停留在这里
   │      │
   │      ▼
   │   检查阶段:setImmediate 的 immediates 队列在这里清空
   │      │
   │      ▼
   └─  收尾;还有事就回到顶部再来一圈,没事就退出

  图说:一圈之内,定时器先于 immediates——这正是谜题里第二个 l 先于 o 的原因。

两个细节值得记住。第一,轮询阶段是 Node 的主战场:Node 是服务端运行时, 它大部分时间花在「问操作系统有没有新连接、新数据」上18; 浏览器的事件循环则围着用户界面转——保证滚动和点击不被卡住是它的第一要务19。 两个运行时的循环为完全不同的目标优化,这是「何时执行」差异的深层来源。 第二,Node 的循环会跑完即退:没有任何待处理事件时,进程直接退出; 浏览器的循环永远假设「还有事」,标签页开着它就转着20。 这就是为什么你的 Node 脚本执行完最后一行就退出,而网页永远活着。

3.5 那四种结果,各自是怎么来的

现在回到开头。同一段代码,四种输出,逐个归因:

运行方式 输出 原因(按三层归因)
Node.js + .js(CommonJS) Hello Node 的默认顺序:nextTick → 微任务 → 定时器 → immediates
Node.js + .mjs(ESM) elHo\nl 同为 Node,但 ESM 模块的启动路径不同,队列清空顺序跟着变21
Deno elHlo Deno 没有 nextTick,H 落进微任务;immediates 的处理时机也不同22
Bun Helo\nl Bun 用的是另一颗引擎(JavaScriptCore),调度实现又是一种

注意第二行的荒唐之处:同一个 Node.js,只是文件扩展名不同, 队列的排空顺序就变了,H 和 e 的先后整个颠倒。作者的解释是: ESM 是后来才加进 Node 的,加的时候才发现它和 CommonJS 的启动顺序不同, 这个差异连 Node 自己都没预先设计过23。

把这四种结果摆在一起,作者下了一个全书反复引用的判断: 这些执行顺序的差异,「大多是意外副作用,而不是有意设计」24。 Deno 一开始根本没打算兼容 Node,后来被迫倒着补齐;Bun 换了引擎, 调度从头另写。没有谁在规范里写下「H 必须先于 e」——因为规范管不到这里。

3.6 三层归因法:全书的工作框架

到这里可以把第 1 章的工具箱收齐了。谈任何 JavaScript 行为,先问三个问题:

  ① 语言定义的?  → 所有运行时必须一模一样(promise 的行为)
  ② 引擎定义的?  → 同一引擎的运行时大体一样(V8 怎么优化你的代码)
  ③ 宿主定义的?  → 每个运行时可以随便不一样(队列何时清空、有哪些 API)

语言规范(ECMAScript)定义的部分,所有运行时都必须一致——比如 promise 的可观察行为, 无论在哪个引擎哪个宿主里都相同25。宿主定义的部分,每个运行时可以完全不同—— 比如 process.nextTick 的存在与否、清空时机26。 至于「JavaScript」和「ECMAScript」这两个名字:同一门语言, 但「JavaScript」这个商标 2009 年起归 Oracle 所有,标准组织只能用自己的名字 ECMAScript 称呼它——这就是规范叫 ECMA-262、语言 officially 叫 ECMAScript 的来历27。

这套归因法不是学术洁癖,是有实际收益的:谜题教你的是—— 凡是宿主层的顺序,都不要依赖。 作者的原话:无论用哪个运行时, 都应避免依赖异步事件的精确时序和顺序;他也承认,这条规矩现实中常常守不住28。

3.7 「调度」到底指什么:一个按钮的全程

「调度」这个词本章反复出现,值得用一个最普通的例子钉死。 把下面这行 HTML 装进浏览器:

<button onclick="alert('hi')">hello</button>

页面解析渲染时,浏览器发现这个按钮带一个 onclick 属性, 里面是一句小巧的 JavaScript。这句代码不会立刻执行—— onclick 告诉浏览器的是:「等用户点了这个按钮,而且只在这个时候, 把这句代码排进执行计划」。点击发生后,它也只是被「安排在极短的时间之后执行」 (短到看起来像立刻);真正驱动这个安排的,就是前面说的那个事件循环35。

把「调度」拆成三个动作,全书后面所有章节都会用到这套词汇:

  1. 登记:外部事件发生时(点击、定时器到点、网络数据到达), 宿主把「该运行哪段代码」放进某个队列;
  2. 转圈:事件循环一圈一圈地检查各队列,取出到期的任务;
  3. 执行:取出的一段代码一口气跑完,跑完才轮到下一个。

3.8 为什么每个运行时的调度各不相同:设计目标不同

谜题里那些差异不是同行之间相互使坏,而是各运行时的设计目标本来不同, 书里把两个代表对立讲得很清楚36:

浏览器的事件循环 Node.js 的事件循环
围着什么转 用户界面:滚动、点击、输入 操作系统:网络连接、文件读写、定时器
头等大事 界面永远流畅可响应,所以按固定节奏转圈 尽快收发事件,收完一件马上去看下一件
生命期 标签页开着就永远转,永远假设「还有事」 没事可做就退出,进程结束

这两个循环的取舍完全相反:浏览器宁可多转几圈空转,也不能让用户感觉「卡了」; Node 宁可把每一圈塞满,也不为「保持活泼」浪费一次轮询。 谜题里那四个字母的先后顺序,就是这两套取舍(外加 Deno、Bun 各自的第三套) 漏到表面的样子37。

再往历史深处看一层,Deno 的差异是「先选了另一条路,再回头补」的典型: 它最初完全没打算兼容 Node.js,一心只做浏览器兼容;直到用户强烈要求, 才开始补 Node 兼容层——所以它的 nextTick、setImmediate 行为 从来就没打算和 Node 一致,是后来被迫倒着逼近的38。 而 Node 自己也干净不到哪去:CommonJS 和 ESM 两种模块方式的执行顺序差异, 是在 ESM 加进来之后才被发现、从来没人设计过的39。 作者对此的总结毫不客气:这些执行顺序差异,大多是意外副作用,而不是有意选择40。

还有一个经常被忽略的运行时:Cloudflare Workers。它同样嵌着 V8, 但事件循环为「边缘的短生命周期请求」而设计;同一段谜题代码跑进去, 又是另一份答案41。所以「V8 相同 ⇒ 行为相同」是错的: 引擎相同只保证「怎么执行」相同,「何时执行」永远归宿主。

3.9 一张引擎—宿主对照表

把第 1 章的表 1.2 换成我们的说法,以后在别处看到「这个运行时用的什么引擎」 就不会懵42:

宿主运行时 引擎 说明
Node.js V8(Google) 服务端老大哥,全书的基准环境
Deno V8(Google) 与 Node 同引擎,所以「怎么执行」几乎一致
Cloudflare Workers V8(Google) 同引擎,事件循环为边缘请求设计
Chrome / Edge V8(Google) 浏览器阵营
Firefox SpiderMonkey(Mozilla) 换引擎 ⇒ 实现层行为可能变
Safari JavaScriptCore(Apple) WebKit 自带
Bun JavaScriptCore(Apple) 服务端新秀,但引擎是 JSC 不是 V8

这张表解释了谜题结果的另一层:Bun 的答案与众不同, 首先是因为它换了一颗引擎——调度实现整个重写, 连 process.nextTick 都是照着行为模仿的,像不像全看运气43。

3.10 这本书自己的定位:车主手册

第 1 章里有一段定调的话值得转述。作者说,我们对工具的要求一向是 「别让我想它怎么工作」:开车去买菜,没人愿意每次拧钥匙前先复习一遍 电路、油路、变速箱44。但一旦车打不着火,你需要的就不是驾驶教材, 而是车主手册——那本逐件告诉你「每个零件是干什么的、为什么这样造」的书。 这本书给自己的定位就是 JavaScript 和它的运行时的车主手册45。

这一定位解释了全书的写法:不教你「怎么开得更快」,而教你 「引擎盖下面每一层为什么在这样做」。它也解释了基准环境的选择: 全书示例以 Node.js 24.0.0 以上为准,需要对照时再拉到 Deno、 Cloudflare Workers、Chrome/Edge 里跑;所有示例代码开源在 GitHub (jasnell/how-javascript-things-work),包括作者拿去考客户的 「更复杂版本」谜题46。

3.11 ECMAScript 与 JavaScript:一个名字的两条来路

这两个名字在本书里混用,值得把来路说清,免得在别处读到时犯嘀咕: 它们指同一门语言,但「JavaScript」是 Oracle 的注册商标 (2009 年随收购 Sun 而来),标准组织 Ecma International 给规范起名时 只能用自己的名字,于是规范叫 ECMA-262,语言的正式名字叫 ECMAScript47。 本书行文中两者随便换,你读到时心里等价替换即可。

三层归因法的三条判据,原书写成了三条「预期」,可以背下来:

  • 语言定义的东西 → 每个运行时的实现都应该一样(例:promise 的可观察行为);
  • 引擎定义的东西 → 用同一引擎的运行时大体一样(例:V8 的优化策略);
  • 宿主定义的东西 → 每个运行时可以完全不一样(例:nextTick 队列何时清空)48。

谜题里每一行代码,都能在这三条里找到自己的位置: Promise.resolve().then(...) 落在第一条(所以它到处都先清), process.nextTick(...) 落在第三条(所以 Deno、Bun 各有各的答案)49。

3.12 谜题难在哪:考的不是语法,是管辖权

这个谜题作者在客户团队里用过很多次,连资深工程师也常常栽—— 原因书里点得很透:它逼你停下来,掂量自己到底懂不懂天天在用的这门语言50。 难不在于任何一行代码的语法,而在于你必须同时知道三件事:

  1. process.nextTick()、queueMicrotask()、promise 各自单独怎么工作;
  2. 它们彼此之间怎么互动(谁先谁后);
  3. 以上两条在哪个运行时的哪个版本上成立。

前两条好歹有文档,第三条才是真正的暗礁:应用行为依赖「具体运行时 + 它的具体版本」,甚至依赖「文件扩展名」这种看起来毫无关系的细节51。 作者给团队出的原版谜题比这里的大得多,完整版本连同答案都放在 GitHub 仓库里,感兴趣的可以拿去考同事52。

3.13 每个运行时不只有一个队列

第 1 章专门用一小节强调:事件循环不是运行时提供的唯一队列, 每个运行时可以有自己的一套,而且各不相同53。这个事实是理解所有 「换个环境结果变了」类 bug 的总钥匙。书里还提醒了一种典型死法:

你的代码盲目假设「A 队列的任务全部处理完之后,才会处理 B 队列的任务」—— 换一个运行时,顺序整个反过来54。

setTimeout(fn, 0) 和 setImmediate(fn) 到底谁先跑? 在 Node 里答案取决于当前处于事件循环的哪一圈——这也是社区著名的 「零毫秒定时器不一定比 setImmediate 早」困惑的来源,其根源就在于 定时器和 immediates 分属两个队列,由不同的阶段清空55。

队列之外还有个更大的前提:JavaScript 永远是作为对事件的反应而运行的。 没有「自己跑起来」的 JavaScript——总得有宿主说「现在轮到你了」: 要么是一行脚本被加载,要么是一个回调被触发56。记住这一句, 后面所有章节(尤其是错误的旅程)都建立在这上面。

3.14 第 1 章自己的总结(原书四条)

原书章末的四条总结,逐条对应本章的四个论证,照录其精神57:

  1. 同一段代码在 Node.js、Deno、Bun 里结果不同——因为运行时的实现选择不同;
  2. 事件循环控制执行顺序,每个运行时的调度优先级不同;
  3. 语言规范管「必须一致」的部分,实现和宿主管「可以变」的部分;
  4. 分清语言、实现、宿主三层,能防住意料之外的 bug。

这四条里第 3 条是全书的承重墙:后面每一章的展开方式, 都是先问「这属于哪一层」,再决定「该去哪一层找答案」。

3.15 换个扩展名就换答案:CommonJS 与 ESM 的分岔

谜题最诡异的一行结果,值得单独一节。Node.js 里 .js 文件默认按 CommonJS 方式执行——这是 Node 用了十几年的老模块方式; 把文件复制成 .mjs(或配置 "type": "module"),同一份代码就改走 ESM(ECMAScript Modules,语言标准化的模块方式)58。

复制、改名、运行,三步之后59:

  $ cp listing1.1.js listing1.1.mjs
  $ node listing1.1.mjs
  elHo
  l

H 和 e 的先后整个颠倒,还多出一行60。作者特意声明「下面的结果不是笔误」, 并要求读者接受一个事实:代码一字未改,只是被当成另一种模块来加载, 队列的排空顺序乃至事件循环的运行方式就都变了61。 为什么会这样,原书只说「模块系统的章节再讲」——而那个章节在 MEAP 里 还不存在;目前能确定的是:两种模块方式的启动路径不同,初始化时 排进各队列的「第一批任务」也不同,而第一批任务会改变后续所有相对顺序62。

这一节的普适教训不在模块,而在归因:连「文件扩展名」这种和逻辑 八竿子打不着的因素,都会通过「宿主如何加载你的代码」这条暗线 影响执行顺序。「宿主定义的」这一层,渗透得比你以为的深。

3.16 把事件循环剥到最里层:它只是一个循环

剥掉所有术语,事件循环的实现朴素得惊人:一个 do-while 循环—— 「进来以后,依次走完每个阶段;最后一个阶段收尾时检查还有没有活, 有就回到顶部再来一圈,没有就退出」63。把它想成一个「更花哨的 for 循环」是准确的;花哨之处在于每个阶段里干的事,而不在于循环本身64。

这也解释了第 1 章一条容易被略过的附注:每个运行时的事件循环 实现都不同——浏览器的围绕 UI 交互,Node 的围绕操作系统事件; 这条附注在书里是以「注意」框的形式出现的,因为作者认定它是 全章最容易被读漏的一句65。

回到 3.2 节的走查,把每一步的队列状态并排成一张总表,谜题就变成了查表题:

步骤 动作 nextTick 队列 微任务队列 定时器 immediates 已输出
0 五行语句登记完毕 H e、l l(0ms) o —
1 清空 nextTick 空 e、l l o H
2 清空微任务 空 空 l o Hel
3 事件循环:定时器阶段 空 空 空 o Hell
4 事件循环:检查阶段 空 空 空 空 Hello
5 全部队列空,进程退出 — — — — —

(队列里的字母代表对应的回调。这张表是 3.2 节走查的压缩版, 两步之间的相对顺序只看「哪一列先被清空」。)

第 1 章还有一句看似题外、实则点题的话:这本书要讲的所有怪异, 几乎都是「语言及其实现把复杂度——机制层面的大量细节——藏得太好」的结果——你可能从头到尾 不知道运行时在偷偷摆弄多个任务队列、不知道你的数字不是真数字、 不知道 promise 的用法正在让代码慢上几个数量级,而一切看起来都正常, 直到它不正常——或者在一个环境里完全正确、在下一个环境里惨败66。 第一章的任务就是让你从此对这些「藏起来的复杂度」有知觉。

3.17 Deno 实录:连报错都带着兼容线索

把谜题原样丢给 Deno,会先看到半个答案再挨一记报错:elH 打出来了, 然后 setImmediate is not defined67。这个报错本身信息量很大—— Deno 的报错信息写得像个老熟人:「setImmediate 在 Deno 全局作用域不可用。 从 node:timers 显式导入它,或者加 --unstable-node-globals 标志」68。

两个细节值得记住。第一,process.nextTick() 和 setImmediate() 是「没有标准定义」的 Node 私货,Deno 肯模仿前者的行为、 却让你自己开口要后者——兼容到什么程度,每个运行时自己拿捏69。 第二,按报错给出的建议加上 import { setImmediate } from 'node:timers' 之后, 代码终于能跑完,输出 elHlo——既不等于 Node 的任何一种, 也不等于 Bun 的 Helo 加换行加 l70。 作者在书中对 Deno 这版的评价是:「没报错,结果出来了——可惜, 跟前面两个都不一样。」71 四个结果摆在一起, 「调度由宿主私定」就从抽象原则变成了摆在桌面上的四张打印小票。

如果把这一章浓缩成一句可以贴在显示器边的话,那就是: 执行顺序不是你代码的属性,而是「代码 × 运行时 × 加载方式」三者合谋的产物。 写出依赖特定顺序的代码,等于把三个要素中的两个交给别人保管。 而「交给别人保管」在工程上的含义很具体:升级 Node 小版本、 把脚本挪进 Workers、给项目加 "type": "module"—— 任何一件平常小事,都可能让埋着的时序假设现形。排查这类 bug 时, 第一个该问的问题永远是:「这段顺序,是谁定的?」

作者的判断与证据

哪些是作者给出证据的判断,哪些是他的主张,分开标:

有证据的判断:

  • 六行代码四 种输出的每个环节都有可复现的命令行实录(node listing1.1.js → Hello, 复制成 .mjs 再跑 → elHo\nl),书中逐行给出29。这是可验证的实验证据。
  • 「nextTick 不在规范也不在 V8 里」是一个可证伪(可能被证据推翻)的查证主张:去规范全文搜不到即成立, 作者以自己的信用担保30。
  • Node 事件循环分阶段、轮询阶段对接操作系统,引自 Node.js 官方文档的事件循环图31。

作者的主张(证据较弱或属经验之谈):

  • 「这些差异大多是意外副作用」——这是作者的工程经验判断,不是哪个规范或实验的结论; 他给的支撑是 Deno/Node 各自的历史沿革,属于事后叙事32。
  • 「避免依赖异步时序」这条铁律,他自己承认「常常不可能遵守」, 等于承认这是一条知易行难的建议33。

判断(我们的,不是书里的): 这一章的三层框架(语言/引擎/宿主)是全书最可迁移的 工具,价值超过事件循环知识本身——它同样适用于分析其他「同标准多实现」的体系 (JVM 语言、数据库的各家方言、Web 标准各家浏览器)。 如果错,会错在: 规范里存在大量「实现定义」留白,有些差异的根子在规范故意不说 (如字符串的宽松定义),三分法偶尔会显得比实际更整齐——但「先分层、再定位」 的动作本身在任何情况下都不会错。

边界与局限

  • 谜题的时效性极强。 所有输出都是 Node 24 / 当时的 Deno / 当时的 Bun 的行为; 这些调度细节没有任何规范锚定,随时可能变。把这张表当「各运行时的性格快照」读, 别当永恒定律34。
  • 第 1 章承诺的「模块系统章节」在 MEAP 里还不存在——ESM 与 CommonJS 为什么 改变启动顺序,书里只说「以后再讲」,而那个「以后」目前没交付。
  • 第 1 章反复预告的「promise 一章」在 MEAP 里还不存在:微任务队列「与事件循环没有直接关系」的完整论证,原书许诺在那一章展开;本拆解在 12 章用「异步边界断裂」补上。
  • 多线程的 JavaScript(Worker)整个跳过了,只提了一句「实现一般把执行关联到线程上」。 现代 Node 的 worker_threads、浏览器的 Web Worker 都不在讨论范围。
  • 单线程铁律的「跑不完不撒手」有个例外没讲:如果一个函数永远跑不完 (死循环),事件循环就永远等不到下一圈——这是面试常考、书中没提的推论。

可带走的

  1. 谈任何 JavaScript 行为,先分三层:语言规范 / 引擎实现 / 宿主运行时; 说不清是哪层,就说不清这个问题。
  2. 引擎管「怎么执行」,宿主管「何时执行、能用什么」;setTimeout、事件回调全是调度。
  3. 单线程铁律:一段代码跑不完,后面全排队;所以别写长任务,别把事件循环堵死。
  4. 队列有四种、管辖权不同:microtask 是语言定义的,nextTick 和 immediates 是 Node 私有的。
  5. 执行的先后看队列优先级,不看代码书写顺序;优先级是 Node 私定的,别处不认。
  6. 凡是宿主层的调度顺序,都不要依赖;依赖了,就是在给跨运行时的 bug 留门。
  7. ESM 和 CommonJS 不只是语法区别,连事件循环的排空顺序都不同——迁移时这是个暗坑。
  8. Node 的事件循环会「无事即退」,浏览器的永远转着——脚本和网页的生命周期本质不同。
  9. .js 与 .mjs 不只是模块格式差别:连队列排空顺序都会变,迁移老代码时先跑一遍时序敏感的逻辑。
  10. 「这段代码在哪个运行时、以哪种方式加载」是执行行为的一部分,不 是环境细节。

原文地图

主题 原书章 原文位置
Hello 谜题与四种输出 1 When a hello isn’t hello text/04-ch01-1-when-a-hello-isn-t-hello.txt:47(搜「long used to test」) · :344(搜「four different results」)
生产事故背景(媒体公司) 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:59(搜「minutes instead of milliseconds」)
Node 执行顺序九步走查 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:127(搜「order of operations」)
快递员比方与单线程 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:175(搜「delivery driver」) · :613(搜「run one bit」)
.js vs .mjs 两种输出 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:207(搜「not a typo」)
Deno/Bun 差异与「意外副作用」 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:416(搜「did not initially start out」)
引擎与宿主的分工 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:498(搜「is the JavaScript Engine」) · :504(搜「embeds the JavaScript engine」)
浏览器 vs Node 的事件循环目标 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:626(搜「designed primarily around」)
Node 事件循环分阶段 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:640(搜「multiple phases」) · :646(搜「poll phase」)
microtask 队列与事件循环无关 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:664(搜「not directly related」)
nextTick 的管辖权 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:683(搜「immediates queue」) · :739(搜「anywhere in either the ECMAScript」)
三层归因法与 ECMAScript 名称 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:695(搜「distinct components」) · :708(搜「registered trademark」)
「车主手册」定位 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:474(搜「owner’s manual」)
「调度」的按钮实例 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:584(搜「suppose I load」) · :601(搜「not run right away」)
浏览器 vs Node 的循环目标 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:626(搜「designed primarily around」) · :634(搜「predictable and regular cycles」)
Deno/Node 的历史成因 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:416(搜「did not initially start out」)
引擎—宿主对照表 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:510(搜「Table 1.2」)
ECMAScript 名称与三条判据 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:708(搜「registered trademark」) · :728(搜「language-defined」)
.js 换 .mjs 结果翻转 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:207(搜「not a typo」) · :223(搜「how the event loop runs」)
Deno 报错与提示 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:264(搜「hint: Import it explicitly」) · :311(搜「elHlo」)
「复杂度藏得太好」总题 同上 text/04-ch01-1-when-a-hello-isn-t-hello.txt:35(搜「hide their complexity」) · :640(搜「do-while loop」)


  1. 出处:「1 When a hello isn’t hello」第 344 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:344,搜「four different results」)。原书表 1.1 的完整对照:Node.js/CommonJS 输出 Hello,Node.js/ESM 输出 elHo 加换行加 l,Deno 输出 elHlo,Bun 输出 Helo 加换行加 l。 ↩

  2. 出处:「1 When a hello isn’t hello」第 492 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:492,搜「dynamically evaluated language」)与第 498 段(搜「is the JavaScript Engine」)。注意一个反直觉点:JavaScript 也是要先编译成二进制指令才执行的,「脚本语言不用编译」是错觉。 ↩

  3. 出处:「1 When a hello isn’t hello」第 504 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:504,搜「Table 1.2」)。表中列出:Node.js/Deno/Chrome 用 V8,Firefox 用 SpiderMonkey,Safari 和 Bun 用 JavaScriptCore。补充(不在书里,来自通用知识):JavaScriptCore 常简称 JSC,是 WebKit 项目的引擎。 ↩

  4. 出处:「1 When a hello isn’t hello」第 236 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:236,搜「host runtimes」)。宿主运行时的定义就是「能编译并运行 JavaScript 代码的应用程序」。 ↩

  5. 出处:「1 When a hello isn’t hello」第 504 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:504,搜「scheduling when to run」)。 ↩

  6. 出处:「1 When a hello isn’t hello」第 613 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:613,搜「run one bit of JavaScript at a time」)。 ↩

  7. 出处:「1 When a hello isn’t hello」第 175 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:175,搜「delivery driver」)。 ↩

  8. 出处:「1 When a hello isn’t hello」第 181 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:181,搜「continuous cycle」)。 ↩

  9. 出处:「1 When a hello isn’t hello」第 613 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:613,搜「operating system threads」)。语言规范里根本没有「线程」这个词,是各实现在操作系统线程上把它当单线程来管。 ↩

  10. 出处:「1 When a hello isn’t hello」第 107 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:107,搜「nice friendly message」)与第 127 段(搜「order of operations」)。走查的九步顺序以原书第 127–163 段为准。 ↩

  11. 出处:「1 When a hello isn’t hello」第 127–163 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:151,搜「Drain the nextTick queue」;:154 搜「Drain the microtask queue」;:157 搜「Start the event loop」)。清队列的次序是:先 nextTick,再 microtask,再进事件循环处理定时器与 immediates。 ↩

  12. 出处:「1 When a hello isn’t hello」第 187 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:187,搜「delivers all nextTicks」)。原文:Node 先送完所有 nextTick 再送 microtask,浏览器则根本没有 nextTick。 ↩

  13. 出处:「1 When a hello isn’t hello」第 664 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:664,搜「microtask queue」)。语言定义了微任务队列供 promise 使用,但每个宿主被允许「在它认为合适的任何时候」清空它。 ↩

  14. 出处:「1 When a hello isn’t hello」第 683 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:683,搜「immediates queue」):「Node.js decides when these are processed, not the JavaScript language specification or engine implementation.」 ↩

  15. 出处:「1 When a hello isn’t hello」第 739 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:739,搜「anywhere in either the ECMAScript」)。 ↩

  16. 出处:「1 When a hello isn’t hello」第 664 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:664,搜「not directly related to one another」)。作者预告这个结论会在 promise 一章展开,而那个章节在 MEAP 里尚不存在——本拆解在 12 章用「异步边界断裂」补上它。 ↩

  17. 出处:「1 When a hello isn’t hello」第 640 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:640,搜「multiple phases」)与第 677 段(图 1.1 的说明,搜「phases of the Node.js Event Loop」)。 ↩

  18. 出处:「1 When a hello isn’t hello」第 646 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:646,搜「poll phase」)。 ↩

  19. 出处:「1 When a hello isn’t hello」第 626 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:626,搜「designed primarily around」)。 ↩

  20. 出处:「1 When a hello isn’t hello」第 646 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:646,搜「run for as long as the browser tab is open」)。 ↩

  21. 出处:「1 When a hello isn’t hello」第 207 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:207,搜「not a typo」)与第 223 段(搜「changes not only the order」)。原书明说:跑成 ECMAScript 模块不仅改变各队列的排空顺序,甚至改变事件循环本身的运行方式。 ↩

  22. 出处:「1 When a hello isn’t hello」第 250 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:250,搜「no actual standard definition」):process.nextTick() 和 setImmediate() 是「没有实际标准定义」的 Node.js API。 ↩

  23. 出处:「1 When a hello isn’t hello」第 416 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:416,搜「wasn’t even considered at all」)。 ↩

  24. 出处:「1 When a hello isn’t hello」第 416 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:416,搜「unintended side effect rather than a conscious choice」)。这是原话。 ↩

  25. 出处:「1 When a hello isn’t hello」第 745 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:745,搜「expected to be the same no matter」)。 ↩

  26. 出处:「1 When a hello isn’t hello」第 739 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:739,搜「entirely Node.js specific and host-defined」)。 ↩

  27. 出处:「1 When a hello isn’t hello」第 708 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:708,搜「registered trademark」)。补充(不在书里,来自通用知识):接手 Sun 的 Oracle 同时获得了「JavaScript」商标;标准化组织 Ecma International 的名字来自欧洲计算机制造商协会的历史缩写。 ↩

  28. 出处:「1 When a hello isn’t hello」第 422 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:422,搜「avoid depending on the precise timing」)。 ↩

  29. 出处:「1 When a hello isn’t hello」第 114 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:114,搜「listing1.1.js」)与第 214 段(搜「listing1.1.mjs」)。 ↩

  30. 出处:「1 When a hello isn’t hello」第 739 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:739,搜「V8’s source code」)。 ↩

  31. 出处:「1 When a hello isn’t hello」第 677 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:677,搜「nodejs.org/en/learn」)。 ↩

  32. 出处:「1 When a hello isn’t hello」第 416 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:416,搜「work backwards approximating」)。 ↩

  33. 出处:「1 When a hello isn’t hello」第 422 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:422,搜「not possible to follow」)。 ↩

  34. 出处:「1 When a hello isn’t hello」第 769 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:769,搜「24.0.0 or higher」)。所有行为都以 Node.js 24.0.0+ 为基线,其他运行时的版本在书中未逐一钉死——这正是「宿主行为无规范锚」的实证。 ↩

  35. 出处:「1 When a hello isn’t hello」第 601 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:601,搜「not run right away but is scheduled」)。「点击后才排进执行、且短到看似立即」的描述在同段。 ↩

  36. 出处:「1 When a hello isn’t hello」第 626 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:626,搜「designed primarily around」)。「Node 尽快收发事件」的设计目标见同段;「浏览器永远假设还有事」见第 646 段(搜「run for as long as the browser tab is open」)。 ↩

  37. 出处:「1 When a hello isn’t hello」第 634 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:634,搜「turning the event loop at predictable」):浏览器为保持界面流畅按固定节奏转圈,Node 为尽快分发事件优化——「正是这类差异导致了谜题中的行为差异」。 ↩

  38. 出处:「1 When a hello isn’t hello」第 416 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:416,搜「did not initially start out」):Deno 起初以浏览器兼容为目标,后来才补 Node 兼容,「从未打算匹配 Node 的时序行为,此后只能倒着逼近」。 ↩

  39. 出处:「1 When a hello isn’t hello」第 416 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:416,搜「wasn’t even considered at all」):ESM 加入之后才发现与 CommonJS 的执行顺序差异,并非预先设计。 ↩

  40. 出处:「1 When a hello isn’t hello」第 416 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:416,搜「unintended side effect rather than a conscious choice」)。原话如此。 ↩

  41. 出处:「1 When a hello isn’t hello」第 404 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:404,搜「Cloudflare’s Workers」):同一代码在浏览器、Cloudflare Workers 中还会出现更多结果差异。 ↩

  42. 出处:「1 When a hello isn’t hello」第 510–571 段表 1.2(text/04-ch01-1-when-a-hello-isn-t-hello.txt:525,搜「Node.js」)。「Bun 用 JavaScriptCore」一行见表内 Bun 行(搜「JavaScript Core (Apple)」)。 ↩

  43. 出处:「1 When a hello isn’t hello」第 739 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:739,搜「can and do vary in behavior」):其他运行时的 nextTick 是模仿品,行为「可以也确实」各不相同。 ↩

  44. 出处:「1 When a hello isn’t hello」第 462 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:462,搜「take my dog down to the park」):开车不想每次都复习电路、油路、变速箱。 ↩

  45. 出处:「1 When a hello isn’t hello」第 468 段与第 468 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:468,搜「owner’s manual」;:474 搜「owner’s manual for JavaScript」)。 ↩

  46. 出处:「1 When a hello isn’t hello」第 769 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:769,搜「how-javascript-things-work」):基线 Node.js 24.0.0+,示例含「给客户用过的更复杂版本」。 ↩

  47. 出处:「1 When a hello isn’t hello」第 702–714 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:708,搜「registered trademark」)与第 714 段(搜「Ecma International」)。 ↩

  48. 出处:「1 When a hello isn’t hello」第 728–735 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:728,搜「language-defined」)。三条判据依次列出,promise 与 nextTick 分别是第一、三条的实例(见第 739、745 段)。 ↩

  49. 出处:「1 When a hello isn’t hello」第 745 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:745,搜「expected to be the same no matter」)与第 739 段(搜「entirely Node.js specific」)。 ↩

  50. 出处:「1 When a hello isn’t hello」第 47 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:47,搜「long used to test」):「连资深 JavaScript 开发者也常常被它难住,只因为它迫使你停下来思考,自己天天用的语言到底懂多少。」 ↩

  51. 出处:「1 When a hello isn’t hello」第 95 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:95,搜「what version of that runtime」):应用行为依赖运行时、运行时版本,「甚至看似无关紧要的文件扩展名」。 ↩

  52. 出处:「1 When a hello isn’t hello」第 65 段与第 65 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:65,搜「simplified version」;:769 搜「more complex version I’ve used with customers」)。 ↩

  53. 出处:「1 When a hello isn’t hello」第 652–658 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:658,搜「not the only queue」):事件循环不是运行时唯一的队列,队列及其差异是「谜题结果不同的关键」。 ↩

  54. 出处:「1 When a hello isn’t hello」第 670 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:670,搜「blindly assume」):代码假设一个队列先于另一个处理,换个运行时发现「恰恰相反」。 ↩

  55. 出处:「1 When a hello isn’t hello」第 640–646 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:640,搜「divided into multiple phases」;:646 搜「timers phase」与「checks」):定时器与 immediates 由不同阶段处理。 ↩

  56. 出处:「1 When a hello isn’t hello」第 613 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:613,搜「always run as a reaction to an event」)。 ↩

  57. 出处:「1 When a hello isn’t hello」第 781–789 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:781,搜「runtime implementation choices」;四条的原文表述在 :781–:789)。 ↩

  58. 出处:「1 When a hello isn’t hello」第 101 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:101,搜「interpret this script as CommonJS」):Node 默认把脚本当 CommonJS,这是 ESM 出现之前就在用的模块方式。 ↩

  59. 出处:「1 When a hello isn’t hello」第 207–217 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:214,搜「listing1.1.mjs」)。 ↩

  60. 出处:「1 When a hello isn’t hello」第 214–217 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:216,搜「elHo」)。 ↩

  61. 出处:「1 When a hello isn’t hello」第 207 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:207,搜「not a typo」);「甚至改变事件循环本身的运行方式」见第 223 段(搜「how the event loop runs」)。 ↩

  62. 出处:「1 When a hello isn’t hello」第 223 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:223,搜「necessarily changes」):跑成 ESM「必然改变各队列的排空顺序,甚至事件循环的运行方式」;原因作者声明留待模块章节,见第 101 段与第 223 段。 ↩

  63. 出处:「1 When a hello isn’t hello」第 640 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:640,搜「do-while loop」):「本质上仍是一个 do-while 循环……走完最后阶段检查还有没有活,有就回到开头再来。」 ↩

  64. 出处:「1 When a hello isn’t hello」第 607 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:607,搜「fancy for-loop」):「把它想成队列最容易——它往往不过是个花哨的 for 循环或 do-while 循环。」 ↩

  65. 出处:「1 When a hello isn’t hello」第 620–626 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:620,搜「Note」):「每个运行时实现事件循环的方式都不同」以注意框形式给出,浏览器与 Node 的目标差异见框内(搜「interactions with a user interface」)。 ↩

  66. 出处:「1 When a hello isn’t hello」第 35 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:35,搜「hide their complexity almost too well」):「你可能搭完整个应用都不知道运行时在偷偷摆弄多个任务队列、数字不是真数字、promise 用法全错导致代码慢几个数量级……一切看起来都正常,直到它不正常。」 ↩

  67. 出处:「1 When a hello isn’t hello」第 257–265 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:258,搜「Uncaught (in promise) ReferenceError」)。 ↩

  68. 出处:「1 When a hello isn’t hello」第 263–265 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:264,搜「hint: Import it explicitly」):报错附带的 info 与 hint 原文。 ↩

  69. 出处:「1 When a hello isn’t hello」第 250 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:250,搜「no actual standard definition」);「Deno 在努力改进对 Node 的兼容」见同段(搜「improving compatibility」)。 ↩

  70. 出处:「1 When a hello isn’t hello」第 277–293 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:311,搜「elHlo」)与第 331 段(搜「Helo」)。 ↩

  71. 出处:「1 When a hello isn’t hello」第 317 段(text/04-ch01-1-when-a-hello-isn-t-hello.txt:317,搜「sort of」):「没有错误,也确实得到了结果——可惜跟前面两个都不一样。」 ↩