Webpack 性能优化
为什么要做优化
webpack 打包优化并没有什么固定的模式,一般我们常见的优化就是拆包、分块、压缩等,并不是对每一个项目都适用,针对于特定项目,需要不断调试不断优化;这边主要记录的是整体思路,每一块都可以单独去深入实践;
打包效率优化
如何分析打包速度
使用speed-measure-webpack-plugin
测量你的 webpack 构建期间各个阶段花费的时间;
// 分析打包时间
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();
// ...
module.exports = smp.wrap(prodWebpackConfig) // prodWebpackConfig 为webpack的配置项
复制代码
分析影响打包效率的原因
-
开始打包,我们需要获取所有的依赖模块----搜索时间
-
解析所有的依赖模块(解析成浏览器可运行的代码)-----解析时间
webpack 根据我们配置的 loader 解析相应的文件。日常开发中我们需要使用 loader 对 js ,css ,图片,字体等文件做转换操作,并且转换的文件数据量也是非常大。由于 js 单线程的特性使得这些转换操作不能并发处理文件,而是需要一个个文件进行处理
-
将所有的依赖模块打包到一个文件 -----压缩时间
将所有解析完成的代码,打包到一个文件中,为了使浏览器加载的包更新(减小白屏时间),所以 webpack 会对代码进行优化。
JS 压缩是发布编译的最后阶段,通常 webpack 需要卡好一会,这是因为压缩 JS 需要先将代码解析成 AST 语法树,然后需要根据复杂的规则去分析和处理 AST,最后将 AST 还原成 JS,这个过程涉及到大量计算,因此比较耗时,打包就容易卡住
-
二次打包 ----- 二次打包时间
当更改项目中一个小小的文件时,我们需要重新打包,所有的文件都必须要重新打包,需要花费同初次打包相同的时间,但项目中大部分文件都没有变更,尤其是第三方库
针对每个不同节点的优化手段
搜索时间
缩小文件搜索范围 减小不必要的编译工作
webpack 打包时,会从配置的 entry
触发,解析入口文件的导入语句,再递归的解析,在遇到导入语句时 webpack 会做两件事情:
- 根据导入语句去寻找对应的要导入的文件。例如
require('react')
导入语句对应的文件是./node_modules/react/react.js
,require('./util')
对应的文件是./util.js
。 - 根据找到的要导入文件的后缀,使用配置中的 Loader 去处理文件。例如使用 ES6 开发的 JavaScript 文件需要使用 babel-loader 去处理
主要优化手段
-
优化 loader 配置
使用 Loader 时可以通过
test
、include
、exclude
三个配置项来命中 Loader 要应用规则的文件 -
优化 resolve.module 配置
resolve.modules
用于配置 webpack 去哪些目录下寻找第三方模块,resolve.modules
的默认值是['node_modules']
,含义是先去当前目录下的./node_modules
目录下去找想找的模块,如果没找到就去上一级目录../node_modules
中找,再没有就去../../node_modules
中找,以此类推 -
优化 resolve.alias 配置
resolve.alias
配置项通过别名来把原导入路径映射成一个新的导入路径,减少耗时的递归解析操作。 -
优化 resolve.extensions 配置
resolve.extensions
列表要尽可能的小,不要把项目中不可能存在的情况写到后缀尝试列表中- 频率出现最高的文件后缀要优先放在最前面,以做到尽快的退出寻找过程。
- 在源码中写导入语句时,要尽可能的带上后缀,从而可以避免寻找过程。
-
优化 module.noParse 配置
module.noParse
配置项可以让 Webpack 忽略对部分没采用模块化的文件的递归解析处理,这样做的好处是能提高构建性能。 原因是一些库,例如 jQuery 、ChartJS, 它们庞大又没有采用模块化标准,让 Webpack 去解析这些文件耗时又没有意义。
配置示例参考
// 编译代码的基础配置
module.exports = {
// ...
module: {
// 项目中使用的 jquery 并没有采用模块化标准,webpack 忽略它
noParse: /jquery/,
rules: [
{
// 这里编译 js、jsx
// 注意:如果项目源码中没有 jsx 文件就不要写 /\.jsx?$/,提升正则表达式性能
test: /\.(js|jsx)$/,
// babel-loader 支持缓存转换出的结果,通过 cacheDirectory 选项开启
use: ['babel-loader?cacheDirectory'],
// 排除 node_modules 目录下的文件
// node_modules 目录下的文件都是采用的 ES5 语法,没必要再通过 Babel 去转换
exclude: /node_modules/,
},
]
},
resolve: {
// 设置模块导入规则,import/require时会直接在这些目录找文件
// 可以指明存放第三方模块的绝对路径,以减少寻找
modules: [
path.resolve(`${project}/client/components`),
path.resolve('h5_commonr/components'),
'node_modules'
],
// import导入时省略后缀
// 注意:尽可能的减少后缀尝试的可能性
extensions: ['.js', '.jsx', '.react.js', '.css', '.json'],
// import导入时别名,减少耗时的递归解析操作
alias: {
'@compontents': path.resolve(`${project}/compontents`),
}
},
};
复制代码
优化解析时间 - 开启多进程打包
运行在 Node.js 之上的 webpack 是单线程模式的,也就是说,webpack 打包只能逐个文件处理,当 webpack 需要打包大量文件时,打包时间就会比较漫长
-
thread-loader
// ... const threadLoader = require('thread-loader'); const jsWorkerPool = { // options // 产生的 worker 的数量,默认是 (cpu 核心数 - 1) // 当 require('os').cpus() 是 undefined 时,则为 1 workers: 2, // 闲置时定时删除 worker 进程 // 默认为 500ms // 可以设置为无穷大, 这样在监视模式(--watch)下可以保持 worker 持续存在 poolTimeout: 2000 }; const cssWorkerPool = { // 一个 worker 进程中并行执行工作的数量 // 默认为 20 workerParallelJobs: 2, poolTimeout: 2000 }; threadLoader.warmup(jsWorkerPool, ['babel-loader']); threadLoader.warmup(cssWorkerPool, ['css-loader', 'postcss-loader']); module.exports = { // ... module: { rules: [ { test: /\.js$/, exclude: /node_modules/, use: [ { loader: 'thread-loader', options: jsWorkerPool }, 'babel-loader' ] }, { test: /\.s?css$/, exclude: /node_modules/, use: [ 'style-loader', { loader: 'thread-loader', options: cssWorkerPool }, { loader: 'css-loader', options: { modules: true, localIdentName: '[name]__[local]--[hash:base64:5]', importLoaders: 1 } }, 'postcss-loader' ] } // ... ] // ... } // ... } 复制代码
优化压缩时间
webpack4 默认内置使用 terser-webpack-plugin
插件压缩优化代码;使用多进程并行运行来提高构建速度。并发运行的默认数量为 os.cpus().length - 1
module.exports = {
optimization: {
minimizer: [
new TerserPlugin({
parallel: true,
}),
],
},
};
复制代码
合理利用缓存
使用 webpack 缓存的方法有几种,例如使用 DLL
、cache-loader
,HardSourceWebpackPlugin
或 babel-loader
的 cacheDirectory
标志。 所有这些缓存方法都有启动的开销。 重新运行期间在本地节省的时间很大,但是初始(冷)运行实际上会更慢
-
Babel-loader
cacheDirectory
{ test: /\.js$/, use: 'babel-loader?cacheDirectory', include: [resolve('src'), resolve('test') ,resolve('node_modules/webpack-dev-server/client')] } 复制代码
-
Cache-loader
module.exports = { module: { rules: [ { test: /\.ext$/, use: ['cache-loader', ...loaders], include: path.resolve('src'), }, ], }, }; 复制代码
-
HardSourceWebpackPlugin --- 二次构建速度会非常的快
页面加载性能优化
如何分析
浏览器渲染原理中入手
结论:
- 网络通信更快 - 网络层面
- 拿到数据后,渲染更快 - 渲染层面
关键指标
-
白屏时间
白屏时间节点指的是从用户进入网站(输入url、刷新、跳转等方式)的时刻开始计算,一直到页面有内容展示出来的时间节点;
这个过程包括dns查询、建立tcp连接、发送首个http请求(如果使用https还要介入TLS的验证时间)、返回html文档、html文档head解析完毕
-
怎么计算?
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>白屏时间</title> <script> // 开始时间 window.pageStartTime = Date.now(); </script> <link rel="stylesheet" href=""> <link rel="stylesheet" href=""> <script> // 白屏结束时间 window.firstPaint = Date.now() </script> </head> <body> <div>123</div> </body> </html> 在html文档的head中所有的静态资源以及内嵌脚本/样式之前记录一个时间点, 在head最底部记录另一个时间点,两者的差值作为白屏时间 白屏时间 = firstPaint - pageStartTime 复制代码
-
-
首屏时间
-
首屏时间 = 白屏时间 + 首屏渲染时间
-
如何计算
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>首屏时间</title> <script> // 开始时间 window.pageStartTime = Date.now(); </script> <link rel="stylesheet" href=""> <link rel="stylesheet" href=""> </head> <body> <div>123</div> <div>456</div> // 首屏可见内容 <script> // 首屏结束时间 window.firstPaint = Date.now(); </script> // 首屏不可见内容 <div class=" "></div> </body> </html> 由于浏览器解析HTML是按照顺序解析的,当解析到某个元素的时候, 觉得首屏完成了,就在此元素后面加入<script>计算首屏完成时间 首屏时间 = firstPaint - pageStartTime 复制代码
-
-
可操作时间
// 原生JS实现dom ready window.addEventListener('DOMContentLoaded', (event) => { console.log('DOM fully loaded and parsed'); }); 复制代码
-
总下载时间
总下载时间即window.onload触发的时间节点 复制代码
-
window.performance
- memory字段代表JavaScript对内存的占用 - navigation 统计的是一些网页导航相关的数据 - timing 它包含了网络、解析等一系列的时间数据 复制代码
相关的时间计算: DNS查询耗时 = domainLookupEnd - domainLookupStart TCP链接耗时 = connectEnd - connectStart request请求耗时 = responseEnd - responseStart 解析dom树耗时 = domComplete - domInteractive 白屏时间 = domloading - fetchStart domready可操作时间 = domContentLoadedEventEnd - fetchStart onload总下载时间 = loadEventEnd - fetchStart 复制代码
优化的手段
网络通信的时间更快
-
静态资源CDN
-
优点: 就近原则 & 多级缓存策略
-
如何配置
1. index.html <body> <div id="app"></div> <!-- built files will be auto injected --> <!--生产环境--> <script src="https://cdn.bootcss.com/vue/2.6.11/vue.min.js"> </script> <!-- 引入组件库 --> <script src="https://cdn.bootcss.com/vue-router/3.2.0/vue-router.min.js"> </script> <script src="https://cdn.bootcss.com/axios/0.23.0/axios.min.js"></script> <script src="https://cdn.bootcss.com/element-ui/2.15.6/index.js"></script> </body> 2. webpack.config.js configureWebpack: { externals: { "vue": "Vue", "vue-router": "VueRouter", "axios": "axios", "moment": "moment", "element-ui": "ELEMENT", } }, 3. 去掉原本代码上的 import vue from 'vue'; import vueRouter from 'vue-router'; 复制代码
-
-
资源合并 (http2.0就不需要做这个)
-
域名分片(多域名)(浏览器可并发 6-8个请求 ,http2.0就不需要做这个)
-
缓存策略
考虑拒绝一切缓存策略:Cache-Control:no-store 考虑资源是否每次向服务器请求:Cache-Control:no-cache 考虑资源是否被代理服务器缓存:Cache-Control:public/private 考虑资源过期时间:Expires:t/Cache-Control:max-age=t,s-maxage=t 考虑协商缓存:Last-Modified/Etag 复制代码
- 强缓存(资源存储在 memory cache 或 disk cache)
- Expires: 为http1.0定义的绝对的过期时间(使用本地时间)、本地时间喝服务器时间存在较大差异会错乱
- Cache-Control: http1.1出现的缓存控制字段、优先级更高、
- 协商缓存
- Last-Modified: 资源最后修改时间,当客户端再次请求该资源的时候,会在其请求头上附带上If-Modified-Since字段,值就是之前返回的Last-Modified值。如果资源未过期,命中缓存,服务器就直接返回304状态码,客户端直接使用本地的资源。否则,服务器重新发送响应资源
- Etag: 通过校验码;这样就保证了在文件内容不变的情况下不会重复占用网络资源。响应头中Etag字段是服务器给资源打上的一个标记,利用这个标记就可以实现缓存的更新。后续发起的请求,会在请求头上附带上If-None-Match字段,其值就是这个标记的值;优先级高
- Service Worker: Service Worker 是一个相对来说比较新的技术,其目的也主要是为了提高web app的用户体验,可以实现离线应用消息推送等等一系列的功能, 从而进一步缩小与原生应用的差距。 Service Worker可以看做是一个独立于浏览器的Javascript代理脚本,通过JS的控制,能够使应用先获取本地缓存资源(Offline First),从而在离线状态下也能提供基本的功能。 出于安全性的考虑,Service Worker 只能在https协议下使用,不过为了便于开发,现在的浏览器默认支持localhost使用Service Worker。
- 强缓存(资源存储在 memory cache 或 disk cache)
-
资源压缩
- gzip压缩方式 & br
- 代码文件压缩(注释/空格/变量名)
- 静态资源(图标,图片资源)
- 头和报文(http1.1减少不必要的头,减少cookie的数据量)
- Tree-shaking
- split-chunk 拆包
-
通信协议上(http1.0/http1.1/http2.0)
-
减少http请求
一个完整的 HTTP 请求需要经历 DNS 查找,TCP 握手,浏览器发出 HTTP 请求,服务器接收请求,服务器处理请求并发回响应,浏览器接收响应等过程
-
启用HTTP2.0
- 二进制协议;解析速度快
- 多路复用,多个请求共用一个TCP连接
- 首部压缩
-
-
按需加载和懒加载
渲染层面
- 需要使用一个测试工具服务器压力测试(ab) (node --prof 生成整个运行日志,可以被解析成占用率)
- 前端代码层面
- html 语义化标签加强dom解析
- 多用伪元素,减少js操作dom
- 逻辑和展示解藕;
- 减少作用域查找和闭包;
- css文件放置头部,js文件放置在底部
- js: 算法复杂度
- web worker: 创造多线程环境
- 减少重绘和回流
- 渲染方式 (csr => ssr)-- next/nuxt
- 优点: 首屏渲染快,SEO 好
- 缺点: 配置麻烦,增加了服务器的计算压力
- 静态站点生成方案(SSG)Gatsby/Gridsome