IT加油站

页面秒开却依然卡?排查长任务、交互阻塞与水合延迟

16浏览 • 14小时前 • 软件教程 • MA124328

原文: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 面板,录制定位为"感觉很慢"的那次交互。

录制过程中:

  1. 等页面稳定下来,
  2. 像用户那样点击或输入,
  3. 界面更新后停止录制,
  4. 检查交互前后的主线程时间线。

长任务是指主线程 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 %}


延伸阅读

原文:https://dev.to/johnnylemonny/your-page-loaded-fast-so-why-does-it-still-feel-slow-3npm(作者 @johnnylemonny)

#INP #前端性能优化 #JavaScript #Core Web Vitals #Chrome DevTools #长任务