我们遗忘的前端性能优化技巧
让现代前端应用变慢最快的方式,就是假设框架会自动帮你处理性能问题。十年前定义高性能网站的那些底层技术——显式图片尺寸、延迟加载第三方脚本、字体显示提示、手动预连接——至今仍然重要。但如今框架横亘在你与平台之间,而这层抽象会以 Lighthouse 审计乐于标记的方式悄然泄漏。2019 年,ButterCMS 曾写道”浏览器即将原生支持懒加载”。那个未来已经到来,成为了基线标准,然后又悄悄变成了我们不再去检查的东西。本文将梳理那些被我们委托给框架的前端性能基础知识,以及当这种委托失效时在生产环境中出现的故障模式。
核心要点
- 原生
loading="lazy"自 Safari 15.4(2022 年 3 月)起已在所有主流浏览器中获得支持,因此任何在此之前编写的 Intersection Observer 封装都是增加包体积的冗余代码。 - Google Fonts 可以通过
font-display: swap来提供字体,但你自己 CSS 中自定义的@font-face块不会继承该行为——在网络较慢时,每一个都可能导致不可见文本的闪烁。 setTimeout(fn, 0)在下一个任务中运行,可能恰好落在用户交互期间;而requestIdleCallback会等待真正的空闲时段,因此它才是处理非紧急工作的正确原语。- 对操作 DOM 的脚本使用
defer,只对真正独立的脚本使用async,因为async脚本按网络到达顺序执行,而非文档顺序。 - 自 2024 年 3 月 INP 取代 FID 成为核心网页指标以来,阻塞主线程的未节流滚动和 resize 处理器现在已是排名信号,而不仅仅是流畅度问题。
为图片显式设置 width/height 仍可防止布局偏移
在图片尺寸来自运行时 API 响应的 React 应用中,浏览器没有可分配的预留空间,因此每张在首次绘制后加载的图片都可能导致布局偏移——无论你的框架图片组件能否正确处理静态资源。累积布局偏移(CLS)是 Google web.dev CLS 文档定义的核心网页指标之一,其可见的故障表现非常直观:图片加载时页面发生跳动,用户的点击落在了错误的按钮上。
这里泄漏的抽象正是框架的图片组件。当你为 Next.js 的 <Image> 传入 width 和 height 时,它会预留空间;但对于 MDX 内容、CMS 渲染的 HTML 或任何该组件未曾处理的标记中的原始 <img> 标签,它则无能为力。现代浏览器会根据 width 和 height 属性计算隐式的 aspect-ratio(MDN 对此行为有详细说明),因此即使 CSS 覆盖了渲染尺寸,这些属性也能预留空间。
// 修改前:尺寸在运行时才获取,没有任何空间预留
<img src={product.imageUrl} alt={product.name} />
// 修改后:属性在图片加载前就设定了宽高比容器
<img
src={product.imageUrl}
alt={product.name}
width={product.width}
height={product.height}
style={{ width: '100%', height: 'auto' }}
/>
当 API 不返回尺寸时,可在容器上设置 aspect-ratio。无论哪种方式,空间都应在字节到达之前就已存在。
Discover how at OpenReplay.com.
自定义字体的 font-display: swap 与预连接
每个缺少 font-display: swap 的自定义 @font-face 块,在网络较慢时都可能导致不可见文本的闪烁(FOIT)——段落在字体加载完成之前会一直保持空白。font-display 描述符可直接控制这一行为:swap 会立即渲染回退文本,待自定义字体加载完成后再进行替换,从而产生无样式文本闪烁(FOUT)而非不可见文本,详见 MDN 的 font-display 参考文档。
这里的泄漏源于委托。当样式表 URL 包含相应的 display 参数时,Google Fonts 可以在其提供的 CSS 中注入 font-display: swap,因此使用托管样式表的团队从未考虑过这个问题——然后他们为品牌字体编写了自己的 @font-face 块,却没有继承这一行为。一个没有该描述符的自托管字体,会在每位冷缓存访客的浏览器上触发 FOIT。
自托管同时也失去了 Google 样式表隐式鼓励的预连接。web.dev 关于早期网络连接的指南建议预连接到字体源,以便在 CSS 中发现字体 URL 之前完成 DNS、TCP 和 TLS 握手。
@font-face {
font-family: "BrandSans";
src: url("/fonts/brand-sans.woff2") format("woff2");
font-display: swap; /* 回退文本立即显示,无 FOIT */
}
<link rel="preconnect" href="https://fonts.cdn.example.com" crossorigin />
审查所有手动编写的 @font-face 块。使用托管 CSS 的习惯会掩盖那些需要修复的问题。
第三方来源的 preconnect 与 dns-prefetch
打包工具和框架会为自身的 CDN 来源处理预连接,但第三方分析端点、图片 CDN 和 A/B 测试服务对构建步骤是不可见的——除非你手动添加 <link rel="preconnect">,否则它们的 DNS 查找只会在请求时发生。ButterCMS 在 2019 年精确描述了这一机制:preconnect 告诉浏览器”尽早”完成 DNS 查找、初始连接和 TLS 协商,而不是等到发现 script 标签时才进行。
DNS 和 TLS 握手的开销并未消失,只是框架不再提醒你而已。一个 Segment 端点、一个 Cloudinary 来源或一个第三方标签管理器,都需要全新的连接建立过程,这会阻塞其后的资源。对于确定会提前访问的来源使用 preconnect,对于可能访问的来源使用更轻量的 dns-prefetch 提示——因为 preconnect 会建立完整连接,无论是否使用都需要付出代价。web.dev 详细介绍了这两种提示之间的权衡。
<!-- 关键第三方来源:立即建立完整连接 -->
<link rel="preconnect" href="https://cdn.imagecdn.example" crossorigin />
<!-- 可能但不确定的来源:仅解析 DNS -->
<link rel="dns-prefetch" href="https://analytics.example.com" />
将这些标签放在 <head> 的靠前位置,在触发请求的脚本和样式表之前。
原生 loading="lazy" 已取代你的 Intersection Observer 封装
原生 loading="lazy" 自 2022 年 3 月 Safari 15.4 发布以来,已在所有主流浏览器中获得支持——任何在此之前编写的 Intersection Observer 封装现在都是增加包体积和维护负担的冗余代码。Chrome 在版本 77(2019 年 8 月)中率先支持,Firefox 在版本 75(2020 年 4 月)中跟进,详见 MDN img 元素参考文档中的浏览器兼容性表格。
这里的泄漏是历史性的,而非框架特有的。在该属性达到基线标准之前的那些年里,代码库积累了大量 useLazyImage hook 和 <LazyImage> 组件,而这些组件至今仍在运行——为每张图片运行一个观察器、持有 ref、在交叉时重新渲染——做的却是浏览器现在原生且在主线程之外就能完成的事情。同样的属性也适用于 iframe,这对折叠线以下的嵌入地图和视频播放器尤为重要。
// 修改前:手动实现的观察器,已被平台能力所取代
function LazyImage({ src, alt }) {
const ref = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting) setVisible(true);
});
io.observe(ref.current);
return () => io.disconnect();
}, []);
return <img ref={ref} src={visible ? src : undefined} alt={alt} />;
}
// 修改后:由浏览器在主线程之外处理
<img src={src} alt={alt} loading="lazy" width={800} height={600} />;
保留显式尺寸——懒加载图片与立即加载图片一样容易导致布局偏移。
第三方脚本的 defer 与 async
实用决策规则:对任何读写 DOM 的脚本使用 defer,只对真正独立的脚本使用 async——因为 async 脚本按网络到达顺序执行,而非文档顺序,两个存在依赖关系的 async 脚本会产生竞争条件。HTML 现行标准的 script 元素定义规定:defer 脚本在解析完成后按文档顺序运行,而 async 脚本在下载完成后立即运行。
这里的泄漏是社会性的,而非技术性的:有人将供应商分析代码片段完全按照供应商的复制粘贴说明粘贴到 <head> 中,没有任何属性,而一个普通的 <script> 会在下载和执行完成之前阻塞解析。可见的故障表现是交互延迟。当第三方脚本阻塞交互时,回放录像会显示用户在同一控件上反复点击——这是典型的愤怒点击模式。
| 属性 | 执行时机 | 顺序保证 | 适用场景 |
|---|---|---|---|
| 无 | 立即阻塞解析器 | 文档顺序 | 几乎不适用 |
async | 下载完成后立即执行 | 网络到达顺序 | 独立的分析脚本 |
defer | 解析完成后执行 | 文档顺序 | 任何操作 DOM 的脚本 |
<!-- 修改前:阻塞解析器,延迟首次绘制和交互 -->
<script src="https://vendor.example/analytics.js"></script>
<!-- 修改后:独立脚本,不阻塞解析器 -->
<script src="https://vendor.example/analytics.js" async></script>
用 requestIdleCallback 替代 setTimeout(fn, 0)
setTimeout(fn, 0) 将工作安排在下一个任务队列槽中,可能恰好落在用户交互期间;而 requestIdleCallback 会等待真正的空闲时段,因此它才是分析初始化、预取水合和遥测批处理的正确原语。MDN 的 requestIdleCallback 参考文档对此有详细说明:回调在浏览器空闲期间触发,并接收一个可在继续工作前检查的截止时间。
这是大多数团队从未真正采用的原语——setTimeout(fn, 0) 成了”稍后执行”的惯用写法,但它实际上并不会让出控制权给用户。自 2024 年 3 月 INP 取代 FID 成为核心网页指标以来(详见 web.dev 的 INP 公告),落在交互期间的主线程工作不再只是流畅度问题——它已成为排名信号。requestIdleCallback 在 Chrome 和 Firefox 中受支持,但 Safari 不支持,因此需要做特性检测并提供回退方案。
function whenIdle(fn) {
if ("requestIdleCallback" in window) {
requestIdleCallback(fn, { timeout: 2000 });
} else {
setTimeout(fn, 0); // Safari 回退方案
}
}
// 将非紧急工作推迟到交互路径之外
whenIdle(() => initAnalytics());
timeout 选项确保即使浏览器始终未进入空闲状态,工作最终也会执行。
对滚动、Resize 和输入事件进行防抖与节流
阻塞主线程的未节流滚动、resize 和输入处理器现在已是排名信号,而不仅仅是流畅度问题——它们造成的每一帧延迟都可能触发 INP 违规。这一模式之所以被打破,是因为 useEffect 让附加原始监听器变得极其简单:三行代码,没有速率限制,一个在每个滚动帧都会触发的处理器。
防抖(Debounce)在活动停止后运行函数——适用于搜索输入和 resize 结束后的工作。节流(Throttle)限制触发频率——适用于手势进行中必须持续更新的滚动位置追踪。MDN 滚动事件参考文档指出,scroll 事件可能以很高的频率触发,并建议对开销较大的处理器进行节流。
useEffect(() => {
let ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
updateScrollPosition(window.scrollY);
ticking = false;
});
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
requestAnimationFrame 门控将更新节流为每帧一次,{ passive: true } 则告知浏览器该处理器不会调用 preventDefault,让浏览器无需等待你的 JavaScript 即可执行滚动。
复合效应
本文中的每一项技术,都是我们卸载给框架默认行为并停止验证的平台知识。它们没有一项是新的——这正是重点所在。单独来看,缺少 font-display 或一个未延迟的标签只损失毫秒级的时间;但叠加在一起,它们就是那个使用了现代工具却仍然感觉沉重的应用与感觉流畅的应用之间的差距。下一步具体行动:打开 DevTools,对照上述规则审查你手动编写的 @font-face 块、第三方 <script> 标签和 useEffect 监听器,并删除那些已被浏览器原生能力所取代的 Intersection Observer 封装。
常见问题
当你只关心活动停止后的最终状态时,使用防抖,例如在用户停止输入后发起搜索请求,或在 resize 结束后重新计算布局。当你需要在持续手势期间以受限频率获取更新时,使用节流,例如追踪滚动位置。防抖等待暂停;节流在事件持续触发时限制频率。
是的。loading 属性同时适用于 img 和 iframe 元素,因此折叠线以下的嵌入地图、视频播放器和第三方小部件都可以无需 Intersection Observer 封装即可原生延迟加载。浏览器支持与图片的推出时间线基本一致,在同一时期于 Chrome、Firefox 和 Safari 中达到基线标准。请保留显式的 width 和 height 以防止布局偏移,因为懒加载元素与立即加载元素一样容易导致偏移。
它们会产生竞争条件,可能以错误的顺序执行。async 脚本在各自下载完成后立即运行,按网络到达顺序而非文档顺序执行,因此依赖另一个 async 脚本的脚本可能先运行并报错。解决方法是对两个脚本都使用 defer,这能保证在解析完成后按文档顺序执行;或者将依赖项和被依赖项打包到同一个 bundle 中加载。
延迟为零的 setTimeout 将工作安排在下一个任务队列槽中,浏览器可能立即执行,包括在用户交互期间,因此它实际上并不会让出控制权给用户。requestIdleCallback 则等待真正的空闲时段,并传递一个可在继续工作前检查的截止时间。自 2024 年 3 月 INP 成为核心网页指标以来,这一区别变得尤为重要,因为落在交互期间的工作现在已是排名信号。
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.