一、记录优化前的初始状态
1.查看首屏初次渲染加载时间
打开控制面板,勾选 停用缓存 后,刷新项目首屏,得到首屏初次渲染的资源加载情况。
标记1处:
DomContentLoaded:dom内容加载完毕的时间为11.64秒,它对应着瀑布栏下蓝色的那条竖线。
标记2处:
加载时间:页面上所有的资源(图片,音频,视频等)被加载完成的时间为11.66秒,对应着瀑布栏下红色的那条竖线。
我们主要关注首屏的加载时间这个指标,这里11.66秒过长了,那么我们重点关注下完成加载时间最晚的几个资源文件,也就是标记3和标记4处的资源文件。
观察标记4处的瀑布图,我们发现加载时间最长的是vendor.js这个文件,这是我们项目的打包出来的库文件,如果我们能把其中体积较大且首屏用不上的第三方包从里面拆分出来,等到需要用的时候再去加载或者在首屏加载完成后页面空闲的时候再去加载,那么首屏的加载时间会缩短一些。同理,加载时间较长的index.js文件是我们的打包的业务代码文件,如果我们能利用路由懒加载把其他的路由文件拆分出来,那么首屏也会更快的渲染出来。
标记3处是antd和icons的cdn资源的加载,因为这块的资源是无法改动的,所以它可能决定了我们首屏加载时间优化的上限,我们最好的结果可能就是在antd.js文件加载完成的时候完成首屏的加载,那么这里大概就是根据第二张图里antd.js的进入队列的时间和antd.js加载的时间加总,得到七八秒是我们优化的上限。
2.查看二次加载时间
我们取消 停用缓存 的勾选,再次刷新页面,查看二次渲染的情况。因为二次渲染利用了缓存,所以加载时间为856毫秒,结果是比较好的。
3.Lighthouse评分情况
二、性能优化(上)(访问效率)
(一)去除console.log
1.配置方法
// webpack.prod.js
const TerserJSPlugin = require('terser-webpack-plugin')
module.exports ={
//...
optimization: {
minimizer: [
new TerserJSPlugin({
//...
terserOptions: {
compress: {
drop_console: true
}
}
}
)]
//...
}
2.优化结果
我们利用webpack-bundle-analyzer插件查看去除前后业务代码文件index.js的体积变化,确实小了一些。
去除前:
去除后:
(二)拆分首屏不需要的vendor库文件
利用webpack-bundle-analyzer插件分析发现vendor.js文件中最大的的三个包分别是monaco-editor,antd,以及
涂鸦公共组件库tuya-fe.
(PS:笔者首屏分析中说项目引入了antd的cdn,结果在vendor里也发现了antd,确认了下cdn和vendor里的antd版本是一致的,那么vendor这里的antd是重复打包了,于是笔者在webpack的external节点配置中把antd排除在打包范围之外)
因为monaco-editor和tuya-fe首屏是用不到的体积又很大,下面我们对monaco-editor和tuya-fe进行拆包。
1.配置方法
//webpack.prod.js
optimization: {
//....
splitChunks: {
chunks: 'all',
// 缓存分组
cacheGroups: {
// 拆分monaco-editor
monacoEditor: {
chunks: 'async',
name: 'chunk-monaco-editor',
priority: 22,
test: /[\/]node_modules[\/]monaco-editor[\/]/,
enforce: true,
reuseExistingChunk: true,
},
// 拆分tuya-fe
tuyaComponent: {
chunks: 'async',
name: 'chunk-tuya-component',
priority: 12,
test: /[\/]node_modules[\/]@tuya-fe[\/]galaxy-public-components[\/]/,
enforce: true,
reuseExistingChunk: true,
},
vendor: {
name: 'vendor', // chunk 名称
priority: 1,
test: /[\/]node_modules[\/]/,
minSize: 0, // 大小限制
chunks: 'all',
minChunks: 1, // 最少复用过几次
},
// 公共的模块
common: {
name: 'common', // chunk 名称
priority: 0, // 优先级
minSize: 0, // 公共模块的大小限制
minChunks: 2, // 公共模块最少复用过几次
},
},
},
//...
},
2.优化效果
vendor.js拆分前
vendor.js拆分后:
(三)路由懒加载&异步路由合并&异步路由prefetch
异步路由prefetch:
我们希望使用魔法注释使得异步路由文件能够在首屏加载完成后的浏览器空闲时间去下载,这样就能使得其他路由的访问更快一些(由于首屏请求过该路由文件,等到访问这个路由的时候,直接从预缓存里去获取这个文件资源)
异步路由合并:
由于除了首屏以外的每个路由文件体积都很小,如果一个个拆分出来,http请求会过多,那么我们通过给多个路由命名相同的webpackChunkName的方式,来合并多个路由文件。
1.配置方法
// router-view.tsx
import { Suspense } from 'react'
<Router>
<Suspense fallback={<div>loading...<div/>}>
<Switch>{routeRender(routes)}</Switch>
</Suspense>
</Router>
// router.ts
import { lazy } from 'react'
const routes: RouteItem[] =[
{
{
key: 'addVirtualDevice',
title: '创建设备',
path: '/virtual/device/create',
component: lazy(() =>
import(
/* webpackChunkName: "chunk-other-pages", webpackPrefetch: true */ '@pages/virtual/create-device'
),
),
},
{
title: '版本信息列表',
path: '/virtual/firmware/version/management',
component: lazy(() =>
import(
/* webpackChunkName: "chunk-other-pages", webpackPrefetch: true */ '@pages/virtual/firmware-list/firmware-version-management'
),
),
hide: true,
},
]
2.优化效果
路由拆分前:
路由拆分后:
三、性能优化(上)优化效果总结
1.查看首屏初次渲染时间
加载时间从之前的11.66秒,降低至6.32秒。
如下图标记2处,我们从index.js业务代码文件中所拆分出来的异步路由会在首屏加载完成后,浏览器空闲的时候去加载,那么就使得index.js包含首屏代码的文件体积更小,能尽快的加载完首屏内容。
同时,在下图标记1处,我们看到通过实施上面所说的所有优化方法后,加载完成的最后一个资源文件是antd.js的cdn外链资源,因为该资源的不可改动,所以我们对首屏加载时间的优化基本是达到了优化的上限了。
2.关于异步路由prefetch的效果展示
如下图,我们可以看到,prefetch的作用是,当访问到非首屏路由时,因为在首屏预加载过该路由文件,所以直接从预提取缓存里获取该路由文件,所花时间仅2毫秒。
3.查看二次加载时间
二次加载的时间从856ms降低至了818ms.
4.lighthouse评分:
评分从之前的75分提升至了91分。
四、性能优化(中)(打包构建速度,开发效率)
(一)并行压缩JS
TerserWebpackPlugin支持多进程方式执行代码压缩,能提高项目构建速度。
插件 TerserWebpackPlugin 默认已开启并行压缩能力,通常情况下保持默认配置即 parallel = true 即可获得最佳的性能收益
1.配置方法
const TerserJSPlugin = require('terser-webpack-plugin')
module.exports ={
//...
optimization: {
// 并行压缩 js
minimizer: [
new TerserJSPlugin()
],
}
//...
}
2.优化效果
如下图,我们可以看到使用并行压缩后,生产环境下的项目构建速度是从23.15s降低到了12.67s.
不使用并行压缩,运行yarn run build的结果:
使用并行压缩,运行yarn run build的结果:
(二)no-parse
默认情况下无论我们导入的模块(库)是否依赖于其它模块(库), 都会去分析它的依赖关系但是对于一些独立的模块(库)而言, 其根本不存在依赖关系, 但是webpack还是会去分析它的依赖关系这样就大大降低了我们打包的速度.
所以对于一些独立的模块(库), 我们可以提前告诉webpack不要去分析它的依赖关系这样就可以提升我们的打包速度.
我们项目中使用到了lodash这个库,因为这个库是没有引入其他的包的,那么我们就可以告诉webapck不用分析lodash的依赖关系。(独立的库常见的有jquery,lodash)
1.配置方法
module: {
noParse:'/lodash/'
}
2.优化效果
笔者实践后翻车了,不论是开发环境还是生产环境下项目构建速度都没有明显提升,有的时候反而更慢了。
笔者后来检查了下项目中引入的lodash,发现整个项目只按需引入了debounce这一个函数,可能是因为引入的lodash过小,所以没有明显的变化。
笔者将按需引入改成全部引入后,才发现有了较为明显的速度提升。
如下全部引入lodash的情况下,使用noParse,开发环境下构建速度从4917ms提升到了4772ms
如下全部引入lodash的情况下,使用noParse,生产环境下构建速度从16.94s提升到了16.43s
根据项目的实际情况,优化的实际效果对优化方法做取舍,那么笔者就不使用no-parse优化项目。
(三)热更新
就是你在页面用户交互(输入框输入文字,下拉框进行了选择等等)写了些东西后,然后又改了文件,devServer
重新打包,导致页面刷新,交互的东西没有了,那如果希望交互的东西还在,改的东西能更新到页面上,而且页面不刷新,就需要用到热更新。
热更新插件是webpack内置插件HotModuleReplacementPlugin。
但是笔者在实践过程中,发现即便不配置热更新,css模块的热更新也是实现了的。
经过查阅得知,对于css模块而言, 在css-loader中已经帮我们实现了热更新, 只要css代码被修改就会立即更新。
JS模块热更新的实现是难点,笔者之前按照webpack官网中的热更新配置去配置项目,但是并没有起到作用,经查阅得知,原生js的热更新配置和react+ts的热更新配置是不同的,官网上是对原生js的配置。
1.配置方法(react+ts的JS模块热更新配置)
文章参考:
https://www.codeleading.com/article/16042772093/
https://github.com/gaearon/react-hot-loader
影子设备项目配置方法
// app.tsx
import { hot } from 'react-hot-loader/root'
export default hot(App)
//src/index.tsx
if (ENV === 'local') {
const realModule = module as any
if (realModule.hot) {
realModule.hot.accept(() => {
ReactDOM.render(
<>
<WrapperApp onGlobalStateChange={null} />
</>,
document.querySelector('#root'),
)
})
}
}
//.barbelrc
{
"plugins": [
"react-hot-loader/babel"
]
]
}
// webpack.dev.js
module.exports = {
devServer: {
hot: true
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
],
}
2.优化效果
(1)配置前
未配置的时候,开发环境下,我们在输入框输入复制两个字
然后我们更改页面标题,添加‘热更新’字样,保存后页面刷新了,输入框里的内容被清空了。
(2) 配置后
开发环境下,当我们修改页面标题增加了‘热更新’字样保存后,发现输入框里的内容还在,js模块热更新配置成功。
五、性能优化(下)(不适用于libra发布平台)
以下所要实现的四种优化方法笔者没有集成到项目中去,因为gzip,http2和缓存策略这三个优化方法,libra发布平台已经自动帮我们实现了,预渲染因为libra发布平台的项目里的全局变量插入是在项目build之后的流程原因,以及做预渲染的话,项目里引入的CDN需要在build之前存在,而libra上传cdn是在项目build之后,所以这个方法并不适用于我们涂鸦的libra发布平台。
优化前的准备工作(使用nginx部署影子设备项目):
前端项目部署nginx服务器
(一)Gzip
开启gzip压缩可以降低网络传输文件大小,有效的加速网页内容的加载。
1.配置方法
nginx.conf文件添加如下配置:
http {
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
}
2.优化效果
开启gzip压缩前
控制台网络面板,大小这一栏显示的是资源文件的网络传输大小,将鼠标悬浮在其上,显示的resource size才是资源本身的大小。
我们可以通过Content-Encoding这一栏查看是否是gzip压缩。
开启gzip压缩后:
我们通过比对vendor.js文件,可以看到开启gzip后,文件网络传输大小从957kB降低到了308kB,是原来的三分之一都不到,文件加载时间也从10.08秒降低到了3.63秒。
整体的首屏的加载时间也从13.28秒降低到了6.76秒。
(二)HTTP2
http1.1和http2相比,http2有哪些优势?
http2的优势:
1.二进制传输
2.请求和响应多路复用
https是我们去使用http2的必须的条件。我们只能在https的情况下,才能去开启http2.
1.配置https服务器方法
前端项目部署nginx服务器
2.优化效果
结果http1.1和http2首屏加载时间差不多。
lighthouse评分的话http2的分数更高一些。
(三)缓存优化
(1)强缓存(Cache-Control, disk cache)
如果资源没有过期,不用跟服务器进行通信,直接使用本地缓存的资源。
强缓存是在响应头中设置cache-control来控制强缓存的逻辑,比如Cache-Control:max-age=31536000(单位是秒)。只要根据响应头里date和cache-control:max-age加总计算得到资源到期时间,比对再次获取资源的当前时间,只要资源没有过期,就命中强缓存。
Cache-Control的值的设定
max-age 设置缓存的过期时间
no-cache 不用本地的强制缓存,正常的向服务端请求,服务端怎么处理我们不管(禁用强缓存,可用协商缓存)
no-store不用本地缓存,也不用服务端的缓存措施,让服务端把资源重新返回一次,这个更彻底(强缓存和协商缓存都禁用)
private只能允许最终用户左右缓存
public允许中间路由,中间代理做缓存
ngix配置强缓存且禁用协商缓存的方法:
初次请求:
date为初次请求的时间
第二次请求(资源未过期的情况):
笔者在八点零六分刷新页面,再次去获取vendor.js资源,因为根据date和cache-control:max-age加总计算,
发现资源还没有到期,所以这个时候命中了强缓存(从状态代码可以看出来,显示内存缓存),而且压根也没有向服务端发起请求(从请求表头显示预配表头可以看出来)
二次请求(资源过期的情况):
资源过期的话,界面和初次请求是一样的,向服务器发起了新的请求,只是响应头里的date会更新为这次新的请求的时间。之后获取资源就根据最近一次向服务端请求资源的时间(date)加总cache-control:max-age的时间得到资源到期时间,比对当前获取资源的时间,没到期就命中强缓存,到期了就重新向服务端发起请求,响应头date会更新,以此循环往复。
(2)协商缓存(Last-Modified和Etag,304状态码)
客户端向服务端询问,资源有无变化,如果服务端检查(判断客户端资源是否和服务端的一样)后发现资源没变,服务端告诉客户端,你可以用你本地的缓存,不用我再给你,返回的状态码是304。如果服务端发现不一样,返回200和新的资源。
使用Etag资源标识做协商缓存
使用Last-Modified做协商缓存
请求示例可以看到协商缓存能减小资源网络传输大小。
nginx配置禁用强缓存开启协商缓存
初次请求(分开截了两张图)
二次请求
(3)协商缓存和强缓存综合流程(优先级:强缓存>etag>Last-Modified,三者可以同时设置)
(4)项目缓存优化策略
1.对于html文件(禁用强缓存):
单页应用只有一个html文件作为唯一的入口,所有的资源都是通过这个html文件去进行后续的加载的,如果资源更新了,我们希望html缓存这个时候过期,这样用户才不会是拿到旧的文件。如果html被强缓存了,那么它始终都是拿到的旧的js,css等资源。
对html文件,我们使用协商缓存或者no-store完全不缓存都是可以的。
nginx配置(index.html完全不缓存):
配置效果展示:
如下,因为完全禁用了缓存,每次都是去服务端重新获取资源,返回状态码200。
2.对js,css,图片等静态资源文件(contenthash+强缓存)
(1)webpack配置
配置output的js,css等静态资源文件为文件名称加上内容的hash值,一旦内容发生了变化, 内容的hash值就会发生变化, 文件的名称也会发生变化一旦文件的名称发生了变化, 浏览器就会自动去加载新打包的文件.
内容变化hash值才变化,这样可以避免更新发布文件后,因为名字没变,使用缓存,结果还是显示旧的页面内容;
内容不变hash值不变,那么就可以利用缓存,加载资源速度更快;
(2)缓存配置
因为文件内容改变,文件名会改变,文件内容不变,文件名不变,所以我们可以设置强缓存。
nginx配置
配置效果:
浏览器自动根据七天算出来是604800秒,自动添加上了cache-control:max-age
(5)注意事项
笔者实践中发现懒加载路由文件的强缓存在谷歌正常模式下是不生效的,无痕模式或者用火狐浏览器才能生效。
(四)预渲染
在打包的过程中将我们单页应用的页面提前渲染。这样可以加快首屏加载的速度。
1.配置方法
(1)安装react-snap插件
(2)package.json文件
"scripts": {
"postbuild":"react-snap",
},
"reactSnap": {
"source": "dist",
"minifyHtml": {
"collapseWhitespace": false,
"removeComments": false
}
},
(3)修改项目内容
1.将项目需要的全局变量插入index.html文件中,如影子设备需要的变量是ENV和REGION
2.将webpack中publicPath配置为’/',而不是cdn前缀,因为预渲染的时候发现引入的js不存在,会报错。
或者我们也可以先发布日常,将js,css静态资源上传cdn后,再进行build预渲染操作。
3.修改index.js文件
import ReactDOM, { hydrate } from 'react-dom'
function render(props) {
const { container, onGlobalStateChange } = props
// ReactDOM.render(
// <>
// <WrapperApp onGlobalStateChange={onGlobalStateChange} />
// </>,
// container
// ? container.querySelector('#root')
// : document.querySelector('#root'),
// )
const rootElement = document.querySelector('#root')
if (rootElement.hasChildNodes()) {
hydrate(
<WrapperApp onGlobalStateChange={onGlobalStateChange} />,
container ? container.querySelector('#root') : rootElement,
)
} else {
ReactDOM.render(
<WrapperApp onGlobalStateChange={onGlobalStateChange} />,
container ? container.querySelector('#root') : rootElement,
)
}
}
if (!window.__POWERED_BY_QIANKUN__) {
render({})
}
4.将首页的接口请求进行条件判断(在预渲染的时候不去请求)
因为预渲染的时候会完整执行首页的内容,包括请求接口,发现接口请求不到,就会报错。
(4)执行预渲染
执行yarn run build即可,build完成后就会自行执行react-snap了。
2.优化效果
我们看到打包出来的index.html文件里是有内容的,就是影子设备首页的内容。
六、优化后lighthouse评分情况
综合以上(性能优化(上)和性能优化(下)中的提高访问效率的优化手段)lighthouse评分结果如下:
将评分从91分提升到99分的是预渲染的作用,因为优化(下)前三个都是libra平台实现过了,是体现在91分里的。
七、参考资料
慕课网: 前端性能优化企业级解决方案 6大角度+大厂视野
对应课程笔记: 聊一聊前端性能优化