webpack5优化实战原创
引言
z之前讲了如何使用webpack5从0到1搭建项目,没有看过的可以点击链接前往食用。花了几天时间开发完项目,很开心地运行打包命令并准备将其部署到服务器,结果打包完惊呆了,发现包的大小是5.59M
,打包时间花了15s
,这明显是有问题的,有强迫症的我决定对其进行一个比较正式的优化,优化完成后包的大小是687.11KB
,打包时间花了5.21s
,优化效果还是很明显的;另外我这个项目是一个小项目,只花了两三天的开发时间,优化效果可能不是很明显,但是对于比较大的项目,应用了以下我总结的可优化的点后,理论上优化效果是很可观的。
接下来我会围绕webpack优化进行讲述,其中也会作某些原理性地解释,尽量做到让大家知道为什么这么做,以及这么做的结果是什么。不同于webpack4及其以前的版本,webpack5自身处理了很多优化性的东西,很多以前在4中被广泛使用的插件在5中已经用不到了,这一点需要特别注意。废话不多说,让我们开始吧。
优化前
这个项目是一个大屏展示,主要实现了可视化展示数据的功能,整体代码量不大,给大家看一下项目效果图,只有一个页面:
打包完成后控制台显示的结果是bundle size: 5.59M(Parsed);打包时间15s
,这时候webpack只有一个配置文件webpack.config.js
,并且mode: development
,所以打包出来的size会这么大,接下来优化的第一步是先分离配置。
分离配置文件
我们的目标是将一个配置文件分离成三个,分别是webpack.common.js、webpack.dev.js、webpack.prod.js
,对应的是通用配置、开发环境配置、生产环境配置。
我们把配置文件都放在src/config
目录下,方便进行管理,项目结构如下:
接下来先安装一个库,用来合并webpack的通用配置:
yarn add webpack-merge
复制代码
我的未经优化的配置代码如下:
// webpack.common.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const APP_DIR = path.resolve(__dirname, '../src');
const OUPUT_DIR = path.resolve(__dirname, '../dist');
module.exports = {
entry: {
app: APP_DIR,
},
output: {
path: OUPUT_DIR,
filename: '[name].[contenthash].js',
},
resolve: {
modules: [path.resolve(__dirname, '../node_modules')],
alias: {
'@/images': path.resolve(__dirname, '../src/assets/images'),
'@/utils': path.resolve(__dirname, '../src/utils'),
'@': path.resolve(__dirname, '../src'),
},
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
},
},
},
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.(scss|css)$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 1,
},
},
'postcss-loader',
],
},
{
test: /\.(?:ico|gif|png|jpg|jpeg)$/i,
type: 'asset/resource',
},
{
test: /\.(woff(2)?|eot|ttf|otf|svg|)$/,
type: 'asset/inline',
},
],
},
plugins: [
new HtmlWebpackPlugin({
title: '铁木真大屏展示',
template: path.resolve(__dirname, '../public/index.html'),
filename: 'index.html',
}),
],
};
复制代码
// webpack.dev.js
const webpack = require('webpack');
const path = require('path');
const { merge } = require('webpack-merge');
const common = require('./webpack.common');
module.exports = merge(common, {
mode: 'development',
devtool: 'inline-source-map',
devServer: {
historyApiFallback: true,
contentBase: path.join(__dirname, '../public'),
open: false,
hot: true,
quiet: true,
port: 8082,
proxy: {
'/api': {
target: 'http://xxx.xxx.com',
changeOrigin: true,
},
},
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
],
});
复制代码
// webpack.prod.js
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const { merge } = require('webpack-merge');
const common = require('./webpack.common');
module.exports = merge(common, {
mode: 'production',
devtool: 'source-map',
plugins: [
new CleanWebpackPlugin(),
],
});
复制代码
到目前为止,我们已经分离了开发环境和生产环境,还有公共配置。公共配置不用多说,主要是配置了项目的入口、打包后的文件及输出的位置、解析模块的规则以及模板文件的绑定;生产环境关注的可以自动编译及刷新并支持HMR,所以使用了内置的devServer
;生产环境需要对代码进行压缩,在打包前清理旧的文件,引用的是clean-webpack-plugin
插件。
打包性能分析
这里推荐两个插件,webpack-bundle-analyzer
可以帮助我们分析打包后的依赖包大小,webpackbar
提供了友好的编译进度提示,通过以下方式安装:
yarn add webpackbar webpack-bundle-analyzer
复制代码
加入配置文件:
// webpack.prod.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
// ...
plugins: [
new BundleAnalyzerPlugin(),
]
}
复制代码
// webpack.common.js
const WebpackBar = require('webpackbar');
module.exports = {
// ...
plugins: [
new WebpackBar(),
]
}
复制代码
这是我在package.json中scripts
的配置:
{
"scripts": {
"start": "webpack serve --config config/webpack.dev.js",
"build": "webpack --config config/webpack.prod.js",
}
}
复制代码
执行yarn run build
,可以在运行终端看到打包的进度,并且在浏览器可以看到包的依赖关系图,默认展示地址是http://127.0.0.1:8888/
,可以分析出哪些包特别大:
打包优化
去掉大的库中没有用到的代码
从上图可以分析得出moment
占用的大小是个大头,其中locale
占了很大一部分,这也是我目前不需要用到的,所以我可以借用moment-locales-webpack-plugin
插件移除moment
中未用到的代码,先来安装它:
yarn add moment-locales-webpack-plugin
复制代码
以下是配置:
// webpack.common.js
const MomentLocalesPlugin = require('moment-locales-webpack-plugin');
module.exports = {
// ...
plugin: [new MomentLocalesPlugin()]
}
复制代码
Google有一个非常好的仓库,列出了通常会遇到的有问题的依赖,并告诉你如何使用webpack去压缩你的库,如果运气好的话,可以从中找到你想要优化的库,就像我从中找到了moment
的优化插件。
缩小文件搜索范围
Webpack 启动后会从配置的 Entry 出发,解析出文件中的导入语句,再递归的解析。 在遇到导入语句时 Webpack 会做两件事情:
- 根据导入语句去寻找对应的要导入的文件。例如
require('react')
导入语句对应的文件是./node_modules/react/react.js
,require('./util')
对应的文件是./util.js
。 - 根据找到的要导入文件的后缀,使用配置中的 Loader 去处理文件。例如使用Typescript开发的 JavaScript 文件需要使用
ts-loader
去处理。
以上两件事情虽然对于处理一个文件非常快,但是当项目大了以后文件量会变的非常多,这时候构建速度慢的问题就会暴露出来。 虽然以上两件事情无法避免,但需要尽量减少以上两件事情的发生,以提高速度。
接下来一一介绍可以优化它们的途径。
优化loader配置
由于 Loader 对文件的转换操作很耗时,需要让尽可能少的文件被 Loader 处理。
// webpack.common.js
module.exports = {
// ...
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
// 缓存转换出的结果
cacheDirectory: true,
// 只对src目录下的文件使用babel-loader处理,可以缩小命中范围
include: path.resolve(__dirname, '../src'),
presets: ['@babel/preset-env'],
},
},
},
]
}
}
复制代码
优化resolve.modules
配置
resolve.modules
用于配置 Webpack 去哪些目录下寻找第三方模块。
resolve.modules
的默认值是 ['node_modules']
,含义是先去当前目录下的 ./node_modules
目录下去找想找的模块,如果没找到就去上一级目录 ../node_modules
中找,再没有就去 ../../node_modules
中找,以此类推,这和 Node.js 的模块寻找机制很相似。
当安装的第三方模块都放在项目根目录下的 ./node_modules
目录下时,没有必要按照默认的方式去一层层的寻找,可以指明存放第三方模块的绝对路径,以减少寻找,配置如下:
// webpack.common.js
const path = require('path');
module.exports = {
// ...
resolve: {
// 使用绝对路径指明第三方模块存放的位置,以减少搜索步骤
// 其中 __dirname 表示当前工作目录,也就是项目根目录
modules: [path.resolve(__dirname, 'node_modules')]
}
}
复制代码
优化resolve.mainFields
配置
resolve.mainFields
用于配置第三方模块使用哪个入口文件。
安装的第三方模块中都会有一个 package.json
文件用于描述这个模块的属性,其中有些字段用于描述入口文件在哪里,resolve.mainFields
用于配置采用哪个字段作为入口文件的描述。
resolve.mainFields
的默认值和当前的 target
配置有关系,对应关系如下:
- 当
target
为web
或者webworker
时,值是["browser", "module", "main"]
- 当
target
为其它情况时,值是["module", "main"]
以 target
等于 web
为例,Webpack 会先采用第三方模块中的 browser
字段去寻找模块的入口文件,如果不存在就采用 module
字段,以此类推。
为了减少搜索步骤,在你明确第三方模块的入口文件描述字段时,你可以把它设置的尽量少。 由于大多数第三方模块都采用 main
字段去描述入口文件的位置,可以这样配置 Webpack:
// webpack.common.js
module.exports = {
// ...
resolve: {
// 只采用 main 字段作为入口文件描述字段,以减少搜索步骤
mainFields: ['main'],
},
};
复制代码
优化resolve.alias
配置
resolve.alias
配置项通过别名来把原导入路径映射成一个新的导入路径。
在实战项目中经常会依赖一些庞大的第三方模块,以 React 库为例,安装到 node_modules
目录下的 React 库的目录结构如下:
├── dist
│ ├── react.js
│ └── react.min.js
├── lib
│ ... 还有几十个文件被忽略
│ ├── LinkedStateMixin.js
│ ├── createClass.js
│ └── React.js
├── package.json
└── react.js
复制代码
可以看到发布出去的 React 库中包含两套代码:
- 一套是采用 CommonJS 规范的模块化代码,这些文件都放在
lib
目录下,以package.json
中指定的入口文件react.js
为模块的入口。 - 一套是把 React 所有相关的代码打包好的完整代码放到一个单独的文件中,这些代码没有采用模块化可以直接执行。其中
dist/react.js
是用于开发环境,里面包含检查和警告的代码。dist/react.min.js
是用于线上环境,被最小化了。
默认情况下 Webpack 会从入口文件 ./node_modules/react/react.js
开始递归的解析和处理依赖的几十个文件,这会时一个耗时的操作。 通过配置 resolve.alias
可以让 Webpack 在处理 React 库时,直接使用单独完整的 react.min.js
文件,从而跳过耗时的递归解析操作。
// webpack.prod.js
module.exports = {
resolve: {
// 使用 alias 把导入 react 的语句换成直接使用单独完整的 react.production.min.js 文件,
// 减少耗时的递归解析操作
alias: {
'react': path.resolve(
__dirname,
'../node_modules/react/umd/react.production.min.js'
),
}
},
};
复制代码
除了 React 库外,大多数库发布到 Npm 仓库中时都会包含打包好的完整文件,对于这些库你也可以对它们配置 alias。
但是对于有些库使用本优化方法后会影响到Tree-Shaking优化,因为打包好的完整文件中有部分代码你的项目可能永远用不上。 一般对整体性比较强的库采用本方法优化,因为完整文件中的代码是一个整体,每一行都是不可或缺的。 但是对于一些工具类的库,例如 lodash,你的项目可能只用到了其中几个工具函数,你就不能使用本方法去优化,因为这会导致你的输出代码中包含很多永远不会执行的代码。
优化resolve.extensions
配置
在导入语句没带文件后缀时,Webpack 会自动带上后缀后去尝试询问文件是否存在。resolve.extensions
用于配置在尝试过程中用到的后缀列表。默认是:
extensions: ['.js', '.json']
复制代码
也就是说当遇到 require('./data')
这样的导入语句时,Webpack 会先去寻找 ./data.js
文件,如果该文件不存在就去寻找 ./data.json
文件,如果还是找不到就报错。
如果这个列表越长,或者正确的后缀在越后面,就会造成尝试的次数越多,所以 resolve.extensions
的配置也会影响到构建的性能。 在配置 resolve.extensions
时你需要遵守以下几点,以做到尽可能的优化构建性能:
- 后缀尝试列表要尽可能的小,不要把项目中不可能存在的情况写到后缀尝试列表中。
- 频率出现最高的文件后缀要优先放在最前面,以做到尽快的退出寻找过程。
- 在源码中写导入语句时,要尽可能的带上后缀,从而可以避免寻找过程。例如在你确定的情况下把
require('./data')
写成require('./data.json')
。
相关 Webpack 配置如下:
// webpack.common.js
module.exports = {
// ...
resolve: {
// 尽可能的减少后缀尝试的可能性
extensions: ['.tsx', '.ts', '.js'],
},
};
复制代码
优化module.noParse
配置
module.noParse
配置项可以让 Webpack 忽略对部分没采用模块化的文件的递归解析处理,这样做的好处是能提高构建性能。 原因是一些库,例如 jQuery 、ChartJS, 它们庞大又没有采用模块化标准,让 Webpack 去解析这些文件耗时又没有意义。
在上面的 优化 resolve.alias 配置 中讲到单独完整的 react.production.min.js
文件就没有采用模块化,让我们来通过配置 module.noParse
忽略对 react.production.min.js
文件的递归解析处理, 相关 Webpack 配置如下:
// webpack.prod.js
const path = require('path');
module.exports = {
// ...
module: {
// 独完整的 `react.production.min.js` 文件就没有采用模块化,忽略对 `react.production.min.js` 文件的递归解析处理
noParse: /react\.production\.min\.js$/,
},
};
复制代码
以上就是所有和缩小文件搜索范围相关的构建性能优化了,在根据自己项目的需要去按照以上方法改造后,你的构建速度一定会有所提升。
保持最新的版本
使用最新的webpack版本。webpack官方一直在改进性能。webpack的最新推荐版本是:5.11.0
保持Node.js的最新版本也有助于提高性能。除此之外,让你的包管理器(例如npm或yarn)保持最新状态也会有帮助。更新的版本可以创建更高效的模块树并提高解析速度。
减少辅助程序
尽可能少地使用loader和插件。
Resolving
前文中已经将过如何加快检索速度,即缩小文件搜索范围;另外如果项目中未使用npm link
or yarn link
,可以设置resolve.symlinks: false
。
开发环境优化
开启缓存
缓存生成的webpack模块和块,以提高构建速度,推荐在开发环境开启,生产环境关闭:
// webpack.dev.js
module.exports = {
//...
cache: {
type: 'memory',
},
};
复制代码
测试发现,每次编译的时间都会比上次要少一点,这得益于webpack缓存的配置。
Devtool
注意不同devtool设置之间的性能差异。
eval
有最好的性能,但是不能帮助我们很好地跟踪代码- 如果您能忍受稍微差一点的映射质量,那么
cheap-source-map
会有更好的性能 - 增量地构建使用
eval-source-map
大多数情况下,eval-cheap-module-source-map
是最好的选择。
// webpack.dev.js
module.exports = {
// ...
devtool: 'eval-cheap-module-source-map'
}
复制代码
避免使用生产环境下才需要用的工具
某些实用程序、插件和加载器只有在生产环境下构建时才有意义,例如压缩代码、输出随机字符串的文件名等,以下的工具应在开发环境中排除:
[fullhash]
/[chunkhash]
/[contenthash]
TerserPlugin
主要改了output
的配置,配置如下:
// webpack.dev.js
module.exports = {
output: {
filename: '[name].js'
}
}
复制代码
// webpack.common.js
const OUPUT_DIR = path.resolve(__dirname, '../dist');
module.exports = {
output: {
path: OUPUT_DIR,
}
}
复制代码
// webpack.prod.js
module.exports = {
output: {
filename: '[name].[contenhash].js',
}
}
复制代码
最小的入口chunk
通过保持入口块较小,确保它不容易释放。下面的配置为运行时代码创建了一个额外的块,所以生成的chunk较小:
// webpack.dev.js
module.exports = {
optimization: {
runtimeChunk: true
}
}
复制代码
避免额外的优化步骤
webpack做了额外的算法工作来优化输出的大小和加载性能。这些优化适用于较小的代码库,但在较大的代码库中可能代价高昂,我们将额外的优化给关闭掉:
// webpack.dev.js
module.exports = {
optimization: {
removeAvailableModules: false,
removeEmptyChunks: false,
splitChunks: false
}
}
复制代码
输出不带路径信息
webpack有能力在输出包中生成路径信息。然而,这会给捆绑数千个模块的项目带来垃圾收集的压力。在options.output
中将此选项关闭。pathinfo设置:
// webpack.dev.js
module.exports = {
output: {
pathinfo: false,
}
}
复制代码
TypeScript Loader
在使用ts-loader时,要提高构建时间,请使用transpileOnly
加载器选项。这个选项本身关闭了类型检查。再次进行类型检查,使用ForkTsCheckerWebpackPlugin
。这可以通过将TypeScript类型检查和ESLint lint移动到单独的进程来加速它们。
安装ForkTsCheckerWebpackPlugin
插件:
yarn add --dev fork-ts-checker-webpack-plugin
复制代码
// webpack.common.js
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
module.exports = {
module: {
rules: [
{
test: /\.tsx?$/,
use: {
loader: 'ts-loader',
options: {
transpileOnly: true,
},
},
exclude: /node_modules/,
},
]
},
plugins: [
// ...
new ForkTsCheckerWebpackPlugin()
]
}
复制代码
优化到这一步,开发环境的编译时间降到了3.26s
,生产环境的打包时间降到了5.38s
,并且支持Typescript
的类型检查,当然是以另一个进程的方式在运行,当有未通过类型检查的代码时,控制台就可以给出错误提示:
生产环境优化
并行构建
如果是多页应用,即有多个入口,可以使用parallel-webpack
进行并行构建,它充分利用了CPU的多核特性。
由于我这个项目只有一个入口,不需要并行构建,所以暂时没有进行相关配置,有需要的可以点击上面链接查看parallel-webpack
插件的使用方法。
Source Maps
Source Maps的开发非常昂贵,请确认是否真的需要它?我在生产环境使用的是source-map
,测试发现对实际的编译速度及bundle size
的影响不大,大家可以根据需要自行选择:
// webpack.prod.js
module.exports = {
devtool: 'source-map'
}
复制代码
Tree Shaking
Tree Shaking 可以用来剔除 JavaScript 中用不上的死代码。它依赖静态的 ES6 模块化语法,例如通过 import
和 export
导入导出。
为了更直观的理解它,来看一个具体的例子。假如有一个文件 util.js
里存放了很多工具函数和常量,在 main.js
中会导入和使用 util.js
,代码如下:
util.js
源码:
export function funcA() {
}
export function funB() {
}
export const a = 'a';
复制代码
main.js
源码:
import {funcA} from './util.js';
funcA();
复制代码
Tree Shaking 后的 util.js
:
export function funcA() {
}
复制代码
由于只用到了 util.js
中的 funcA
,所以剩下的都被 Tree Shaking 当作死代码给剔除了。
需要注意的是要让 Tree Shaking 正常工作的前提是交给 Webpack 的 JavaScript 代码必须是采用 ES6 模块化语法的, 因为 ES6 模块化语法是静态的(导入导出语句中的路径必须是静态的字符串,而且不能放入其它代码块中),这让 Webpack 可以简单的分析出哪些 export
的被 import
过了。 如果你采用 ES5 中的模块化,例如 module.export={...}
、require(x+y)
、if(x){require('./util')}
,Webpack 无法分析出哪些代码可以剔除。
上面讲了 Tree Shaking 是做什么的,接下来一步步教你如何配置 Webpack 让 Tree Shaking 生效。
- 首先,为了把采用 ES6 模块化的代码交给 Webpack,需要配置 Babel 让其保留 ES6 模块化语句,修改
babel.config.json
:
{
"presets": [
"@babel/preset-env",
[
"env",
{
"modules": false
}
]
]
}
复制代码
其中 "modules": false
的含义是关闭 Babel 的模块转换功能,保留原本的 ES6 模块化语法。
-
在
package.json
中添加sideEffects
属性:一些导入的文件都会被tree shaking,这意味着如果使用像
css-loader
这样的loader来解析CSS,需要将该后缀名的文件加入到sideEffects
中,避免被当成无用的代码删除了。
{
"name": "xxx",
"sideEffects": [
"*.css"
],
}
复制代码
- 在
production
中使用,即生产环境,webpack
在生产环境下默认启用了压缩及tree shaking
。
压缩CSS
安装css-minimizer-webpack-plugin
yarn add css-minimizer-webpack-plugin
复制代码
配置:
// webpack.prod.js
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
module.exports = {
// ...
optimization: {
// ...
minimizer: [new CssMinimizerPlugin()],
}
}
复制代码
CSS压缩只在生产环境下生效,如果想要在开发环境下使用,需要设置optimization.minimize
的值为true
。
提取CSS
使用optimization.splitChunks.cacheGroups
将CSS提取到一个单独的文件中,还需要用到mini-css-extract-plugin
插件,先安装依赖yarn add mini-css-extract-plugin
,以下是完整配置:
// webpack.prod.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
// ...
optimization: {
splitChunks: {
cacheGroups: {
styles: {
name: 'styles',
test: /\.css$/,
chunks: 'all',
enforce: true,
},
},
},
},
module: {
rules: [
{
test: /\.(scss|css)$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
options: {
publicPath: './',
},
},
{
loader: 'css-loader',
options: {
importLoaders: 1,
},
},
'postcss-loader',
],
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
chunkFilename: '[id].[contenthash].css',
ignoreOrder: false,
}),
]
}
复制代码
其它优化
图片优化
关于使用图片的格式选择,如果是小图标,需要透明底的,建议使用.png
g格式,而颜色丰富的大图建议使用.jpg
,可以以较小的空间保证图片的质量。
推荐一个软件ImageOptim
,可以帮助我们几乎无所压缩图片,可以节省20~80%
的空间,通过ImageOptim
优化完成之后,大大地降低打包的时间。
总结
到这里我们的优化工作就告一段落,截至目前,在开发环境的编译时间是3.37s
生产环境的编译时间是5.21s
,bundle size: 687.11KB
总体下来还是取得了一定成果,相信大家根据以上步骤选择适合自己项目的优化项,也能取得一个不错的结果,另外我将这个项目制作成了一个脚手架,可以点击此处查看源码,感觉还不错的话可以给个star鼓励一下。
TODO
- 将脚手架做成一个
npm cli
部署到npm上