页面秒开却依然卡?排查长任务、交互阻塞与水合延迟
原文:https://dev.to/johnnylemonny/your-page-loaded-fast-so-why-does-it-still-feel-slow-3npm(作者 @johnnylemonny)
页面不到一秒就显示出来了。
用户点击了一个按钮。
什么也没发生。
内容早已可见,网络请求也已经完成,加载报告看起来也挺体面。问题出在主线程太忙——忙着解析、执行和水合 JavaScript,根本没空响应。
一个页面可以加载得很快,却依然让人感觉很慢。
如果你只优化了首次渲染,那你测的可能只是界面"变得可见"的时刻,而不是它"变得可用"的时刻。
{% card %}
可见不等于可交互。
要测的是用户点击、输入、展开菜单或提交表单之后发生的事。一张快速的截图并不能保证应用真的响应迅速。
{% endcard %}
从真实交互入手
别一上来就盯着打包体积目标。
先从用户真实会执行的动作开始:
- 打开主导航、
- 在搜索框中输入、
- 应用筛选条件、
- 展开商品面板、
- 把商品加入购物车、
- 提交表单。
然后记录用户实际经历的过程:
点击 ↓ 可见的响应开始 ↓ 界面更新完成
如果输入之后有明显的停顿,那么即便初始内容出现得很快,这个页面也存在响应性问题。
Interaction to Next Paint(INP,交互到下次绘制)衡量的是用户在整个页面访问期间交互的延迟。Web 性能指南建议,在第 75 百分位上将 INP 控制在 200 毫秒以内,才能算良好的用户体验。
现场数据(Field Data)是最有力的证据,因为它反映的是真实设备、真实网络和真实交互。实验室工具则用来复现和解释问题。
在 Performance 面板中找出长任务
打开浏览器 DevTools,切换到 Performance 面板,录制定位为"感觉很慢"的那次交互。
录制过程中:
- 等页面稳定下来,
- 像用户那样点击或输入,
- 界面更新后停止录制,
- 检查交互前后的主线程时间线。
长任务是指主线程 UI 连续不间断地占用至少 50 毫秒的一段时间。在该任务运行期间,浏览器无法处理其他主线程任务,包括输入事件处理和渲染工作。
常见原因包括:
- 开销巨大的事件处理器、
- 解析和求值大型脚本、
- 同步数据处理、
- 重复渲染、
- 强制布局(forced layout)、
- 第三方脚本、
- 水合(hydration)工作、
- 初始化当前还用不到的功能。
在基于 Chromium 的 DevTools 中,长任务会在性能追踪(performance trace)里以视觉方式标记出来。展开任务,查看调用树(call tree)或自底向上(bottom-up)视图,找出耗时最多的函数。
不要看到第一个耗时的函数名就去优化,先看清完整的调用栈。框架代码之所以在执行,可能是因为应用代码塞了太多活给它。
衡量 JavaScript 本身,而不只是传输体积
压缩可以让脚本在网络上传输时很小,但留给浏览器的工作量依然可观。
JavaScript 仍然必须被:
下载 ↓ 解压 ↓ 解析 ↓ 编译 ↓ 执行
一个压缩后只增加少量负载的包,背后可能执行了昂贵的初始化、遍历庞大的 DOM、注册大量监听器,或者调度了反复执行的任务。
用 Network 面板了解传输体积,用 Performance 面板了解主线程开销。
问自己这些问题:
- 这个脚本在首次交互之前是否必须存在?
- 这个库是被完整使用了吗?
- 页面上没有的组件,是否也在执行初始化?
- 每次导航是否都会重新执行这个脚本?
- 是否有同样的计算被反复执行?
- 输入发生的那一刻,是否有第三方代码在运行?
先删掉工作,再谈拆分工作
代码分割(code splitting)很有用,但最好的"延迟加载",往往是页面根本不再需要的 JavaScript。
排查这些:
- 未使用的库、
- 重复的工具函数、
- 已废弃的统计分析集成、
- 超出所支持浏览器策略范围的 polyfill、
- 为了一个小工具函数而引入的大型依赖、
- 全局加载却极少渲染的组件、
- 可以用 HTML 或 CSS 替代的初始化逻辑。
举例来说,如果只有一个路由展示图表,就不该在每个页面都加载图表库。
const { renderChart } = await import("./chart.js");
renderChart(data);
动态 import 把模块的加载推迟到真正需要它的时刻之后。
但要注意,只有当该功能确实不需要提前拿到代码时,这样做才能提升响应性。把必需的工作直接挪进点击处理器,可能只是把加载延迟换成了交互延迟。
更好的做法是在浏览器空闲时间加载可选功能,或者等主要内容变为可交互之后再加载。
把长任务拆成更小的片段
有些工作本身是必要的,但不一定非要占用主线程、一次性不间断跑完。
假设要处理一个大批量集合:
function processAllItems(items) {
for (const item of items) {
processItem(item);
}
}
如果这个循环变成了长任务,就周期性地让出执行权:
async function processItems(items) {
for (let index = 0; index < items.length; index++) {
processItem(items[index]);
if (index % 100 === 0) {
await scheduler.yield();
}
}
}
让出执行权后,浏览器就有机会先处理输入、渲染等更高优先级的工作,然后再继续执行剩下的任务。
最佳的分片大小取决于单个条目的开销和目标设备。固定条目数的写法便于演示,但在真实项目中需要实际测量来确定。
如果你的浏览器支持范围里没有 scheduler.yield(),就使用合适的调度降级方案,并保持渐进增强的行为。
把 CPU 密集型工作移出主线程
对于不需要直接访问 DOM 的高开销计算,可以考虑使用 Web Worker。
适合放进 Worker 的场景包括:
- 解析大型数据集、
- 图像处理、
- 压缩、
- 复杂的搜索索引、
- 数值计算。
Worker 会引入消息传递和生命周期管理的复杂度。只有当测量结果显示 CPU 工作确实阻塞了交互时才使用它,而不是把每个函数都默认包一层 Worker。
让事件处理器只做必要的事
事件处理器应该只完成产生下一个可见反馈所需的最少工作量。
下面这个处理器同步做的事太多了:
button.addEventListener("click", () => {
calculateLargeReport();
storeAnalyticsPayload();
updateRecommendations();
openPanel();
});
用户要等一堆和面板无关的工作跑完,才能看到面板打开。
优先呈现可见的响应:
button.addEventListener("click", () => {
openPanel();
setTimeout(() => {
calculateLargeReport();
storeAnalyticsPayload();
updateRecommendations();
}, 0);
});
这个简化示例把任务拆开了,但 setTimeout(..., 0) 并不能让昂贵的工作变便宜。如果延后的工作量仍然很大,就继续拆分、移入 Worker,或者干脆去掉。
另外,尽量避免在修改样式后立刻读取布局信息。反复的读写交替会强制触发同步布局:
// 反复读写的模式
for (const item of items) {
item.style.width = `${container.offsetWidth / 2}px`;
}
先把需要的测量值一次性读出来,再统一应用更新:
const width = container.offsetWidth / 2;
for (const item of items) {
item.style.width = `${width}px`;
}
审查第三方脚本
占用主线程的不止你自己的代码。
统计分析、A/B 实验、广告、客服聊天、支持挂件、个性化推荐和标签管理器,都可能在用户交互前后执行。
针对每一个第三方脚本,记录清楚:
- 归属方是谁、
- 它为什么存在、
- 在哪里加载、
- 是否每个页面都需要、
- 消耗多少主线程时间、
- 访问了哪些用户数据、
- 失败时会发生什么。
移除那些没有明确负责人、也没有可衡量用途的脚本。
非核心工具应在主要体验可用之后再加载。要小心标签管理器——它可能会悄悄把已经移除的脚本重新恢复回来。
一个第三方脚本可能体积不大,不足以主导网络传输量,但仍然可能因为初始化或反复回调而制造长任务。
警惕 Hydration 延迟
服务端渲染的 HTML 可能已经显示出来,但客户端 JavaScript 还没完成行为绑定。
这就造成了一种很糟糕的状态:
按钮已经可见 按钮看起来可以点击 点击处理器还没准备好
缩小这个空窗期的办法:
- 减少客户端 JavaScript 的加载量、
- 避免对静态区块做 hydration、
- 隔离交互式组件、
- 延后加载低优先级挂件、
- 保持初始客户端状态足够小、
- 在不需要交互的地方优先使用服务端或平台原生行为。
不同框架的工具各有差异,但原则是稳定的:不要让浏览器去 hydration 那些从不需要客户端接管的页面部分。
验证改进效果
在相同条件下重复同一次交互。
对比这些指标:
优化前 - 交互延迟 - 最长任务耗时 - 主线程总工作量 - JavaScript 求值时间 优化后 - 交互延迟 - 最长任务耗时 - 主线程总工作量 - JavaScript 求值时间
用 CPU 节流来暴露在高性能开发机上被掩盖的问题。然后用真实用户数据再验证一遍,因为真实用户的设备、浏览器扩展、后台应用和交互习惯各不相同。
打包体积变小是有价值的证据,但不能证明卡顿的交互已经被修复。
{% details 响应性审查清单 %}
- [ ] 从一次真实的点击、触摸或按键开始。
- [ ] 有 INP 字段数据时先看数据。
- [ ] 在 Performance 面板中录制这次交互。
- [ ] 找出输入事件附近的长任务。
- [ ] 改代码之前先看清调用栈。
- [ ] 先移除不必要的 JavaScript。
- [ ] 把可选功能延后到关键交互之外。
- [ ] 把必要的长任务拆成更小的片段。
- [ ] 把合适的 CPU 工作移入 Web Worker。
- [ ] 审查第三方脚本。
- [ ] 检查是否存在"可见但未 hydration"的控件。
- [ ] 修改完成后复现同样的交互。
{% enddetails %}
核心要点
首屏加载速度和交互速度有关联,但它们并不是同一个问题。
要让页面响应灵敏,主线程必须留出足够空间来处理用户输入和渲染。
实际的工作流程是:
选择一个真实的交互操作
↓
在 DevTools 中录制它
↓
找到阻塞主线程的任务
↓
移除不必要的工作
↓
延迟、拆分或移出必要的工作
↓
重复同一个交互操作
↓
用真实用户数据验证
不要只优化页面变得可见的那一瞬间。
要优化的是用户真正开始使用它的那一刻。
你的应用中,哪个交互操作感觉比首屏加载还要慢?
{% cta https://web.dev/articles/optimize-inp %}
阅读 web.dev 的 INP 优化指南
{% endcta %}
延伸阅读
- MDN:JavaScript performance optimization
- MDN:PerformanceLongTaskTiming
- web.dev:Optimize long tasks
- web.dev:Optimize Interaction to Next Paint
- W3C:Long Tasks API
原文:https://dev.to/johnnylemonny/your-page-loaded-fast-so-why-does-it-still-feel-slow-3npm(作者 @johnnylemonny)