前言
2024 年的今天,Lighthouse 已成为衡量和提升网页性能的首选工具。然而,对于新手开发者而言,初次使用 Lighthouse 时往往感到迷茫,不知从何入手。
因此,我在 GitHub 上基于 React 和 Webpack 搭建了一个 Demo 项目,模拟了现实中可能遇到的性能问题(为了更加凸显问题刻意的写了一些额外的代码和配置),并逐步展示优化过程。在这个项目中的 commit 记录中,我详细记录了如何将 Lighthouse 性能评分从 2分
提升到 99分
的过程。涵盖了代码压缩、图片优化、预加载、懒加载等常见优化技巧。
项目地址:lighthouse-demo
你可以将这个项目克隆到本地,并切到 before_optimization
分支,尝试自己进行优化,看看能将分数提升到多少。然后,跟随这篇文章和 commit 记录,一步一步提升 Lighthouse 评分。
0. 本地启动项目
- 克隆项目到本地
git clone git@github.com:leowux/lighthouse-demo.git
- 切换到
before_optimization
分支
git checkout before_optimization
- 安装依赖
npm install
- 预览项目(打包+启动)
npm run preview
- 打开 Chrome Devtool 工具,切到 Lighthouse 栏,点击分析页面按钮。
等待些许时间就可以看到性能评分了:
- 修改代码后,需要重新执行
npm run preview
,在进行 lighthouse 性能分析。
1. 开启文本压缩
相关 Commit:perf: enable text compression
在 Lighthouse 评分界面往下滑,就可以发现官方提供的一些诊断建议(DIAGNOSTICS),我们可以参照这些建议进行优化。 如下图,Lighthouse 建议开启文本压缩以缩减网络资源的体积,从而提升资源的加载速度。
打开 Demo 项目我们可以发现,项目是通过 express
启动的服务,因此我们只需要安装 compression
依赖,然后在 server.js
文件通过 app.use
添加文本压缩的中间件 compression
即可。
再次运行
npm run preview
,分析页面,结果分数从 2 分提升到 21 分。可以发现跟资源加载速度相关的指标,FCP(首次内容绘制时间)、LCP(最大内容绘制时间)以及 Speed Index(速度指标)都有相应的提升。
2. 优化图片偏移
相关 Commit:perf: avoid large layout shifts
接下来 Lighthouse 诊断出页面存在严重的布局偏移问题,并帮我定位了问题所在的图片元素。
首先简单介绍一下什么是布局偏移,以及相关的 Lighthouse 指标 CLS(累积布局偏移):
布局偏移:当用户浏览网页时,页面上的元素(例如文本、图片、按钮)如果在加载过程中发生了意外位置变化,就会产生布局偏移。这样的情况可能会导致用户体验不佳,例如点击某个按钮时,由于按钮位置变化而点到了别的地方。
CLS:该指标衡量的是网页整个生命周期内所有意外布局偏移的总和。CLS 的值越大,说明页面的视觉稳定性越差。
常见的导致布局偏移原因以及解决方案:
- 图片没有指定尺寸,导致加载时内容位置变化;
- 解决方案:在
<img>
标签中明确指定width
和height
属性;
- 解决方案:在
- 动态加载的内容(如广告)没有预留空间;
- 解决方案:为广告、动态内容等预留固定的占位元素;
- 使用字体时,字体加载后替换了默认字体;
- 解决方案:使用
font-display: swap
避免字体加载时的布局抖动。
- 解决方案:使用
回到 Demo 项目,Lighthouse 已经帮我们定位到发生布局偏移的图片。查看代码发现:图片没有指定固定的高度属性,我们给 img
标签添加 height
属性即可。
优化后分数从 21 分提升到 52 分,与之相关的 CLS 指标从 0.825 降低到了 0。可以看出 LCS 在整个 Lighthouse 性能评分占据着较大的比重(25%)。
3. 延迟加载JS脚本
相关 Commit:perf: defer script
下图中 Lighthouse 诊断出:存在阻塞页面渲染的资源,并且建议延迟加载所有非必要资源( Resources are blocking the first paint of your page. Consider delivering critical JS/CSS inline and deferring all non-critical JS/styles)
在 Lighthouse 面板中,资源对渲染的影响可能并不直观。我们可以借助 Performance 工具对页面进行录制,在录制完成后查看性能报告中的 Main 视图(主线程),来清晰地呈现浏览器的渲染情况:
在上图 Performance 面板我们可以看到,JS 资源的加载和执行阻塞了页面渲染,导致页面首次渲染的时间延后了很长时间。这里涉及到浏览器在加载页面时的一个机制:在解析 HTML 过程中,如果遇到同步的 JS 脚本,会优先加载并且执行它,执行完毕后再去解析 HTML。针对这种情况,我们可以给 script
标签添加 defer
属性,让其异步加载 JS 资源,并且等待 HTML 解析完毕后再去执行 JS 脚本。
回到 Demo 项目,页面的 script
标签是通过 webpack
的 HtmlWebpackPlugin
插件插入 HTML 中的,因此我们需要将插件的 scriptLoading
属性从 blocking
修改为 defer
,就可以实现 JS 脚本的延迟加载。
优化后分数从 52 分提升到 57 分,并且与页面渲染速度相关的指标 FCP 和 LCP 都有相应的提升。
4. 减少JS执行时间
相关 Commit:perf: reduce javascript execution time
下图中 Lighthouse 建议减少 JS 的执行时间,由于 JavaScript 是单线程,在处理耗时较长的任务时就没办法及时响应用户操作,从而影响到一个非常重要的指标——TBT(总阻塞时间)。
简单介绍一下 TBT 指标:
- TBT 衡量的是网页未响应用户输入(例如鼠标点击、屏幕点按或键盘按下操作)的总时长。
- 计算规则:将 FCP 和 TTI 之间所有长任务的阻塞部分相加。(任何执行时间超过 50 毫秒的任务都是长任务。50 毫秒之后的时间属于阻塞部分。例如,如果 Lighthouse 检测到时长为 70 毫秒的任务,则阻塞部分将为 20 毫秒。)
- 常见优化手段:
- 减少 JavaScript 执行时间,优化业务逻辑代码,去掉无效代码,使用更高效的算法或数据结构,避免重复计算或不必要的操作。
- 最小化主线程工作,将一些耗时的任务分片执行,利用 requestIdleCallback 或 setTimeout 将任务拆分为多个小于50毫秒的子任务,或者将任务移动到 Web Worker 中执行。
那么如何定位长任务?我们可以继续使用 Performance 工具 对页面进行录制和分析。若 Main 栏的任务出现红色条,则表明这是个长任务。
由于代码经过了打包,导致我们无法直接定位到代码的具体位置,有需求的同学可以通过 source map
精确定位。我们直接来到项目的主组件 src/App.tsx
,可以发现这里存在一个复杂计算,每次渲染 App 组件是都会重复进行计算。这个计算值不依赖于任何状态,所以我们可以直接计算出结果,避免计算导致的长任务耗时。(如果依赖前端状态可以用 useMemo
进行缓存,避免重复计算。)
优化后分数从 57 分提升到 86 分,TBT 阻塞时间从 9390ms 减少至 190ms,有明显的提升。
5. 分包和提取CSS文件
相关 Commit:perf: split chunks and extract css
在优化完 JS 执行时间后,细心的朋友也许会发现,TBT 阻塞时间还有 190s。我们再次打开 Performance 面板进行分析,发现脚本评估(Evaluate Script)占据着主线程大部分时间,且大于脚本执行(Function call)的时间。
- 脚本评估是什么?脚本评估时,系统会先对其进行解析以检查是否存在错误。如果解析器未发现错误,则脚本会编译为字节码然后可以继续执行。
- 如何优化脚本评估?我们可以通过拆分脚本,将脚本评估工作分散到许多较小的任务中,从而减少长任务的阻塞时间,降低 TBT 指标。
首先,打开 Network 看一下资源的组成:
可以发现除了图片资源和页面 document 之外,只有一个 JS 资源。我们再来分析一下 JS 资源的构成,打开本地终端,运行脚本分析命令:
npm run analyze
我们发现样式文件和一些第三方 JS 依赖(antd、moment、react-dom等)都被打进 main.js
了,导致 JS 资源的体积较大,有 367 kb。
分离 JS Bundle
首先我们先利用 Webpack 自带的 splitChunks
分包功能,将第三方 JS 依赖分离成单独的 JS Bundle:
在 Webpack 配置文件
scripts/webpack.base.js
中,通过 optimization.splitChunks
属性,我们可以配置 JS 的分包规则:
chunks
属性设置为all
,表示对所有(异步&同步)文件提取公共部分,进行分包;cacheGroups
属性用来设置缓存组,可以对特定路径下的文件进行分包。
提取 CSS 文件
除了第三方 JS 依赖之外,目前样式文件也被打进了 main.js
里,我们可以通过 mini-css-extract-plugin
插件将样式文件抽离成单独的 css
文件。
最终,我们将 main.js
文件拆分成了多个单独的 js
文件和 css
文件:
做完分包以及 CSS 文件提取之后,TBT 的阻塞时间从 190ms 降低到 30ms,缩短了脚本评估的时间。但是总分却没有变化,LCP 反而增加了,其原因是单独抽离 CSS 资源阻塞了 HTML 的解析和渲染(后面会详细阐述)。同时资源的总体积并没有缩减,所以 Lighthosue 的分数变化不大。
6. 缩减资源体积
相关 Commit:perf: import antd on demand & perf: dayjs replace momentjs
完成分包后,继续使用 Lighthouse 分析页面:
经过诊断,Lighthouse 建议减少未使用的 JS 和 CSS(Reduce unused JavaScript & CSS),并且定位到具体的位置:antd-icon.js
、moment.js
、antd.css
。
通过 Network 面板,我们也可以直观的看到 antd-icon.js
和 antd.css
资源体积都比较大。
针对 antd(3.x版本)
我们可以直接通过具体的文件路径引入组件、图标和样式,以实现按需引入:
同时,针对 moment
(75.9kb)库,我们可以采用功能基本相同,但是体积更小的 dayjs
(2kb)进行替换:
经过优化后,JS 和 CSS 资源的体积都得到了削减:
优化过资源体积后,Lighthouse 评分从 86 分提升到了 97 分,TBT 和 LCP 指标都有显著的提升。
7. 图片优化
相关 Commit:perf: optimize images & perf: preload largest content image
优化完 JS 和 CSS 资源后,Lighthouse 下一步的诊断建议是针对图片进行优化:提供下一代的图片格式以及预加载 LCP 图片(Serve images in next-gen formats & Preload Largest Contentful Paint image)
优化图片格式
目前项目使用的 .jpg
格式的图片,Lighthouse 建议使用 .webp
格式的图片。
WebP 是一种现代图片格式,可为网络上的图片提供出色的无损和有损压缩。使用 WebP,网站站长和 Web 开发者可以创建更小、更丰富的图片,从而提高网页加载速度。
我们可以通过线上图片转换网站 JPG to WEBP | CloudConvert 将图片转化为 .webp
格式,然后在项目 assets/images
目录下进行替换。
根据上图,可以看到图片资源的体积在转化为 webp
格式后削减了一半以上,优化效果还是十分明显的。
预加载 LCP 图片
LCP 资源指的是页面中最大的可见内容元素(通常是图片,视频或文本),对于本项目来说,LCP 资源是页面最开头的三张松鼠图片的其中之一。一般来说,我们希望 LCP 资源与在网页加载的第一个资源的同时开始加载。换句话说,如果 LCP 资源的加载时间晚于第一个资源,则说明有改进的空间。
针对本项目,我们可以通过 <link ref="preload" href="..." as="image" />
标签预加载 LCP 图片资源:
预加载优化完毕,我们再来通过 Performance 面板对比一下优化前后 LCP 资源加载顺序的区别:
优化前,LCP 图片资源在 JS/CSS 资源加载完成后再加载:
优化后,LCP 图片资源与 JS/CSS 同步加载:
最终,经过图片格式优化和预加载优化后,评分从 97 分提高到 99 分, LCP 从 2.6s 缩减到 2.0s。
8. 延迟加载CSS文件
相关 Commit:perf: defer css and split chunks
经过上文一系列的优化,目前页面的性能已经变得十分可观。Lighthouse 的诊断建议只剩下一项:消除阻塞渲染的资源(Eliminate render-blocking resources):
通过诊断信息我们可以知道:CSS 资源 main.css
的加载阻塞了渲染。通过下图 Performance 面板 Network 栏,我们也可以看到 main.css
资源被标记了 Render blocking
标识。
上文中提到过 <script />
脚本标签可以通过添加 defer
属性实现延迟加载的功能,但对于 <link ref="stylesheet" />
样式标签实现延迟加载会相对复杂一些,但我们仍然可以通过以下的方式实现:
- 将
rel="stylesheet"
修改为rel="preload"
,并添加as="style"
,这样就可以让浏览器以为这是一个异步加载资源; - 添加
onload="this.onload=null;this.rel='stylesheet'"
回调事件,这样可以在资源加载完后,使浏览器以样式文件的格式进行解析;
又因为 link
标签是由 mini-css-extract-plugin
生成的,但是这个插件不提供修改 link
标签属性的方法,所以为了实现上述的 1、2 步骤,我们需要自己实现一个 Webpack Plugin
。
实现思路如下:利用 HtmlWebpackPlugin
的 beforeEmit
钩子,获取当前的 HTML 内容,然后给 link
的标签替换上述 1、2 步骤中的属性。
具体实现代码如下(代码地址见本段文章开头相关 Commit):
通过上述比较 Hack 的方案,成功实现了 main.css
资源的延迟加载(defer loading),Render blocking
标识被成功的去除:
最终,Lighthouse 分数定格在 99 分,红色的诊断建议也清理完毕!🎉🎉🎉
总结
通过上述优化过程,我们可以将优化手段分为以下几个方面:
-
资源加载优化
- 开启文本压缩减少资源体积(使用
compression
库); - 延迟加载 JS 脚本(使用
script defer
关键字); - 延迟加载 CSS 文件(通过修改
link
标签属性); - 预加载关键资源(preload LCP 图片);
- 开启文本压缩减少资源体积(使用
-
图片相关优化
- 避免图片布局偏移(设置固定尺寸);
- 使用下一代的图片格式(
.jpg
替换为.webp
格式);
-
代码优化
- 代码分割(使用 Webpack 的
splitChunks
功能) - 按需引入(通过具体的文件路径引用组件)
- 减少代码执行时间(分割长任务和优化昂贵计算)
- 寻找体积更小的第三方依赖(
dayjs
替换momentjs
)
- 代码分割(使用 Webpack 的
通过这些优化手段,我们成功将 Lighthouse 性能评分从 2 分提升到 99 分。具体指标改善包括:
- FCP(首次内容绘制)从 3.7s 降至 0.8s;
- LCP(最大内容绘制)从 8.4s 降至 2.0s;
- CLS(累积布局偏移)从 0.825 降至 0;
- TBT(总阻塞时间)从 9400ms 降至 0ms;
这些优化不仅提升了性能评分,更重要的是显著改善了用户体验。希望这个示例项目能帮助大家更好地理解和实践网页 Lighthouse 性能优化。