引言
刚开始接触性能优化的时候,入手点大多都是 performance,因此这篇文章首先会从 performance 出发,逐步去探索优化的具体措施与方案,同时结合笔者之前的一些浅薄经验,系统性地去整理性能优化的手段,将方法论牢牢地把握住。
Performance
先上图
可以看出,performance 传递的信息是非常多的,这里挑几个重要参数来着重讲解,以 xx 平台为例,首先我们先录制页面开始加载到 LCP 的过程,对应的 performance 如下:
在主线程 Main 中,可以看到有很多爆红的 Task。首先介绍一下主线程和 Task:
-
- Task(任务)是指浏览器执行的单独工作,包括呈现解析 html、css、运行 js 等工作,当然,js 是这些 task 的主要来源
- 主线程是浏览器中运行大多数任务的地方,也是执行所编写的几乎所有 JS 的地方。需要注意的是,主线程一次只能处理一个任务,任何用时超过 50ms 的任务都是长任务。对于长任务来说,任务的总时间减去 50ms 称为任务的阻塞时段。
那么对于长任务来说,可能存在哪些问题呢?首先,在任何任务的执行期间,浏览器都会阻止发生互动,但只要任务运行时间不长,用户就不会感知到。但是如果页面中有耗时很多的任务正在执行,比如一个耗时 250ms 的长任务,那么用户在任务开始时进行操作,浏览器会先阻塞 250ms 后才会响应用户操作,观感十分卡顿。同时如果页面被阻塞了很长时间,界面甚至可能会崩溃。
因此,第一个优化点就是将长任务拆分为多个较小的任务:
-
- 首先,我们先点开具体某个长任务,如下图:
·
这里着重关注 summary 中为黄色的部分,这也是 js 运行的耗时,为了更直观的进行分析,我们这里选择在一个长任务中 js 占比较多的任务:
-
- 点击 Call tree,进入每个事件的子事件从而更直观地分析某段 js 的耗时
最终可以定位到这个文件,此文件的执行耗时占比达到了 96.1%。这个文件是什么呢,点进对应的 source 可以知道,这个文件的作用是在解析一个插件,且此平台大多数的长任务都是由于解析插件所导致的,但是我们所分析的页面并没有使用到这个插件,因此可以分析出:此平台在页面初始化的时候是全量加载的所有插件,并没有做到按需加载,因此可以确定后续的优化方案。
-
- 当我们分析其余页面时也是如此,找准长任务中耗时占比较大的 js 文件,并去对应地优化即可,这篇文章向我们介绍了如何推迟代码执行的方案,很详细:优化耗时较长的任务,在优化老系统以及优化长任务时很有借鉴意义。比如前段时间优化 cloudbus 使用体验,便在 tab 切换时将接口调用放在 setTimeOut 中,从而短暂中断工作让出主线程去响应用户的操作行为,以此实现感官上的 tab 快速切换。
打包工具
在众多场景中,针对项目性能的优化往往可以通过调整构建工具来实现,以 Webpack 为例。此类优化的主要目标通常聚焦于两个方面:减小最终打包文件的大小以及提升打包过程的速度。基于纯粹的方法论视角,我们可以探讨一下具体有哪些措施
减少项目的打包体积
-
- 抽离 css,也就是使用
mini-css-extract-plugin将 css 抽离为单独的文件,以便有效减少单个文件的大小,有效利用浏览器的并发请求 - 对 js、css 进行压缩。
optimize-css-assets-webpack-plugin插件可以有效压缩 css 文件 - 首先使用
webpack-bundle-analyzer可视化打包结果的每个文件大小及其依赖,并进行针对性优化,在实际操作过程中,我通常会去关注如下几点:
- 抽离 css,也就是使用
-
-
- 同种功能库的重复引用
- 是否可以拆包
-
-
- 组件懒加载。一些不是首屏渲染的组件可以使用懒加载,减小入口文件体积,以提高首屏的渲染速度。最常见的是路由页面及弹窗组件。webpack 提供的
import()可以将组件异步引入。该组件模块将被打包成一个单独的文件。 - 代码拆分(splitChunks)。webpack 默认是将所有代码都打包到入口文件中,很容易让入口文件的体积变得非常大,不利于加载。通过
spiltChunks可以将代码拆分成多个文件,有效利用浏览器并发请求 - TreeShaking。也就是所说的摇树,指去掉项目中未用到的代码,包括 js 和 css。js 的摇树在 ESM 下会自动实现;css 的摇树则需要用插件
purifycss-webpack实现,但是有很大风险,推荐使用 purgecss,tailwind 底层对 css 的切割也是使用的 purgecss
- 组件懒加载。一些不是首屏渲染的组件可以使用懒加载,减小入口文件体积,以提高首屏的渲染速度。最常见的是路由页面及弹窗组件。webpack 提供的
提高项目的打包速度
-
- 首先使用
speed-measure-webpack-plugin插件分析每个 loader 和 plugin 的耗时,针对性地进行优化。loader 是打包时消耗性能的大户,使用 loader 转换资源时,通过 include 指定处理范围,或者 exclude 排除一些不需要处理的文件,可以减少处理时间 resolve.modules配置第三方模块的查找范围,默认是 ["node_modules"],查找时会逐层查找,可以指明第三方模块的绝对路径,可以减少查找时间。同时resolve.alias配置用来指定路径的别名,也可减少模块的查找过程。
- 首先使用
resolve: {
modules: [path.resolve(__dirname, "./node_modules")],
alias: {
'@': path.join(__dirname, './src/')
}
}
- 3. 缓存打包结果,
hard-source-webpack-plugin插件可以将打包结果保存到硬盘中,加速比 dll 更加明显,且使用十分简单:
const HardSourceWebpackPlugin = require('hard-source-webpack-plugin')
plugins: [
new HardSourceWebpackPlugin()
]
- 4. happypack 多进程打包,优化 loader 的最佳方案。happyPack 通过开启多个进程处理 loader,加快构建速度。该插件适合大型项目,因为开启多线程和 happyPack 需要一些时间。需要注意的是,此插件与
mini-css-extract-plugin插件不兼容
const HappyPack = require("happyPack")
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length })
module: [
rules: [
{
test: /.css$/,
use: ["HappyPack/loader?id=css"]
}
]
],
plugins: [
new HappyPack({
id: "css",
loaders: ["style-loader", "css-loader"],
ThreadPool: happyThreadPool // 共享进程池
})
]
通用优化手段
网络优化
连接优化
连接建立分为 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
减少HTML体积
Gzip
// header中查看压缩方式
content-encoding: gzip
content-encoding: br
减少标签嵌套
react组件多了之后很容易存在嵌套过多的情况, 其实很多嵌套是可以避免的。减少嵌套, 也可以减少DOM Tree的复杂度, 无论是可读性还是性能上都会有比较大的提升。
清除冗余内容
-
- 注释
- 空标签&空属性
- type="text/javascript" & type="text/css"
// https://github.com/terser/html-minifier-terser
{
collapseWhitespace: true,
removeComments: true,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
useShortDoctype: true
}
减少HTML白屏
骨架屏
为了让界面的数据加载过程中减少白屏的时间, 可以在真实数据返回之前先展示骨架屏提升用户体验。
SSG(Static Site Generation)
对于某些界面来说, 界面内容不是经常变动,对于这些静态内容, 是可以提前渲染的, 直接注入到html里面。当然这个也可以结合骨架屏去做
CSS
CSS体积优化
减少冗余css内容
-
- 重复代码
.box {
border: 1px solid silver;
}
.box {
border: 1px solid silver;
}
-
- 对同一个节点的覆盖
@keyframes one {
0% {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes one {
0% {
transform: rotate(0deg);
}
to {
transform: rotate(359deg);
}
}
.box {
animation-name: one;
}
-
- 空节点
.box {}
@media screen {}
{color: green;}
-
- 注释
以上冗余内容的优化可以借助cssnano工具来实现: www.cssnano.cn/docs/introd…
-
- 无用的css样式, 已删除标签的样式
删除无用的css样式可能就需要开发者自己进行判定了, 养成良好的编码习惯, 删除一个dom元素也要删除对应的css代码
代码优化
-
- 属性简写
transform: translate3d(0, 0, 0); => transform: translateZ(0);
min-width: initial; => min-width: 0;
width:0.5px => width:.5px
-
- selector精简
- 尽量精准查找, 比如可以用一个id找到就不要用多个class
- selector尽量合并, 比如下面的两个box就应该写在一起
.box {
color: blue;
}
.box {
font-weight: 700;
}
chunk拆分
公共资源
合理拆分css中的公共资源, 这个可以借助webpack提供的splitChunks来实现。
首屏资源
由于css加载会阻塞界面渲染, 所以header中放的css最好都是首屏需要的, 对于首屏不需要的css文件可以先放到首屏代码的后面在引入。
JS
减少体积
splitChunks
js的chunk拆分和css一样都可以使用webpack来实现, 但是webpack5已经内置了splitChunks的配置。
默认的配置对于大部分项目已经够用了, 如果有特殊需求可以自己覆盖配置。
-
- 新 bundle 被两个及以上模块引用,或者来自 node_modules
- 新 bundle 大于 30kb (压缩之前)
- 异步加载并发加载的 bundle 数不能大于 5 个
- 初始加载的 bundle 数不能大于 3 个
gzip
const CompressionPlugin = require("compression-webpack-plugin")
module.exports = {
plugins: [
new CompressionPlugin(...options)
]
}
treeshaking
可以参考上述 webpack 中提到的 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 属性
-
-
- 占位
比较简单了, 在图片加载完成之前要有一个灰色的背景。
-
- 渐进式加载
图片渐进式加载需要先展示一个模糊的图片, 让用户有个预期, 再等待完整的图片下载完成后替换成完整的图片, 对用户体验有很大的提升。
可以参考:图片渐进加载优化
DOM解析优化
非首屏内容后置
关于 js 和 css 是如何阻塞渲染的可以参考这个文章, 很详细:
可以先说一下结论:JS 会阻塞 DOM 的解析和渲染,CSS 不会阻塞 DOM 的解析但会阻塞 DOM 的渲染。
JS后置
由于 JS 会阻塞 DOM 渲染和解析, 所以 JS 文件最好放到 body 后面, 如果是 ssr 的界面,这样 JS就不会阻塞首屏内容渲染。
异步加载
async 或者 defer
运行时优化
缓存
数据缓存
对于多次打开不会经常变化的数据, 可以根据场景添加本地缓存, 在重复打开的时候快速读取展示, 比如用户名, 地址信息之类的数据。
-
- localStorage
- indexDB
资源缓存
开启一个缓存,将我们的应用所需要缓存的文件全部添加进去,当再次加载这些资源时,可以直接使用缓存的文件。
API优化
接口逻辑拆分
首屏的接口尽量只返回首屏需要的数据, 比如一个列表,可以先返回一个列表的基础信息, 再根据需要返回详情, 尽量不要一次全部返回。
无用字段过滤
有些服务端的接口会将整个对象都打包到接口里面, 但是这些字段又不是前端完全需要的, 不仅会增加API的体积,也会影响传输效率。
数据分页
数据过多的情况应该做分页,请求和处理都会比较快。
容器层(针对于移动端)
预连接(DNS+TCP)
预连接方案的原理就是在Webview需要之前便已经建立好和服务器的TCP连接, 等到Webview需要的时候直接复用这个连接, 如果连接复用成功, 可以将DNS时间和TCP连接时间都降为0
预连接的功能需要客户端支持,需要注意两个问题:
-
- 多机房问题导致域名不用链接无法复用: 客户端可以提前知道多机房域名, 转换好再进行预连接
- 预连接时机问题, 预连接默认只会保留10s, 如果10s没有使用就会废弃, 所以合理选择预连接的时机非常重要
数据预取
数据预取方案优势在于将请求数据的时间点提前, 由客户端发起请求, 等 Web 需要的时候将prefetch 的结果直接返回,减少等待请求的时间。
静态资源优化
离线化
针对 APP 内的 webview, 在客户端支持离线化的基础上, 可以将前端 html + 静态资源添加到离线资源中, 将会极大的加快资源加载速度。