前言
LCP
是页面核心性能指标中一个比较重要的指标,但是LCP
的优化方向真是又多又繁杂,本文主要根据个人5年的前端性能优化经验,从优化资源的传输 和构建产物优化 两部分来介绍优化LCP
的方法。
优化资源传输
前端资源的网络传输速度,直接性的影响了页面的FCP、LCP
指标,间接性也影响到了CLS
与INP
,所以优化资源的请求是前端性能优化的重点之一。
CDN
为什么需要CDN?
如果用户常规的访问静态资源是直接访问到源服务器,这会存在以下一些问题:
- 因为这些是静态资源几乎是不变的,没有什么数据依赖的,频繁的请求是会造成服务器的负载。
- 如果用户访问的地方离源服务器比较远,因为传输距离较长,所以请求的响应时间也会拉长。
CDN
很好的解决了这两个问题,在知道CDN
怎么解决问题之前,我们了解下资源是如何通过CDN
被响应至用户侧的。
CDN响应机制
- 源站存储: 页面产物上传通常存放在源站上。这通常是他们控制的服务器,或者是专门的托管服务(例如OSS)。
- 内容复制: CDN服务提供商会在全球范围内的多个边缘节点上缓存这些内容的副本。这个过程可以是主动的,也可以是被动的。
-
2.1 CDN缓存模式
-
主动缓存: 这种缓存方式是将源站的资源主动推送到边缘节点,也称之为资源的预热,在应对一些大流量的请求时,降低源站的资源请求是十分有效的。
-
被动缓存: 这个是用户请求到
CDN
边缘节点,但是边缘节点未获取到资源,边缘节点可能会去中心节点或源站读取资源,然后存储起来供后续请求使用。
-
-
2.2 资源的缓存规则
- 私有资源: 私有资源通常指的是面向单个用户的数据,由
cache-control: private
的标头指示,所以不会被CDN缓存。 - 公共资源: 如果资源没有
cache-control: no-store
都是可以被缓存的,其中公共资源缓存的时间取决于缓存的过期时间,例如max-age
字段。
- 私有资源: 私有资源通常指的是面向单个用户的数据,由
-
2.3 CDN手动缓存刷新
- 通常为了应对峰值流量,
CDN
可能缓存了index.html
这种不会改变名字的文件,这个时候我们就需要主动去刷新(删除)CDN
的缓存资源。
- 通常为了应对峰值流量,
-
用户请求: 当用户尝试访问这些内容时,他们的请求会被路由到最近的边缘节点。
-
边缘节点响应: 边缘节点会响应用户请求,提供缓存的内容。如果边缘节点没有缓存所需的内容,它会从源站或其他边缘节点拉取内容,然后提供给用户并缓存起来。
CDN的优点
-
资源就近加载,减少响应时延
CDN通过在全球分布的边缘服务器上缓存内容(如HTML页面、图片、视频和其他文件),使得用户可以从地理位置上最近的服务器获取内容。
-
缓解服务器负载
CDN从源站拿到资源之后,会主动或被动的将资源缓存在自己的边缘节点上,只有资源失效后才会访问源站,减少了源站的负载。
-
优化的数据传输
CDN还采用各种数据传输优化技术,如数据压缩、TCP连接优化、SSL/TLS握手优化等,减少数据传输量,提高传输效率。这些优化措施在流量高峰期间尤其重要,能够帮助更快地处理更多的用户请求。
回到性能优化上,CDN让用户更近的边缘节点获取资源,降低了首屏资源获取的时延,提升页面LCP性能。
预加载(preload)关键资源
页面加载时,资源是默认会有一定的下载优先级的,通常来说会有以下的下载优先级:
- 放置了
<script>
和<link>
的资源标记,优先级相同的资源按先后顺序进行加载,通常JS(high)
的优先级小于CSS(highest)
,这个优先级主要考虑的浏览器的CSS需优先于JS加载。 - 设置了
preload
的资源会被优先下载 - 如果使用了
async
和defer
下载脚本,资源的下载的优先级会被降低 - 非当前视图的资源优先级可能也会不同,例如当前视图的资源优先级
案例分析
如上图所示,这是我们lighthouse
对我们一个页面LCP
的一个优化建议,建议我们预加载首屏影响LCP的图片资源,并且能大约节省1040ms
的LCP
的时间。
我们在html
的header
标签中添加以下代码。
js
<link rel="preload" href="https://cdn.xxx.png" as="image">
预加载最适合浏览器通常在较晚时间才发现的资源,比如我们案例中的图片是被打包至JS中的,所以需要等待JS响应并执行了,才能开始图片的请求,这种情况我们就可以预加载图片资源。
预加载虽然好,但是也不能滥用 ,因为如果过多的资源与加载了,还是会造成带宽竞争的情况 ,并且占用过多的浏览器资源,从而导致都优先了就没有优先加载的情况了。
最佳实践
- 考虑好资源的优先级,延迟加载非首屏的资源的加载。防止因为用户的带宽限制导致资源带宽竞争,对影响LCP的资源进行优先加载,例如影响首屏的JS、CSS。
- 字体文件可能会导致页面元素的抖动,可能会影响LCP和CLS的结果,所以尽可能提前加载字体元素。
- 预加载不易被优先发现的资源,但是影响首屏LCP响应的资源(例如JS或CSS中的图片)。
预连接(preconnect)
我们知道要从服务器请求资源需要先建立连接,主要有以下几步:
- DNS解析:查询域名并解析为具体的IP地址
- 三次握手建立连接
- 建立SSL连接(如果域名是HTTPS的话)
用法
向文档的 <head>
添加 <link>
标记。
html
<link rel="preconnect" href="https://example.com">
对比效果
接下来我们看看如果没有预连接前后的效果对比:
我们可以观测到没有预连接的,需要到具体请求被发起时才会建立连接并进行下载,而建立预连接的资源,可以直接开始下载工作。
根据Google的数据,preconnect
提前建立连接,这大致可以为我们页面加载节省100-500ms。
为什么既然有了preload
还需要preconnect
?
-
首先我们要知道两者的定位,preload是直接可以提前加载具体的资源,而preconnect预先建立请求的连接 。
preload
尽可能对首屏的核心资源使用,可以提前请求并加载资源,而preconnect
我们可以对相关资源的域名提前连接,并不需要直接加载具体资源。并且preload
占用浏览器资源会比preconnect
,所以需要考虑好优先级。 -
优化无具体的url的资源 。很多情况我们渲染的图片资源可能是后端接口下发的
CDN
链接,我们是不知道具体的链接的,这时候我们可以对相关可能CDN
域名进行预链接的建立。
最佳实践
preconnect
同样会占用浏览器资源,所以最佳实践还是将该功能用在比较最核心的url
上。
DNS预解析(dns-prefetch)
用法
向文档的 <head>
添加 <link>
标记。
html
<link rel="dns-prefetch" href="http://example.com">
应用场景
相比于preload
和preconnect
可以预加载资源和提前建立连接,dns-prefetch
仅是可以提前对域名做DNS
解析,虽然看着优化幅度不大,但是其资源占用少,可以应对页面有很多个第三方资源域名的场景。
根据Google数据,大约也能节省20-120ms,虽然不多,但是蚊子腿也是肉呀!
预获取(prefetch)后续资源
用法
向文档的 <head>
添加 <link>
标记。
html
<link rel="prefetch" href="https://cdn.example.com/later.js" as="script">
应用场景
前面主要介绍的是首屏相关的资源相关的优化措施,但是我们通常优化策略是将首屏的资源和非首屏的资源分割开,以确保首屏的性能,但是页面后续部分的资源加载也是很重要的,而我们可以利用prefetch
的方式,在浏览器空闲期间,就可以提前下载这些网页的资源,从而缩短后续浏览的加载时间。
图片资源优化
图片资源对前端页面是必不可少的一个部分,特别是对电商页面,几乎全都是商品图片,所以优化图片资源也是我日常工作的一部分。
最佳实践
1. 强烈推荐图片CDN
当我提到CDN的时候,千万不要第一反应是前面不是介绍过CDN,这里我介绍的不是CDN可以让用户就近加载图片,图片CDN更加侧重于对图片的处理,例如图片的缩放、降质和文件格式的转换,从而减小图片最终传输大小。
谈谈为什么我强烈推荐使用图片CDN?
我结合一下我们公司H5开发流程来说一下,我们H5
页面展示图片都是由运营将图片上传到后台的CMS
,然后服务端上传至OSS
,而图片的链接会通过接口下发至前端,所以接口下发图片我们是无法主动的使用任何工具再去进行降质压缩、尺寸的修改、以及转化webp
或者avif
格式的操作的,而图片CDN
可以帮我们解决这些困扰。
案例分析:
接下来我们通过一个案例来看看图片CDN所带来的性能提升。
优化前,图片资源大小是114kb,内容下载时间为23.43ms。
优化措施:
- 首先我们看到这个商卡接口下发的图片尺寸
(Intrinsic size)
和实际渲染的尺寸(rendered size)
差距十分大,所以需要对图片进行缩放。 - 和设计同学协商下合理的降质比例。
- 最后可以根据浏览器环境判断是否支持webp格式 ,如果环境支持,再启动webp降质。
优化后,优化的图片大小是10.2kb,内容下载时间为0.23ms。
2. 选择合适像素数(倍图)的图片
可能大家一开始听到像素数会有点懵,但是一说"1倍图"、"3倍图"大家肯定就清楚说的是啥了。像素分为CSS像素和设备像素,单个CSS像素可以直接展示单个设备像素,也可以展示多个设备像素的展示。比如"2倍图"通常是指为了适应高分辨率显示设备而制作的图像资源,其像素密度是标准分辨率图像的两倍。所以说设备像素越多,屏幕展示的内容越精细(如下图所示)。
3. 使用webp格式的图片
webp和avif相比与传统的png、jpg、gif格式通常能够在相同的视觉质量下,拥有更小的图片尺寸。而webp格式的图片相比于avif有更好的兼容性,所以推荐webp格式图片,但是在使用前还是建议判断下 环境是否支持webp格式的图片,不支持的话还是建议降级至png或jpg。
4. 压缩图片
对于图片优化最简单粗暴的方法就是压缩图片,可以使用类似imagemin-webpack-plugin
的插件去压缩图片资源。
构建产物优化
我们在产物构建的时候能做工作有哪些呢?
- 非首屏资源的懒加载,减少首屏的资源传输。
- 第三方资源按需引入,减少产物总体积。
- 产物文件进行压缩,并通过HTTP数据压缩算法,减小产物传输体积。
首屏资源懒加载
HTTP2
传输协议为首屏的加载速度带来了很大的提升,因为其可以让页面资源并行传输,使得资源加载过程中不再会有HTTP队头阻塞的问题,但是如果认为只要页面接入了HTTP2
万事大吉那就错了。
我们做性能优化还是得考虑用户的使用环境,很多页面性能较差的用户有一个重要的原因就是加载速度被当前网络带宽所限制。而HTTP2并行加载 ,可能造成资源的竞态 ,所以一定需要把有限的带宽用到优先级更高的资源上面。
案例分析
接下来我们用一个案例来简单了解下如何进行首屏懒加载:
- 项目情况
项目利用Vite进行打包,前端框架使用的是React,并且为了更好模拟正常开发还引入lodash、ismobilejs、swiper
等第三方依赖。
- 项目结构
txt
|____main.jsx
|____App.css
|____App.jsx // 首屏组件
|____Components
| |____Lazy1 // 非首屏组件Lazy1
| | |____index.css
| | |____Lazy1.jsx
| |____Lazy // 非首屏组件Lazy
| | |____Lazy.jsx
| | |____index.css
| |____Lazyload.jsx // 利用Suspense实现的懒加载组件
Lazyload.js
的内容:
js
import { useState, useEffect, useRef, Suspense } from "react"
const LazyLoadComponent = ({ children }) => {
console.log('children', children);
const [isIntersecting, setIsIntersecting] = useState(false);
const ref = useRef();
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setIsIntersecting(true);
observer.unobserve(ref.current);
}
}, {
rootMargin: '0px',
threshold: 0.1
});
if (ref.current) {
observer.observe(ref.current);
}
return () => {
observer.disconnect();
}
}, []);
return (
<div ref={ref}>
{
isIntersecting ? (
<Suspense fallback={<div>loading....</div>}>
{ children }
</Suspense>
) : (
<div>
Component is not yet visible ...
</div>
)
}
</div>
)
}
export default LazyLoadComponent;
在main.jsx
导入首屏组件和非首屏的两个组件:
jsx
import React, { useState } from 'react'
import LazyLoadComponent from './Components/Lazyload'
import './App'
import './App.css'
const LazyComp = React.lazy(() => import('./Components/Lazy/Lazy'));
const LazyComp1 = React.lazy(() => import('./Components/Lazy1/Lazy1'));
function App() {
const [count, setCount] = useState(0);
return (
<>
<div>
<App />
<LazyLoadComponent>
<LazyComp />
</LazyLoadComponent>
<LazyLoadComponent>
<LazyComp1 />
</LazyLoadComponent>
</>
)
}
export default App;
目前未做任何优化的情况,所有的组件、第三方依赖、以及所有组件的css会被打包在一起。如下图所示(测试需要,未考虑包体积超过一定程度再分包):
优化前,css资源大小为19.59kb,js资源的大小为301.21kb。
优化措施:
- 接下来我们通过
React.lazy + Suspense
对非首屏组件进行懒加载。 - 将第三方依赖和产物分割开,提升缓存的命中率。
- 分割首屏和其他页面的第三方依赖,降低首屏第三方依赖的产物体积。
关于第3步优化措施的解释:
我们这个测试项目其实每个组件的代码内容不是很多,多的反而是第三方依赖,我们首屏的组件需要加载非首屏的依赖,显然不是很合理,肯定会影响首屏的LCP。
为此我们在vite.config.js
添加以下代码来统计项目的依赖:
js
// vite.config.js
manualChunks(id) {
if (id.includes('node_modules')) {
const modules = id.split('node_modules')[1].split('/')[1];
if (nodeModules.indexOf(modules) === -1) {
nodeModules.push(modules);
console.log('nodeModules', nodeModules);
}
}
}
js
// output
nodeModules [ 'react', 'react-dom', 'lodash', 'ismobilejs', 'swiper', 'scheduler' ]
所以我们把首屏的第三方依赖单独提取出来,我们能识别到lodash、ismobilejs、swiper
是非首屏的依赖,所以我们将其与首屏的依赖分开打包。复杂点的依赖可以使用产物可视化工具(比如vite
的vite-plugin-bundle-visualizer
、webpack
的webpack-analyzer
的插件)来查看分包的情况。
最终我们根据优化措施将vite.config.js
修改为以下配置:
js
// vite.config.js
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('lodash') || id.includes('ismobilejs') || id.includes('swiper')) {
return 'vendor';
} else {
// 首屏的三方依赖
return 'first-screen-bundle';
}
}
}
看看优化后的效果:
优化后,首屏css资源大小为1.3kb,js资源的大小为46kb,首屏资源传输大小减少50%。
图片懒加载
我们经常在开发H5的时候,时常会有业务需求就是在顶部楼层展示出一大堆的商卡(商品的图片+商品名,因为这些商品需要较高的曝光度),但是往往接口可能会下发好几十个商品的信息,如果我们不做优先级的区分,直接全部渲染出来,这就会导致前面说的网络带宽的竞争,使得整体加载速度下降。
案例分析
图片的懒加载通常需要利用Intersection Observer API
,配置一个观察器来监听元素是否进入了可视区域,当元素处于可视区域时,再加载响应资源。
- 未优化前,进入页面后直接加载了所有的10张图片。
我们可以观察到前5张图片的请求优先级为High
,实际上这就是处于页面可视区域的图片,而优先级为Low
的为非可视区域的图片,也加载出来了。
- 我们实现了一个简单的图片懒加载组件:
js
import { useState, useEffect, useRef } from 'react';
const backImg = '';
const LazyImage = ({ src, alt, ...props }) => {
const [imageSrc, setImageSrc] = useState(backImg); // 初始时不加载图片
const imageRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(entries => {
// 只观察第一个元素(我们的图片)
const [entry] = entries;
// 如果元素在视图中
if (entry.isIntersecting) {
setImageSrc(src); // 加载图片
observer.unobserve(imageRef.current); // 停止观察当前元素
}
});
if (imageRef.current) {
observer.observe(imageRef.current); // 开始观察
}
return () => {
if (imageRef.current) {
observer.unobserve(imageRef.current); // 清理
}
};
}, [src]); // 依赖项是图片的 src
return (
<div>
<img
src={imageSrc}
alt={alt}
ref={imageRef}
{...props}
/>
</div>
);
};
export default LazyImage;
- 使用图片懒加载后:
使用图片懒加载的页面,同时加载5张图片,在200ms前完成了资源的加载 ,未进行图片懒加载的同时加载了10张图片,在350ms左右才完成加载 。相比起来性能提升了差不多50%。
资源体积优化
Tree-shaking
Tree-shaking
是一种基于ES module
去删除dead code
(没有被利用代码)的方式,其会在运行时静态的分析出模块的导入和导出关系,确定哪些模块没有被用到后将其删除。
最佳实践
- 尽可能使用ESM模块化规范的第三方包。例如将
lodash
替换成lodash-es
,可以按需引入依赖。 - 使用
babel-plugin-import
实现按需引入。针对一些没有ESM格式第三方依赖,或者是早期组件库,期望按需引入JS
和CSS
,可以使用babel-plugin-import
插件按需引入资源。 - 减小模块导出的粒度,尽量不要将模块进行整体的导入导出。
按需引入JS的polyfill
通过为了兼容一些低版本的浏览器或者使用新的JS语法或方法,JS
的polyfill
肯定是必不可少的依赖,但是如果直接导入babel-polyfill
。
- 默认导入所有的
polyfill
会增大项目产物的体积。 polyfill
会在全局作用域上修改Promise、Set、Array
等原型对象,造成全局原型污染。
最佳实践
- 实现
polyfill
的按需引入
在.babelrc
进行以下配置:
js
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "usage", // 注入的方式按需注入
"corejs": 3, // 指定 core-js 版本
"targets": "> 0.25%, not dead" // 根据目标环境自动确定需要的 polyfill
}
]
],
}
解析: 上述配置是根据目标环境(targets
字段是基于目标浏览器版本)按需引入core-js
的polyfill
的方法,以及对高版本语法的转换。
- 解决全局原型对象污染问题
针对一些新的API,例如当你的代码中使用 Promise
时,如果没有使用@babel/plugin-transform-runtime
插件,可能core-js
会直接在全局的Promise
的原型上面添加方法。
而使用了插件,插件会将其转换为 @babel/runtime-corejs3/core-js/promise
的引用,而不是直接使用全局的 Promise对象。
在.babelrc
引入@babel/plugin-transform-runtime
插件:
js
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "usage",
"corejs": 3, // 指定 core-js 版本
"targets": "> 0.25%, not dead" // 根据目标环境自动确定需要的 polyfill
}
]
],
"plugins": [
[
"@babel/plugin-transform-runtime",
{
"corejs": 3 // 启用按需加载的 polyfill
}
]
]
}
解析: @babel/plugin-transform-runtime
插件既避免了core-js
对全局对象的污染,也使得 polyfill
按需加载成为可能。除了解决全局污染的问题之外,插件还能让babel其他插件复用辅助函数,进一步优化polyfill
的体积。
当然如果你用的是vite
作为构建工具,以上配置可能无法直接复用,但是@vitejs/plugin-legacy
可以极大的简化项目中polyfill
配置工作。
资源构建压缩
HTTP资源传输压缩
在古早的时候,JS和CSS的压缩还是大家津津乐道的性能优化的手段,而且现在不论是webpack5
还是vite
在生产模式默认就会开启资源的压缩,使用terser
(目前esbuild
也是常用的压缩工具)对产物进行压缩后输出。
不知道大家有没有注意vite
的产物输出通常会有两个体积,一个是压缩后的最终产物的体积,还有一个是gzip
之后的体积。
其实gzip
后的资源,可能才是我们HTTP请求中最终传输资源大小。为什么说可能是最终大小呢?因为HTTP
数据压缩算法远远不止是gzip
。
数据压缩算法有很多,例如brotli(br)、deflate、zstd
等,而其中流行的和被浏览器广泛支持的压缩算法是gzip
和brotli(br)
压缩算法。
br与gzip的比较
其实这两个压缩算法无法说出具体的优劣,gzip
压缩算法在兼容性上几乎兼容所有的浏览器环境 ,而brotli(br)
压缩算法在压缩JS/CSS/HTML
在大多数情况下提供比gzip
更高的压缩率,这也意味着资源传输耗时更少。
最佳实践
- 服务端根据
HTTP header
的Accept-Encoding
字段协商响应资源
其实在我们的实际应用的时候更好兼容性和更高的压缩率是可以兼得的。浏览器的请求头中会有一个Accept-Encoding
字段,这是当前浏览器支持的压缩编码,而服务端可以决定从Accept-Encoding
中服务端支持的压缩算法进行协商响应,最后响应头的Content-Encoding
是资源使用的压缩算法。
我们可以看一个案例:
解析: 我们可以看到浏览器请求头的Accept-Encoding
表示支持gzip、deflate、br、zstd
的压缩算法,然后服务端最终使用了br
算法进行资源的压缩。
结语
其实前端资源优化想把所有优化场景和方式都列举出来真的很难,尤其是对首屏LCP
(或古早的FMP
)的优化,所以这个过程也需要足够的耐心,因地制宜的使用适合自己项目的优化措施,肯定会带来巨大的提升。本文很多知识点也是我不断从工作中不断总结和沉淀的,不一定适用于所有人,也并没有列举完全,希望本文可以帮助大家最终形成自己的方法论。