前言
现目前,前端开发使用打包工具更多的还是 webpack,再加上不管是通过 vue-cli、vite、create-react-app 来生成 Vue 项目还是 React 项目,这些脚手架都已经将 webpack 打包工具集成进去了。这对于我们开发者来说省去很多时间去单独配置 webpack,但是有时候也需要去额外配置一些工具,使得集成的 webpack 更加便捷快速,所以这次就给你们来点狠货,分享一些 webpack 的优化技巧。
无论是日常开发还是面试,都应该掌握一些 webpack 的优化技巧,总之你来看了这篇文章是不会吃亏的,反而会让你在今后的工作中得心应手。
性能分析
俗话说:工欲善其事,必先利其器。
要想进行 Webpack 的性能优化,先要知道性能问题出现在哪?所以我们需要对 webpack 的构建进行分析。
依赖分析
使用 webpack 编译源码时,用户可以生成一个包含模块统计信息的 JSON 文件。这些统计信息可以用来分析应用中的依赖关系图,从而优化 webpack 的编译速度。
使用方法
该文件通常由以下 CLI 命令生成:
npx webpack --profile --json=compilation-stats.json
--json=compilation-stats.json 标志告诉 webpack 生成一个包含依赖关系图和其他各种构建信息的 compilation-stats.json 文件。
通常情况下,--profile 标志也会被添加,这样的话每个 module objects 都会增加一个 profile 部分,它包含了特定模块的统计信息。
速度分析
speed-measure-webpack-plugin 插件可以测量各个插件和 loader 所花费的时间,使用之后,构建时,会得到类似下面这样的信息:
对比前后的信息,来确定优化的效果。
使用方法
安装 npm 包
npm install --save-dev speed-measure-webpack-plugin
修改 webpack 配置如下:
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();
modile.exports = smp.wrap({
plugins: [new MyPlugin(), new MyOtherPlugin()],
});
打包体积分析
webpack-bundle-analyzer 包可以帮助了解捆绑包中的真实内容,找出哪些模块构成了其最大尺寸,查找错误到达那里的模块,它可以得到下图的关于 bundle 的信息:
以便我们对 bundle 进行体积优化。
使用方法
安装 npm 包
# NPM
npm install --save-dev webpack-bundle-analyzer
# Yarn
yarn add -D webpack-bundle-analyzer
修改 webpack 配置如下:
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
]
}
构建阶段优化
多线程构建
由于 Javascript 是单线程的,webpack 在构建上默认也是单线程的。
多线程可以提高程序的效率,在 webpack 中,就可以使用 thread-loader 来启用多线程的加载器。
安装
npm i thread-loader -D
配置
{
test: /.js$/,
use: [
'thread-loader',
'babel-loader'
]
}
注意
使用时,需将此 loader 放置在其他 loader 之前。放置在此 loader 之后的 loader 会在一个独立的 worker 池中运行。
在 worker 池中运行的 loader 是受到限制的。例如:
- 这些 loader 不能生成新的文件。
- 这些 loader 不能使用自定义的 loader API(也就是说,不能通过插件来自定义)。
- 这些 loader 无法获取 webpack 的配置。
- 每个 worker 都是一个独立的 node.js 进程,其开销大约为 600ms 左右。同时会限制跨进程的数据交换。
请仅在耗时的操作中使用此 loader!
预热
为了防止启动工作线程时出现高延迟,可以预热工作线程池。
这将引导池中的最大辅助角色数,并将指定的模块加载到节点.js 模块缓存中。
const threadLoader = require('thread-loader');
threadLoader.warmup(
{
// pool options, like passed to loader options
// must match loader options to boot the correct pool
},
[
// modules to load
// can be any module, i. e.
'babel-loader',
'babel-preset-es2015',
'sass-loader',
]
);
减少编译的模块
DllPlugin
它的核心思想是将项目依赖的框架等模块「单独构建打包」,与普通构建流程区分开。
事先把常用但又构建时间长的代码提前打包好(例如 react、react-dom),取个名字叫 dll。后面再打包的时候就跳过原来的未打包代码,直接用 dll。这样一来,构建时间就会缩短,提高 webpack 打包速度。
配置
两个配置文件
webpack.dll.config.js
module.exports = {
entry: {
vendor: ['react', 'react-dom'],
},
output: {
filename: '[name].dll.js',
path: path.join(__dirname, 'dll'),
publicPath: '/dll',
library: '[name]_dll',
},
plugins: [
new webpack.DllPlugin({
context: __dirname,
name: '[name]_dll',
path: path.join(__dirname, 'dll' + '/[name]_manifest.json'),
}),
],
}
webpack.DllPlugin 生成 manifest.json 文件,供 DllReferencePlugin 指向依赖模块位置, 将公共模块 react、react-dom 抽离到项目中 dll 文件下
webpack.app.config.js
plugins: [
new webpack.DllReferencePlugin({
context: __dirname,
manifest: require('./dll/vendor_manifest.json'),
}),
],
webpack.DllReferencePlugin 引用 manifest.json 文件,寻找依赖模块
IgnorePlugin
有的依赖包,除了项目所需的模块内容外,还会附带一些多余的模块
典型的例子是 moment 这个包,一般情况下在构建时会自动引入其 locale 目录下的多国语言包
Webpack 提供的 IgnorePlugin ,即可在「构建模块时」直接剔除那些需要被排除的模块,从而提升构建模块的速度,并减少产物体积。
new webpack.IgnorePlugin({
resourceRegExp: /^\.\/locale$/,
contextRegExp: /moment$/,
});
- resourceRegExp
- 指定需要剔除的文件(夹)
- contextRegExp (可选)
- 特定目录 任何以 'moment' 结尾的目录中匹配 './locale' 的任何 require 语句都将被忽略
除了 moment 包以外,其他一些带有「国际化模块」的依赖包,都可以应用这一优化方式。
按需引入类库模块
「减少执行模块的方式是按需引入」,一般适用于「工具类库」性质的依赖包的优化
典型例子是 lodash 依赖包
优化方式
- 定向引入
- 效果最佳的方式是在「导入声明时只导入依赖包内的特定模块」
- 使用插件
- babel-plugin-lodash
- babel-plugin-import
适用于 antd,antd-mobil,lodash
{
"plugins": [["import",{
"libraryName": "lodash",
"libraryDirectory": "",
"camel2DashComponentName": false, // default: true
}]]
}
注意点
Tree Shaking,这一特性也能减少产物包的体积,但是 Tree Shaking 需要相应导入的依赖包使用 ES6 模块化,而 lodash 还是基于 CommonJS ,需要替换为 lodash-es 才能生效
Tree Shaking 是在优化阶段生效,Tree Shaking 并不能减少模块编译阶段的构建时间。
提升单个模块构建的速度
include/exclude
webpack loader 中配置 include/exclude,是常用的优化特定模块构建速度的方式之一
- include 的用途是只对符合条件的模块使用指定 Loader 进行转换处理
- exclude 则相反,不对特定条件的模块使用该 Loader
例如不使用 babel-loader 处理 node_modules 中的模块 使用范例
module.exports = {
......
module: {
rules: [
{
test: /\.js$/,
include: /src/
exclude: /node_modules/,
use: ['babel-loader'],
},
],
},
}
注意点
通过 include/exclude 排除的模块,并非不进行编译,而是使用 Webpack 「默认的 js 模块编译器进行编译」
在一个 loader 中的 include 与 exclude 配置存在冲突的情况下,优先使用 exclude 的配置,而忽略冲突的 include 部分的配置
noParse
Webpack 配置中的 module.noParse 则是在 include/exclude 的基础上,进一步省略了使用默认 js 模块编译器进行编译的时间
使用范例
module.exports = {
......
module: {
noParse: /jquery|lodash/,
rules: [
{
test: /\.js$/,
use: ['babel-loader'],
},
],
},
}
TypeScript 编译优化
在 Webpack 中使用 ts-loader 编译 TS 时,由于 ts-loader 默认在「编译前进行类型检查,因此编译时间往往比较慢」
通过加上配置项 transpileOnly: true,可以在编译时忽略类型检查
module.exports = {
......
module: {
rules: [
{
test: /\.ts$/,
use: {
loader: 'ts-loader',
options: {
transpileOnly: true,
},
},
},
],
},
}
减少文件搜索范围
Webpack 中的 resolve 配置制定的是在「构建时指定查找模块文件的规则」,合理的配置能减少文件的查找时间
- resolve.modules 指定查找模块的目录范围
- resolve.extensions 指定查找模块的文件类型范围
- resolve.mainFields 指定查找模块的 package.json 中主文件的属性名
- resolve.symlinks 指定在查找模块时是否处理软连接
打包阶段优化
压缩
js 压缩
webpack 默认使用 TerserWebpackPlugin 插件压缩 JavaScript。
webpack v5 开箱即带有最新版本的 terser-webpack-plugin。如果你使用的是 webpack v5 或更高版本,同时希望自定义配置,那么仍需要安装 terser-webpack-plugin。如果使用 webpack v4,则必须安装 terser-webpack-plugin v4 的版本。
首先,你需要安装 terser-webpack-plugin:
$ npm install terser-webpack-plugin --save-dev
然后将插件添加到你的 webpack 配置文件中。例如:
webpack.config.js
const TerserPlugin = require("terser-webpack-plugin");
module.exports = {
optimization: {
minimize: true,
minimizer: [new TerserPlugin()],
},
};
接下来,按照你习惯的方式运行 webpack。
css 压缩
可以使用mini-css-extract-plugin进行 css 提取
MiniCssExtractPlugin,它支持缓存和多进程,「默认开启多进程」,使用了 MiniCssExtractPlugin 过后,样式就被提取到单独的 CSS 文件中了,「样式文件并没有被压缩」。Webpack 「内置的压缩插件仅仅是针对 JS 文件的压缩,其他资源文件的压缩都需要额外的插件」。
可以使用 css-minimizer-webpack-plugin 进行 css 压缩,这个插件并不是配置在 plugins 数组中的,而是添加到了 optimization 对象中的 minimizer 属性中。示例如下:
// ./webpack.config.js
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
module.exports = {
optimization: {
minimizer: [new CssMinimizerPlugin()],
},
module: {
rules: [
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
],
},
plugins: [new MiniCssExtractPlugin()],
};
代码分离
代码分离是 webpack 中最引人注目的特性之一。此特性能够把代码分离到不同的 bundle 中,然后可以按需加载或并行加载这些文件。代码分离可以用于获取更小的 bundle,以及控制资源加载优先级,如果使用合理,会极大影响加载时间。
多入口分包
这是迄今为止最简单直观的分离代码的方式。不过,这种方式手动配置较多,并有一些隐患,我们将会解决这些问题。
使用方式
先来看看如何从 main bundle 中分离 another module(另一个模块):
project
webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
|- /src
|- index.js
|- another-module.js
|- /node_modules
another-module.js
import _ from "lodash";
console.log(_.join(["Another", "module", "loaded!"], " "));
webpack.config.js
const path = require("path");
module.exports = {
mode: "development",
entry: {
index: "./src/index.js",
another: "./src/another-module.js",
},
output: {
filename: "[name].bundle.js",
path: path.resolve(__dirname, "dist"),
},
};
这将生成如下构建结果:
...
[webpack-cli] Compilation finished
asset index.bundle.js 553 KiB [emitted] (name: index)
asset another.bundle.js 553 KiB [emitted] (name: another)
runtime modules 2.49 KiB 12 modules
cacheable modules 530 KiB
./src/index.js 257 bytes [built] [code generated]
./src/another-module.js 84 bytes [built] [code generated]
./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 245 ms
正如前面提到的,这种方式存在一些隐患:
- 如果入口 chunk 之间包含一些重复的模块,那些重复模块都会被引入到各个 bundle 中。
- 这种方法不够灵活,并且不能动态地将核心应用程序逻辑中的代码拆分出来。
防止重复
配置 dependOn option 选项
这样可以在多个 chunk 之间共享模块:
webpack.config.js
const path = require("path");
module.exports = {
mode: "development",
entry: {
index: {
import: "./src/index.js",
dependOn: "shared",
},
another: {
import: "./src/another-module.js",
dependOn: "shared",
},
shared: "lodash",
},
output: {
filename: "[name].bundle.js",
path: path.resolve(__dirname, "dist"),
},
optimization: {
runtimeChunk: "single",
},
};
构建结果如下:
...
[webpack-cli] Compilation finished
asset shared.bundle.js 549 KiB [compared for emit] (name: shared)
asset runtime.bundle.js 7.79 KiB [compared for emit] (name: runtime)
asset index.bundle.js 1.77 KiB [compared for emit] (name: index)
asset another.bundle.js 1.65 KiB [compared for emit] (name: another)
Entrypoint index 1.77 KiB = index.bundle.js
Entrypoint another 1.65 KiB = another.bundle.js
Entrypoint shared 557 KiB = runtime.bundle.js 7.79 KiB shared.bundle.js 549 KiB
runtime modules 3.76 KiB 7 modules
cacheable modules 530 KiB
./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
./src/another-module.js 84 bytes [built] [code generated]
./src/index.js 257 bytes [built] [code generated]
webpack 5.4.0 compiled successfully in 249 ms
由上可知,除了生成 shared.bundle.js,index.bundle.js 和 another.bundle.js 之外,还生成了一个 runtime.bundle.js 文件 (为了解决这个问题)。
SplitChunksPlugin
SplitChunksPlugin 插件可以将公共的依赖模块提取到已有的入口 chunk 中,或者提取到一个新生成的 chunk。让我们使用这个插件,将之前的示例中重复的 lodash 模块去除:
webpack.config.js
const path = require("path");
module.exports = {
mode: "development",
entry: {
index: "./src/index.js",
another: "./src/another-module.js",
},
output: {
filename: "[name].bundle.js",
path: path.resolve(__dirname, "dist"),
},
optimization: {
splitChunks: {
chunks: "all",
},
},
};
使用 optimization.splitChunks 配置选项之后,现在应该可以看出,index.bundle.js 和 another.bundle.js 中已经移除了重复的依赖模块。需要注意的是,插件将 lodash 分离到单独的 chunk,并且将其从 main bundle 中移除,减轻了大小。执行 npm run build 查看效果:
...
[webpack-cli] Compilation finished
asset vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB [compared for emit] (id hint: vendors)
asset index.bundle.js 8.92 KiB [compared for emit] (name: index)
asset another.bundle.js 8.8 KiB [compared for emit] (name: another)
Entrypoint index 558 KiB = vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB index.bundle.js 8.92 KiB
Entrypoint another 558 KiB = vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB another.bundle.js 8.8 KiB
runtime modules 7.64 KiB 14 modules
cacheable modules 530 KiB
./src/index.js 257 bytes [built] [code generated]
./src/another-module.js 84 bytes [built] [code generated]
./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 241 ms
动态导入
按需加载,指的是「在应用运行过程中,需要某个资源模块时,才去加载这个模块」。
- 极大地「降低了应用启动时需要加载的资源体积」
- 提高了应用的「响应速度」
- 节省了「带宽和流量」
当涉及到动态代码拆分时,webpack 推荐使用符合 ECMAScript 提案 的 import() 语法 来实现动态导入
我们不再使用 statically import(静态导入) lodash,而是通过 dynamic import(动态导入) 来分离出一个 chunk:
src/index.js
async function getComponent() {
const element = document.createElement("div");
const { default: _ } = await import("lodash");
element.innerHTML = _.join(["Hello", "webpack"], " ");
return element;
}
getComponent().then((component) => {
document.body.appendChild(component);
});
由于 import() 会返回一个 promise,因此它可以和 async 函数一起使用。之所以需要 default,是因为 webpack 4 在导入 CommonJS 模块时,将不再解析为 module.exports 的值,而是为 CommonJS 模块创建一个 artificial namespace 对象,详见webpack 4: import() and CommonJs
执行 webpack,查看 lodash 分离到一个单独的 bundle:
...
[webpack-cli] Compilation finished
asset vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB [compared for emit] (id hint: vendors)
asset index.bundle.js 13.5 KiB [compared for emit] (name: index)
runtime modules 7.37 KiB 11 modules
cacheable modules 530 KiB
./src/index.js 434 bytes [built] [code generated]
./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 268 ms
Tree Shaking
tree shaking 是指移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块语法的 静态结构 特性,例如 import 和 export。这个术语和概念实际上是由 ES2015 模块打包工具 rollup 普及起来的。
配置方式
webpack.config.js
webpack.config.js;
const path = require("path");
module.exports = {
entry: "./src/index.js",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "dist"),
},
mode: "development",
optimization: {
usedExports: true,
},
};
side effect(副作用)
"side effect(副作用)" 的定义是,在导入时会执行特殊行为的代码,而不是仅仅暴露一个 export 或多个 export。举例说明,例如 polyfill,它影响全局作用域,并且通常不提供 export。
在有没有明确 side effect 的项目中,Tree Shaking 不会删除未使用的代码
我们需要明确指定项目的 side effect,可通过 package.json 的 "sideEffects" 属性进行配置,详见将文件标记为 side-effect-free
{
"name": "your-project",
"sideEffects": false
}
Polyfill
通常我们在项目中会使用 babel 来将很多 es6 中的 API 进行转换成 es5,但是还是有很多新特性没法进行完全转换,比如 promise、async await、map、set 等语法,那么我们就需要通过额外的 polyfill(垫片)来实现语法编译上的支持。
一般处理方式:babel-polyfill.js
引入
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/7.2.5/polyfill.js"></script>
然后就 es6、es7 特性随便写了,但缺点是,babel-polyfill 包含所有补丁,不管浏览器是否支持,也不管你的项目是否有用到,都全量引了,babel-polyfill 打包后体积:88.49k
动态补丁 polyfill.io
polyfill-service 只给用户返回需要的 polyfill,由 polyfill.io 社区维护,部分国内奇葩浏览器 UA 可能无法识别(但可以使用降级返回所需全部 polyfill)
原理:
访问页面,发送请求,识别 User Agent,然后下发不同的 Polyfill
使用方式:
<script src="https://polyfill.io/v3/polyfill.min.js"></script>
source-map
当代码出现 bug 时,source-map 可以快速定位到源代码的位置,但是这个文件很大。所以为了平衡性能和准确性,在开发模式下生成准确(但更大)的 source-map;在生产模式下生成更小(但不那么准确)的源映射。
开发模式:
module.exports = {
mode: 'development',
devtool: 'eval-cheap-module-source-map'
}
生产方式:
module.exports = {
mode: 'production',
devtool: 'nosources-source-map'
}
缓存优化
Cache
webpack5 的 cache 配置会缓存生成的 webpack 模块和 chunk,来改善构建速度。cache 会在开发 模式被设置成 type: 'memory' 而且在 生产 模式 中被禁用
webpack.config.js
module.exports = {
//...
cache: "memory", // memory | filesystem
};
Cache-loader
cache-loader 允许缓存以下 loaders 到(默认)磁盘或数据库。
使用
安装 cache-loader:
npm install --save-dev cache-loader
在一些性能开销较大的 loader 之前添加 cache-loader,以便将结果缓存到磁盘里。
webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.ext$/,
use: ["cache-loader", ...loaders],
include: path.resolve("src"),
},
],
},
};
注意
保存和读取这些缓存文件会有一些时间开销,所以请只对性能开销较大的 loader 使用此 loader。
babel-loader 缓存
只需设置 cacheDirectory = true 即可开启 babel-loader 持久化缓存功能,例如:
module.exports = {
// ...
module: {
rules: [{
test: /\.m?js$/,
loader: 'babel-loader',
options: {
cacheDirectory: true,
},
}]
},
// ...
};