前言
我们已经学习完了开发环境和成产环境的配置,接下来学习 webpack 的性能优化。从环境角度讲,性能优化分为开发环境性能优化和生产环境性能优化。对于开发环境,我们需要优化打包构建速度、优化代码调试。对于生产环境,我们需要优化打包构建速度、优化代码运行性能。
HMR
在之前我们搭建的开发环境中,由于配置了 devServer,所以当文件有修改时会重新编译。但是我们发现如果我只改动 css 文件,保存时 js 文件也一样会被重新编译。并且如果我们写若干个 js 模块内容并将其引入 index.js 文件中,当我修改其中一个模块的内容时,其他模块也会被重新编译。这就说明 devServer 更新时是全量编译的,这意味着以后项目变的非常大并且有非常多的模块时,我只改动其中一个模块后整个项目的模块都要重新编译,这样编译的速度会非常慢,使开发者的体验很差。而 HMR(hot module replacement)就可以帮助我们进行优化,使得我们修改一个模块时,只重新编译我们所修改的模块,而不是打包所有模块,极大提升构建速度。
HMR 的配置很简单,只需要在 devServer 配置项中添加 hot 配置即可。
module.exports = {
// ...此处省略其他配置
devServer: {
contentBase: resolve(__dirname, "dist"),
compress: true,
port: 3000,
open: true,
hot: true,
},
};
module.exports = {
// ...此处省略其他配置
devServer: {
contentBase: resolve(__dirname, "dist"),
compress: true,
port: 3000,
open: true,
hot: true,
},
};
配置后我们发现,只有样式文件的 HMR 是生效的,这是因为 style-loader 内部实现了这个功能。 而 js 文件和 html 文件默认是不能使用 HMR 功能的,html 文件开启 devServer 后甚至无法热更新。解决 html 热更新需要以下配置:
module.exports = {
entry: ["./src/index.js", "./index.html"],
// ...此处省略其他配置
};
module.exports = {
entry: ["./src/index.js", "./index.html"],
// ...此处省略其他配置
};
我们将 index.html 文件加入了入口文件的配置,这样便能解决其热更新的问题。目前我们构建的是单页面应用,即只有一个 html 文件,所以不用而且没必要做 HMR 功能。我们只要解决 js 文件的 HMR 功能即可,需要修改 js 代码,添加支持 HMR 功能的代码。
import print from "./print";
print();
if (module.hot) {
module.hot.accept(".print.js", () => {
print();
});
}
import print from "./print";
print();
if (module.hot) {
module.hot.accept(".print.js", () => {
print();
});
}
这句代码的 module 是一个全局中的变量,并查看 module 中是否有 hot 属性,hot 属性默认为 undefined 或 false,一旦 module.hot 为 true,说明开启了 HMR 功能,这时候我们便运行里面的 HMR 功能代码。module.hot.accept() 方法会监听第一个参数传入文件的变化,一旦发生变化会执行第二个回调函数参数,其他模块不会重新打包构建。
注意:js 文件的 HMR 功能只需要处理非入口文件的其他文件。
source map
source map 是一种提供源代码到构建后代码映射的技术。如果构建后代码出错,由于代码已被打包,我们很难追踪出错代码的位置。而 source map 可以通过映射源代码到构建后的代码,使得可以追踪源代码错误。 开启 source mep 功能我们只需在 webpack.config.js 中添加配置:
module.exports = {
devtool: "source-map",
// ...此处省略其他配置
};
module.exports = {
devtool: "source-map",
// ...此处省略其他配置
};
配置后我们进行编译,会发现 dist 目录下会多出映射文件。

以上只是 source map 最基本的配置,它还有几个参数可配置,具体写法为 [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map
。参数位置不可改变。
source-map
为外部 source map,它通过 .map 内的映射信息将错误映射回源代码,所以这个配置可以提示错误代码的准确信息和源代码的错误位置。
inline-source-map
为内联 source map,它不会生成映射文件,而映射信息变为 base64 后嵌入在对应 js 文件内部的结尾处,这种内联只生成一个内联映射信息。这个配置可以提示错误代码的准确信息和源代码的错误位置。
hidden-source-map
为外部 source map,它会在外部生成一个以 .map 结尾的文件存放映射信息。这个配置可以提示错误代码的原因,但是没有错误的位置,而且不能追踪源代码的错误,只能提示到构建后代码的错误位置。
eval-source-map
也为内联 source map,与 inline 不同的是,它会给每一个文件都生成对应的映射信息并放在 eval 函数内。这个配置可以提示错误代码的准确信息和源代码的错误位置。
nosources-source-map
也会生成一个外部 source map。这个配置可以提示错误代码的准确信息,但是无法追踪源代码的错误位置。
cheap-source-map
为外部 source map。这个配置可以提示错误代码的行信息和源代码的错误行位置,它只能精确到错误的行数,无法精确到列信息(column-mappings)。并且其不包含 loader 的 sourcemap(譬如 babel 的 sourcemap)。
cheap-module-source-map
为外部 source map。。这个配置可以提示错误代码的行信息和源代码的错误行位置,它只能精确到错误的行数,无法精确到列信息(column-mappings)。同时 loader 的 sourcemap 也被简化为只包含对应行的。
总体而言,内联 source map 的构建速度比外部 source map 更快(eval 最快,eval > inline > cheap > ...),但是代码体积会变大。在开发环境下,我们需要考虑构建速度快以及调试友好,所以可以使用 eval-cheap-module-source-map
或者 eval-source-map
,而前端框架的脚手架一般默认使用的是 eval-source-map
。在生产环境下,我们需要考虑源代码的隐藏、代码体积以及调试友好,所以不考虑内联 source map,而具体使用还需看使用场景需求。
oneOf
在生成环境 webpack 的配置文件中,我们通常会配置很多 loader 来处理项目中的文件。随着文件与 loader 增多,有可能会出现一个文件被匹配多次的情况,这样会降低构建速度。oneOf 配置可以帮我们很好的处理:
module.exports = {
module: {
rules: [
{
oneOf: [
// ...只匹配一次的 loader 配置
],
},
// ...其他loader配置
],
},
// ...此处省略其他配置
};
module.exports = {
module: {
rules: [
{
oneOf: [
// ...只匹配一次的 loader 配置
],
},
// ...其他loader配置
],
},
// ...此处省略其他配置
};
配置在 oneOf 内的 loader 只会匹配一个,但是要注意不能有两个配置处理同一种类型文件。如果遇到需要处理两次的情况(比如处理 js 需要 eslint-loader 和 babel-loader),可以将其中一个配置(比如 js 的 eslint-loader)提取到 oneOf 的外面。
缓存
babel 缓存
通常在我们的项目中,js 代码远比 css 等其他代码多。在生产环境中我们需要使用 babel 对 js 代码进行兼容性处理,假设我们有 100 个 js 模块,即使我只改动其中一个模块的内容,这 100 个模块都要重新进行兼容性处理,这样非常浪费构建时间。所以我们需要 babel 的缓存功能,将构建处理后的文件进行缓存,当其中一个模块发生变动时,只构建更新这个模块的文件,其他文件不做变化。
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: "babel-loader",
options: {
prisets: [
// ...此处省略无关配置
],
cacheDirectory: true,
},
},
// ...此处省略其他loader配置
],
},
// ...此处省略其他配置
};
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: "babel-loader",
options: {
prisets: [
// ...此处省略无关配置
],
cacheDirectory: true,
},
},
// ...此处省略其他loader配置
],
},
// ...此处省略其他配置
};
如上所示,我们只需在 babel 的配置项内添加 cacheDirectory: true
即可。
资源缓存
当我们访问一个网页时,会请求大量与网页相关的文件资源,这些资源的请求会消耗时间,而在请求的这段时间内,整个页面是空的,用户体验很不好。所以我们可以用到资源缓存,当用户访问网页时,将访问的资源进行缓存,当第二次进入页面时,就不用发送请求去拿资源,直接从缓存读取资源,从而节省很多请求时间。
资源缓存需要在请求头中配置缓存选项,以后专门开文章讲解,这里不过多赘述。我们现在要讨论的是,当加入资源缓存后遇到的问题以及解决方案。我们配置资源缓存时,会配置缓存的过期时间。如果我们配置过期时间为一个小时,那么在这一个小时内,当前用户访问这个页面时的资源都会从缓存取。这就会出现一个问题:如果当前页面出现 bug 需要紧急修复,那么即使开发人员修复后重新发布新的资源,那这个页面依然不会及时响应,只能等到缓存过期后才能重新请求到新的资源。解决这个问题的方法有三个。
- 对资源文件名使用 hash 值
module.exports = {
output: {
filename: "js/built.[hash:10].js",
path: resolve(__dirname, "dist"),
},
plugins: [
new MiniCssExtractPlugin({
filename: "css/built.[hash:10].css",
}),
],
// ...此处省略其他配置
};
module.exports = {
output: {
filename: "js/built.[hash:10].js",
path: resolve(__dirname, "dist"),
},
plugins: [
new MiniCssExtractPlugin({
filename: "css/built.[hash:10].css",
}),
],
// ...此处省略其他配置
};
webpack 每次构建时会生成一个唯一的 hash 值, [hash:10]
意为取 hash 值的前十位。这样每次构建的文件名都会不同,即使页面有资源缓存,也会去请求新的资源文件。这个方法有个问题:由于我们使用每次打包生成的 hash 给资源文件命名,所以即使我这次只改动了一个文件的内容,打包发布后所有缓存都会失效。
- 对资源文件名使用 chunkhash 值
module.exports = {
output: {
filename: "js/built.[chunkhash:10].js",
path: resolve(__dirname, "dist"),
},
plugins: [
new MiniCssExtractPlugin({
filename: "css/built.[chunkhash:10].css",
}),
],
// ...此处省略其他配置
};
module.exports = {
output: {
filename: "js/built.[chunkhash:10].js",
path: resolve(__dirname, "dist"),
},
plugins: [
new MiniCssExtractPlugin({
filename: "css/built.[chunkhash:10].css",
}),
],
// ...此处省略其他配置
};
chunkhash 如字面意思,是根据“块”生成的 hash,webpack 会将一个入口文件及其引入的所有文件模块打包成一个 chunk,一个 chunk 会生成一个 chunkhash。如果项目是单入口的,那么只会生成一个 chunkhash,多入口才会生成多个 chunkhash,所以这个方法依然会遇到第一个方法遇到的问题,因为拆分的不够“细”。
- 对资源文件名使用 contenthash 值
module.exports = {
output: {
filename: "js/built.[contenthash:10].js",
path: resolve(__dirname, "dist"),
},
plugins: [
new MiniCssExtractPlugin({
filename: "css/built.[contenthash:10].css",
}),
],
// ...此处省略其他配置
};
module.exports = {
output: {
filename: "js/built.[contenthash:10].js",
path: resolve(__dirname, "dist"),
},
plugins: [
new MiniCssExtractPlugin({
filename: "css/built.[contenthash:10].css",
}),
],
// ...此处省略其他配置
};
contenthash 是 webpack 根据文件内容生成的 hash 值。不同文件的 contenthash 值一定不一样,并且同一个文件如果内容不变,那么无论生成多少次 contenthash 值都不会改变。所以这个方法能很好的解决前两种方法遇到的问题,让代码上线运行后缓存更好用。
tree shaking
从字面意思来看,这个优化叫“树摇”或者“摇树”。如果把我们的项目当做树,把使用的第三方插件当做树枝,那么项目使用到第三方插件的内容就是树叶,是健康的树叶,而未使用到的第三方插件的内容就是枯萎的树叶。当我们用力摇晃这颗树,就会将枯萎的树叶摇落。tree shaking 就是为了去除我们在项目中没有使用到的代码,这样可以让我们代码的体积变小。
tree shaking 的使用前提条件为:1. 必须使用 ES6 模块化;2. 开启 production 环境。当满足这两个条件后,webpack 会自动帮我们进行 tree shaking。
如果只是这样配置,打包编译后会出现问题,可能会把 css、@babel/polyfill 等有副作用的文件干掉。所以我们需要在 package.json 中配置:
{
// ...省略其他配置
“sideEffects”: ["*.css", "*.less"]
}
{
// ...省略其他配置
“sideEffects”: ["*.css", "*.less"]
}
如上所示,我们需要配置 sideEffects 项,将有用却被 tree shaking 干掉的文件或文件类型配置到数组中。
code split
代码分割可以帮助我们将打包后的文件分割为多个文件,这样做可以让文件请求并行,加快文件请求速度,并且还能实现按需加载。当我们开发单页面应用时,整个项目的代码量会非常大,届时可以安照路由进行文件分割,将每个路由的内容拆成单独的文件,这样便能实现按需加载。常用的分割代码方案有三种,彼此可以组合使用。
通过多入口实现
我们可以将 entry 项从单入口改为多入口,多入口的特点是每当有一个入口,最终打包编译后就有一个对应的 bundle,从而实现代码分割。
module.exports = {
entry: {
main: "./src/main.js",
index: "./src/index.js",
},
output: {
filename: "js/[name].[contenthash:10].js",
path: resolve(__dirname, "dist"),
},
};
module.exports = {
entry: {
main: "./src/main.js",
index: "./src/index.js",
},
output: {
filename: "js/[name].[contenthash:10].js",
path: resolve(__dirname, "dist"),
},
};
其中,entry 下配了几个入口,则会输出几个对应的 bundle。filename 配置中的 [name]
是将原本的文件名配置成输出后的文件名。由于我们的项目实际中经常出现页面变动,使用这种实现方式就显得不太灵活,并且这种方式多用在多页面应用的项目中。
通过 splitChunks 实现
我们可以通过 splitChunks 实现代码分割。
module.exports = {
// ...省略其他配置
optimization: {
splitChunks: {
chunks: "all",
},
},
};
module.exports = {
// ...省略其他配置
optimization: {
splitChunks: {
chunks: "all",
},
},
};
如果配合单入口使用,这个配置可以将项目 node_module 中代码单独打包一个 chenk 最终输出。如果配合多入口使用,这个配置可以自动分析多入口 chunk 中是否有共的文件,如果有则将其打包成一个单独 chunk。
通过 js 代码实现
我们可以通过使用 js 的 import 动态导入语法,让某个文件被单独打包成一个 chunk。
// test.js
export function testFunction() {
console.log("这是 test 的函数");
}
// index.js
import(/* webpackChunkName: 'test' */ "./test")
.then(({ testFunction }) => {
testFunction();
})
.catch(() => {
console.error("文件加载失败!");
});
// test.js
export function testFunction() {
console.log("这是 test 的函数");
}
// index.js
import(/* webpackChunkName: 'test' */ "./test")
.then(({ testFunction }) => {
testFunction();
})
.catch(() => {
console.error("文件加载失败!");
});
其中,/* webpackChunkName: 'test' */
为设置打包后独立文件的命名,命名为 test
。'./test'
为动态导入的文件路径。import()
返回值是一个 Promise 对象,其 then
的回调函数参数是 ES6 Module,所以我们可以将其内容解构使用。