主线程不属于你
Sure! Could you please provide the text you’d like translated? I’ll keep the source line unchanged and preserve all formatting as requested.
主线程是用户的资源
当用户访问你的网站或应用时,他们的浏览器会为以下任务分配 单个线程:
- 运行你的 JavaScript,
- 处理他们的交互,以及
- 绘制他们在屏幕上看到的内容。
这就是 主线程,它是你的代码与使用它的人的直接连接。
作为开发者,我们常常在不考虑终端用户设备的情况下使用它——无论是中端手机还是高端游戏电脑。主线程 不属于我们;它属于他们。
“我们像免费资源一样消耗用户的主线程预算,却在界面出现卡顿时感到惊讶。”
每消耗一毫秒执行 JavaScript,浏览器就少了一毫秒可以用来:
- 响应点击,
- 更新滚动位置,
- 识别键盘输入。
当你的代码运行时间过长时,你并不是在抽象意义上制造“卡顿”;你是在忽视正试图与你交互的用户。
因为主线程一次只能执行 一件事,在你的 JavaScript 运行期间,其他所有操作都必须等待:
- 点击被排队,
- 滚动冻结,
- 键入的字符堆积,期待你尽快完成。
如果你的代码在 50 毫秒 内响应,没人会注意。到 500 毫秒 时,界面会显得迟缓,几秒后浏览器甚至可能提示要彻底终止你的页面。
用户看不到你的代码在执行,他们只会感受到破碎的体验,并先责怪自己,然后是浏览器,最后是你。
人类感知与 200 ms 预算
浏览器厂商花了多年时间研究人类对响应性的感知。研究结果汇聚成一个明确的阈值:
| 延迟 | 感知体验 |
|---|---|
| ≤ 100 ms | 瞬间 |
| 100 – 200 ms | 可察觉的延迟 |
| > 200 ms | 差 |
业界将其形式化为 Interaction to Next Paint (INP) 指标——任何超过 200 ms 的情况都被视为差,并已开始影响搜索排名。
这 200 ms 的预算不仅仅是给你的 JavaScript 留的。浏览器还需要时间进行样式计算、布局和绘制,所以你的代码只能使用剩余的时间——大约 ≈ 50 ms 每次交互,超过这个时间体验就会开始显得迟钝。这就是你从并不属于你的资源中得到的分配。
帮助你成为好客的 API
Web 平台已经发展,以帮助你避免阻塞主线程。许多这些 API 的出现是因为浏览器工程师厌倦了看到开发者不必要地阻塞线程。
Web Workers
在完全独立的线程中运行 JavaScript。繁重的计算——解析大型数据集、图像处理、复杂计算——可以在 worker 中进行,而不会阻塞主线程。
// Main thread: delegate work and stay responsive
const worker = new Worker('heavy-lifting.js');
// Send a large dataset from the main thread to the worker
// The worker then processes it in its own thread
worker.postMessage(largeDataset);
// Receive results back and update the UI
worker.onmessage = (e) => updateUI(e.data);
Workers 不能操作 DOM,但这一限制是有意为之;它强制在“工作”和“交互”之间保持清晰的分离。
requestIdleCallback
仅在浏览器没有更重要的任务时运行代码。(由于 WebKit 的一个 bug,Safari 的支持在撰写本文时仍在等待。)当用户正在积极交互时,你的回调会等待;当一切安静时,你的代码才会得到执行机会。
requestIdleCallback((deadline) => {
// Process tasks from a queue you created earlier.
// deadline.timeRemaining() tells you how much time you have left.
while (tasks.length && deadline.timeRemaining() > 0) {
processTask(tasks.shift());
}
// If there are tasks left, schedule another idle callback to finish later.
if (tasks.length) {
requestIdleCallback(processRemainingTasks);
}
});
非常适合非紧急工作,如分析、预取或后台更新。
navigator.scheduling.isInputPending
(目前仅限 Chromium。)此 API 让你在任务进行中检查是否有用户在等待。
function processChunk(items) {
// Process items from a queue one at a time.
while (items.length) {
processItem(items.shift());
// Check if there’s pending input from the user.
if (navigator.scheduling?.isInputPending()) {
// Yield to the main thread to handle user input,
// then resume processing after.
setTimeout(() => processChunk(items), 0);
// Stop processing for now.
return;
}
}
}
你明确地在询问“有人想引起我的注意吗?”,如果答案是肯定的,你就会停止并让出给他们。
微妙的主线程犯罪
显而易见的犯罪——无限循环、渲染 100 000 行表格——很容易被发现。微妙的犯罪看起来无害。
JSON.parse()在大型 API 响应上 会阻塞主线程,直到解析完成。在开发者的机器上感觉是瞬间的;但在一部 CPU 受限且有多个标签页竞争的中端手机上,它可能需要 300 ms,期间完全忽略用户的交互。
主线程不会逐渐退化;要么响应迅速,要么根本不响应,而你的用户正是在你可能从未测试过的环境中运行你的代码。
测量你无法管理的东西
你无法管理你无法衡量的东西。Chrome DevTools 的 Performance 面板可以准确显示主线程时间的去向——前提是你知道去哪里查看。
- 打开 Performance 面板并记录一次会话。
- 找到 “Main” 轨道。
- 查找长时间的黄色 JavaScript 执行块。
- 超过 50 ms 的任务会用红色阴影标记,以突出超时部分。
如果你更喜欢引导式的方法,可以使用 Insights 面板自动显示长任务。
performance.measure() 用于精确仪表化
// Mark the start of a heavy operation
performance.mark('parse-start');
// The operation you want to measure
const data = JSON.parse(hugePayload);
// Mark the end of the operation
performance.mark('parse-end');
// Measure the duration
performance.measure('JSON parse', 'parse-start', 'parse-end');
现在你可以在 Performance 面板中看到该操作所花费的精确时间。
Takeaway
把主线程视为 用户的有限预算。
使用 Workers、requestIdleCallback 和 isInputPending 来保持 UI 响应,避免隐藏的阻塞调用,并始终衡量你的影响。当你尊重这份预算时,体验会显得瞬时,用户保持满意,站点在真实环境以及搜索排名中表现更佳。
// Measure for later analysis
performance.measure('json-parse', 'parse-start', 'parse-end');
Web Vitals 库可以在生产环境中捕获所有主流浏览器的真实用户 INP 分数;当你看到峰值时,就知道该从哪里开始调查。
在你的应用代码执行第一行之前,框架已经在初始化、hydration(水合)和虚拟 DOM 协调上消耗了一部分用户的主线程预算。这并不是在反对框架,而是提醒我们要了解自己的开销。一个 200 ms 的 hydration 成本相当于在你做任何事之前就已经消耗了四倍的每次交互预算,这必须是你有意识的选择,而不是意外。
一些框架已经开始认真对待这一点:
- Qwik – 可恢复性 完全避免了 hydration。
- React – 并发特性让渲染可以让位于用户输入。
这些都是对同一根本约束的响应:主线程是有限的,而我们一直在不经意地消耗它。技术方案固然重要,但它们源自视角的转变。当我最终内化了“主线程属于用户,而不是我”这一点,我的决策也随之改变。
性能不再是 代码执行有多快 的问题,而是 代码执行期间界面保持多么响应 的问题。阻塞主线程不再是实现细节,而是像在夺取不属于你的东西。
浏览器给了我们一条执行线程,也把同一条线程交给用户用于与我们构建的内容交互。我们至少应该公平地共享它。