这篇文章终于要进入 Webpack 搭建的重点了 —— Webpack 性能优化。当我们的项目规模越来越大的时候,Webpack 的性能优化就成为我们绕不开的坎,这将很大程度上影响我们的开发效率。Webpack 性能优化也分为开发环境的性能优化和生产环境的性能优化,接下来我将一一介绍相关的优化方案。
第一个要重点优化的地方,在于开发环境项目打包的构建速度。之前我们配置的开发环境,不管修改 index.js 依赖的哪一个模块,所有模块都会被重新加载,这样就使得开发的过程效率变得十分低下。我们先复制一下之前配置好的开发环境的代码:
// resolve是用来拼接绝对路径的方法
const { resolve } = require('path');
const HTMLWebpackPlugin = require('html-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');
module.exports = {
entry: './src/js/index.js',
output: {
filename: 'js/built.js',
path: resolve(__dirname, 'dist')
},
module: {
rules: [
// 打包样式资源
{
test: /\.less$/,
use: ['style-loader','css-loader','less-loader']
},
{
test: /\.css$/,
use: ['style-loader','css-loader']
},
// 打包图片资源
{
test: /\.(jpg|png|gif)$/,
loader: 'url-loader', // 需要同时下载 url-loader 与 file-loader
options: {
limit: 8 * 1024,
name: '[hash:10].[ext]',
outputPath: 'imgs' // 输出路径取决于你引入路径
}
},
{
test: /\.html$/,
loader: 'html-loader' // 处理 html中的 img 资源
},
// 打包其他资源
{
exclude: /\.(html|js|css|less|jpg|png|gif)/,
loader: 'file-loader',
options: {
name: '[hash:10].[ext]',
outputPath: 'assets' // 输出路径取决于你引入路径
}
}
]
},
plugins: [
// 打包 html 资源
new HTMLWebpackPlugin({
template: './src/index.html'
}),
// 配置JS检查规则
new ESLintPlugin({
fix: true, // 对打包生成的文件自动纠错
extensions: ['js', 'json', 'coffee'], // 只检查 js 结尾的文件
exclude: '/node_modules/' // 排除 node_modules
})
],
mode: 'development',
// 配置开发服务器 (需要下载 webpack-dev-server )
devServer: {
overlay: {
warnings: true,
errors: true
}, // 在浏览器上全屏显示编译的 errors 或 warnings
compress: true, // 启动gzip压缩
port: 8080, // 配置服务器端口号
open: true // 自动打开浏览器
}
}
然后在 index.js 文件和 add.js 文件中写入如下代码:
运行 webpack serve,你会看到控制台打印出如下信息:
看着好像没有什么问题,但现在假如我们稍微修改一下 add.js 文件,你会发现浏览器自动刷新的同时,又重新加载了一遍 index.js 文件:
假如你修改一下 index.less 样式文件,你也会发现 index.js 文件被重新加载了:
这显然不是我们所希望的,每次修改一个模块,所有模块都会被重新打包一次,这将极大的影响我们的开发效率。这个时候我们需要借助 Webpack 提供的热模块替换(Hot Module Replacement)技术。Hot Module Replacement,简称 HMR,它能实现局部模块更新。开启它的方式很简单,我们只需要在 devServer配置中,将 hot 设置为 true 即可:
const { resolve } = require('path');
const HTMLWebpackPlugin = require('html-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');
module.exports = {
entry: './src/js/index.js',
output: {
filename: 'js/built.js',
path: resolve(__dirname, 'dist')
},
module: {
rules: [
// 打包样式资源
{
test: /\.less$/,
use: ['style-loader','css-loader','less-loader']
},
{
test: /\.css$/,
use: ['style-loader','css-loader']
},
// 打包图片资源
{
test: /\.(jpg|png|gif)$/,
loader: 'url-loader', // 需要同时下载 url-loader 与 file-loader
options: {
limit: 8 * 1024,
name: '[hash:10].[ext]',
outputPath: 'imgs' // 输出路径取决于你引入路径
}
},
{
test: /\.html$/,
loader: 'html-loader' // 处理 html中的 img 资源
},
// 打包其他资源
{
exclude: /\.(html|js|css|less|jpg|png|gif)/,
loader: 'file-loader',
options: {
name: '[hash:10].[ext]',
outputPath: 'assets' // 输出路径取决于你引入路径
}
}
]
},
plugins: [
// 打包 html 资源
new HTMLWebpackPlugin({
template: './src/index.html'
}),
// 配置JS检查规则
new ESLintPlugin({
fix: true, // 对打包生成的文件自动纠错
extensions: ['js', 'json', 'coffee'], // 只检查 js 结尾的文件
exclude: '/node_modules/' // 排除 node_modules
})
],
mode: 'development',
// 配置开发服务器 (需要下载 webpack-dev-server )
devServer: {
overlay: {
warnings: true,
errors: true
}, // 在浏览器上全屏显示编译的 errors 或 warnings
compress: true, // 启动gzip压缩
port: 8080, // 配置服务器端口号
open: true, // 自动打开浏览器
hot: true // 开启热模块替换功能
}
}
开启后我们重新执行 webpack serve,然后修改 index.less 文件,你会发现页面并没有重新刷新,但样式生效了:
HMR 功能生效了。但是,假如你修改的是 add.js 文件,你会发现页面还是重新刷新了。这是因为样式文件默认可以使用 HMR 功能,这个功能在 style-loader 内部实现了。而 js 文件默认不能实现这个功能,要实现对 js 文件的热模块替换,我们需要在入口文件 index.js 中添加支持 HMR 的代码:
此时再修改 add.js 文件,其它模块也不会重新打包一次了:
现在基于 Webpack 的项目基本是单页面应用,HMR 功能默认不为 html 文件进行服务(注意:HMR 功能也无法对入口JS文件生效)。同时,开启 HMR 后会有一个问题,你会发现 html 文件也无法支持热更新了(修改 html 文件,浏览器不会刷新)。要想解决这个问题,我们需要同时将 index.html 作为入口文件进行引入:
const { resolve } = require('path');
const HTMLWebpackPlugin = require('html-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');
module.exports = {
entry: [
'./src/js/index.js',
'./src/index.html' // 同时将 index.html 文件作为入口文件引入, 以防止开启 HMR 后无法实现 html 文件的热更新
],
output: {
filename: 'js/built.js',
path: resolve(__dirname, 'dist')
},
module: {
rules: [
// 打包样式资源
{
test: /\.less$/,
use: ['style-loader','css-loader','less-loader']
},
{
test: /\.css$/,
use: ['style-loader','css-loader']
},
// 打包图片资源
{
test: /\.(jpg|png|gif)$/,
loader: 'url-loader', // 需要同时下载 url-loader 与 file-loader
options: {
limit: 8 * 1024,
name: '[hash:10].[ext]',
outputPath: 'imgs' // 输出路径取决于你引入路径
}
},
{
test: /\.html$/,
loader: 'html-loader' // 处理 html中的 img 资源
},
// 打包其他资源
{
exclude: /\.(html|js|css|less|jpg|png|gif)/,
loader: 'file-loader',
options: {
name: '[hash:10].[ext]',
outputPath: 'assets' // 输出路径取决于你引入路径
}
}
]
},
plugins: [
// 打包 html 资源
new HTMLWebpackPlugin({
template: './src/index.html'
}),
// 配置JS检查规则
new ESLintPlugin({
fix: true, // 对打包生成的文件自动纠错
extensions: ['js', 'json', 'coffee'], // 只检查 js 结尾的文件
exclude: '/node_modules/' // 排除 node_modules
})
],
mode: 'development',
// 配置开发服务器 (需要下载 webpack-dev-server )
devServer: {
overlay: {
warnings: true,
errors: true
}, // 在浏览器上全屏显示编译的 errors 或 warnings
compress: true, // 启动gzip压缩
port: 8080, // 配置服务器端口号
open: true, // 自动打开浏览器
hot: true // 开启热模块替换功能
}
}
这样就能使 index.html 不受 HMR 功能的影响,保持热更新的功能了。
entry 可以是单个字符串(单入口模式),也可以是数组或对象的多入口模式。当 entry 为对象时,有几个入口就生成几个文件;但是当入口为数组时,最终还是只生成一个打包文件。所以将 entry 设置为数组的情况,一般也是用来使 html 文件支持 HMR 功能。
source-map 是一种能让我们在打包后的代码中追踪源代码的技术,它可以生成源代码与构建代码之间的映射。在日常开发的过程中,有时候我们打包后代码报错,我们只能看到编译后的代码错误的位置,却不知道源代码报错的位置。这个时候我们就要借助 source-map 技术。webpack4+ 已经默认帮我们启动了 source-map,但 source-map 有多种不同的形式。改变 source-map 配置,我们只需要设置 devtool 属性即可。
例如,我们尝试将 devtool 属性设置为 'source-map':
// resolve是用来拼接绝对路径的方法
const { resolve } = require('path');
const HTMLWebpackPlugin = require('html-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');
module.exports = {
entry: [
'./src/js/index.js',
'./src/index.html' // 同时将 index.html 文件作为入口文件引入, 以防止开启 HMR 后无法实现 html 文件的热更新
],
output: {
filename: 'js/built.js',
path: resolve(__dirname, 'dist')
},
module: {
rules: [
// 打包样式资源
{
test: /\.less$/,
use: ['style-loader','css-loader','less-loader']
},
{
test: /\.css$/,
use: ['style-loader','css-loader']
},
// 打包图片资源
{
test: /\.(jpg|png|gif)$/,
loader: 'url-loader', // 需要同时下载 url-loader 与 file-loader
options: {
limit: 8 * 1024,
name: '[hash:10].[ext]',
outputPath: 'imgs' // 输出路径取决于你引入路径
}
},
{
test: /\.html$/,
loader: 'html-loader' // 处理 html中的 img 资源
},
// 打包其他资源
{
exclude: /\.(html|js|css|less|jpg|png|gif)/,
loader: 'file-loader',
options: {
name: '[hash:10].[ext]',
outputPath: 'assets' // 输出路径取决于你引入路径
}
}
]
},
plugins: [
// 打包 html 资源
new HTMLWebpackPlugin({
template: './src/index.html'
}),
// 配置JS检查规则
new ESLintPlugin({
fix: true, // 对打包生成的文件自动纠错
extensions: ['js', 'json'], // 只检查 js 结尾的文件
exclude: '/node_modules/' // 排除 node_modules
})
],
mode: 'development',
// 配置开发服务器 (需要下载 webpack-dev-server )
devServer: {
overlay: {
warnings: true,
errors: true
}, // 在浏览器上全屏显示编译的 errors 或 warnings
compress: true, // 启动gzip压缩
port: 8080, // 配置服务器端口号
open: true, // 自动打开浏览器
hot: true // 开启热模块替换功能
},
devtool: 'source-map' // 启动 source-map
}
我们直接运行 webpack,然后查看打包后的 js 文件,你会发现多了一个 .map 文件。这个文件保存的就是打包后的 built.js 与打包前的代码之间的映射关系。
此时打开浏览器控制台,你会发现它会精准提供错误代码的错误原因以及源文件代码(add.js)的错误位置:
如果我们把刚才的 devtool 设置为 none,webpack 就会关闭这个功能。devtool 可以配置的值有很多,具体可以查看 官方文档之devtool。官方文档说明,验证 devtool 属性时, 可以使用 [inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map
之类的组合模式。其中一些值适用于开发环境,一些适用于生产环境。
例如,当你将 devtool 设置为 inline-source-map,然后运行 webpack,你会看到打包后的代码在 built.js 文件底部直接生成整体的 source-map 信息:
同时,打开控制台点击对应的错误位置,你可以看到它同时提供的错误代码的错误原因以及源代码的错误位置。将 devtool 设置为 source-map 与 设置为 inline-source-map 的唯一区别在于后者生成的映射信息是直接添加到文件底部的:
source-map 其实有两种存在形式,一种是内联,一种是在外部生成 .map 文件。内联的构建速度比生成外部文件更快些,它会在打包好的 built.js 内部直接添加 source-map 代码而不是创建一个 .map 文件输入到磁盘空间。
若你将 dev-tool 设置为 hidden-source-map,它将和 source-map 配置项一样,生成一个外部的 .map 文件。但是它只能提供错误代码的错误原因,却不能提供源代码错误的准确位置,只能提供打包后代码的错误位置:
eval-source-map 同样也是生成内联的映射信息,它同样会提供的错误代码准确信息以及源代码的错误位置。区别在于,它会将参与打包的每个模块包裹在 eval() 方法中,然后在模块末尾添加模块来源//# souceURL, 依靠 souceURL 找到原始代码的位置:
包含 source-map 关键字的其它配置项都会产生一个 .map 文件( inline 模式的是将产生的 map 文件经过 base64 编码以 DataURI 的形式嵌入了),而 eval 模式依靠 sourceURL 来定位原始代码。因此, eval 模式比其它模式快得多。
设为 nosources-source-map 也是生成一个外部 .map 文件,它也会提供错误代码的错误原因以及源代码的错误位置。区别在于,若你点击了错误位置想查看源代码是看不到的,该配置项会帮我们隐藏掉源代码,防止源代码泄露:
cheap-source-map 要和我们一开始的 source-map 配置项来进行对比,我们先在 add.js 文件的错误代码同一行添加其它代码测试一下:
运行后你会发现它和 source-map 配置项一样,生成外部 .map 文件,同时提供源代码错误原因和错误位置。区别在于,对于错误位置, cheap-source-map 只能精确到一整行:
而我们将配置项改为 source-map 对比一下,你会发现 source-map 更精确的提供了代码的错误具体位置,这被称为精确到列:
cheap-module-source-map 和 cheap-source-map 显示效果是几乎一致的,区别在于它会将相关 loader 的 source-map 映射信息一并加入。
如果关于 source-map 如何配置的内容你坚持看到了这里,那说明你是一个很有耐心的开发者。说了这么多,在项目中我们究竟要如何去配置 devtool 选项呢?通常,我们需要根据构建速度、调试友好程度以及是否隐藏源代码这三者中来平衡做出选择。对于开发环境,通常希望更快速的 source map,需要添加到 bundle 中以增加体积为代价。但是对于生产环境,则希望更精准的 source map,需要从 bundle 中分离并独立存在。
对于开发环境,我们不需要隐藏源代码。而从构建速度的角度来说,我们可以首选 eval-cheap-source-map,其次为 eval-source-map;从调试友好性来说,我们可以首选 source-map,其次为 cheap-module-source-map,最后为 cheap-source-map。两种情况折中考虑,在开发环境,我们可以选择 eval-source-map 或 eval-module-source-map 作为开发环境的配置。
对于生产环境,我们不使用内联的方式去生成映射代码,它会使打包后的单个文件体积太大。生产环境我们不希望别人看见我们的源代码,可以选择 hidden-source-map(隐藏源代码,可提示构建后代码错误信息)或 nosources-source-map (全部隐藏);当然,有时候生产环境我们也需要进行调试最后再隐藏掉源码信息,那这时候我们就需要从调试友好性入手,考虑选择 source-map 或 cheap-module-source-map。具体的配置,还是要根据自己的项目需求进行配置。
oneof 是 webpack 对 loader 的匹配所做的优化。之前我们编写的关于 loader 配置,参与打包的文件会去匹配 module.rules 中的每一项规则。哪怕已经匹配成功某一项规则,还是会继续往后匹配其它规则,这就浪费了不必要的性能开销。如果希望匹配到某一项规则后直接结束当前文件的 loader 匹配,可以像下面这样对 webpack.config.js 文件进行修改:
const {
resolve
} = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');
// 设置 node 环境变量
process.env.NODE_ENV = 'production';
module.exports = {
entry: './src/js/index.js',
output: {
filename: 'built.js',
path: resolve(__dirname, 'dist')
},
module: {
rules: [
{
// 使用 oneOf 让文件只与其中一项规则进行匹配
oneOf: [
// 单独抽离css文件并进行兼容性处理
{
test: /\.less$/,
use: [
MiniCssExtractPlugin.loader, // 单独抽离css文件
'css-loader',
'less-loader',
{
loader: 'postcss-loader', // 对 css 进行兼容性处理(需要配置 package.json 文件的 browserslist 属性)
options: {
ident: 'postcss',
plugins: () => [
require('postcss-preset-env')() // 读取 postcss-preset-env 插件的环境变量
]
}
}
]
},
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader, // 单独抽离css文件
'css-loader',
{
loader: 'postcss-loader', // 对 css 进行兼容性处理(需要配置 package.json 文件的 browserslist 属性)
options: {
ident: 'postcss',
plugins: () => [
require('postcss-preset-env')() // 读取 postcss-preset-env 插件的环境变量
]
}
}
]
},
// 对js进行兼容性处理
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader',
options: {
// 预设:指示babel做怎么样的兼容性处理
presets: [
[
'@babel/preset-env',
{
// 按需加载
useBuiltIns: 'usage',
// 指定core-js版本
corejs: {
version: 3
},
// 指定兼容性做到哪个版本浏览器
targets: {
chrome: '60',
firefox: '60',
ie: '9',
safari: '10',
edge: '17'
}
}
]
]
}
},
{
test: /\.(jpg|png|gif)/,
loader: 'url-loader',
options: {
limit: 8 * 1024,
name: '[hash:10].[ext]',
outputPath: 'imgs',
esModule: false
}
},
{
test: /\.html$/,
loader: 'html-loader' // 处理 html文件 src 属性引入的图片资源
},
{
exclude: /\.(js|css|less|html|jpg|png|gif)/,
loader: 'file-loader',
options: {
outputPath: 'media'
}
}
]
}
]
},
plugins: [
// 压缩html文件
new HtmlWebpackPlugin({
template: './src/index.html',
minify: {
collapseWhitespace: true, // 移除空格
removeComments: true // 移除注释
}
}),
new MiniCssExtractPlugin({
filename: 'css/index.css' // 对抽离的css文件进行重命名
}),
// 压缩css
new OptimizeCssAssetsWebpackPlugin(),
// 配置JS检查规则
new ESLintPlugin({
fix: true, // 对打包生成的文件自动纠错
extensions: ['js', 'json'], // 只检查 js、json 结尾的文件
exclude: '/node_modules/' // 排除 node_modules
})
],
// 生产环境默认压缩js文件
mode: 'development',
devtool: 'source-map'
}
不过,有时候会遇到某些文件需要进行两次匹配,把它们都放在 oneOf 里是无法实现的。这时候只需要把额外的匹配项放在 rules 数组下的最外层就行了:
const {
resolve
} = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');
// 设置 node 环境变量
process.env.NODE_ENV = 'production';
module.exports = {
entry: './src/js/index.js',
output: {
filename: 'built.js',
path: resolve(__dirname, 'dist')
},
module: {
rules: [
// 争对需要多次匹配的文件, 放到 rules 下的最外层而不是 oneOf 里面
{
test: /\.js$/,
use: [] // 执行一些其它的匹配规则
},
{
// 使用 oneOf 让文件只与其中一项规则进行匹配
oneOf: [
// 单独抽离css文件并进行兼容性处理
{
test: /\.less$/,
use: [
MiniCssExtractPlugin.loader, // 单独抽离css文件
'css-loader',
'less-loader',
{
loader: 'postcss-loader', // 对 css 进行兼容性处理(需要配置 package.json 文件的 browserslist 属性)
options: {
ident: 'postcss',
plugins: () => [
require('postcss-preset-env')() // 读取 postcss-preset-env 插件的环境变量
]
}
}
]
},
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader, // 单独抽离css文件
'css-loader',
{
loader: 'postcss-loader', // 对 css 进行兼容性处理(需要配置 package.json 文件的 browserslist 属性)
options: {
ident: 'postcss',
plugins: () => [
require('postcss-preset-env')() // 读取 postcss-preset-env 插件的环境变量
]
}
}
]
},
// 对js进行兼容性处理
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader',
options: {
// 预设:指示babel做怎么样的兼容性处理
presets: [
[
'@babel/preset-env',
{
// 按需加载
useBuiltIns: 'usage',
// 指定core-js版本
corejs: {
version: 3
},
// 指定兼容性做到哪个版本浏览器
targets: {
chrome: '60',
firefox: '60',
ie: '9',
safari: '10',
edge: '17'
}
}
]
]
}
},
{
test: /\.(jpg|png|gif)/,
loader: 'url-loader',
options: {
limit: 8 * 1024,
name: '[hash:10].[ext]',
outputPath: 'imgs',
esModule: false
}
},
{
test: /\.html$/,
loader: 'html-loader' // 处理 html文件 src 属性引入的图片资源
},
{
exclude: /\.(js|css|less|html|jpg|png|gif)/,
loader: 'file-loader',
options: {
outputPath: 'media'
}
}
]
}
]
},
plugins: [
// 压缩html文件
new HtmlWebpackPlugin({
template: './src/index.html',
minify: {
collapseWhitespace: true, // 移除空格
removeComments: true // 移除注释
}
}),
new MiniCssExtractPlugin({
filename: 'css/index.css' // 对抽离的css文件进行重命名
}),
// 压缩css
new OptimizeCssAssetsWebpackPlugin(),
// 配置JS检查规则
new ESLintPlugin({
fix: true, // 对打包生成的文件自动纠错
extensions: ['js', 'json'], // 只检查 js、json 结尾的文件
exclude: '/node_modules/' // 排除 node_modules
})
],
// 生产环境默认压缩js文件
mode: 'development',
devtool: 'source-map'
}
Babel 编译是打包构建过程中对性能消耗极大的一项操作,在 Babel 编译的过程中,我们可以在 babel-loader 的配置选项中设置 cacheDirectory 为 true。之后的 webpack 构建,将会尝试读取缓存(使用默认的缓存目录 node_modules/.cache/babel-loader)
,来避免在每次执行时,可能产生的、高性能消耗的 Babel 重新编译过程:
const {
resolve
} = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');
// 设置 node 环境变量
process.env.NODE_ENV = 'production';
module.exports = {
entry: './src/js/index.js',
output: {
filename: 'built.js',
path: resolve(__dirname, 'dist')
},
module: {
rules: [
{
// 以下loader只会匹配一个
// 注意:不能有两个配置处理同一种类型文件
oneOf: [
// 单独抽离css文件并进行兼容性处理
{
test: /\.less$/,
use: [
MiniCssExtractPlugin.loader, // 单独抽离css文件
'css-loader',
'less-loader',
{
loader: 'postcss-loader', // 对 css 进行兼容性处理(需要配置 package.json 文件的 browserslist 属性)
options: {
ident: 'postcss',
plugins: () => [
require('postcss-preset-env')() // 读取 postcss-preset-env 插件的环境变量
]
}
}
]
},
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader, // 单独抽离css文件
'css-loader',
{
loader: 'postcss-loader', // 对 css 进行兼容性处理(需要配置 package.json 文件的 browserslist 属性)
options: {
ident: 'postcss',
plugins: () => [
require('postcss-preset-env')() // 读取 postcss-preset-env 插件的环境变量
]
}
}
]
},
// 对js进行兼容性处理
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader',
options: {
// 预设:指示babel做怎么样的兼容性处理
presets: [
[
'@babel/preset-env',
{
// 按需加载
useBuiltIns: 'usage',
// 指定core-js版本
corejs: {
version: 3
},
// 指定兼容性做到哪个版本浏览器
targets: {
chrome: '60',
firefox: '60',
ie: '9',
safari: '10',
edge: '17'
}
}
]
]
},
cacheDirectory: true // 开启 babel 缓存
},
{
test: /\.(jpg|png|gif)/,
loader: 'url-loader',
options: {
limit: 8 * 1024,
name: '[hash:10].[ext]',
outputPath: 'imgs',
esModule: false
}
},
{
test: /\.html$/,
loader: 'html-loader' // 处理 html文件 src 属性引入的图片资源
},
{
exclude: /\.(js|css|less|html|jpg|png|gif)/,
loader: 'file-loader',
options: {
outputPath: 'media'
}
}
]
}
]
},
plugins: [
// 压缩html文件
new HtmlWebpackPlugin({
template: './src/index.html',
minify: {
collapseWhitespace: true, // 移除空格
removeComments: true // 移除注释
}
}),
new MiniCssExtractPlugin({
filename: 'css/index.css' // 对抽离的css文件进行重命名
}),
// 压缩css
new OptimizeCssAssetsWebpackPlugin(),
// 配置JS检查规则
new ESLintPlugin({
fix: true, // 对打包生成的文件自动纠错
extensions: ['js', 'json'], // 只检查 js、json 结尾的文件
exclude: '/node_modules/' // 排除 node_modules
})
],
// 生产环境默认压缩js文件
mode: 'development',
devtool: 'source-map'
}
此外,Webpack 还有另一项重要的缓存策略,通过哈希值进行文件名缓存。日常开发编译打包生成静态资源文件时,我们总是会利用文件名带上哈希值的方式尽可能的让浏览器利用自身缓存,而不是频繁的请求服务器资源。浏览器有时候向服务器请求文件之前,如果遇到文件名没有发生变化,就会尝试从缓存中去读取。因此,我们在打包构建的过程中,常常需要对样式文件和 JS 文件在命名的过程中添加上哈希值。
现在的 Webpack 提供三种哈希值:hash、chunkhash、contenthash,在 filename 属性中对它们进行添加。
hash
我们先来尝试 hash,对输出的 js 和 css 文件名添加上 [hash]:
运行一下 webpack,打包后的 js 和 css 文件名都添加上了相同的哈希值:
使用 hash,每一次打包都会生成不同的哈希值,且每个文件的哈希值都一样。即每次修改任何一个文件,所有文件名的 hash 值都将改变,整个项目的文件浏览器缓存都将失效。
chunkhash
若改为 chunkhash,则会为打包后不同的 chunk 使用不同的哈希值。在生产环境中,通常会需要分离第三方类库和应用本身的代码,因为第三方类库更新频率不高,这样浏览器可以直接从缓存读,不需要项目每次上线再获取一次。分离模块需要在 entry 配置多入口,如下图:
接着我们把文件名哈希值改为 chunkhash,然后运行 webpack:
打包后可以看到 index.js 与 jquery.js 这两个模块打包后使用了不同的 hash 值。不过,由于 webpack 的编译理念,它会将有依赖关系的 module 打包后的文件视为一个 chunk。所以在这里,来自相同 chunk 的 index.js 与 index.less 打包后使用的哈希值还是一样的:
这就意味着,每次我们修改 index.less 文件,依赖 index.less 的 index.js 文件以及同 chunk 下所有有依赖关系的文件名都会跟着改变,这样依然对浏览器起不到足够的缓存作用:
contenthash
解决项目文件资源缓存的终极方法就是 contenthash 。contenthash 是根据文件自身内容计算得到的哈希值,这意味着不管你改动哪个模块中的哪个文件,都只有该文件自身文件名会发生变化重新构建,其它文件名都依旧保持不变,这更有利于线上代码的缓存:
const {
resolve
} = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');
// 设置 node 环境变量
process.env.NODE_ENV = 'production';
module.exports = {
entry: {
main: './src/js/index.js',
vendor: './src/js/jquery.js'
},
output: {
filename: 'built.[contenthash:10].js', // [contenthash:10] 表示取 contenthash 的前十位
path: resolve(__dirname, 'dist')
},
module: {
rules: [
{
oneOf: [
// 单独抽离css文件并进行兼容性处理
{
test: /\.less$/,
use: [
MiniCssExtractPlugin.loader, // 单独抽离css文件
'css-loader',
'less-loader',
{
loader: 'postcss-loader', // 对 css 进行兼容性处理(需要配置 package.json 文件的 browserslist 属性)
options: {
ident: 'postcss',
plugins: () => [
require('postcss-preset-env')() // 读取 postcss-preset-env 插件的环境变量
]
}
}
]
},
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader, // 单独抽离css文件
'css-loader',
{
loader: 'postcss-loader', // 对 css 进行兼容性处理(需要配置 package.json 文件的 browserslist 属性)
options: {
ident: 'postcss',
plugins: () => [
require('postcss-preset-env')() // 读取 postcss-preset-env 插件的环境变量
]
}
}
]
},
// 对js进行兼容性处理
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader',
options: {
// 预设:指示babel做怎么样的兼容性处理
presets: [
[
'@babel/preset-env',
{
// 按需加载
useBuiltIns: 'usage',
// 指定core-js版本
corejs: {
version: 3
},
// 指定兼容性做到哪个版本浏览器
targets: {
chrome: '60',
firefox: '60',
ie: '9',
safari: '10',
edge: '17'
}
}
]
],
cacheDirectory: true // 开启 babel 缓存
},
},
{
test: /\.(jpg|png|gif)/,
loader: 'url-loader',
options: {
limit: 8 * 1024,
name: '[hash:10].[ext]',
outputPath: 'imgs',
esModule: false
}
},
{
test: /\.html$/,
loader: 'html-loader' // 处理 html文件 src 属性引入的图片资源
},
{
exclude: /\.(js|css|less|html|jpg|png|gif)/,
loader: 'file-loader',
options: {
outputPath: 'media'
}
}
]
}
]
},
plugins: [
// 压缩html文件
new HtmlWebpackPlugin({
template: './src/index.html',
minify: {
collapseWhitespace: true, // 移除空格
removeComments: true // 移除注释
}
}),
new MiniCssExtractPlugin({
filename: 'css/index.[contenthash:10].css' // [contenthash:10] 表示取 contenthash 的前十位
}),
// 压缩css
new OptimizeCssAssetsWebpackPlugin(),
// 配置JS检查规则
new ESLintPlugin({
fix: true, // 对打包生成的文件自动纠错
extensions: ['js', 'json'], // 只检查 js、json 结尾的文件
exclude: '/node_modules/' // 排除 node_modules
})
],
// 生产环境默认压缩js文件
mode: 'development',
devtool: 'none'
}
webpack 还提供了一个可以优化打包构建速度的 loader:cache-loader。它主要也是用于在一些性能开销较大的 loader 之前(比如 babel-loader),并可将所要优化的 loader 编译结果缓存到磁盘里。第一次打包构建需要消耗一定的性能去缓存结果,但是当下次再打包构建时,速度将会快很多。我们先对该 loader 进行安装:
npm install cache-loader --save-dev
我们依旧以 babel-loader 来进行测试,为了看得更清楚,我将 babel-loader 的其它配置选项暂时去掉,使用其默认值,并且也不开启刚才介绍的 babel 缓存:
const {
resolve
} = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');
// 设置 node 环境变量
process.env.NODE_ENV = 'production';
module.exports = {
entry: {
main: './src/js/index.js',
vendor: './src/js/jquery.js'
},
output: {
filename: 'built.[contenthash:10].js',
path: resolve(__dirname, 'dist')
},
module: {
rules: [
{
oneOf: [
// 单独抽离css文件并进行兼容性处理
{
test: /\.less$/,
use: [
MiniCssExtractPlugin.loader, // 单独抽离css文件
'css-loader',
'less-loader',
{
loader: 'postcss-loader', // 对 css 进行兼容性处理(需要配置 package.json 文件的 browserslist 属性)
options: {
ident: 'postcss',
plugins: () => [
require('postcss-preset-env')() // 读取 postcss-preset-env 插件的环境变量
]
}
}
]
},
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader, // 单独抽离css文件
'css-loader',
{
loader: 'postcss-loader', // 对 css 进行兼容性处理(需要配置 package.json 文件的 browserslist 属性)
options: {
ident: 'postcss',
plugins: () => [
require('postcss-preset-env')() // 读取 postcss-preset-env 插件的环境变量
]
}
}
]
},
// 对js进行兼容性处理
{
test: /\.js$/,
exclude: /node_modules/,
use: ['babel-loader']
},
{
test: /\.(jpg|png|gif)/,
loader: 'url-loader',
options: {
limit: 8 * 1024,
name: '[hash:10].[ext]',
outputPath: 'imgs',
esModule: false
}
},
{
test: /\.html$/,
loader: 'html-loader' // 处理 html文件 src 属性引入的图片资源
},
{
exclude: /\.(js|css|less|html|jpg|png|gif)/,
loader: 'file-loader',
options: {
outputPath: 'media'
}
}
]
}
]
},
plugins: [
// 压缩html文件
new HtmlWebpackPlugin({
template: './src/index.html',
minify: {
collapseWhitespace: true, // 移除空格
removeComments: true // 移除注释
}
}),
new MiniCssExtractPlugin({
filename: 'css/index.[contenthash:10].css' // 对抽离的css文件进行重命名
}),
// 压缩css
new OptimizeCssAssetsWebpackPlugin(),
// 配置JS检查规则
new ESLintPlugin({
fix: true, // 对打包生成的文件自动纠错
extensions: ['js', 'json'], // 只检查 js、json 结尾的文件
exclude: '/node_modules/' // 排除 node_modules
})
],
// 生产环境默认压缩js文件
mode: 'development',
devtool: 'none'
}
打包一次,所花费的时间为 2882ms :
现在我们在 babel-loader 前面加入 cache-loader,再运行一次:
const {
resolve
} = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');
// 设置 node 环境变量
process.env.NODE_ENV = 'production';
module.exports = {
entry: {
main: './src/js/index.js',
vendor: './src/js/jquery.js'
},
output: {
filename: 'built.[contenthash:10].js',
path: resolve(__dirname, 'dist')
},
module: {
rules: [
{
oneOf: [
// 单独抽离css文件并进行兼容性处理
{
test: /\.less$/,
use: [
MiniCssExtractPlugin.loader, // 单独抽离css文件
'css-loader',
'less-loader',
{
loader: 'postcss-loader', // 对 css 进行兼容性处理(需要配置 package.json 文件的 browserslist 属性)
options: {
ident: 'postcss',
plugins: () => [
require('postcss-preset-env')() // 读取 postcss-preset-env 插件的环境变量
]
}
}
]
},
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader, // 单独抽离css文件
'css-loader',
{
loader: 'postcss-loader', // 对 css 进行兼容性处理(需要配置 package.json 文件的 browserslist 属性)
options: {
ident: 'postcss',
plugins: () => [
require('postcss-preset-env')() // 读取 postcss-preset-env 插件的环境变量
]
}
}
]
},
// 对js进行兼容性处理
{
test: /\.js$/,
exclude: /node_modules/,
use: ['cache-loader', 'babel-loader'] // 在 babel-loader 前面加入 cache-loader,将 babel 编译结果缓存进磁盘
},
{
test: /\.(jpg|png|gif)/,
loader: 'url-loader',
options: {
limit: 8 * 1024,
name: '[hash:10].[ext]',
outputPath: 'imgs',
esModule: false
}
},
{
test: /\.html$/,
loader: 'html-loader' // 处理 html文件 src 属性引入的图片资源
},
{
exclude: /\.(js|css|less|html|jpg|png|gif)/,
loader: 'file-loader',
options: {
outputPath: 'media'
}
}
]
}
]
},
plugins: [
// 压缩html文件
new HtmlWebpackPlugin({
template: './src/index.html',
minify: {
collapseWhitespace: true, // 移除空格
removeComments: true // 移除注释
}
}),
new MiniCssExtractPlugin({
filename: 'css/index.[contenthash:10].css' // 对抽离的css文件进行重命名
}),
// 压缩css
new OptimizeCssAssetsWebpackPlugin(),
// 配置JS检查规则
new ESLintPlugin({
fix: true, // 对打包生成的文件自动纠错
extensions: ['js', 'json'], // 只检查 js、json 结尾的文件
exclude: '/node_modules/' // 排除 node_modules
})
],
// 生产环境默认压缩js文件
mode: 'development',
devtool: 'none'
}
这一次构建的速度要比刚才没有加 cache-loader 的时候还要长,为 3499ms:
但是别着急,我们在开启了 cache-loader 运行过后,再运行一次:
这一次只花费了 2272ms,明显比前面两次都快了,这说明我们的 cache-loader 确实生效了。
Tree Shaking 是一个术语,翻译过来为 “树摇”,这是 Webpack 内置的功能,其目的在于去除我们在应用程序中未引用的代码,使我们代码的体积变得更小。使用 Tree Shaking 有两个前提条件:① 它依赖 ES6 模块化 ② 运行环境 mode 为 production。使用这个功能的方法非常简单,例如,我们在 test.js 中导出两个函数:
然后在 index.js 中只引入其中一个:
将环境设为生产环境后进行打包,打包后的文件便不会包含关于 multi 函数体的相关代码(这里去看打包后的代码可能没法直接看出来,因为函数名被做了简化处理,但如果搜索刚才 multi 函数里包含的 * 号,是找不到的,说明该函数确实被去除了):
此外, Webpack4+ 还引入了一个新的特性:sideEffects。它是 package.json 文件中的一个属性,如果设置为 false,表示所有文件都没有副作用,它就会为所有参与打包的文件都进行 Tree Shaking(关于副作用这个概念,可以看看 webpack sideEffect 观察 或者 官网 Tree Shaking 自行理解...)。
但是,实际使用中,将 sideEffects 设为 false,会造成 css 资源文件也被 Tree Shaking 掉了:
// package.json 文件
{
"name": "webpack-study",
"version": "1.0.0",
"description": "",
"main": "index2.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "webpack-dev-server"
},
"author": "可乐",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.12.13",
"@babel/polyfill": "^7.12.1",
"@babel/preset-env": "^7.12.13",
"babel-loader": "^8.2.2",
"core-js": "^3.8.3",
"css-loader": "^5.0.1",
"eslint": "^7.19.0",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-plugin-import": "^2.22.1",
"eslint-webpack-plugin": "^2.4.3",
"file-loader": "^6.2.0",
"html-loader": "^1.3.2",
"html-webpack-plugin": "^4.5.1",
"less": "^4.0.0",
"less-loader": "^7.2.1",
"mini-css-extract-plugin": "^1.3.4",
"optimize-css-assets-webpack-plugin": "^5.0.4",
"postcss-loader": "^3.0.0",
"postcss-preset-env": "^6.7.0",
"style-loader": "^2.0.0",
"url-loader": "^4.1.1",
"webpack": "^4.44.2",
"webpack-cli": "^4.3.1",
"webpack-dev-server": "^3.11.2"
},
"browserslist": {
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
],
"production": [
"last 1 version",
"> 1%",
"maintained node versions",
"not dead"
]
},
"eslintConfig": {
"extends": "airbnb-base",
"rules": {
"no-console": "off"
}
},
"sideEffects": false
}
解决该问题,我们更改 sideEffects 如下就可以了:
{
"name": "webpack-study",
"version": "1.0.0",
"description": "",
"main": "index2.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "webpack-dev-server"
},
"author": "可乐",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.12.13",
"@babel/polyfill": "^7.12.1",
"@babel/preset-env": "^7.12.13",
"babel-loader": "^8.2.2",
"core-js": "^3.8.3",
"css-loader": "^5.0.1",
"eslint": "^7.19.0",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-plugin-import": "^2.22.1",
"eslint-webpack-plugin": "^2.4.3",
"file-loader": "^6.2.0",
"html-loader": "^1.3.2",
"html-webpack-plugin": "^4.5.1",
"less": "^4.0.0",
"less-loader": "^7.2.1",
"mini-css-extract-plugin": "^1.3.4",
"optimize-css-assets-webpack-plugin": "^5.0.4",
"postcss-loader": "^3.0.0",
"postcss-preset-env": "^6.7.0",
"style-loader": "^2.0.0",
"url-loader": "^4.1.1",
"webpack": "^4.44.2",
"webpack-cli": "^4.3.1",
"webpack-dev-server": "^3.11.2"
},
"browserslist": {
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
],
"production": [
"last 1 version",
"> 1%",
"maintained node versions",
"not dead"
]
},
"eslintConfig": {
"extends": "airbnb-base",
"rules": {
"no-console": "off"
}
},
"sideEffects": ["*.css", "*.less"]
}
代码分割是 Webpack 中一项方便JS文件管理的技术,可以解决单个文件体积过大问题。将单个文件分成多个文件可以实现并行加载,从而让我们的项目启动更快。此外,我们还可以根据所需要的文件实现按需加载的功能。
之前我们的 entry 都是单入口模式,不管多少个文件参与打包,最终都只输出了一个文件。
那假如我们希望有几个文件,就打包几个文件出来,又应该怎么做呢?
第一种方式是将入口 entry 属性改为多入口模式,想打包几个对应的文件,就写几个对应的入口:
const { resolve } = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
// entry: './src/js/index.js', // 单入口模式
entry: { // 多入口模式
index: './src/js/index.js',
test: './src/js/test.js'
},
output: {
// [name]表示取文件名
filename: 'js/[name].[contenthash:10].js',
path: resolve(__dirname, 'dist')
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
minify: {
collapseWhitespace: true,
removeComments: true
}
})
],
mode: 'production'
};
可以看到打包出来的文件变成了两个:
但是,现在测试一下。假如我在两个文件夹同时引入了 jquery 插件,然后再进行打包:
当然我们得先运行一下 npm install jquery 把 jquery 插件安装下来进行测试。安装完成后再打包一次:
你可以看到这次打包出来的两个文件体积都变得非常大,因为它们同时都包含了 jquery 代码。但这两个文件引入的是相同的 jquery 代码,重复打包无疑会浪费没必要的性能,有没有什么办法可以优化一下呢?
除了之前介绍的那五个属性, webpack.config.js 文件还可以配置一个叫做 optimization 的属性,当我们将 optimization.splitChunks.chunks 设置为 all 时,它便会为我们启动代码分割功能。
const { resolve } = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
// entry: './src/js/index.js', // 单入口模式
entry: { // 多入口模式
index: './src/js/index.js',
test: './src/js/test.js'
},
output: {
filename: 'js/[name].[contenthash:10].js',
path: resolve(__dirname, 'dist')
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
minify: {
collapseWhitespace: true,
removeComments: true
}
})
],
mode: 'production',
optimization: {
splitChunks: {
chunks: 'all' // 启动代码分割功能
}
}
};
它的功能主要体现在两点:
第一点,它可以帮助我们将 node_modules 中所依赖的包单独打包成一个文件。现在我们先恢复之前的多入口 entry 为单入口,然后将两个文件中的其中一个文件关于 jQuery 的代码注释掉,然后执行 webpack:
打包后你可以看到,关于 jQuery 的代码都被单独打包到一个文件里了。
第二点,它可以帮助我们分析多入口 chunk 中有没有公共的文件,并将公共的文件单独打包成一个 chunk。我们继续将入口改为多入口,让两个文件都引入 jQuery 代码。然后我们先注释掉 optimization.splitChunks.chunks,看看打包后的情况:
可以看到打包出来的两个文件体积都非常大,因为它们都包含了相同的 jQuery 包的代码。那我们现在恢复 optimization.splitChunks.chunks,接着运行看看:
可以看到,除了多入口打包出来的两个文件之外,相同的 jQuery 代码只被打包了一次并放进了单独一个文件。这无疑为我们打包构建的过程减少了很多不必要的重复开销。
除了前面两种方式,还有第三种方式可以将文件单独打包,但这里不是指 ES6 的模块化语法,而是 webpack 根据 ES2015 loader 规范实现的用于动态加载的 import() 方法,这种也叫做运行时 chunk。
import() 特性依赖于内置的 Promise。如果想在低版本浏览器中使用 import(),记得使用像 es6-promise 或者 promise-polyfill 这样 polyfill 库,来预先填充(shim) Promise 环境。以前实现动态加载使用的是 require.ensure() 方法,该方法同样为 webpack 特有的,现已被 import() 取代。
我们现在先去掉那些关于 jQuery 的代码,然后改为单入口。让 index.js 文件先像往常的方式一样引用 text.js 文件:
运行后,index.js 与 text.js 的代码被打包合并在一个文件里:
但假如我们按以下语法进行编写:
运行后可以看到,关于 test.js 代码被神奇的单独打包到了一个文件中:
如果你希望重命名动态导入的文件名,你只需要在 import 方法中添加如下代码即可:
使用这种方法要特别注意一点,那就是打包后的 main.js 文件是根据打包后的 test.js 文件名来引入的。
这里打包后的文件带有 contenthash,如果我们修改 test.js 文件,会使打包后的 test.js 文件名发生变化。由于引用的 test.js 文件哈希值变了,main.js 打包后的文件名也发生了变化:
有一个方法可以解决这种情况,那就是让 main.js 与所引用的文件之间的映射关系单独打包成一个文件来解析。将包含 chunks 映射关系的文件名单独从 main.js 里提取出来的方法,webpack 已经帮我们封装好了,具体的实现方式在 optimization.runtimeChunk 属性上:
const { resolve } = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/js/index.js', // 单入口模式
// entry: { // 多入口模式
// index: './src/js/index.js',
// test: './src/js/test.js'
// },
output: {
// [name]表示取文件名
filename: 'js/[name].[contenthash:10].js',
path: resolve(__dirname, 'dist')
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
minify: {
collapseWhitespace: true,
removeComments: true
}
})
],
mode: 'production',
optimization: {
splitChunks: {
chunks: 'all'
},
runtimeChunk: true // 将通过 import() 方法引入的文件的文件名单独保存为一个文件, 防止通过 import() 引入的文件的文件名改变导致入口文件的文件名也跟着改变
}
};
此时我们重新运行一次,会发现它多了一个 runtime~main.0c381a92e7.js 文件:
然后我们修改一下 test.js 文件后再打包,打包后你会看到生成的 main.js 文件名依然保持不变:
也许你已经发现了,虽然打包后的 main.js 文件名没有发生变化,但它还是重新构建了一次。是的,这个方法并不能阻止 main.js 重新打包构建(main.js 依赖 test.js,本身就没法做到不重新构建),它本来就是为了防止我们的代码频繁变更导致上线后 main.js 文件在浏览器的缓存失效。如果项目规模很大而代码优化分包又没做好的话,浏览器重新加载一次就会非常耗时,这样不利于用户体验。
我们浏览器正常情况下对文件的加载是并行加载,但同一时间并行加载的文件个数有所限制,这时候就要用到懒加载和预加载技术了。
这里要讲的主要是针对JS文件的懒加载,懒加载也叫延迟加载,它用到的语法,其实就是我们上面介绍的动态导入 import() 方法。我们可以按如下方式编辑代码进行打包,然后打开构建后的 index.html 文件进行测试:
打开浏览器控制台可以看到,一开始只有 index.js 文件被加载:
而当我们点击了按钮,才会对 test.js 文件进行加载:
并且哪怕我们多次点击按钮,test.js 文件也只会被加载一次。之后访问该文件只会从缓存中进行读取,不会再次进行加载:
此外,import 语法还支持我们对文件进行预加载,我们现在先强制刷新页面清除缓存,看一下控制台的 Network:
一开始 test.js 文件确实是没有被加载的,只有我们点击了按钮, test.js 文件才会被加载:
现在我们在刚才的 import 方法里的注释语句写上 webpackPrefetch: true,webpack 就会为我们预加载这个文件(但不会立即执行):
之后你如果再点击按钮,它就会从缓存中去读取 test.js 文件。可以对比后面的加载时间,就能知道加载速度确实变快了。当然,这里依然只读取一次,之后就缓存在内存中了:
总结一下,懒加载指的是当文件需要的时候进行加载,而预加载会在我们使用之前,提前加载好这个文件。并且预加载会等到浏览器加载完其它资源后进入空闲状态时,再去加载当前文件资源。对于懒加载和预加载的选择,还是要根据我们项目的实际情况做决定。有些文件资源体积比较大的,就不适合做懒加载,而适合预加载。但是预加载这项技术,目前只能在一些高版本的浏览器才能使用。
在介绍 PWA 离线缓存技术之前,我们需要先弄懂什么是 PWA,由于时间原因,这里只做简要介绍,关于这个概念,推荐阅读以下两篇文章:
PWA这项技术在2015年由谷歌团队首次提出,到2017年才开始推广开来。PWA全称为 Progressive Web App (渐进式 Web 应用),它基于 Service Worker、Cache Storage 等技术实现的,让网页Web应用和原生应用拥有相近的沉浸式体验。通过这些技术,使得网页应用能够像原生应用一样可以进行离线缓存、发布推送通知、桌面访问等功能。
PWA 最重要的功能就是在离线状态下还可以使用当前网页,防止服务器崩溃的时候页面直接崩溃。workbox 是一个由谷歌浏览器团队发布的用来协助创建 PWA 应用的 JavaScript
库。在 webpack 中,我们可以通过直接安装 workbox-webpack-plugin 插件来使用这个库。我们执行安装语句:
npm i workbox-webpack-plugin --save
安装完成后我们只需在 plguins 属性下面实例化调用该插件即可,此外还要注意,PWA 中的核心 Service Worker 代码必须运行在服务端才能发挥作用,因此我们还要把之前开启服务器的配置添加进去查看运行效果:
const {
resolve
} = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
// 设置 node 环境变量
process.env.NODE_ENV = 'production';
module.exports = {
entry: './src/js/index.js',
output: {
filename: 'built.[contenthash:10].js',
path: resolve(__dirname, 'dist')
},
module: {
rules: [{
oneOf: [{
test: /\.css$/,
use: [
'style-loader',
'css-loader'
]
}]
}]
},
plugins: [
// 压缩html文件
new HtmlWebpackPlugin({
template: './src/index.html',
minify: {
collapseWhitespace: true, // 移除空格
removeComments: true // 移除注释
}
}),
// 启动 PWA 功能
new WorkboxWebpackPlugin.GenerateSW({
skipWaiting: true, // 强制等待中的 Service Worker 被激活
clientsClaim: true // Service Worker 被激活后使其立即获得页面控制权
})
],
// 生产环境默认压缩js文件
mode: 'development',
devtool: 'none',
devServer: {
overlay: true, // 在浏览器上全屏显示编译的 errors 或 warnings
compress: true, // 启动gzip压缩
port: 8082, // 配置服务器端口号
open: true // 自动打开浏览器
}
}
做好了 webpack.config.js 文件的基本配置后,我们还需要在 js 文件中注册核心的 Service Worker。Service Worker 是浏览器在后台独立于网页运行的脚本。是它让 PWA 拥有极快的访问速度和离线运行能力。下面这段是MDN官网对 Service Worker 的介绍:
Service workers 本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器。这个 API 旨在创建有效的离线体验,它会拦截网络请求并根据网络是否可用采取来适当的动作、更新来自服务器的的资源。它还提供入口以推送通知和访问后台同步 API。
要让 workbox-webpack-plugin 插件发挥作用,我们还需要在入口文件 index.js 中加入如下代码注册 Service Worker:
// 注册 Service Worker
if ('serviceWorker' in navigator) { // 判断当前浏览器是否支持 Service Worker
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/service-worker.js') // 注册 Service Worder
.then(() => {
console.log('Service Worder 注册成功');
})
.catch(() => {
console.log('Service Worder 注册失败');
});
});
}
现在我们可以写一些代码,然后运行 webpack serve 指令在本地服务器进行测试:
打开控制台可以看到,我们的 Service Worker 是注册成功了。那要怎么体现 PWA 的作用呢?我们点击控制台的 Network 选项,然后将网页设置为离线状态,再对页面进行刷新:
此刻你再刷新页面,不出意外的话,你会发现虽然底下无法对本地服务器发起网络请求,但是我们的页面依然正常显示着,这就说明我们的 PWA 离线缓存技术成功实现啦:
如果想了解更多这个 workbox 库底层的实现逻辑,可阅读文章 workbox-webpack-plugin 创建 pwa。
单我们执行一些昂贵的操作时(比如 babel 编译),如果遇到项目规模很大,打包构建所花费的时间将是非常的大。对于这些开销较大的 loader 操作,webpack 提供了一个叫做 thread-loader 的 loader,允许我们给某些昂贵的 loader 操作同时开启多个线程执行。这个插件主要争对 babel-loader 去使用,它放在所要优化的 loader 操作之前,我们先安装该 loader:
npm i thread-loader --save-dev
然后我们在 babel-loader 之前去使用 thread-loader:
const {
resolve
} = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');
// 设置 node 环境变量
process.env.NODE_ENV = 'production';
module.exports = {
entry: './src/js/index.js', // 单入口模式
output: {
filename: 'built.[contenthash].js',
path: resolve(__dirname, 'dist')
},
module: {
rules: [{
oneOf: [
// 单独抽离css文件并进行兼容性处理
{
test: /\.less$/,
use: [
MiniCssExtractPlugin.loader, // 单独抽离css文件
'css-loader',
'less-loader',
{
loader: 'postcss-loader', // 对 css 进行兼容性处理(需要配置 package.json 文件的 browserslist 属性)
options: {
ident: 'postcss',
plugins: () => [
require('postcss-preset-env')() // 读取 postcss-preset-env 插件的环境变量
]
}
}
]
},
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader, // 单独抽离css文件
'css-loader',
{
loader: 'postcss-loader', // 对 css 进行兼容性处理(需要配置 package.json 文件的 browserslist 属性)
options: {
ident: 'postcss',
plugins: () => [
require('postcss-preset-env')() // 读取 postcss-preset-env 插件的环境变量
]
}
}
]
},
// 对js进行兼容性处理
{
test: /\.js$/,
exclude: /node_modules/,
use: [
{
loader: 'thread-loader', // 在 babel-loader 执行的时候开启多线程打包,线程启动时间大概为 600ms
options: {
workers: 2 // 同时开启两个线程
}
},
{
loader: 'babel-loader',
options: {
// 预设:指示babel做怎么样的兼容性处理
presets: [
[
'@babel/preset-env',
{
// 按需加载
useBuiltIns: 'usage',
// 指定core-js版本
corejs: {
version: 3
},
// 指定兼容性做到哪个版本浏览器
targets: {
chrome: '60',
firefox: '60',
ie: '9',
safari: '10',
edge: '17'
}
}
]
],
cacheDirectory: true // 开启 babel 缓存
}
}
],
}
]
}]
},
plugins: [
// 压缩html文件
new HtmlWebpackPlugin({
template: './src/index.html',
minify: {
collapseWhitespace: true, // 移除空格
removeComments: true // 移除注释
}
}),
new MiniCssExtractPlugin({
filename: 'css/index.[contenthash].css' // 对抽离的css文件进行重命名
})
],
// 生产环境默认压缩js文件
mode: 'production',
devtool: 'none'
}
在我们项目规模特别大的时候,多线程打包的优化效率就很明显了。但是,每个线程都是一个单独的node.js进程,其开销约为600毫秒,线程间通信也有开销。因此如果使用不当,反而会适得其反。具体的效果,这里便不再做演示,感兴趣的朋友可以自己动手去尝试。
externals 是 webpack.config.js 的又一个配置,它用于防止某些依赖被打包。对于它的使用,官网的解释如下:
防止将某些 import 的包 (package) 打包到 bundle 中,而是在运行时 (runtime) 再去从外部获取这些扩展依赖 (external dependencies) 。
在开发环境中,我们需要依赖到某些库(比如 jQuery)的时候,通常会直接把这个包安装下来,通过 import 进行本地导入。但有时候有一些库的体积十分巨大,比如像 echarts、jQuery 之类的库,如果每次都要参与打包构建,消耗的时间是非常大的。
所以对于这些比较大型的库,如果我们希望在打包的过程中将它们从项目中排除,就要借助 externals 属性了。具体的配置如下:
const { resolve } = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/js/index.js',
output: {
filename: 'js/built.js',
path: resolve(__dirname, 'build')
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html'
})
],
mode: 'production',
externals: {
jquery: '$' // 将 jQuery 库排除在打包范围之外
}
};
这个时候,即使我们在入口文件引入了 jQuery,jQuery 的代码也不会被打包进来:
不过既然打包后的代码不包含 jQuery 代码,那我们再去使用 jquery 肯定会报错,所以这时候我们就要手动在 index.html 文件中使用 CDN 的方式引入该库了:
这样打包后的代码才可以正常运行。这里要注意的一点是,externals 的 key 指的是当前库的名称( jquery ), value 指的这个库在我们引入时暴露出来的变量 ( $ 变量或 jQuery 变量 ):
上面讲到的 externals 属性,主要是用于防止某些依赖被打包,然后通过 CDN 的方式去引入。但有时候我们会为了项目在上线后运行稳定,希望所依赖的库一起打包进行我们代码。第三方库通常都是稳定不变的,每次都参与重新打包构建并没有什么必要。那我们有没有什么办法,能在开发的过程中对第三方库的打包进行一些优化配置呢?
答案是肯定的,我们可以借助 webpack 提供的 DLL技术(Dynamic Link Library,动态链接库)。如果说 externals 是用于彻底不进行第三方库的打包构建,dll 则可以帮助我们在整个项目的开发过程中,只对第三方库进行单独打包构建一次。
通常来说,我们的代码都可以至少简单区分成业务代码和第三方库。如果不做处理,每次构建时都需要把所有的代码重新构建一次,耗费大量的时间。然而大部分情况下,很多第三方库的代码并不会发生变更(除非是版本升级),这时就可以用到 dll :把复用性较高的第三方模块打包到动态链接库中,在不升级这些库的情况下,动态库不需要重新打包,每次构建只重新打包业务代码。
那具体应该怎么操作呢?首先,我们需要单独建一个 js 文件,我们把它取名为 webpack.dll.js 的文件,然后按以下方式编写该文件:
// webpack.dll.js
const { resolve } = require('path');
const webpack = require('webpack');
module.exports = {
entry: {
lib: ['jquery', 'vue'] // 左边为最终打包生成的文件名的 [name] ,右边数组包含所要单独打包的库
},
output: {
filename: '[name].dll.js', // 打包生成的文件名
path: resolve(__dirname, 'dist/dll'), // 文件保存的目录
library: '[name]_dll_[hash]' // 所打包的库向外暴露出去的变量名(这里为 lib_dll_[hash值])
},
plugins: [
// 生成 manifest.json 文件, 提供与所打包的动态链接库(jquery、vue)在 node_modules 里的映射关系
new webpack.DllPlugin({
name: '[name]_dll_[hash]', // 动态链接库的全局变量名称(映射所打包的库向外暴露出去的变量名, 需要和 output.library 中保持一致。该字段的值也就是输出的 manifest.json 文件 中 name 字段的值)
path: resolve(__dirname, 'dist/dll/manifest.json') // 输出文件路径和文件名
})
],
mode: 'production'
};
这个文件用于单独打包第三方库并生成一个与这些库形成映射关系的 manifest.json 文件。现在我们要运行这个文件,之前执行 webpack 会默认执行 webpack.config.js 文件,现在我们要在 webpack 中执行这个 webpack.dll.js 文件,我们需要执行如下指令:
webpack --config webpack.dll.js
执行完毕后,你会发现在 dist 目录下生成了一个 dll 文件夹,文件夹中包含的 lib.dll.js 存放的就是我们刚才所打包的第三方库( jquery、vue ),而另一个 manifest.json 文件保存的是与 lib.dll.js 所打包出来的库的映射关系:
现在我们已经将第三方库单独打包并放入 dist 目录下的 dll 文件里了,那现在我们就要回到我们的 webpack.config.js 文件,让它读取 manifest.json 文件并跳过对应依赖的打包。这个过程不需要引入新的插件,使用 webpack 自带的 DllReferencePlugin 插件即可:
const { resolve } = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
module.exports = {
entry: './src/js/index.js',
output: {
filename: 'built.js',
path: resolve(__dirname, 'dist')
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html'
}),
// 读取 manifest.json 文件并跳过读取到的库的打包过程
new webpack.DllReferencePlugin({
manifest: resolve(__dirname, 'dist/dll/manifest.json'),
})
],
mode: 'production'
};
接着我们重新运行 webpack 指令(注意:这次不用再运行 webpack --config webpack.dll.js 指令了):
可以看到,虽然我们的代码里引入了 jquery 和 vue,但很明显这两个库都没有参与打包。然而,我们现在还无法使用这两个库:
原因很简单,我们还没有引入文件资源,我们需要在 index.html 中对单独的 lib.dll.js 库进行引入:
现在便能正常使用啦:
写了这么多,现在来总结一下吧。关于 Webpack 的所能做的性能优化,我们主要从代码的打包构建速度、代码开发的调试以及代码线上运行时的性能这三方面去着手。对于打包构建速度的优化,我们可以使用热模块替换 HMR 局部构建、使用 oneof 减少插件读取次数、开启 babel 缓存、进行多线程打包、以及最后讲到的 externals 和 dll 进行依赖排除或依赖独立打包;对于代码开发的调试,本文介绍了多种 source-map 的应用场景;而对于代码运行时的性能优化,我们可以使用文件名缓存(contenthash)方便线上代码更新、使用Tree Shaking 去除不必要的代码加载、使用代码分割技术优化代码加载速度(并行加载)、使用懒加载与预加载去提高代码解析效率、以及利用 PWA 技术让代码可以离线缓存访问。
前端性能优化是一个非常零碎的工作,我们需要感谢 Webpack 为我们封装了如此多的优化技巧。对于 Webpack 的性能优化,此文便暂时讲到这里,希望这篇文章能够为你的日常工作带来帮助,感谢您的阅读。
最后附上关于 webpack.config.js 的 output、devServer、resolve、optimization 属性的一些详细配置的扩展知识:
// resolve是用来拼接绝对路径的方法
const {
resolve
} = require('path');
const HTMLWebpackPlugin = require('html-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');
module.exports = {
entry: './src/js/index.js',
output: {
filename: 'js/[name].[contenthash:10].js', // [name] 的默认名为 main, 入口为对象的方式的话, [name] 的值对应对象的 key 值
path: resolve(__dirname, 'dist'),
// -------------- 以下为扩展配置 -----------------
// publicPath: '/', // 所有引入的资源的公共路径 (比如引入了 img/1.png, 打包后就变为 /img/1.png, 该属性一般用于生产环境)
// chunkFileName: 'js/[name]_chunk.js', // 通过 import() 动态导入、optimization 代码分割等非入口文件方式打包出来的 chunk 名
// library: '[name]', // 整个库打包后向外暴露的变量名
// libraryTarget: 'window' // 将整个库打包后向外暴露的变量名添加到 window 上, node 环境可设为 'global'; 此外, 也可将值设置为 commonjs、amd 等模块化关键字使该变量以该模块化形式暴露出去
},
module: {
rules: [
// 打包样式资源
{
test: /\.less$/,
use: ['style-loader', 'css-loader', 'less-loader']
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
// enforce: 'pre' // 设为 pre 表示优先执行该 loader, 设为 post 表示延后执行该 loader
}
]
},
plugins: [
// 打包 html 资源
new HTMLWebpackPlugin({
template: './src/index.html'
})
],
mode: 'development',
// 配置开发服务器 (需要下载 webpack-dev-server )
devServer: {
// 在浏览器上全屏显示编译的 errors 或 warnings
overlay: true,
// 启动gzip压缩
compress: true,
// 指定端口号
port: 8080,
// 自动打开浏览器
open: true,
// -------------- 以下为扩展配置 -----------------
// 运行代码的目录
contentBase: resolve(__dirname, 'dist'),
// 监视 contentBase 目录下的所有文件,一旦文件变化就会 reload
watchContentBase: true,
watchOptions: {
ignored: /node_modules/ // 忽略文件
},
// 指定域名
host: 'localhost',
// 不要显示启动服务器日志信息
clientLogLevel: 'none',
// 除了一些基本启动信息以外,其他内容都不要显示
quiet: true,
// 如果出错了, 不要全屏提示
overlay: false,
// 服务器代理(解决开发环境跨域问题)
proxy: {
// 一旦devServer(8080)服务器接受到 /api/xxx 的请求,就会把请求转发到另外一个服务器(3000)
'/api': {
target: 'http://localhost:3000',
// 发送请求时,请求路径重写:将 /api/xxx --> /xxx (去掉/api)
pathRewrite: {
'^/api': ''
}
}
}
},
// -------------- 以下为扩展配置 -----------------
// 解析模块的规则
resolve: {
// 配置解析模块路径别名: 优点是可以简写路径,缺点是写路径的时候编辑器没有自动提示
alias: {
$css: resolve(__dirname, 'src/css') // 比如 improt 'src/css/index.less/ 可以简写为 import '$css/index.less'
},
// 配置省略文件路径的后缀名(这样引入文件的时候可以不加后缀,webpack 会自动从该数组中逐个比对并引入第一个比对成功的文件)
extensions: ['.js', '.json', '.jsx', '.css'],
// 告诉 webpack 解析模块是去找哪个目录(默认是去当前目录下的 node_modules 文件找,如果找不到就一层一层往上一级文件找 node_modules 文件。为了提高效率, 这里可以直接指定为 resolve(__dirname, '../../node_modules')(这里表示node_modules 被放在当前 webpack.config.js 文件的外层两级的目录下), 当然为了防止出错我们后面可以继续写上 'node_modules' 让其同时具备自动查找的能力 )
modules: [resolve(__dirname, '../../node_modules'), 'node_modules']
},
optimization: {
splitChunks: {
chunks: 'all'
// 以下为 webpack 默认值
/*
minSize: 30 * 1024, // 所要分割的 chunk 最小为30kb
maxSize: 0, // 所要分割的 chunk 没有最大限制
minChunks: 1, // 所要提取的 chunk 最少被引用1次
maxAsyncRequests: 5, // 按需加载时并行加载的文件的最大数量
maxInitialRequests: 3, // 入口js文件最大并行请求数量
automaticNameDelimiter: '~', // 名称连接符
name: true, // 可以使用命名规则
// 分割chunk的组
cacheGroups: {
// vendors组:检测 node_modules 文件并将其打包到 vendors~xxx.js 文件中
vendors: {
test: /[\\/]node_modules[\\/]/,
// 以下属性为所要满足的规则,但前提是也需要满足上面的公共规则。如:文件大小超过30kb,至少被引用一次。
// 优先级
priority: -10
},
// 默认组
default: {
// 以下属性为所要满足的规则,如果同名可覆盖上面的规则
// 所要提取的 chunk 最少被引用2次
minChunks: 2,
// 优先级
priority: -20,
// 如果当前要打包的模块,和之前已经被提取的模块是同一个,就会复用,而不是重新打包模块
reuseExistingChunk: true
}
}*/
},
// 抽离入口文件与其它依赖之间的映射关系单独保存为一个文件(防止代码变更后入口文件名也发生变更)
runtimeChunk: true,
// 配置生产环境的更好的压缩方案(webpack4.26之后的版本都是维护Teser库来压缩代码)
minimizer: [
new TerserWebpackPlugin({
// 开启缓存(如babel缓存)
cache: true,
// 开启多进程打包
parallel: true,
// 启动source-map
sourceMap: true
})
]
}
}