前端 ing 仓库中关于性能优化的大一统文章:一些很那么常见的性能优化手段
很多朋友在简历上都会写上:页面加载时间从 xx 秒优化到 xx 秒。
下面让我们针对这个问题依次来延伸一下
首先,一定要将指标暴露出来,加载时间指的是 FCP,还是 LCP、FMP;每种回答都会有一些独特的答案。先将各自的概念熟悉一下:
- FCP(First Contentful Paint):从页面开始加载到页面渲染出第一片有意义内容的时间点
- LCP(Largest Contentful Paint):从页面开始加载到页面渲染出最大内容元素的时间点
- FMP(First Meaningful Paint):从页面开始加载到页面主要内容完成渲染的时间
- TTI(Time To Interactive):页面开始记载到页面可交互的时间,反映了用户完全能够使用页面的时间
- CLS(Cumulative Layout Shift——累积布局偏移):页面元素在加载过程中发生的布局偏移总量,反映了页面的布局稳定性
于是变成了:优化页面加载时间 FCP 从 xxx 秒到 xxxx 秒,提升了用户的使用体验
通用
关键路径分析:
对于一个常规的非移动端 CSR 来说,首屏前的主要流程为:加载 HTML、CSS、JS 等资源文件 ——> 代码运行至 mount 阶段发起 API 接口请求 ——> 等待收到数据后渲染首屏
针对于 FCP,LCP 以及 FMP,通用的地方就在于都需要去进行静态资源的加载,所以先针对静态资源这个点详细讲一讲优化措施
优化措施:
网络优化:
连接优化
连接建立分为 DNS 查询和 TCP 连接两个步骤。
DNS 查询可以通过 DNS Prefetch 来进行优化
当浏览器从第三方服务跨域请求资源的时候,在浏览器发起请求之前,这个第三方的跨域域名需要被解析为一个IP地址,这个过程就是DNS解析,DNS缓存可以用来减少这个过程的耗时,DNS解析可能会增加请求的延迟,对于那些需要请求许多第三方的资源的网站而言,DNS解析的耗时延迟可能会大大降低网页加载性能。
<link rel="dns-prefetch" href="https://fonts.googleapis.com/">
Preconnect 同样也可以减少后续请求的延迟, 但是它比dns-prefetch做的更多一些, 他会提前建立TCP连接, 能减少更多的时间。
<link rel="preconnect" href="https://fonts.googleapis.com/" crossorigin>
推荐的写法是这样:
<link rel="preconnect" href="https://fonts.googleapis.com/" >
<link rel="dns-prefetch" href="https://fonts.googleapis.com/">
开启HTTP2
http2新特性:
- 二进制分帧
HTTP/2 将 HTTP 协议通信分解为二进制编码帧的交换,这些帧对应着特定数据流中的消息。所有这些都在一个 TCP 连接内复用。 这是 HTTP/2 协议所有其他功能和性能优化的基础。
- 多路复用
将 HTTP 消息分解为独立的帧,交错发送,然后在另一端重新组装是 HTTP 2 最重要的一项增强。事实上,这个机制会在整个网络技术栈中引发一系列连锁反应,从而带来巨大的性能提升
- Server push(服务端推送)
- 头部压缩
HTTP2的升级将会为网站性能带来很大的提升,而且会减少很多前端常用的优化工作,省了很多事, 比如 雪碧图 & 文件合并 & 内容内嵌 & 域名分片。
针对 HTML/CSS/JS 等资源加载耗时:
HTML
- 减少HTML体积
* Gzip
// header中查看压缩方式
content-encoding: gzip
content-encoding: br
* 减少标签嵌套
react组件多了之后很容易存在嵌套过多的情况, 其实很多嵌套是可以避免的。减少嵌套, 也可以减少DOM Tree的复杂度, 无论是可读性还是性能上都会有比较大的提升
- 减少HTML白屏
* 骨架屏
为了让界面的数据加载过程中减少白屏的时间, 可以在真实数据返回之前先展示骨架屏提升用户体验。
* SSG(静态内容渲染)
对于某些界面来说, 界面内容不是经常变动,对于这些静态内容, 是可以提前渲染的, 直接注入到html里面。当然这个也可以结合骨架屏去做
CSS
- CSS体积优化
* 减少冗余css内容
冗余内容的优化可以借助cssnano工具来实现: www.cssnano.cn/docs/introd…
* 减少无用的css样式, 已删除标签的样式
可借助 purgecss 工具来实现:purgecss,作为 tailwind 底层对 css 处理的库,可以完全信任
- chunk拆分
* 公共资源
合理拆分css中的公共资源, 这个可以借助webpack提供的splitChunks来实现
* 首屏资源
由于css加载会阻塞界面渲染, 所以 header 中放的 css 最好都是首屏需要的, 对于首屏不需要的 css 文件可以先放到首屏代码的后面在引入
- 给 link 文件添加 preload,提前加载首屏 css
JS
- **减少体积**
* **splitChunks**
js 的 chunk 拆分和 css 一样都可以使用 webpack 来实现, 但是 webpack5 已经内置了splitChunks 的配置:splitChunk
默认的配置对于大部分项目已经够用了, 如果有特殊需求可以自己覆盖配置
- 新 bundle 被两个及以上模块引用,或者来自 node_modules
- 新 bundle 大于 30kb (压缩之前)
- 异步加载并发加载的 bundle 数不能大于 5 个
- 初始加载的 bundle 数不能大于 3 个
* **gzip**
const CompressionPlugin = require("compression-webpack-plugin")
const options = {
algorithm: 'gzip',
}
module.exports = {
plugins: [
new CompressionPlugin(...options)
]
}
* **treeshaking**
Tree-shaking 的本质用于消除项目一些不必要的代码。早在编译原理中就有提到 DCE (dead code eliminnation),作用是消除不可能执行的代码,它的工作是使用编辑器判断出某些代码是不可能执行的,然后清除。
Tree-shaking 同样也是消除项目中不必要的代码,但是和 DCE 又有略不相同。可以说是 DCE的一种实现,它的主要工作是应用于模块间,在打包过程中抽出有用的部分,用于完成 DCE。
* **公共资源利用**
对于一些常见的公共资源比如 React, 也可以不打包进产物中, 可以直接使用 CDN 进行引用。
图片
- **图片压缩**
图片压缩最好的办法是在上传的时候进行压缩,压缩后如果可以选择 webp 格式的图片就更好了
- **加载体验**
* 图片尺寸
这是个比较常见的问题, 业务场景上展示可能只有6060的一个空间, 但是用的却是一个10001000以上分辨率的图片, 增加了网络消耗甚至某些设备对于过大的图片还会有性能问题。
* 图片格式
根据Google较早的测试,WebP 的无损压缩比网络上找到的 PNG 档少了45%的文件大小,即使这些 PNG 档在使用 pngcrush 和 PNGOUT 处理过,WebP 还是可以减少28%的文件大小。所以最好还是使用 webp 格式的图片。
* 懒加载
对页面加载速度影响最大的就是图片,一张普通的图片可以达到几 M 的大小,而代码也许就只有几十 KB。对于图片过多的页面,为了加速页面加载速度,所以很多时候我们需要将页面内未出现在可视区域内的图片先不做加载, 等到滚动到可视区域后再去加载。这样子对于页面加载性能上会有很大的提升,也提高了用户体验。具体有两种实现方案:
+ 一是可以在 img 标签上加上 lazy 属性,不过有些浏览器并不支持,需要做好降级处理
+ 二是使用 js,当滚动到可视区域后再将对应的 img 标签加上 src 属性
- 方法一:通过 scrollTop+滚动高度判断图片进入可视区域
- 方法二:通过 IntersectionObserver 判断图片进入可视区域
* 占位
比较简单了, 在图片加载完成之前要有一个灰色的背景。
* 渐进式加载
图片渐进式加载需要先展示一个模糊的图片, 让用户有个预期, 再等待完整的图片下载完成后替换成完整的图片, 对用户体验有很大的提升。
可以参考:图片渐进加载优化
DOM解析优化
- **非首屏内容后置:**
关于 js 和 css 是如何阻塞渲染的可以参考这个文章, 很详细:
可以先说一下结论:JS 会阻塞 DOM 的解析和渲染,CSS 不会阻塞 DOM 的解析但会阻塞 DOM 的渲染
* **JS 后置**
由于 JS 会阻塞 DOM 渲染和解析, 所以 JS 文件最好放到 body 后面, 如果是 ssr 的界面,这样 JS就不会阻塞首屏内容渲染。
- **异步加载**
async 或者 defer
FCP
那我们先研究一下 FCP,可以直接通过浏览器 devtool 中的 Performance 直接得到具体加载数据
根据 FCP 的含义,如果要优化 FCP,那么核心要求就是:尽早渲染首屏内容,减少阻塞首屏渲染的资源和脚本
其优化方案与上述提到了“通用”相差无二,不过可以通过添加骨架屏的形式,提前提供可供感知的内容,改善 FCP 的体验。
FMP
FMP 通过 performance 是无法获取到具体时间的,因为针对的页面不同,其页面的主要内容也不同,通常有两种方式确定 FMP 的时间:
- 第一种是通过页面最大有意义元素算法,算法具体原理可以参考:FMP 量化算法,有时间看就行,博主秋招只被问过一次
- 第二种是通过人为确定,在规定的最大有意义元素出现之时上报埋点统计时间就行
FMP 的优化方法除了上述“通用”方案外,还有哪些可以优化的措施呢,我们可以思考一下。思路回到首屏前的主要流程:
FMP 既然是定义的页面关键元素,那么在部分情况下,这些关键元素的数据肯定会与后端存在交互,那么这里就针对第二点(代码运行至 mount 阶段发起 API 接口请求)与第三点(等待收到数据后渲染首屏)分别仔细说一下
针对接口请求耗时
- 接口请求/响应时机提前
在根组件函数外发起请求,并将接口返回的数据存储到 store 中
- 接口耗时优化
* 数据缓存:使用场景很小,仅针对于二级页面的数据跟一级页面数据及其一致的场景
+ a 页面缓存二级子页面 a1 页面数据:
- 情况 1:如果 a/a1 页面核心数据相同,则可以在 a 页面缓存数据,供 a1 页面首屏使用
- 情况 2:在 a 页面发起 a1 页面数据接口请求作为缓存:有额外开销,可以在功能渗透较高的情况下增加
针对渲染优化
这里就需要跑 performance 了,通过 performance,首先需要确定在首屏渲染中有没有长任务,如果有的话,需要针对性将长任务优化为短任务,以免造成页面阻塞
那么,我们应该怎么优化长任务呢:
setTimeout:
通过 setTimeout 短暂中断工作让出主线程,适合于需要按照顺序执行的一系列函数,比如:
function saveSettings () {
// 优先处理用户可见的关键任务
validateForm();
showSpinner();
updateUI();
// 将对用户不可见的工作延后到单独的任务中执行
setTimeout(() => {
saveToDatabase();
sendAnalytics();
}, 0);
}
schedule.yield:
由于 setTimeout 是宏任务,所以当我们使用其让出主线程以让任务推迟到后续任务中运行时,该任务会添加到队列的末尾。如果此时有其它任务正在等待,它们将在延迟执行的代码之前运行。
这时候,我们可以就需要用到 schedule.yield(),一个专门为让出浏览器主线程而设计的 API。在上述情况中,如果我们使用该 API,函数将会从上述停止的位置继续执行:
async function saveSettings () {
// 优先处理用户可见的关键任务
validateForm();
showSpinner();
updateUI();
// 让出主线程
await scheduler.yield()
// 将对用户不可见的工作延后到单独的任务中执行
saveToDatabase();
sendAnalytics();
}
web worker:
老生常谈的一个概念了,可以将一些耗时操作放在 worker 中进行执行,以免阻塞主线程
.....讲不完的渲染优化方式,放在首屏之中,大概可以参考的方式就如上
LCP
LCP 倒没有什么说的,其含义是页面最大元素,通过 performance 可以进行统计
其性能优化方案与 FMP 相差无异