性能优化专项学习
性能优化概述
开发困扰:
- 对页面性能不够重视,导致用户体验不佳,出现崩溃、卡顿、白屏问题。
- 不懂怎么分析页面性能瓶颈,无法进行针对性优化。
- 不理解优化的原理,只能照搬资料,优化效果不佳,甚至是反效果。
性能优化对用户体验非常重要,对转化率提升具有重要意义,对业务提升具有重要价值,特别是低端机型。
系统掌握前端性能优化,为自己个人增值,提升自己开发页面的性能体验,提升自己负责业务的业务量。
性能指标
性能指标概述
性能是用户在页面加载和运行时的直观体验(感觉),但我们优化的时候,不能用感觉去衡量性能怎么样,必须要用可衡量的值去判断性能优化结果,这就是性能的指标。了解性能指标,可以给优化过程制定明确目标。
常见性能指标如下:
首屏时间(最常用):页面以多快的速度加载和渲染元素到页面上。
加载后的响应时间:页面加载和执行 JS 代码后多久能响应用户交互。
视觉稳定时间:页面什么时候变稳定,变得不影响观看交互。
首屏时间
首屏时间最大程度决定了网页的用户体验,是性能中最关注的部分,首屏时间使用哪个指标衡量,目前没有固定的标准。
目前首屏时间常用的指标主要有 FMP 和 LCP。
指标 | 含义 | 问题 |
---|---|---|
FP:First Paint,首次绘制 | 白屏时间,它代表浏览器第一次向屏幕传输像素的时间,也就是页面在屏幕上首次发生视觉变化的时间 | 像素的改变不够直观,代表首屏时间过于单薄,不是用户关注的内容 |
FCP:First Contentful Paint,首次内容绘制 | 测量页面从开始加载到页面内容的任何部分在屏幕上完成渲染的时间 | 内容仅包括文本、图像(包括背景图像)、svg 元素或非白色的 canvas 元素,不一定是用户关注的真正内容。背景、loading |
LCP:Largest Contentful Paint,最大内容绘制 | 测量页面开始加载到最大文本块内容或图片显示在页面中的时间 | 存在兼容性问题,低版本安卓和IOS不支持 |
FMP:First Meaningful Paint,首次有意义绘制 | 首次有意义的绘制,是页面主要内容出现在屏幕上的时间 | 什么是主要内容?目前尚无标准化的定义 |
DOMContentLoaded | 当初始的 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发,而无需等待样式表、图像和子框架的完全加载 | SPA 页面,index.html 加载完成后,DOMContentLoaded 即被触,但页面空白 |
OnLoad | 当整个页面及所有依赖资源如样式表和图片都已完成加载时,将触发load事件 | SPA 页面,index.html 加载完成后,OnLoad即被触发,但页面空白 |
优化目标
性能指标目标:FMP或者LCP 指标在 P95 分位上达到 3.5S。也就是前 95% 的用户首屏时间需要在 3.5S 内。
从 2 个维度进行提升:
- 「真的快」:可以客观衡量的指标,像首屏时间、网页访问时间、交互响应时间、跳转页面时间。
- 「觉得快」:用户主观感知的性能,通过视觉引导等手段转移用户对等待时间的关注。
性能优化工具
每个业务工程都有其不同的地方,需要针对分析和优化。学会怎么分析性能瓶颈,这是性能优化的前提。从复杂系统中找到性能问题的关键所在,这是性能优化的第一步。
分析工具:一般都是本地对性能某一方面进行分析,比如打包情况、资源大小、请求顺序、JS执行情况等。
合成监控:一种模拟网页加载或者脚本运行来测量性能指标的方式,输出网页性能报告。这种方式的价值在于提前发现可能存在的性能问题,不依赖于用户上报。用于在本地开发时提前发现一些性能问题。单个样本,不全面。
真实用户监控:记录用户真实操作的一种被动监控,它的特点是记录真实用户在网页交互中的性能数据。反映用户使用的真实情况,和用户设备、网速、环境等息息相关。群体样本,客观全面。
Chrome Network
Chrome 浏览器 F12 开发者工具中 Network 标签。
Chrome Network:显示网络资源加载耗时及顺序,能看到资源的名称、状态、使用的协议(http1/http2)、资源类型、资源大小、资源时间线等情况。
可以发现一些常见问题:某个 js 文件过大,成为性能瓶颈;接口请求存在依赖关系,串行请求;存在一些很琐碎的小文件,几百B的 js 文件。
Chrome Performance
Chrome 浏览器 F12 开发者工具中 Performance 标签,可以观察页面渲染表现及JS执行情况。
请求瀑布图:瀑布图的横轴是时间轴,瀑布图上有很多五颜六色的色块,不同颜色代表不同的资源。通过分析资源的顺序、请求分布和请求详情,得出一些优化结论。
主线程火焰图:分析具体函数耗时,面板中会有很多Task,如果是Long Task右上角会被标红,可选择放大查看具体耗时点。
详情饼图:用于展示各种类型任务的耗时占比,可以看出下载、执行、渲染、空闲等时间。
在左侧的 insights 面板里面,可以看到瓶颈和优化措施。
webpack-bunld-analyzer
以视图的方式显示 webpack 输出文件的大小,可视化帮我们分析打包体积和包的组成,让我们可以用针对性的分包合包构建策略优化我们的模块bundle。
分析步骤:
查看哪个包比较大。
分析包的组成,查看组成是否合理,是否能够缩小体积。
比如:vendor 把大部分 node_modules 的库都打包进来,体积太大,首屏不一定使用的也打包进来,任何依赖的改变也容易改变 hash 值,影响缓存,对首屏影响很大。
Lighthouse
Lighthouse:是谷歌开发的合成测试工具(自动集成在devtools中) ,它既可以作为浏览器插件运行,也可以作为 cli 脚本,甚至以程序化的方式运行在你的 Node.js 代码中。它通过一系列的规则来对网页进行评估分析,最终给出一份评估报告。
利用LightHouse进行合理的页面性能优化,看这一篇就够了!
ARMS 监控工具
ARMS监控工具:ARMS前端监控专注于对Web场景、Weex场景和小程序场景的监控,从页面打开速度(测速)、页面稳定性(JS诊断错误)和外部服务调用成功率(API)这三个方面监测Web和小程序页面的健康度。
性能优化概述
页面打开,是浏览器通过网络从服务器请求具有一定大小的资源,并且展示的过程。
性能优化可以归结为 3 类优化方向:
请求越快:加快请求。
资源越小:减小内容。
越早展示:提前渲染。
优化1:加快请求
使用HTTP2.0协议
HTTP2.0 协议的优化:
二进制分帧:采用二进制格式传输数据,解析更高效。
多路复用:同一个域名下只需要建立一个 HTTP 连接,单个连接可以承载任意数量的双向数据流。
头部压缩:对 HTTP 头采用 HPACK 进行压缩传输,节省流量;对于相同头信息,不重复发送。
使用HTTP1.1的效果--串行、阶梯式请求,同时发起请求少,一个接一个,慢。
使用HTTP2.0的效--并发请求,同时发起的请求多,快。
从 HTTP1.1 升级到 HTTP2.0 之后,首屏时间降低了 20% 左右,效果显著。
升级 HTTP2.0 需要 CDN 开启 HTTP2.0,NG 安装 HTTP2.0 插件。同时需要注意兼容性问题。
使用预加载preload和预提取prefetch
preload 是为了尽早加载首屏需要的关键资源(强制尽快),从而提升页面渲染性能(css,js,image,viedo,audio,fonts等等),这个加载跟页面解析是并行的(与HTTP2.0使用),不会阻塞页面本身的加载。
prefetch 是在浏览器空闲的时候下载将来可能访问的资源,需要业务逻辑中进行判断做按需加载。目前前端业务基本用得少,有部分用了react-lodable 或者 component-lodable 的可以设置。
使用 Webpack 的 PreloadWebpackPlugin 插件,对关键资源进行预加载,提高页面加载速度,同时也可以对非关键资源进行预提取。
module.exports = {
plugins: [
new PreloadWebpackPlugin({
// 指定预加载
rel: 'preload',
// 对所有代码块尽心预加载
include: 'allChunks',
// 黑名单,不处理
fileBlacklist: [/\.map$/, /hot-update\.js$/, /runtime\..*\.js$/],
// 白名单,优先级最高,若指定了白名单,只处理白名单的内容
// 白名单指定关键的公共资源,如:chunk-vendors.js
fileWhitelist: [/chunk-/]
}),
]
}
思考:对公共的bundle,preload是否对性能一定有提升?
一般情况下有提升。preload 资源是提前加载,公共的 bundle 是业务页面必须加载的,使用 preload 能充分利用 html 解析、index.js 加载解析的时间提前加载,提升首屏显示。
但是,如果 index.js 中有前置请求接口,那 preload 资源可能会把前置请求接口挤掉,让你前置请求失效。因为在某些浏览器内核,preload 资源是最高优先级。
比如,A 页面的性能瓶颈其实是某个接口,因为 preload 资源导致前置接口请求没有提前。大部分时候,页面的 js、css 都能击中缓存,preload 资源节约的时间比不上提前请求接口的时间。
因此,此时不要对公共 bundle 进行 preload,从而让接口能够提前发起,更能优化性能。
但需要注意,部分浏览器内核可能对请求数量有限制,所以这么设置后,可能前置请求和 bundle 也不是并发的。甚至在某些低端机型中,就算 bundle 不设置 preload,它也会在接口请求之前加载。
所以说,同一个优化措施,对不同模块效果不一样,甚至不同浏览器内核效果都不一样,优化措施要对比论证、客观分析。
没有一招打遍天下的措施。
使用dns-prefetch和preconnect加快网络连接
DNS 解析也是开销,通常浏览器查找一个给定域名的 IP 地址要花费 20~120 毫秒,在完成域名解析之前,浏览器不能从服务器加载到任何东西。
那么如何减少域名解析时间,加快页面加载速度呢?可以对业务项目的主要的域名进行 dns 预解析。同理可以对 tcp 进行预连接。
<link rel="dns-prefetch" href="https://example.com">
<link rel="preconnect" href="https://fonts.googleapis.com">
特性 | preconnect | dns-prefetch |
---|---|---|
优化阶段 | 提前建立连接(TCP + TLS) | 提前解析 DNS |
资源消耗 | 相对较高(涉及 TCP 和 TLS 过程) | 相对较低(仅 DNS 解析) |
适用场景 | 明确关键跨域资源的域名,追求极致连接速度 | 不确定具体资源或有多个潜在域名,先优化 DNS 解析 |
优先级 | 较高(对关键资源效果显著) | 较低(作为基础优化手段) |
最佳实践建议
组合使用:对于一些非常关键的跨域资源域名,可以同时使用 preconnect 和 dns-prefetch,先通过 dns-prefetch 解析 DNS,再用 preconnect 建立连接(不过要注意浏览器对连接数量等的限制,避免过度优化导致问题)。
按需添加:不要滥用,只对真正会用到的、对性能有重要影响的域名使用,避免不必要的资源浪费(如浏览器连接数占用、DNS 解析请求过多等)。
监测效果:可以使用浏览器的开发者工具(如 Chrome 的 Lighthouse 审计、Network 面板等)来观察使用这些属性后资源加载性能的变化,评估优化效果。
静态资源CDN托管
CDN 的全称是 Content Delivery Network,即内容分发网络。CDN 服务商将源站的资源缓存到遍布全国的高性能加速节点上,当用户访问相应的业务资源时,用户会被调度至最接近的节点最近的节点 ip 返回给用户,使内容能够传输的更快,更加稳定。可以提升首次请求的响应能力。
CDN作为静态资源文件的分发,做好静态资源的缓存工作,就能加快网站的加载速度,比如前端文件、图片、JS脚本等。公司购买的云服务,本身就有CDN 的服务。
网络服务器调整缓存策略
css、js、图片文件,通过webpack构建之后,文件名全部加上了hash,只要文件内容变化,文件名一定会变化,因此这部分文件为充分利用缓存,设置超长缓存(比如半年、一年)来提升页面性能。
html文件(一般是工程的首页代码)由于无法添加hash,为避免发版之后长时间才更新文件,只能设置短时间(比如10分钟)短缓存,在一定程度上利用缓存。
有些文件名不会更新的JS文件、css文件,比如
no-change-script.js
,就算发版了,文件名也不会变化。针对此类文件可以约定文件名.cc
结尾,nginx 中设定.cc
结尾的文件名设置为十分钟短缓存,避免无法更新文件。
构建配置充分利用缓存
充分利用缓存,减少重复资源的请求。要求项目与项目间公共的资源尽量能复用,每次打包的 bundle 尽量不变。主要是从构建配置上面进行处理。
1. 提取引导模板(extracting boilerplate)
在 Webpack 中,"提取引导模板" (extracting boilerplate) 是指将 Webpack 的运行时代码(runtime) 和模块管理逻辑从主应用程序代码中分离出来。目的是提升长期缓存效率和应用加载性能。
runtime 指的是 webpack 的运行环境(具体作用就是模块解析、加载) 和 模块信息清单, 模块信息清单在每次有模块变更(hash 变更)时都会变更,比如页面新引一个组件,runtime chunk就会改变。
如果runtime chunk不单独拿出来,就会放到 index.hash.js
bundle中,每次改变业务代码,会改变 index.hash.js
文件(首页必须),缓存就失效了,导致每次发版都要重新请求 index.hash.js
文件,无法利用缓存。
设置runtimeChunk之后,webpack就会生成一个个 runtime~xxx.js
的文件。将 runtime 文件独立抽出来后,依赖的加载一般不影响 app.js bundle了(除非修改了 app.jsx)。
一般来说,runtime bundle 都非常小,单独发送请求并不划算。因为重新发版后 index.html 必须更新(里面bundle和版本号改变,且设置了 5min 缓存时间),所以把 runtime 集成到 index.html 入口文件是个巧妙的处理方式。通过 script-ext-html-webpack-plugin 实现。
module.exports = {
optimization: {
runtimeChunk: 'single',
},
plugins: [
new ScriptExtHtmlWebpackPlugin({
inline: /runtime\..*\.js$/, // 匹配 runtime 文件名
}),
new PreloadWebpackPlugin({
rel: 'preload',
fileBlacklist: [/runtime\..*\.js$/], // 要从 preload 剔除 runtime 文件
}),
]
}
2. 避免module id和chunk id以默认的方式自增,锁定模块标识符
这一点不太理解。
module 就是没有被编译之前的代码,通过 webpack 的根据文件引用关系生成 chunk 文件,webpack 处理好 chunk 文件后,生成运行在浏览器中的代码 bundle。
一个module id是一个模块的唯一标识符,module的改变会引起chunk的改变,最终引起bundle的改变,这是一个连锁反应。需要尽量避免module的大规模改变。
问题:如果不显式设置,默认module id是一个数值,其他module id以自增的方式依次用数字命名,模块的增加和减少,代码的改动,都会批量改变module id的值,最终引起大部分bundle文件名的改变。可能就会出现,改了几行代码,整个模块所有文件缓存都会失效的情况。
解决:要使用唯一的标记替换module id,让module id不再以自增的方式改变,用hash替代,与文件内容相关。只有代码改变的module被重新打包,其他无关的bundle名字不变。
optimization: {
runtimeChunk: 'single',
moduleIds: 'hashed',
namedChunks: true,
}
3. 使用contenthash避免jsx、图片、css的改变带来的变化
filename:对应于 entry 里面生成出来的文件名。
chunkFilename:chunkFilename 就是未被列在 entry 中,但有些场景需要被打包出来的文件命名配置。比如按需加载(异步)模块的时候。
原来使用 hash 命名,每次构建都容易引起整个项目页面 bundle 的改变。所以调整成使用 contenthash。不同值的含义:
- hash:计算与整个项目的构建相关;
- chunkhash:计算与同一 chunk 内容相关;
- contenthash:计算与文件内容本身相关。
output: {
filename: '[name].[hash:8].js',
chunkFilename: '[name].[contenthash:8].js'
},
imageUrlLoaderOption: {
limit: 1, // 所有image都不转换为base64
name: 'static/images/[name].[contenthash:8].[ext]'
},
miniCssExtractPluginOption: {
filename: 'css/[name].[contenthash:8].css',
chunkFilename: 'css/[name].[contenthash:8].css'
},
4. 修改optimization.splitChunks 配置,优化分包策略
充分利用缓存,减少重复资源的请求。这就要求项目与项目间公共的资源尽量能复用,以及每次发版打包的 bundle 尽量不变。主要是从构建上面进行处理,策略原则:
不变的依赖尽量一起打包,利用缓存,减少HTTP请求次数。比如 mobx-h5、swiper 等几乎不变的三方包。
相关的依赖尽量一起打包,降低依赖批量变化带来的影响。比如和人脸识别相关的依赖一起打包。
合并体积小的 chunk 文件,像几KB甚至几B的文件。小文件的请求开销比下载时间更久,因为网络下载速度是逐步爬升的,需要充分利用提升后稳定的网速,50KB的文件和100KB的时间差距可能只有0.2倍。
不要让某个文件成为瓶颈,避免单个文件体积过大,拖慢页面加载速度,尽量让多个大小均匀的包并发请求,较多的请求数更能抢占网速。一个包最多在100KB。当然前提是,使用二零HTTP2.0,并发请求没有限制的情况下,否则在HTTP 1.1的情况下,不拆包更合理,因为拆包多请求可能会更慢。
splitChunks: {
cacheGroups: {
// 调试组件单独打包
vconsole: {
test: /[\\/]node_modules[\\/]vconsole[\\/]dist.*/,
name: 'vconsole',
chunks: 'all',
minChunks: 1,
priority: 1000,
},
fixed: {
// 很少变化的依赖
test: /[\\/]node_modules[\\/](mobx-h5|mobx-common|async-await)|swiper)[\\/]/,
name: 'bundle-fixed',
chunks: 'all',
priority: 900,
reuseExistingChunk: true
},
ui: {
// UI组件库
test: /[\\/]node_modules[\\/](UI)/,
name: 'bundle-ui',
chunks: 'all',
minChunks: 2,
priority: 850,
reuseExistingChunk: true
},
face: {
// 人脸识别相关包
test: /[\\/]node_modules[\\/](face)/,
name: 'bundle-face',
chunks: 'all',
priority: 800,
reuseExistingChunk: true
},
common: {
// 其它第三方包
test: /[\\/]node_modules[\\/]/,
name: 'bundle-common',
chunks: 'all',
minChunks: 2,
priority: 500,
reuseExistingChunk: true
},
commontemp: {
// temp打入bundle-temp中,避免index.xx.js过大以及减少请求
test: /[\\.]temp/,
name: 'bundle-temp',
chunks: 'all',
minChunks: 2,
priority: 400,
reuseExistingChunk: true
},
mergeChunkCss: {
// 合并零碎的css小文件
test: /[\\/]node_modules[\\/].*\.(sc|c|sa|le)ss$/,
name: 'bundle-common',
chunks: 'all',
minChunks: 2,
priority: 1000,
reuseExistingChunk: true,
minSize: 0
},
}
}
5. 抽取业务框架的代码成独立脚本
把各个业务模块,都需要使用的重复代码抽离成独立的js脚本,不同项目通过脚本引入的方式进行引用。
不同业务项目跳转时,由于页面缓存的存在,重复的依赖不需要再次下载,节约网络开销,加快了页面打开的速度。
<!-- index.html -->
<link rel="preload" as="script" href="./common.js">
核心接口前置请求
核心接口前置到 app.xxx.js 中请求,让接口请求和 chunk-xx.hash.js 等资源请求并发,尽快加快请求速度。数据存储在全局变量中,进一步减低请求次数。
也就是让核心的接口在执行 app.xxx.js 的时候就发起请求,尽可能提前。
// app.jsx
// 核心接口前置请求
requestOne();
class App extends Component {}