前言
如果你对于性能优化无从下手,或者没有相关的实战项目练手,可以阅读这篇文章,文章最后一节也给出了 Chrome 团队关于性能 demo 项目,如果你对性能优化感兴趣一起来试试吧!
main 分支上是未优化前的代码,如果你想参考我的一些优化建议,可以切换到 feat/opt 分支进行比对查看。
性能优化主要以下三个核心指标,接下来结合实际项目来看看如何优化这三个核心指标。
- Largest Contentful Paint (LCP) :衡量加载性能。 为了提供良好的用户体验,LCP 必须在网页首次开始加载后的 2.5 秒内发生。
- Interaction to Next Paint (INP) :衡量互动。为了提供良好的用户体验,网页的 INP 不得超过 200 毫秒。
- Cumulative Layout Shift (CLS) :衡量视觉稳定性。为了提供良好的用户体验,必须将 CLS 保持在 0.1. 或更低。
优化 LCP
它表示网页主要内容的加载速度,具体而言,即从用户开始加载网页到最大的图片或文本块在视口中呈现之间的时间。
排查 LCP 不佳的原因
使用 Lighthouse 进行页面分析
页面整体的性能分析
筛选 LCP 相关的诊断信息
使用 Performance
为了模拟性能较差的机型,将 Performance CPU及 Network 进行调整。
通过 performance 可以排查:
- network 中阻塞的相关资源
- main 中阻塞的脚本
优化 LCP
上图中可以看到,衡量 LCP 时间分为四个子指标:TTFB、Load Delay、Load Time、Render Delay。
new PerformanceObserver((list) => {
// lcp 相关的 entry
const lcpEntry = list.getEntries().at(-1);
// 导航相关
const navEntry = performance.getEntriesByType("navigation")[0];
// 最大内容渲染时间
const lcpResEntry = performance
.getEntriesByType("resource")
.filter((e) => e.name === lcpEntry.url)[0];
const ttfb = navEntry.responseStart;
const lcpRequestStart = Math.max(
ttfb,
// Prefer `requestStart` (if TOA is set), otherwise use `startTime`.
lcpResEntry ? lcpResEntry.requestStart || lcpResEntry.startTime : 0
);
const lcpResponseEnd = Math.max(
lcpRequestStart,
lcpResEntry ? lcpResEntry.responseEnd : 0
);
const lcpRenderTime = Math.max(
lcpResponseEnd,
// Use LCP startTime (the final LCP time) because there are sometimes
// slight differences between loadTime/renderTime and startTime
// due to rounding precision.
lcpEntry ? lcpEntry.startTime : 0
);
}).observe( {
type: "largest-contentful-paint",
buffered: true
})
Time to first byte从用户开始加载页面到浏览器收到 HTML 文档响应的第一个字节所需的时间。- navEntry.responseStart 读取
- Resource load delay 资源加载延迟
- 如果 LCP 资源是图片等外部资源,则是 lcpResEntry.requestStart - ttfb
- 如果 LCP 资源非外部资源,则是0
- Resource load time 资源加载时间
- 如果 LCP 资源是图片等外部资源,则是 lcpEntry.startTime 或者 lcpEntry.renderTime
- 如果 LCP 资源非外部资源,则是0
- Element render delay 元素渲染延迟(从 LCP 资源加载完毕到 LCP 元素完全呈现所经过的时间。)
- 如果 LCP 资源是图片等外部资源,则是 lcpEntry.startTime - lcpResEntry.responseEnd
- 如果 LCP 资源非外部资源,则是 lcpEntry.startTime - ttfb
所以优化 LCP 就是优化这四个子指标的时间。我们来实际的案例中查看。
案例分析
案例中的 LCP 元素非外部资源。所以资源加载延迟和 资源加载时间都是 0,主要时间都在元素渲染延迟的花费上。
优化前
优化网络请求
从 Network 中看到 LCP 出现前,有非常多的 js、css、json 资源需要请求,这些都会影响 LCP 加载时间。
优化后
- 对于第三方库这类变动比较少的直接可以缓存在浏览器中
- 对于可能发生变动的 js 可以采用协商缓存的方式缓存在服务器上
- 内嵌 style,减少 http 请求
- 影响 LCP 的第三方依赖直接加载,不需要使用 defer
- 第三方依赖使用 cdn min 版本
- 服务器内存缓存比如使用 node-cache
- 拆分 css 文件,按需加载
优化 JS
js 脚本解析也会阻塞 LCP 时间。
- 提前下载必要脚本
<link rel="preload" href="" as="script"> - 将大型 JavaScript 文件拆分为多个脚本文件,并按需加载
- 避免使用 document.write
- 减少首屏 js 任务
- 使用 Intersection Observer API,requestAnimationFrame 等 api 提高性能
- 拆分长任务,出让浏览器控制权
- 有大量计算使用 Web Workers
优化 INP
Interaction to Next Paint (INP) 是一个稳定的 Core Web Vitals 指标,可以使用 Event Timing API 中的数据来评估网页响应能力。INP 会在网页生命周期内观察用户与网页进行的所有点击、点按和键盘互动的延迟时间,并报告最长持续时间。
良好的响应速度意味着网页对互动的响应速度很快。当网页响应互动时,浏览器会在呈现的下一帧中提供视觉反馈,表明互动已成功。
排查 INP 不佳的原因
Web Vitals 插件
安装插件后,进行交互后会打印性能指标。针对 INP 时间比较高的交互可以使用 Perforamnce 进行详细定位。
使用 performance
当输入文本对列表进行过滤时,脚本执行完成耗时 1.67s。严重影响用户体验,我们的目标时 200ms 以内。后续会分析如何进行优化。
优化 INP 时间
当我们使用 Performance 排查到比较耗时的地方已经成功一半。接下来从三部分进行优化:
-
输入延迟:在用户发起与网页的互动时开始,在互动的事件回调开始运行时结束。
- 点击到回调函数执行之间的时间,原因如冒泡、主线程在加载脚本、快速点击互动交互影响
-
处理时长,其中包含事件回调运行完成所需的时间。
- js 本身执行时间
- 呈现延迟时间,即浏览器呈现下一帧(包含互动的视觉效果)所需的时间。
- 渲染时间
输入延迟优化
网页已呈现并不代表该网页已完成加载。根据网页完全正常运行所需的资源数量,用户有可能在网页仍在加载时尝试与网页互动。
输入延迟可能会相当长。这可能是由于主线程上发生的活动(可能是由于加载、解析和编译脚本)、提取处理、计时器函数,甚至是来自快速连续发生且彼此重叠的其他互动所致。
- 尽快完成网页资源加载
- 减少资源加载大小
- 静态资源使用浏览器缓存
- 分页加载等
- 防抖、节流处理,避免同时多次触发回调
- 减少事件冒泡路径。合理给 dom 绑定事件
处理时长优化
Performance 问题排查
主要涉及我们写的代码,重点关注。来看看上面 1.67s 的处理中包含哪些逻辑。
其中有 1.51s 再执行 handleSearch 方法,紫色的表示 dom 重新渲染。说明这个方法中频繁触发了 dom 渲染。
继续放大 dom 渲染发现这块频繁触发了 replaceChildren,点击 Layout 可以看到线指向触发 Layout 的源代码。
排查到具体的源代码,我们可以根据业务逻辑,来优化,具体可以参考我的优化分支。
优化策略
- 减少浏览器重绘和回流操作
- 延迟非关键代码逻辑,可以使用 requestIdleCallback、setTimeout
- 使用 requestAnimation 优化动画
- 优化代码逻辑实现
如果在 api 层面没有太大的优化空间,有时候优化代码实现逻辑可能是比较有效的策略。
// 优化前
/**
* 1. 获取所有的列表项
* 2. 将它们都移除
* 3. 对列表项进行排序,并根据 searchStr 控制列表项是否展示
* 4. 将所有列表项又重新插入 dom 中
*/
function searchTerms(searchStr) {
const mainEl = document.querySelector('main');
// Remove, sort, and re-add term elements.
const termEls = [...document.querySelectorAll('article.term')];
termEls.forEach(tEl => tEl.remove());
const sortedTermEls = sortTerms(termEls, searchStr);
sortedTermEls.forEach(tEl => mainEl.append(tEl));
styleTerms(searchStr);
}
// 优化后
/**
* 1. 遍历所有列表项,将不展示的项隐藏,展示的移除(用于后续排序)
* 2. 对移除的 dom 进行排序
* 3. 对 dom 进行关键词高亮
* 4. 仅插入展示的 dom
*/
function searchTerms(searchStr) {
const mainEl = document.querySelector('main');
const termList = [...document.querySelectorAll('article.term')];
// 存储需要展示的 dom
const map = new Map()
termList.forEach(termEl => {
const term = termEl.dataset.term;
const includeTerm = term.toLowerCase().includes(searchStr.toLowerCase());
if (includeTerm) {
mainEl.removeChild(termEl)
map.set(term, termEl)
} else {
termEl.style.display = 'none'
}
})
const searchResult = sortTerms([...map.values()], searchStr)
searchResult.forEach(termEl => {
const highlightedElements = getHighlightedSearchElements(termEl, searchStr);
termEl.querySelector('h3').replaceChildren(...highlightedElements);
termEl.style.display = ''
})
searchResult.forEach(el => {
mainEl.appendChild(el)
})
}
优化后从 1.67s 减少到 250ms,优化体验大幅提升,我们还可以继续通过 Performance 针对性能卡点进行排查优化。性能优化通常是综合的原因导致,通过不断减少各项子指标最终达到理想的效果。
呈现延迟时间优化
当页面的 DOM 较小时,渲染工作通常很快就会完成。然而,当 DOM 变得非常大时,渲染工作往往会随着 DOM 大小的增加而变大。渲染工作与 DOM 大小之间的关系不是线性关系,但大型 DOM 的渲染工作量确实比小型 DOM 多。
- 懒加载,使用
IntersectionObserver监听目标元素与根节点的交叉情况。 - 分页查询更新 dom
- 虚拟滚动
- 使用
content-visibility控制元素是否渲染其内容
优化 Cumulative Layout Shift
它会结合视口中可见内容的偏移量和受影响元素移动的距离来衡量内容的不稳定性。布局偏移可能会分散用户的注意力。
优化CLS
Cumulative Layout Shift (CLS) 是三个核心网页指标指标之一。它会结合视口中可见内容的偏移量和受影响元素移动的距离来衡量内容的不稳定性。
布局偏移可能会分散用户的注意力。假设您在开始阅读一篇文章时突然元素在网页内四处移动,导致您迷失方向,需要再次找到相应位置。
排查 LCP 不佳的原因
使用 Lighthouse 筛选 CLS
使用 Performance
优化方向
导致 CLS 不佳的最常见原因包括:
- 没有尺寸的图片。
- 解决方案:设置尺寸
- 广告、嵌入和没有尺寸的 iframe。
- 解决方案:可以设置固定高度。或者设置默认高度来减少偏移量。
- 动态注入的内容,如广告、嵌入式内容和无维度的 iframe。
- 解决方案:预留动态内容的空间
- 网络字体。
- 解决方案:预加载字体
没有尺寸的图片
将网络调整 3G 延长图片的加载时间,使用 LightHouse 进行页面分析。
发现 cls 得分 0.656 远大于我们的目标 0.1。我们给图片设置尺寸后看看效果。
<!-- 优化前-->
<body>
<h1>这里是标题</h1>
<div>
<!-- 没有尺寸的图片-->
<img src="https://ts1.cn.mm.bing.net/th/id/R-C.b8e84a0907bf9b5128dfa48be0ae48af?rik=tfH6k%2fT3hkauqw&riu=http%3a%2f%2fwww.08lr.cn%2fuploads%2fallimg%2f220330%2f1-2300141M0.jpg&ehk=dR6hTo1o7lNsHkpE62oIzMtJ%2bmxktf7%2fx6tp3Zt2uB8%3d&risl=&pid=ImgRaw&r=0" alt="">
<img src="https://ts1.cn.mm.bing.net/th/id/R-C.57384e4c2dd256a755578f00845e60af?rik=uy9%2bvT4%2b7Rur%2fA&riu=http%3a%2f%2fimg06file.tooopen.com%2fimages%2f20171224%2ftooopen_sy_231021357463.jpg&ehk=whpCWn%2byPBvtGi1%2boY1sEBq%2frEUaP6w2N5bnBQsLWdo%3d&risl=&pid=ImgRaw&r=0" alt="">
</div>
<div>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
</div
给图片设置尺寸后,CLS 直接变成了0.
<!--优化后-->
<body>
<h1>这里是标题</h1>
<div>
<img style="width: 100px;height: 150px;" src="https://ts1.cn.mm.bing.net/th/id/R-C.b8e84a0907bf9b5128dfa48be0ae48af?rik=tfH6k%2fT3hkauqw&riu=http%3a%2f%2fwww.08lr.cn%2fuploads%2fallimg%2f220330%2f1-2300141M0.jpg&ehk=dR6hTo1o7lNsHkpE62oIzMtJ%2bmxktf7%2fx6tp3Zt2uB8%3d&risl=&pid=ImgRaw&r=0" alt="">
<img style="width: 100px;height: 150px;" src="https://ts1.cn.mm.bing.net/th/id/R-C.57384e4c2dd256a755578f00845e60af?rik=uy9%2bvT4%2b7Rur%2fA&riu=http%3a%2f%2fimg06file.tooopen.com%2fimages%2f20171224%2ftooopen_sy_231021357463.jpg&ehk=whpCWn%2byPBvtGi1%2boY1sEBq%2frEUaP6w2N5bnBQsLWdo%3d&risl=&pid=ImgRaw&r=0" alt="">
</div>
<div>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
<p>这里是内容</p>
</div>
</body>