前言
随着项目工程化的不断普及,使用webpack
的项目也越来越多,webpack
性能优化相关的话题被讨论的也越来越多。一个优秀的项目,不仅在开发阶段,能够极大的提升开发这的开发效率和开发体验,而且可以让用户访问网页时能够有更好的用户体验,更快的加载速度。今天,我们从两个大的方面讨论一下关于webpack
性能优化的一些技巧。
优化开发体验和效率
拆分配置
在我们的项目中,开发环境、测试环境、生产环境所需要的一些打包的配置各不相同,开发时要求尽可能得快速打包,不会对代码进行压缩,不需要单独把样式文件抽离,而且需要开启devServer
和devTool
方便开发调试,而在生产环境时,我们需要对页面代码进行压缩,还需要对我们的资源文件加上md5
以防止资源缓存,还有可能需要从cdn
获取某些资源等等。
基于上述情况,如果我们将所有的配置都放到一起,不仅严重影响开发和生产打包效率,而且可能会出现相互影响和冲突的情况,因此,建议可以按照环境拆分成不同的配置文件,然后在主配置文件中,通过merge
合并配置。
善用exclude
和include
我们都知道,当我们们配置一个loader
的时候,如果webpack
在当前目录没有找到包含指定loader
的node_modules
目录,他就会不断地往上层目录查询,直到到达根目录才会报错,这样其实也是很影响开发效率的。我们的期望是我们明确了我们的loader
就是安装到某个地方,你只去那边找,找不到就直接报错,我去安装就可以了,这样效率更高。此时,我们就可以使用exclude
和include
字段,这两个字段一个是排除、一个是指定,我们可以明确指定去项目根目录下的node_modules
目录下查询。
明确extensions
的种类和顺序
我们在开发中,经常会在导入某些模块时,忽略其扩展名,直接到导入文件名,如
typescript
// 导入a.ts
import a from "./a";
少写了这些扩展名,但是我们的webpack
依然能帮我们正确的找到a.ts
这个文件并导入进来,这都得益于其内置扩展名列表,我们也可以通过配置修改扩展名列表:
js
module.exports = {
resolve: {
extensions: ["ts","js", "json", "less", "sass"]
}
};
但是,自定义这个扩展名列表时,我们有一些细节也需要注意的,不然可能会影响webpack
打包的效率。
- 尽可能少配置扩展名:我们在开发一个项目时,不要一股脑得将一对扩展名扔进这个列表图省事,因为
webpack
根据这个扩展名列表查询未知文件是逐一尝试的,一个未知的扩展名,他会把你配置的扩展名列表中的所有扩展名都尝试过后,发现还是找不到这个文件,才会报错,如果刚开始就扔了一堆扩展名进来,每次尝试都要花时间去检测文件是否存在,影响打包的速度。 - 将使用频率最高的扩展名放在最前面:刚刚也说了,
webpack
是从左到右逐一尝试的,因此,我们项目中如果使用最高频率的是*.ts
文件,那么就把这个扩展名放到第一位,这样只需要尝试一次就可以了,不会浪费时间。 - 如果可能,我们还是尽量的在写代码时补充完整扩展名,这样就可以省去文件检测的时间,如果模块多时,这个时间也是很可观的。
source-map
建议开发环境使用devtool: eval-cheap-module-source-map
既能够让打包速度不会降低太多,又方便浏览器调试
建议生产环境在不需要网站监控异常的情况下使用devtool: none
,无须生成source-map,提升打包速度
建议在生产环境需要对网站异常进行监控时使用devtool: hidden-source-map
,既不会暴露源码信息,又可以使用工具解析源码对应报错的信息。
我们知道,如果我们要开启source-map
的话,只需要在webpack
配置中开启devtool
的相关配置项即可,但是由于需要生成源代码和打包后的代码的映射文件(即使是使用inline-source-map
试试要生成映射文件的,只是这个文件的内容没有单独抽离出来放在一个文件,而是以base64
的形式直接放到打包后文件的最后一行),因此会影响打包速度。因此,大部分情况下,我们一般只会在开发环境开启。
但是有一些场景,我们在生产环境也要开启source-map
,比如说我们需要开发一个网站监控系统,我们当然是希望上报到服务端的代码是源码而非打包后的代码。此时,我们既希望能够收集到源码的具体报错行数等信息,又不希望浏览器能够直接通过source
直接查看我们的源码,这个时候就可以采用hidden-source-map
,这种方式与source-map
的唯一区别就是不会再打包后文件的最后一行添加source-map
的映射关系,但依然会生成*.map
文件,这样,浏览器就不会根据source-map
把我们的源码暴露出去,而我们又生成了我们想要的*.map
文件了。
那么,有了*.map
文件后,我们要怎么根据上传上来的报错信息精确定位到哪个源文件的第几行报错呢,此时我们就需要借助一个工具库来解析我们的*.map
文件了。
bash
yarn add source-map
or
npm i source-map
js
var fs = require('fs'),
path = require('path'),
sourceMap = require('source-map')
// 要解析的map文件路径./test/vendor.8b1e40e47e1cc4a3533b.js.map
var GENERATED_FILE = path.join(
'.',
'test',
'vendor.8b1e40e47e1cc4a3533b.js.map'
)
// 读取map文件,实际就是一个json文件
var rawSourceMap = fs.readFileSync(GENERATED_FILE).toString();
// 通过sourceMap库转换为sourceMapConsumer对象
var consumer = await new sourceMap.SourceMapConsumer(rawSourceMap);
// 传入要查找的行列数,查找到压缩前的源文件及行列数
var sm = consumer.originalPositionFor({
line: 2, // 压缩后的行数
column: 100086 // 压缩后的列数
});
// 压缩前的所有源文件列表
var sources = consumer.sources;
// 根据查到的source,到源文件列表中查找索引位置
var smIndex = sources.indexOf(sm.source);
// 到源码列表中查到源代码
var smContent = consumer.sourcesContent[smIndex];
// 将源代码串按"行结束标记"拆分为数组形式
const rawLines = smContent.split(/\r?\n/g);
// 输出源码行,因为数组索引从0开始,故行数需要-1
console.log(rawLines[sm.line - 1]);
这样,我们就可以根据埋点接口传回来的报错信息通过我们的source-map
找到对应报错的源码位置了。
有效利用缓存
在webpack
中,其实有很多编译的选项都支持我们设置缓存,用空间来换取打包时间,我们可以有效的利用这些缓存策略,让我们的打包速度更快,开发效率更高,下面就以babel-loader
为例:
js
// webpack.config.js
// 以下为babel-loader开启缓存的方式,开启缓存后,只会编译改变的代码
module.exports = {
module: {
rules: [
{
test: /.js$/,
loader: 'babel-loader?cacheDirectory=ture'
}
]
}
};
开启多线程打包
由于NodeJs
是单线程的,因此,默认webpack
的打包过程也是单线程执行的,必须要等上一个任务打包完之后才能进入下一个任务,为了提升打包速度,我们可以借助happyPack
插件开启多线程打包。
js
// webpack.config.js
const HappyPack = require('happypack');
module.exports = {
module: {
rules: [
{
test: /.js$/,
//只在src文件夹下查找
include:[resolve('src')],
exclude:/node_modules/,
//id后面的内容对应下面
loader:'happypack/loader?id=happypack'
}
]
},
plugins: [
new HappyPack({
id:'happypack',
loaders:['babel-loader?cacheDirectory'],
//开启4个线程
threads:4
})
]
};
优化用户体验和加载速度
polyfill
的按需加载
作为一个优秀的web项目,不得不考虑项目代码在不同浏览器的的兼容性问题,而我们最常使用的用来解决一些ES6+
语法在低端浏览器中不支持的问题的方案就是使用@babel/preset-env
和@babel/polyfill
,前者用于转换一些ES6+
才支持的基本语法,如箭头函数、const
、let
等,后者则是负责将一些新的api
打包到代码中,使低端浏览器也能正常使用高级的api
。
但是在使用@babel/polyfill
时,如果不注意使用的方式,有可能会导致我们打包出来的代码异常庞大,影响用户加载网页的速度,降低用户访问网站的体验。
下面我们来看两种不同的使用方式的区别:
javascript
// 在安装完@babel/polyfill后,不经任何配置就直接在项目入口文件中引入
import "@babel/polyfill";
const request = () => new Promise(resolve=>setTimeout(resolve, 800));
上面这段代码确实能够达到我们在低端浏览器中使用高级api
的目的,但是,此时我们的@babel/polyfill
是全量导入的,看一下打包之后代码的大小就会发现,源码大了4xxk
,但实际上我们并没有使用到那么多新的api
,我们真正想要的是按需加载。因此,我们需要优化一下webpack
的配置。
js
const request = () => new Promise(resolve=>setTimeout(resolve, 800));
javascript
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /.js$/,
loader: "babel-loader",
options: {
presets: [
[
"@babel/preset-env",
{
target: {// 浏览器支持版本
edge: "17",
chrome: "67"
},
corejs: 2,// 核心代码版本,2或3
useBuildIns: "usage"// "entry"|"usage"|false
}
]
]
}
}
]
}
};
修改一下webpack
配置,主要关注一下useBuildIns
,有三个可选项,其中entry
需要我们在入口文件中引入@babel/polyfill
,然后在打包时webpack
会根据使用的api
按需加载。而usage
则无需我们在入口文件中显式的引入@babel/polyfill
,webpack
打包时会自动分析依赖进行按需加载,比entry
方便一点。如果设置为false
,则代表不按需加载,即使入口文件中引入了@babel/polyfill
,也会全量导入,不推荐使用。
**注意:**当用户要使用2.x
版本的corejs
时才建议使用@babel/polyfill
,如果用户需要使用3.x
版本的corejs
时,我们就不能使用polyfill
处理兼容性了,此时我们使用以下两个库(原因是因为@babel/polyfill
固定使用的corejs
就是2.x
版本的):
typescript
import "core-js/stable";
import "regenerator-runtime/runtime";
或者如果你觉得使用上面的方式不爽,也可以直接在webpack.config.js
中指定corejs
的版本是3
(注意,如果指定了版本3
,就要额外的安装3.x
版本的corejs
)
公共模块拆分
在一个项目中,总是有一些代码是公用的,会有很多地方引用这些公共模块,也有一些模块是极具业务特性的,只有某个页面的某个模块才会使用到,为了有效利用浏览器缓存,避免不必要的资源浪费,我们通常都会讲公共模块单独拆分,这样浏览器加载一次之后就会将其缓存下来,基于公共模块改变的概率比业务模块小的情况,我们的网站可以在绝大部分情况下直接使用缓存中的公共模块,这样就可以极高的提升加载速度,增强用户体验了。
js
// webpack.config.js
module.exports = {
//...
optimization: {
splitChunks: {
chunks: 'async',// 有效值为 all,async 和 initial,
minSize: 20000,// 生成 chunk 的最小体积(以 bytes 为单位)
minChunks: 1,// 拆分前必须共享模块的最小 chunks 数
maxAsyncRequests: 30,// 按需加载时的最大并行请求数进行分包。
maxInitialRequests: 30,// 按入口点的最大并行请求数进行分包
enforceSizeThreshold: 50000,// 强制执行拆分的体积阈值和其他限制(minRemainingSize,maxAsyncRequests,maxInitialRequests)将被忽略。
// 缓存组可以继承和/或覆盖来自 splitChunks.* 的任何选项。但是 test、priority 和 reuseExistingChunk 只能在缓存组级别上进行配置。将它们设置为 false以禁用任何默认缓存组。
cacheGroups: {
defaultVendors: {// 默认库文件分组
test: /[\\/]node_modules[\\/]/,// 匹配node模块
priority: -10,// 一个模块可以属于多个缓存组。优化将优先考虑具有更高 priority(优先级)的缓存组。默认组的优先级为负,以允许自定义组获得更高的优先级(自定义组的默认值为 0)。
reuseExistingChunk: true,// 如果当前 chunk 包含已从主 bundle 中拆分出的模块,则它将被重用,而不是生成新的模块。这可能会影响 chunk 的结果文件名。
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
},
};
文件名加md5
文件名加md5
既可以让我们有效的利用浏览器缓存,又可以有效的防止无用缓存导致的一些问题。如果我们的文件内容没有改变,md5
自然也就相同,文件名也相同,浏览器如果之前有访问过这个文件的话,就会有缓存,可以直接使用缓存,无须重复加载。当文件内容发生变化时,md5
会发生雪崩效应
,产生巨大的变化,这样就会导致文件名的改变,从而使浏览器的缓存失效,获取最新的资源,以避免因访问缓存中的旧资源而导致的异常。
js
// webpack.config.js
module.exports = {
//...
output: {
filename: '[name].[contenthash].bundle.js',
},
};
Tree shaking
和 SideEffects
webpack
的tree shaking
功能能够有效地对我们的代码进行剪枝
操作,将一些没用的枯枝败叶
(未被使用的代码)剔除掉,只保留真正被适用到的模块代码,这样我们在使用某些工具库时,就不在需要担心因为引入了某些工具方法,而导致整个工具库被引入让打包出来的代码体积极具增大了。配合上sideEffects
一同使用,可以让我们的代码保留我们真正想要的。一句话概括:"取其精华去其糟粕"
js
// webpack.config.js
module.exports = {
// ...
optimization: {
usedExports: true,
},
// ...
};
json
// package.json
// 如果所有代码都不包含 side effect,我们就可以简单地将该属性标记为 false,来告知 webpack,它可以安全地删除未用到的 export。
{
"sideEffects": false
}
- 使用 ES2015 模块语法(即
import
和export
)。- 确保没有编译器将您的 ES2015 模块语法转换为 CommonJS 的(顺带一提,这是现在常用的 @babel/preset-env 的默认行为,详细信息请参阅文档)。
- 在项目的
package.json
文件中,添加"sideEffects"
属性。- 使用
mode
为"production"
的配置项以启用更多优化项,包括压缩代码与 tree shaking。你可以将应用程序想象成一棵树。绿色表示实际用到的 source code(源码) 和 library(库),是树上活的树叶。灰色表示未引用代码,是秋天树上枯萎的树叶。为了除去死去的树叶,你必须摇动这棵树,使它们落下。
利用内置的DLLPlugin
库抽离第三方模块
把复用性较高的第三方模块打包到动态链接库中,在不升级这些库的情况下,动态库不需要重新打包,每次构建只重新打包业务代码。
js
// webpack.dll.config.js
const webpack = require('webpack');
// DllPlugin是webpack的内置模块,无须额外安装
module.exports = {=
entry: {
// 第三方库
react: ['react', 'react-dom', 'react-redux']
},
output: {
// 输出的动态链接库的文件名称,[name] 代表当前动态链接库的名称,
filename: '[name].dll.js',
path: resolve('dist/dll'),
// library必须和后面dllplugin中的name一致 后面会说明
library: '[name]_dll_[hash]'
},
plugins: [
// 接入 DllPlugin
new webpack.DllPlugin({
// 动态链接库的全局变量名称,需要和 output.library 中保持一致
// 该字段的值也就是输出的 manifest.json 文件 中 name 字段的值
name: '[name]_dll_[hash]',
// 描述动态链接库的 manifest.json 文件输出时的文件名称
path: path.join(__dirname, 'dist/dll', '[name].manifest.json')
}),
]
}