CHAPTER 03 · 语言

JavaScript 运行时心智模型

TypeScript 只是给 JavaScript 加了类型;程序怎么跑,完全由 JavaScript 的运行时规则决定。本章是全书最重要的“原理”章之一:异步顺序、并发、闭包、引用——Agent 写出的隐蔽 bug,多半藏在这里。

⏱ 约 60 分钟原理语法速查部分为 会用
读完本章,你应该能够:
  • 预测一段混合了 setTimeout、Promise、await 的代码的输出顺序,并说明原因。
  • 解释为什么 Node 用一个线程就能服务上千个并发请求,以及什么代码会让它“卡死所有人”。
  • 认出并修复三种常见异步 bug:forEach(async)、遗漏的 await、不必要的串行。
  • 解释闭包和“引用相等”,为第 06 章的 React 做好准备。

开场:一个线程,一千个请求

开场问题

Java 的传统 Web 服务器给每个请求分配一个线程;Go 给每个请求一个 goroutine。而 Node.js 执行你的 JavaScript 代码的只有一个线程。它为什么能同时服务上千个请求?又为什么,一个计算量很大的 for 循环就能让所有用户同时卡住?

答案方向:Web 服务器绝大多数时间在等待——等数据库、等网络、等文件。JavaScript 的做法是:等待不占用线程。发起 I/O 后立刻去处理别的事,结果回来时再排队继续。但计算会占用线程:线程在算的时候,队列里的所有人只能等着。理解这两句话,本章就懂了一半。

单线程 + 非阻塞 I/O

线程池模型(传统 Java) 事件循环模型(Node / 浏览器) 线程1 线程2 线程3 …1000 个请求 ≈ 1000 个线程(大部分在等) 每个线程有自己的栈(MB 级内存) 等待期间线程被“占着”但不干活 执行代码 等待 I/O(线程闲置) 主线程 A1 B1 C1 A2 C2 B2 系统层 操作系统 / libuv 并发地等待所有 I/O 请求 A 的前半段(A1)发起数据库查询后立刻让出线程; 查询结果回来,A 的后半段(A2)再排队执行 但如果 B1 是一个跑 2 秒的循环, A2、C2 以及所有新请求都得等 2 秒
左:每个请求独占一个线程,等待时线程闲置。右:一个线程轮流执行各请求“不需要等待的片段”,等待交给系统层并发完成。
类比:一个服务员的餐厅

线程池模型像是每桌配一个服务员,点完菜后这个服务员就站在厨房门口等菜。事件循环模型只有一个服务员:点完 A 桌的菜,把单子交给厨房(系统层 I/O),立刻去 B 桌点菜;厨房喊“A 桌菜好了”,他把这件事记在待办队列里,手头事做完就去上菜。

类比的失配点:如果这个唯一的服务员亲自去做菜(CPU 密集计算),整个餐厅都会停摆——这正是“一个大循环卡死所有请求”的原因。另外,真实系统中“厨房”也是有限的:数据库连接池满了,照样要排队。

这对你意味着:

事件循环:规则只有三条

“排队继续”具体是怎么排的?JavaScript 有两种队列:

事件循环(event loop)的规则:

  1. 从宏任务队列取一个任务,执行到调用栈清空(同步代码一口气跑完,不会被打断)。
  2. 把微任务队列全部清空(执行期间新加入的微任务也在这一轮执行)。
  3. (浏览器)如有需要,渲染一次页面。回到第 1 步。

下面用一段经典代码逐步演示。点击“下一步”,注意观察每一步的调用栈和两个队列,而不只是最后的输出:

这段演示里最反直觉的是两点:setTimeout(fn, 0) 并不“立刻”执行,它要排在所有微任务之后;async 函数在第一个 await 之前是同步执行的(所以 D 在 F 之前打印)。

Promise 与 async/await

Promise 是什么

Promise 是一个“未来值”的容器对象,它只有三种状态:pending(进行中) → fulfilled(成功,带一个值)或 rejected(失败,带一个错误)。状态一旦确定就不再改变。如果你用过 Java 的 CompletableFuture 或 C# 的 Task,概念几乎相同。

async/await 是 Promise 的语法糖:

三个最常见的异步 bug 原理

这三类 bug 都不会报错,只会让程序“偶尔不对”。Agent 写出它们的频率不低,你要一眼能认出来。

Bug 1:forEach 里的 async

原因:forEach 调用回调后丢弃了它的返回值(一个 Promise),根本不知道要等。

Bug 2:遗漏的 await(“漂浮的 Promise”)

后果:返回“成功”时数据还没写入;插入失败时错误变成“未处理的 Promise 拒绝(unhandled rejection)”,可能直接让 Node 进程崩溃,或在 serverless 环境里因为函数已返回而被中途冻结。防御:开启 Lint 规则 no-floating-promises(typescript-eslint 提供;Biome 也有对应规则)。

Bug 3:不必要的串行

这个问题在服务端渲染页面时特别常见,会形成“请求瀑布(waterfall)”,第 07 章还会再遇到它。

组合 Promise 的工具

API行为适用场景
Promise.all全部成功才成功;任一失败立即失败缺一不可的并行请求
Promise.allSettled等全部结束,逐个告诉你成功或失败批量处理,允许部分失败(如批量抓取 10 个链接)
Promise.race第一个结束的(无论成败)决定结果自己实现超时(现在更推荐 AbortSignal)
Promise.any第一个成功的决定结果多个镜像源取最快的
并发上限

Promise.all(1000 个请求) 会同时发出 1000 个请求,可能打爆对方的限流或你自己的连接池。需要限制并发时,用 p-limit 之类的小库,或者分批处理。给 Agent 下任务时,把“并发上限”写成明确的约束。

取消:AbortController

JS 的 Promise 本身不能被取消。标准做法是传递一个 AbortSignal:fetch、很多 SDK(包括 LLM SDK)都接受它。用户关闭页面、切换搜索词、超时,都应该触发取消,避免浪费资源(调用 LLM 时,浪费的是真金白银)。

值、引用与相等

JS 里原始值(string、number、boolean、null、undefined、bigint、symbol)按值比较;对象、数组、函数都是引用,=== 比较的是“是不是同一个对象”,而不是内容是否相同。这和 Java 的 == 对对象的行为一致。

为什么要专门讲这个?因为React 判断“状态是否改变”靠的就是引用比较。直接修改数组(list.push(x))而不创建新数组,React 会认为什么都没变,界面不更新。这是第 06 章的一个核心坑。

注意展开语法是浅拷贝:嵌套对象仍然共享。需要深拷贝时用内置的 structuredClone(obj)。

闭包

函数在创建时会“记住”它所在作用域里的变量,即使外层函数已经返回。这叫闭包(closure)。Java 的 lambda 也能捕获外部变量(但要求 effectively final),Go 的匿名函数行为几乎相同。

闭包本身很好理解。它在实战中的坑叫“过期闭包(stale closure)”:函数捕获的是创建那一刻作用域里的变量,如果之后创建了新的变量(比如 React 每次渲染都会创建新的 state 变量),旧函数看到的还是旧值。第 06 章会用具体例子演示。

模块:import / export

现代 JS 使用 ES 模块(ESM):

你需要知道的几件事:

给有经验者的语法速查 会用

下面这些语法在 Agent 生成的代码里无处不在。你不需要背,但要能一眼认出:

写法含义类比
const / let不可重新赋值 / 可重新赋值(不用 var)Java final / 普通变量
(x) => x * 2箭头函数Java lambda
const { id, url } = link解构:从对象取字段Python 元组解包
const [first, ...rest] = arr数组解构 + 剩余元素Python first, *rest
{ ...a, b: 1 }展开:浅拷贝并覆盖字段Python {**a, "b": 1}
`Hi ${name}`模板字符串Python f-string
a?.b?.c可选链:遇到 null/undefined 就返回 undefinedKotlin ?.
a ?? ba 是 null/undefined 时取 bKotlin ?:
a || ba 是“假值”时取 b(0、"" 也算假!)—
arr.map / filter / reduce返回新数组的函数式操作Java Stream
{ url }属性简写,等价于 { url: url }—
两个“历史包袱”
  • 永远用 ===,不用 ==。== 会做隐式类型转换,"0" == false 为 true。
  • 分清 ?? 和 ||。count || 10 在 count 为 0 时会得到 10——这通常是 bug。需要“只在缺失时给默认值”,用 ??。

关于 this 和 class:知道 现代 TS 全栈代码以函数和普通对象为主,class 用得不多(React 组件早已不用 class)。你只需要知道:普通函数里的 this 取决于怎么被调用,箭头函数没有自己的 this、沿用外层的。遇到相关 bug 时,让 Agent 改成箭头函数或显式传参通常就能解决。

LinkNote 中的落点

用户一次导入 20 个链接。需求是:每个链接抓取 + 生成摘要;最多同时处理 3 个;单个失败不影响其他;单个超过 20 秒算超时失败;全部结束后报告成功/失败数量。

把本章的概念对上号:“最多同时 3 个”= 并发上限;“单个失败不影响其他”= Promise.allSettled;“20 秒超时”= AbortSignal.timeout(20_000);“全部结束后报告”= 必须 await 整体结果,不能用 forEach(async)。

对 Agent 怎么说
模糊
精确
“批量导入有时候不太对,修一下。”
“importLinks 在第 42 行用了 forEach(async),导致函数在处理完成前就返回。改成并发上限为 3 的并行处理,用 Promise.allSettled 收集结果,每个任务 20 秒超时(AbortSignal),最后返回 { ok: number; failed: { url: string; reason: string }[] }。为超时和部分失败各写一个测试。”

精确版本用上了本章的词汇:并发上限、allSettled、AbortSignal、超时。有了准确的词,Agent 就不必猜你的意图。

面试角

问:说说宏任务和微任务的区别?下面代码输出什么?(换一个变体测你自己)

答题框架:先说规则(同步代码 → 清空微任务,包括执行中新加入的 → 取一个宏任务),再逐行推演。输出:5 2 4 1 3。4 在 1 之前,因为微任务队列要在这一轮全部清空;3 在 1 之后,因为它是后加入宏任务队列的。

自检

1. 把演示代码里的 await null 改成 await new Promise(r => setTimeout(r, 0)),E 和 B 谁先打印?

B 先。现在 E 所在的后半段要等一个 setTimeout 触发后才能继续,而这个计时器是在 B 的计时器之后注册的,两者都是 0ms,按先后排队。所以顺序变成 A D F C B E。关键是:你要追踪“剩余代码被挂在哪个队列上”。

2. 一个 Node API 服务里,有个接口会同步解析一个 50MB 的 JSON 文件(约耗时 800ms)。它对其他接口有什么影响?

在这 800ms 内,主线程被占用,所有其他请求(哪怕只是返回一个常量)都无法被处理,延迟全部增加约 800ms。修复思路:流式解析、移到 Worker 线程、预处理后缓存、或者交给后台任务。

3. 下面代码想“给列表加一项并通知界面”,为什么界面可能不更新?items.push(newItem); setItems(items);

push 修改了原数组,items 仍然是同一个引用。React 用引用比较判断状态是否改变,发现“同一个数组”,于是认为没有变化。应写成 setItems([...items, newItem]),创建新数组。

4. const limit = Number(params.limit) || 20; 用户传 limit=0 时会发生什么?应该怎么写?

0 是假值,所以 limit 变成 20。如果 0 是合法值,应该先判断是否缺失(params.limit == null 或用 ??),更好的做法是用 Zod 统一解析和校验参数(第 04 章)。