不知道你是不是也有这种经历:压缩图片、拆包、上 CDN、改缓存策略,能做的表面功夫全做了,跑分工具上的数字还是在70多分徘徊。我之前优化一个内容型页面,整整折腾了两周,LCP和CLS怎么都压不下去。后来把目光从“打包配置”挪回到“浏览器原生API”上,换了思路,前后用了 9 个 API,把页面性能从 70 多一路干到了 90+,而且不是靠清缓存刷出来的虚高分数。
这篇文章就把这 9 个 API 逐个拆开讲,每个 API 解决什么问题、代码长什么样、实测过程中有哪些坑,都会说清楚。适合正在做前端性能优化、被跑分指标卡住的同学参考,哪怕你之前没系统性搞过性能优化,看完也能直接照着改。
1. 为什么我建议盯紧浏览器原生API:一个真实的上分过程
先说背景。我手头那个项目是一个资讯类页面,内容以长列表、图片、内嵌卡片为主。第一轮优化我干的事和大多数人一样:把所有图片换成 WebP、给静态资源加了一年缓存、路由组件改成按需加载、把打包体积从 1.2MB 砍到 600KB。改完一测,分数从 58 涨到了 74,然后就死死卡住不动了。
最让人恼火的是,单看 Network 面板已经没什么大文件了,首屏请求也就 20 来个,可跑分工具里 TBT(总阻塞时间)和 CLS(累积布局偏移)还是飘红。那时候我才反应过来,性能瓶颈早就不是“加载了多少资源”,而是“页面跑起来之后浏览器在主线程上干了多少杂活”。
这个认知转变很重要。跑分工具给出的 90+ 评价,核心权重都压在加载速度和视觉稳定性上,而这两件事要看的是:浏览器解析 HTML 的效率、渲染首屏内容的路径、脚本对主线程的阻塞程度、布局发生偏移的次数。这些指标,恰恰是浏览器原生 API 的用武之地——不需要引入第三方库,直接用浏览器暴露出来的能力去观测、调度、跳过无意义工作。
我给自己定了个原则:能不装库就不装库,能用浏览器自带的能力就不自己造轮子。顺着这个原则,我最终用了 9 个 API,按作用分成三批。第一批管“看”,让页面知道用户到底看到了什么;第二批管“懒”,能不干的事就不干;第三批管“稳”,确保交互和渲染过程不抖不动。
2. 先读透性能指标:这9个API各自打向哪一处瓶颈
很多人一上来就堆 API,堆完发现分数没变化,因为根本没搞清楚每个 API 对应的是哪一个性能指标。跑分工具评分背后,核心看的是 FCP(首次内容绘制)、LCP(最大内容绘制)、CLS(累积布局偏移)、TBT(总阻塞时间)、INP(交互到下次绘制)这几项。我先把这 9 个 API 按“打向哪个指标”做了个映射:
| API | 主要作用 | 直接影响指标 | 典型使用场景 |
|---|---|---|---|
| IntersectionObserver | 元素进入视口检测 | LCP、FCP | 图片、广告位、埋点曝光 |
| PerformanceObserver | 性能条目实时监听 | TBT、LCP、CLS | 性能监控、回归卡点 |
| ResizeObserver | 元素尺寸变化监听 | CLS | 动态内容插入后布局修正 |
| MutationObserver | DOM 变化监听 | TBT、CLS | 第三方脚本后置处理 |
| requestIdleCallback | 空闲期调度低优任务 | TBT | 数据上报、非关键计算 |
| content-visibility | 屏外内容跳过渲染 | 渲染耗时、LCP | 长列表、折叠内容 |
| font-display: swap | 字体加载策略 | FCP、CLS | 自定义字体 |
| Web Animations API | 合成器动画 | INP、TBT | 动画替换 JS 逐帧方案 |
| Page Lifecycle API | 页面状态切换响应 | 整体体验 | 缓存恢复、后台限制 |
表格里的映射关系,就是后面所有改动的依据。你看到某个指标红了,先去想“这个指标背后是哪个环节在拖后腿”,再决定上哪个 API。反过来,先决定用哪个 API 再看它能救哪个指标,就容易做无用功。
这 9 个 API 里,真正能立竿见影的其实就几个,比如 content-visibility、font-display 和 IntersectionObserver。剩下的更像是“治本”的长期工程,比如 PerformanceObserver 和 MutationObserver,它们本身不产生性能收益,但能让你的后续优化不再瞎猜。下面我按批次展开讲。
3. 第一批火力:靠行为观测拿回交互主动权
3.1 IntersectionObserver:替代 scroll 监听,图片懒加载的正确姿势
第一个要说的必须是 IntersectionObserver。它解决了前端一个老痛点——监听滚动位置判断元素是否可见。以前大家用 scroll 事件 + getBoundingClientRect 的方式,每次滚动都要触发大量计算,而且滚动事件的触发频率远高于你真正需要执行回调的频率,主线程被白白占掉。
核心代码长这样:
const lazyImages = document.querySelectorAll('img[data-lazy]'); const imageObserver = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.lazy; img.removeAttribute('data-lazy'); observer.unobserve(img); } }); }, { rootMargin: '200px 0px', threshold: 0.01 }); lazyImages.forEach(img => imageObserver.observe(img));我实测过程中有两个细节值得注意。一个是 rootMargin 不要写太大,我曾经为了“让图片提前加载”把 rootMargin 设成 1000px,结果首屏下方的图片全部提前加载,懒加载等于没做。另一个是加载完成后一定要 unobserve,不然每张图片都会在后续滚动中反复进入观察函数,回调一多照样卡。
首屏 LCP 元素如果是图片,不建议走懒加载,因为它本来就是首屏必须展示的内容,应该用 preload 提示浏览器优先加载。懒加载只适合首屏之外的资源。
3.2 ResizeObserver:动态内容插入后,抢在 CLS 前修正布局
CLS 是评分里最阴间的指标,它不像 LCP 只要你资源够快就行,而是任何一次意外的布局移动都会被记上一笔。最常见的 CLS 来源就是图片加载完成、广告位插入、字体替换后,元素尺寸发生变化,把下面的内容往下推。
ResizeObserver 解决的是“元素尺寸变化时主动响应”的问题。举个我实际遇到的场景:页面里有一个横向滚动的卡片列表,卡片高度由图片和文字共同决定,后端数据返回后有一批卡片的高度和预期不一致,导致下面的排版连续抖动。
我加了一个监听器:
const list = document.querySelector('.card-list'); const resizeObserver = new ResizeObserver(entries => { for (const entry of entries) { if (entry.target === list) { applyStableLayout(); } } }); resizeObserver.observe(list);默认情况下 ResizeObserver 的回调会在每次尺寸变化时触发,如果你只想在结束阶段处理一次,配合 requestAnimationFrame 做防抖。不过说实话,ResizeObserver 的权重是“救火”,真正的做法是从源头给图片和广告位预留固定尺寸——这比任何观测 API 都重要。
3.3 MutationObserver:第三方脚本插 DOM,你总不能拦着,但你可以后处理
很多页面分数低,锅不在你自己的代码,而在第三方脚本:统计脚本、客服组件、AB 测试脚本。它们会在页面运行期间往 body 里插节点,每次插入都可能触发一次布局重算,插得多了 TBT 自然就高。
我不主张一棒子打死第三方脚本(毕竟业务需要),但可以用 MutationObserver 把它们的“副作用”变成“可控后处理”。例如某个统计脚本会在页面底部插入一个 300px 高的容器,如果这个容器在用户滚动到页面中间时突然出现,CLS 就会飙升。
我的处理方式是先用 MutationObserver 监听 body 的子节点变化,发现新增的第三方节点后立即给它设置定位上下文:
const bodyObserver = new MutationObserver(mutations => { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.nodeType === 1 && node.matches('.third-party-widget')) { node.style.position = 'absolute'; node.style.bottom = '0'; } }); }); }); bodyObserver.observe(document.body, { childList: true, subtree: true });这里要提醒一个容易踩的坑:MutationObserver 本身也会占用主线程,监听维度越广,性能损耗越大。能用 childList 就不要开 attributes,能监听某个容器就不要监听整个 body。我见过有人为了“稳妥”把 subtree 和 attributeFilter 全开,结果这个 observer 自己就把页面拖慢了,典型的本末倒置。
3.4 requestIdleCallback:把数据上报等非关键任务挪到浏览器空闲时
requestIdleCallback 可能是这几个 API 里存在感最低、但 TBT 收益最明显的。它可以在浏览器主线程空闲时执行低优先级任务,也可以接收一个超时参数,确保重要任务不会无限期推迟。
我把它用在了埋点上报上。原来是每滚动一定距离就上报一次用户行为,滚动过程中本来就在处理滚动事件,再插一个 XHR 请求,主线程雪上加霜。改成 requestIdleCallback 之后,上报逻辑只在空闲窗口执行:
function scheduleReport(data) { if ('requestIdleCallback' in window) { requestIdleCallback(() => { reportToServer(data); }, { timeout: 2000 }); } else { setTimeout(() => reportToServer(data), 1000); } }说一下为什么 timeout 要设 2000。如果不设,在用户持续滚动、持续交互的情况下,空闲窗口可能迟迟不来,埋点数据就一直堆积。设了 timeout 之后,浏览器最多等我 2 秒,2 秒后即使不空闲也会强制执行,兼顾了性能和数据的及时性。
兼容性方面要特别留意,Safari 至今不支持 requestIdleCallback,所以 fallback 到 setTimeout 是必须的。另外,requestIdleCallback 只在主线程有“空闲时间”时才有意义,如果你前面的脚本已经把主线程占满了,它一样帮不上忙,这就是为什么它要和其他 API 配合用,而不是单兵作战。
4. 第二批火力:把渲染负担“转发”给浏览器引擎
4.1 PerformanceObserver:先拿到真实数据,再谈优化
这个 API 看起来不产生直接性能收益,但我把它放在第二批的第一个,因为它是唯一能让你“量化优化效果”的工具。它的作用是从 Performance Timeline 里实时获取性能条目,包括 LCP、CLS、长任务等。
我用它写了一个轻量的性能监控,线上页面每个用户都会上报关键指标:
const observer = new PerformanceObserver((list) => { const entries = list.getEntries(); entries.forEach(entry => { if (entry.entryType === 'largest-contentful-paint') { sendMetric('LCP', entry.startTime); } if (entry.entryType === 'layout-shift') { sendMetric('CLS', entry.value); } }); }); observer.observe({ entryTypes: ['largest-contentful-paint', 'layout-shift'] });有了这个数据流,后续的每次改动都有据可查。你就可以在发布流程里加一个“性能预算卡点”:LCP 超过 2.5s 或 CLS 超过 0.1 就拦截合并请求。这个方法比我手动跑分靠谱得多,因为它统计的是真实用户的分布,而不是本机一次跑分的结果。
重点提一个易错点:PerformanceObserver 的回调要处理 disconnect。在 SPA 应用里,如果你在每次路由进入时都 new 一个 observer 而不 disconnect,监听器会越积越多,内存上升,长任务也会变多。正确做法是:全局只注册一次,或者用完后立即 disconnect。
4.2 content-visibility:让浏览器跳过屏幕外的渲染工作
这是我这轮优化里立竿见影的一个。原理很简单:把 content-visibility: auto 加到长列表的每个条目上,浏览器会主动跳过屏幕外元素的渲染工作,等用户滚动接近时再渲染。相当于手动告诉浏览器“这些元素现在不用画”。
我给资讯卡片加了这样一个类:
.article-card { content-visibility: auto; contain-intrinsic-size: 480px; }注意 contain-intrinsic-size 必须写,否则浏览器不知道未渲染元素的占位尺寸,滚动条会来回跳动,反而引发新的 CLS。这个值填的是元素在“未渲染”状态下应有的估计高度,比如我的卡片平均高度是 480px,就直接填 480px。如果卡片高度不固定,可以填一个区间,比如contain-intrinsic-size: auto 480px,表示先按 480px 估算,渲染后按真实值修正。
这个属性对长列表页面的 FCP 有明显帮助,因为浏览器不再需要为几十屏的内容一次性做样式计算和布局。但有个副作用要说清楚:搜索、跳转锚点这类需要“立即定位到页内某个位置”的功能,可能会因为目标元素还没渲染而定位不准,需要配合滚动后强制执行渲染。我的处理方案是,对所有带锚点跳转的目标元素临时移除 content-visibility 类,完成定位后再加回来。
4.3 font-display: swap:自定义字体是 CLS 的隐形推手
字体加载这个坑特别隐蔽。你检查图片、检查布局,很难想到 CLS 是字体文件造成的。但如果页面用了自定义字体,而字体文件加载晚于首屏文字渲染,浏览器会先用 fallback 字体绘制文字,等自定义字体到了再替换,替换瞬间文字宽度变化,行高和元素高度跟着变,CLS 就发生了。
font-display: swap 让文字先用 fallback 字体显示,自定义字体到达后直接替换,用户不会看到白屏,但替换瞬间的布局偏移还是会被记进 CLS。所以光有 swap 还不够,最好给文字容器设置和 fallback 字体接近的行高:
body { font-family: 'CustomFont', 'PingFang SC', sans-serif; font-display: swap; }在浏览器还支持 size-adjust 描述符的情况下,能进一步缩小字体替换前后的尺寸差,但保守起见,我会在正文区域刻意选用和系统字体字宽接近的自定义字体,从源头上减小偏移量。这轮改完之后,我的 CLS 从 0.18 降到了 0.05 左右,属于性价比很高的一次改动。
4.4 Web Animations API:动画别再用 JS 逐帧算了
做动画的老办法是:监听 scroll、requestAnimationFrame、改元素的宽度和 top。这一套组合拳打下来,每一帧都要触发布局和绘制,帧率上不去,主线程还被占满。后来我改用 Web Animations API(WAAPI),动画过程交给浏览器自身去处理,能在合成器层面运行,绕开布局和绘制。
一个典型的例子:以前我做卡片展开动画,写的是 JS 里每帧修改 height:
card.animate([ { height: '200px' }, { height: '400px' } ], { duration: 300, easing: 'ease-out' });height 动画在部分浏览器里还是触发布局,最佳做法是只对 transform 做动画,让高度变化一次到位,用 scaleY 模拟展开效果。WAAPI 的优势是代码可读性高、浏览器可以自动优化,配合transform: scale()来用,动画帧率基本稳定在 60fps。
举个更极端的例子:页面里有个数字轮播组件,原来每隔 500ms 就更新一次 DOM 内容。改用 CSS 的 opacity 过渡 + transform 位移来实现切换效果后,新增了合成层,主线程负载大幅下降,TBT 指标直接受益。
4.5 Page Lifecycle API:别忽略 bfcache 和页面切换带来的性能回退
页面性能不只是“首次打开有多快”,还包括“用户切走再切回来有多顺”。Page Lifecycle API 监听 pageshow、pagehide、visibilitychange 这些事件,能让你在页面从 bfcache 恢复时,跳过不必要的初始化工作。
最典型的场景:页面切到后台再返回,如果监听的是 load 事件,bfcache 恢复时不会触发,但有些逻辑绑在 load 上,就漏掉了。反过来,有些人把重逻辑绑在 pageshow 上,但 bfcache 恢复时 pageshow 也会触发,导致每次返回都重新执行一遍重初始化。
我的做法是区分两个场景:
window.addEventListener('pageshow', (event) => { if (event.persisted) { // 从 bfcache 恢复:不需要重新加载数据,只需要恢复交互状态 restoreInteractions(); } else { // 首次加载:走完整初始化 fullInitialize(); } });这样处理后,用户从后台返回页面时,不会出现白屏闪一下、数据重新请求一遍的情况。看似和评分没有直接关联,但用户体验上的“流畅感”是 INP 和 TBT 之外最容易感知到的部分。
5. 代码写对了评分却不动:我踩过的三个测量陷阱与排查链路
前面讲的都是直接用 API 改善页面,但如果你把这个流程走一遍,很可能遇到这种情况:代码明明都加上了,跑分还是老样子,甚至分数更低了。我前后排查了两天才找到原因。下面把三个坑完整列出来,按“现象-排查-根因-解决”的顺序说,方便你对照复现。
5.1 测量环境不干净:扩展、后台窗口都在偷走你的分数
现象:代码改了,分数反而从 82 掉到 75,重新测三次,三次结果都不一样。
排查链路:先看 Network 面板,请求数正常;再看 Performance 面板,发现主线程有一堆我看不懂的长任务。把浏览器扩展逐个停用,再测,分数回来了。
根因:浏览器扩展会在每个页面注入脚本,这些脚本占用主线程,TBT 被拉高。后台窗口开了别的标签页时,浏览器资源被抢占,同样影响测量结果。跑分工具的分数是“当前环境下的分数”,不是“代码质量的分数”。
解决:测量前固定一套环境——无痕窗口、停用所有扩展、只开一个标签页、固定窗口尺寸。有条件的话用独立浏览器 profile 专门跑分。排查顺序是:先关扩展,再关后台页,最后看是不是本地机器硬件导致的,按这个链路一步步排除。
5.2 PerformanceObserver 在 SPA 里重复注册,主线程被自己拖死
现象:加完性能监控后,TBT 不但没降,反而升了几百毫秒。
排查链路:我以为是监控代码本身太重,于是把上报逻辑全部注释,TBT 还是高。看 Performance 面板,发现大量来自 PerformanceObserver 的长任务,每个任务的持续时间是几十毫秒,但数量非常多。
根因:我在路由切换时反复调用 registerPerformanceObserver,每次都没有 disconnect 旧实例。监听器数量呈线性增长,主线程被自己的监控代码占满。
解决:全局只保留一个 PerformanceObserver 实例,或者在路由卸载时调用 observer.disconnect()。排查顺序是:先在控制台打印 PerformanceObserver 的实例数量,再检查路由生命周期钩子里有没有重复初始化。
5.3 content-visibility 引发的滚动条抖动:CLS 不降反升
现象:给所有卡片加上 content-visibility: auto 后,CLS 从 0.06 涨到 0.12,滚动时页面明显在跳。
排查链路:打开 DevTools 的 Rendering 面板,勾选“Layout Shift Regions”,滚动页面,看到一大片红色高亮区域出现在底部。再检查元素的 computed style,发现很多元素因为没有 contain-intrinsic-size,未渲染时占位高度为 0,滚动到附近才突然拥有真实高度,把下面的内容往下推。
根因:content-visibility: auto 需要一个明确的占位尺寸,否则浏览器在“未渲染”状态下的布局模型是残缺的。我只加了 content-visibility,漏了 contain-intrinsic-size,产生了巨量 CLS。
解决:为每个使用 content-visibility 的元素补上 contain-intrinsic-size,取值参考平均渲染高度。排查顺序是:先看滚动条是否稳定,再看 Layout Shift Regions 热力图,最后检查 CSS 里是否漏写占位尺寸。
这三个坑的共同教训是:性能优化里的“测量”环节本身就容易引入误差,不干净的测量环境比代码本身更影响评分。所以遇到“改了半天分数不动”,别急着改代码,先回头检查测量姿势。
6. 上线前后的组合策略:哪些 API 起效最快、哪些要分阶段灰度
9 个 API 不是让你一次全塞进代码里的。我自己是按“快慢结合”的策略分批上的,下面标注了每类改动的收益速度和风险,供你排优先级:
| 优先级 | API | 收益速度 | 风险 | 推荐动作 |
|---|---|---|---|---|
| P0 | content-visibility | 立即 | 滚动条抖动风险 | 加 contain-intrinsic-size 后全量上 |
| P0 | font-display: swap | 立即 | 字体替换 CLS | 配合预设 fallback 字体尺寸 |
| P1 | IntersectionObserver | 1-2 天内 | 低 | 替换现有 scroll 监听懒加载 |
| P1 | requestIdleCallback | 1-2 天内 | low | 数据上报类任务直接换 |
| P1 | PerformanceObserver | 需要积累数据 | 低但需防重复注册 | 上线监控,建立回归卡点 |
| P2 | ResizeObserver | 看动态内容量 | 中 | 优先修复源头尺寸,再用于兜底 |
| P2 | MutationObserver | 看第三方脚本 | 中,需控制监听范围 | 先定位高开销三方脚本再上 |
| P2 | WAAPI | 看动画占比 | 中 | 新动画直接写 WAAPI,旧动画分批改 |
| P3 | Page Lifecycle API | 体验感知明显 | 低 | 有 bfcache 需求的页面优先加 |
我的实际落地顺序是:先上 P0 的两个 CSS 层面能力,因为它们改动最小、风险可控;然后花两天时间把图片懒加载和埋点上报切换到 IntersectionObserver 与 requestIdleCallback;第三步才是接入 PerformanceObserver 做监控和回归卡点,这一步不是改善性能,而是防止后续代码把性能改回去;最后用两周时间逐步替换动画和第三方脚本的处理逻辑。
如果你现在接手的是一个性能很差的存量页面,建议你也按这个顺序来,先拿确定性的收益,再谈长期治理。有一点要特别提醒:MutationObserver 和 ResizeObserver 这类“观测型”API,它们只是让你在乱局中抓住控制权,不是帮你把问题变没。长期来看,从源头给图片定尺寸、把第三方脚本挪到非关键路径、减少不必要的 DOM 变更,才是根本解法。
我自己做完这一轮之后再回头看,发现一个更深的体会:性能优化最关键的转折点,不是用了多少个 API,而是你有没有把“优化”从一次性冲刺变成持续反馈的过程。PerformanceObserver 埋点留下的数据流水线,让每一次代码改动都能快速看到真实影响。这个观念转过来以后,跑分工具上的 90+ 就不再是一个终点,而是一条稳定线的开始。