第 1 章:浏览器 JavaScript 运行时、异步与事件系统
本章定位
本章建立浏览器运行时模型,重点不是讲解 Promise 语法,而是解释事件循环、微任务、宏任务、渲染帧、异步迭代和取消。AI 前端处理 SSE 和 token 流时,这部分决定你能否避免卡顿、重复渲染和组件卸载后的泄漏。
必须掌握清单
必须掌握
能区分调用栈、微任务、宏任务和渲染帧。
相关答案: 同步代码先进入调用栈;一次宏任务结束且调用栈清空后,浏览器会持续清空微任务队列,然后才有机会执行渲染步骤。渲染帧由浏览器按刷新节奏安排,并不保证每个宏任务后都绘制页面。
具体例子: 点击事件属于一个宏任务,事件回调中的同步代码先执行,随后运行其中创建的
Promise.then微任务;如果这一轮需要更新画面,浏览器再执行requestAnimationFrame回调并布局、绘制。能解释
Promise.then、queueMicrotask、requestAnimationFrame和MessageChannel的顺序。相关答案:
Promise.then与queueMicrotask都进入微任务队列,按入队顺序执行;MessageChannel的消息回调属于任务,通常要等微任务清空;requestAnimationFrame在下一次绘制前运行,其先后还受浏览器是否准备渲染影响,不能简单背成永远固定的总顺序。具体例子: 同一段同步代码依次注册
Promise.then、queueMicrotask和MessageChannel时,前两者会先按注册顺序输出;页面进入下一帧时执行requestAnimationFrame,而MessageChannel在后续任务中处理,具体跨帧关系应通过 Performance 面板验证。能用
AsyncIterable消费流式数据,并理解它与普通数组回调的区别。相关答案:
AsyncIterable表示数据会在未来分批到达,for await...of每次等待一个异步结果并天然支持顺序消费;数组回调处理的是已经存在于内存中的有限集合,不能直接表达等待下一块数据和流结束。具体例子: SSE 或 fetch 响应体可以封装为异步迭代器,每收到一个 token 就
yield一次;消费方用for await...of更新消息缓冲区,无需等完整回答下载完毕。能用
AbortController取消 fetch、流和自定义异步任务。相关答案:
AbortController通过同一个AbortSignal把取消意图从调用方传到底层;原生 fetch 会响应信号,自定义任务则必须主动检查signal.aborted或监听abort事件,并在退出时清理资源。具体例子: 用户点击“停止生成”时调用
controller.abort(),请求读取循环捕获AbortError后关闭 reader,自定义定时器也在 abort 监听器中执行clearTimeout,从而终止整条任务链。能设计 token 到消息节点的合批更新,而不是每个 chunk 都触发整棵树渲染。
相关答案: 网络 chunk 的到达频率不应直接等于 UI 提交频率。应先把 token 写入可变缓冲区,再按动画帧或固定时间窗口生成不可变快照,只通知真正依赖该消息的视图。
具体例子: 流读取器连续收到 30 个 token 时只追加到
pendingText,在下一次requestAnimationFrame中合并为一个消息快照;消息组件订阅该快照,侧边栏和历史列表不会跟随每个 token 重渲染。
原理讲解
浏览器执行一次宏任务后,会清空当前微任务队列。渲染不一定发生在每个宏任务之后,浏览器会按帧调度,requestAnimationFrame 在渲染前执行。这个顺序决定为什么高频网络回调不能直接同步更新大量 DOM。
AsyncIterable 表示一个可以逐步消费的异步数据源。它适合 SSE,因为数据是持续到达的,不需要等完整响应,也不需要把所有 chunk 先放进数组。配合 for await...of,每收到一个 chunk 就处理一次。
AbortController 的核心价值是把取消信号从调用方传到最底层。fetch 可以接收 AbortSignal,自定义异步任务也可以监听 signal.aborted 和 signal.addEventListener('abort', ...)。
代码示例或模板
async function renderTokenStream(
stream: AsyncIterable<string>,
signal: AbortSignal,
onChunk: (text: string) => void,
) {
const frameText: string[] = [];
let raf = 0;
for await (const chunk of stream) {
if (signal.aborted) {
return;
}
frameText.push(chunk);
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
onChunk(frameText.join(""));
frameText.length = 0;
});
}
}这个示例把多个 token 合到一个渲染帧,降低高频更新带来的布局和渲染成本。
AI Agent 概念对照
- SSE chunk 对应
AsyncIterable。 - token 到消息节点对应渲染帧合批。
- 工具调用完成对应自定义事件。
- 用户点击停止对应
AbortController。
面试追问
1. setTimeout(0) 为什么不一定立即执行?
查看深度解析
标准回答: 0 表示达到最小延迟后回调才有资格进入任务队列,不表示立刻抢占当前代码。调用栈、已排队任务、微任务、浏览器的计时器钳制和主线程繁忙都会推迟它。
原理展开: 定时器到期只负责排队,事件循环仍要等当前任务结束并完成微任务检查后再选择后续任务;嵌套定时器和后台页面还可能使用更大的最小延迟。
工程示例: 主线程先执行 200ms 的同步计算,即使 setTimeout(fn, 0) 已到期,fn 也只能在计算和当前微任务完成后运行。
常见误区: 把它当成精确计时器,或认为必然早于所有其他异步回调。
继续追问: 如何实现更稳定的周期任务?
回答方向: 用单调时钟计算下一次目标时间并校正漂移;涉及动画时使用 requestAnimationFrame,不要依赖连续 setInterval 精确命中。
2. Promise.then、requestAnimationFrame 和 MessageChannel 的执行顺序是什么?
查看深度解析
标准回答: Promise.then 属于微任务,会在当前任务结束后的微任务检查中运行;requestAnimationFrame 在浏览器获得渲染机会且准备绘制时运行;MessageChannel 回调是另一个任务。能确定微任务先于离开当前任务,后两者的跨帧先后不能脱离注册时机和渲染机会写成永远固定的口诀。
原理展开: 浏览器可以连续执行多个任务而不渲染,也可以在合适的渲染机会执行动画帧回调,因此任务来源和帧调度共同决定观察结果。
工程示例: 当前点击回调注册三者时,Promise 通常先输出;页面准备下一帧时执行 rAF,而 MessageChannel 在后续任务中执行,繁忙页面中二者相对时机应通过 Performance 面板确认。
常见误区: 背诵“微任务 → rAF → MessageChannel”并把它当成所有浏览器、所有上下文的规范保证。
继续追问: Vue 的 nextTick 或 React 提交处在哪一层?
回答方向: 它们是框架调度抽象,可能借助微任务或其他机制;必须区分框架更新完成、浏览器布局和真正绘制三个时点。
3. 为什么 SSE 用 AsyncIterable 比用回调数组更合适?
查看深度解析
标准回答: SSE 是随时间持续到达、结束时间不确定的数据源,AsyncIterable 能用 for await...of 顺序消费每个事件,并自然表达等待、结束、异常和取消。回调数组只是保存一组监听函数,不能描述流本身的生命周期。
原理展开: 异步迭代器把拉取下一项的 Promise 暴露给消费方,使解析、转换和消费可以组合;如果需要多订阅者,再在边界上转换为 Observable 或事件总线。
工程示例: 解析器每读到一个完整 SSE event 就 yield,UI 循环消费并合批更新;连接关闭时迭代结束,协议错误则直接抛给统一错误处理。
常见误区: 认为 AsyncIterable 自动提供背压或广播。浏览器网络缓冲、服务端写入和多消费者仍需单独设计。
继续追问: 如何把取消传进异步迭代器?
回答方向: 接收 AbortSignal,读取前检查状态、监听 abort 并取消 reader;迭代器的 return() 或 finally 中释放连接和监听器。
4. 组件卸载后,如何避免流式请求继续更新已经不存在的内容?
查看深度解析
标准回答: 将请求生命周期绑定到组件或页面 effect:创建 AbortController,把 signal 传给 fetch 和流消费,在 cleanup 中 abort,并让异步循环在写状态前检查 signal 或请求版本。
原理展开: 只用 isMounted 阻止 setState 会留下连接、reader 和定时器;真正的清理要从 UI 一直传播到底层资源,同时防止旧请求晚到覆盖新请求。
工程示例: 路由切换时 cleanup 取消 fetch、调用 reader.cancel、清理 rAF 合批任务;store 用 requestId 忽略已经失效的 token。
常见误区: 只捕获 React 的卸载警告,却不关闭网络连接;或把所有 AbortError 记录为系统故障。
继续追问: 用户停止生成和组件卸载应该共用同一逻辑吗?
回答方向: 共用底层取消协议,但保留不同原因;用户取消可展示“已停止”,卸载取消通常静默,观测指标也应区分。
5. 如果每个 token 都触发 React setState,会出现什么问题?
查看深度解析
标准回答: 高频 token 会造成大量调度、reconcile 和提交,若状态在高层还会让整棵子树重复渲染,引发输入卡顿、滚动抖动和功耗上升。即使 React 能批处理,也不能假设网络事件会被合并成合适的帧频。
原理展开: 网络到达频率和屏幕刷新频率是两个时钟,应先写入消息缓冲,再按帧或时间窗口发布不可变快照,并按消息粒度订阅。
工程示例: 50 个 token 在 16ms 内到达时只更新 pendingText,下一次 rAF 生成一次快照;只有当前消息组件重渲染,历史列表保持稳定。
常见误区: 仅给组件加 memo,但每次都创建新 context value 或在根节点更新状态,仍会导致广泛渲染。
继续追问: 合批窗口应该固定为多少?
回答方向: 从一帧一次开始,再根据首 token 延迟、提交耗时和设备能力调节;页面不可见时可降低频率,但结束和错误事件应立即刷新。
实践任务
实现一个浏览器端 token 流渲染器,输入是一个异步迭代器,输出按帧合并更新 DOM 或 React 状态。加入停止按钮、超时和组件卸载清理。
验收标准
- 能解释输入、状态变化和取消路径。
- 能证明多个 chunk 被合并到一次 DOM 更新。
- 能在组件卸载后确认请求已取消且没有报错。