Webpack 从基础入门到常见优化
导语:本文主要内容为 webpack 基本配置中常见的 loader 及 plugin,打包体积及速度的优化。如果以上内容你已经熟知,完全不必浪费时间再看这篇文章啦。
一点点历史
在 webpack 之前,前端工具也有不少,从 grunt 到 gulp, 从 browserify 再到 webpack、rollup、parcel。虽然 webpack 有让人吐槽的地方,但目前它的确是最最流行的打包工具。如果喜欢考古的话,可以看一下 Vue 最初的版本,就是通过 grunt 来构建的。
webpack 基础配置
// webpack.config.js
module.exports = {
// webpack4 版本升级后宣称零配置,entry 和 output 有默认配置,可省略
entry: './src/index.js', // 打包的默认入口文件,可省略
output: './dist/main.js', // 打包后的默认输出,可省略
mode: 'production', // 以何种环境打包,可省略
module: {
rules: [ // loader 配置:test 指定规则,use 指定 loader
{test: /\.js$/, use: 'babel-loader'}
]
},
plugins: [ // 插件配置
]
};
上面是一个最简单的配置文件,如果不需要对文件进行特殊处理的话,我们甚至可以不需要进行任何配置。webpack 默认仅支持 js 及 json 文件的处理,所以当我们需要处理其他文件,或者需要对 js 文件进行一些特殊处理(比如转义、压缩)的时候,就需要 loader 和 plugin 了。
常见 loader
loader 可以被当成为一个函数,输入一个资源,经过处理之后,返回新的资源。
| 名称 | 描述 |
|---|---|
| babel-loader | 转换ES6语法 |
| ts-loader | ts语法转换为js语法 |
| less-loader | less语法转换为css语法 |
| css-loader | css文件的加载和解析 |
| file-loader | 图片、字体等静态资源打包 |
| thread-loader | 多进程打包 |
常见 plugins
plugins 作用于构建过程的生命周期,可实现对打包资源的优化、资源管理、环境变量注入等功能。
| 名称 | 描述 |
|---|---|
| CleanWebpackPlugin | 清理构建目录 |
| ExtractTextWebpackPlugin | 将css提取为独立文件 |
| CopyWebpackPlugin | 把文件拷贝到输出目录 |
| HtmlWebpackPlugin | 使用html模板承载输出的 bundle 文件 |
| UglifyjsWebpackPlugin | js 压缩 |
| ZipWebpackPlugin | 将打包后的资源压缩为 zip 包 |
不同 mode 下的优化
mode 有三种模式:development、production、none
| 模式 | 描述 |
|---|---|
| development | 设置process.env.NODE_ENV的值为development。 开启 NamedChunksPlugin 和NamedModulesPlugin 方便调试。 |
| production | 会将 process.env.NODE_ENV 的值设为 production。 启用 FlagDependencyUsagePlugin(编译时标记依赖), FlagIncludedChunksPlugin(标记子chunks,防子chunks多次加载), ModuleConcatenationPlugin(预编译所有模块到一个闭包中), NoEmitOnErrorsPlugin(在输出阶段时,遇到编译错误跳过), OccurrenceOrderPlugin(给经常使用的ids更短的值), SideEffectsFlagPlugin(安全地删除未用到的 export 导出) , UglifyJsPlugin(代码压缩) |
| none | 无任何优化 |
文件指纹策略
| 策略 | 描述 |
|---|---|
| hash | 和整个项目构建相关,只要项目文件有修改,整个构建的hash都会更改 |
| ChunkHash | 和webpack打包的chunk 有关,不同的entry回生成不同的 chunkhash |
| ContentHash | 根据文件内容来定义 hash,文件内容不变,则 ContentHash不变 |
根据以上描述,在打包不同的文件时,应该也就能做出合适的选择了: js 文件指纹使用 chunkhash;因为在入口改变的时候,hash 改变,可以改变缓存; css 文件使用 contenthash;如果使用 chunkhash 的话,那么如果我们在 js 文件中引入了 css,即使 css 内容没有更改,在改变 js 的时候,hash 值也会变更; 图片、字体资源使用hash;
js的hash 设置比较简单,只需要设置出口文件的占位符即可:
// js 文件配置
entry: {
index:'./src/main.js',
},
output: {
path:path.join(__dirname,'dist')
filename:'[name][chunkhash:8].js'
}
如果是将 css 文件通过 style-loader 导入到页面中,是不需要设置hash的;只有当把 css 文件单独导出的时候,才需要配置 hash。
// css 文件配置
plugins:[
new MiniCssExtractPlugin({
filename:'[name]_[contenthash:8].css'
})
]
图片资源一般需要 配合 file-loader 或者 url-loader 来进行解析,file-loader 在配置文件名称的时候,提供了丰富的占位符配置:
file-loader 的name配置:
| 占位符 | 含义 |
|---|---|
| ext | 资源后缀名 |
| name | 文件名称 |
| path | 文件相对路径 |
| folder | 文件所在文件夹 |
| hash | 文件内容的hash,默认 md5 生成 |
| emoji | 随机的指代文件内容的emoji |
// 图片资源配置
rules:[
{
test:/\.(jpg|png|gif|jpeg)$/,
use:{
'file-loader',
options:{
name:'img/[name]_[hash:8].[ext]'
}
}
}
]
source-map的使用
| 关键字 | 含义 |
|---|---|
| eval | 使用 eval 包裹代码 |
| source-map | 生成.map文件 |
| cheap | 不包含列信息,也不包括loader的source-map |
| module | 包括loader的sourcemap |
| inline | 将.map作为DataURL嵌入,不单独生成.map文件 |
根据需求不同,开发环境下可选择的 source-map:
eval - 每个模块都使用 eval() 执行,并且都有 //@ sourceURL。此选项会非常快地构建。主要缺点是,由于会映射到转换后的代码,而不是映射到原始代码(没有从 loader 中获取 source map),所以不能正确的显示行数。
eval-source-map - 每个模块使用 eval() 执行,并且 source map 转换为 DataUrl 后添加到 eval() 中。初始化 source map 时比较慢,但是会在重新构建时提供比较快的速度,并且生成实际的文件。行数能够正确映射,因为会映射到原始代码中。它会生成用于开发环境的最佳品质的 source map。
cheap-eval-source-map - 类似 eval-source-map,每个模块使用 eval() 执行。这是 “cheap(低开销)” 的 source map,因为它没有生成列映射(column mapping),只是映射行数。它会忽略源自 loader 的 source map,并且仅显示转译后的代码,就像 eval devtool。
cheap-module-eval-source-map - 类似 cheap-eval-source-map,并且,在这种情况下,源自 loader 的 source map 会得到更好的处理结果。然而,loader source map 会被简化为每行一个映射(mapping)。
生产环境下可选择的 sourcemap:
(none)(省略 devtool 选项) - 不生成 source map。如果不需要记录生产环境的脚本错误,这是一个不错的选择。
hidden-source-map - 与 source-map 相同,但不会为 bundle 添加引用注释。如果你只想 source map 映射那些源自错误报告的错误堆栈跟踪信息,但不想为浏览器开发工具暴露你的 source map,这个选项会很有用。
webpack 优化
优化主要从两方面来体现,即打包速度、打包体积。
打包速度一般通过 speed-measure-webpack-plugin 这个插件来统计,这个插件用起来十分简单,一般通过包裹起webpack 的默认配置即可:
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();
const webpackConfig = smp.wrap({
plugins: [
new MyPlugin(),
new MyOtherPlugin()
]
});
运行后,可以根据不同 loader 消耗的时间,做出针对性的优化。

打包体积一般通过 webpack-bundle-analyzer 插件来统计,可以非常方便地统计第三方模块文件地大小及业务组件代码地大小。使用也非常简单,并且提供了丰富的配置选项:
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
]
}
减小打包体积
减小打包体积最直接地手段是对打包资源进行压缩:
js 资源:webpack 在 production 模式下默认进行压缩;
css 资源:使用 optimize-css-assets-webpack-plugin 配合 mini-css-extract-plugin 抽离 css 资源并且压缩;
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = {
// ...
module: {
rules: [
{
test: /\.less$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader', {
loader: 'postcss-loader',
options: {
plugins: () => [
require('autoprefixer')(),
],
},
}],
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name]_[contenthash:8].css',
}),
new OptimizeCssAssetsPlugin({
assetNameRegExp: /\.css$/g,
cssProcessor: require('cssnano'),
}),
],
};
- 图片资源压缩:量少地时候可以通过 tinypng 等网站进行压缩,量大地时候,可以使用image-webpack-loader。此外,图片也可以通过懒加载技术(不同的框架都有对应的实现),来显著减少不必要的请求;
rules: [{
test: /\.(gif|png|jpe?g|svg)$/i,
use: [
// loader 地执行顺序为从右到左
'file-loader',
{
loader: 'image-webpack-loader',
options: {
mozjpeg: {
progressive: true,
quality: 65
},
// optipng.enabled: false will disable optipng
optipng: {
enabled: false,
},
pngquant: {
quality: [0.65, 0.90],
speed: 4
},
gifsicle: {
interlaced: false,
},
// the webp option will enable WEBP
webp: {
quality: 75
}
}
},
],
}]
- html:使用 html-webpack-plugin 压缩并且删除注释;当然,html-webpack-plugin 地作用不止于此,可查看官方文档解锁更多功能:
new HtmlWebpackPlugin({
template: path.join(__dirname, 'src/template/index.html'),
filename: 'index.html',
inject: true,
minify: {
html5: true,
collapseWhitespace: true,
preserveLineBreaks: false,
minifyCSS: true,
minifyJS: true,
removeComments: true,
},
}),
- 资源内联:除了压缩之外,也可以考虑通过使用 style-loader 内联 css,减少请求,避免页面闪动;通过 raw-loader 来内联页面初始化脚本,raw-loader 也可以被用来引入 html 模板:
<%= require('raw-loader!./meta.html')} %>
使用 SplitChunks 分离公共脚本,虽然不会显著减小包的体积,但是通过抽离出基本不会变更的公共代码,能够更好地利用缓存——即使业务代码变更,公共代码可继续使用缓存;webpack4 中已经默认支持了这项功能,无需引用插件,chunks 参数说明:
- async 异步引入的库进行分离(默认);
- initial 同步引入的库进行分离;
- all 所有引入的库进行分离
- 更多参数说明请查看文档;
optimization: {
splitChunks: {
chunks: 'all',
minSize: 30000,
maxSize: 0,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
automaticNameDelimiter: '~',
name: true,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10,
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
},
- tree shaking
webpack production 模式下默认支持 js tree shaking;只支持 ES6 模块;有副作用的代码不会被优化(比如在函数的prototype 属性上添加方法,即使方法没有被使用到,也不会被优化);通过以上描述,可以发现, tree shaking 的部分优化,其实在写代码的时候,通过 eslint 等工具也可以规避。会被优化的代码分以下三种情况:
- 代码不会被执行;
- 代码执行的结果不会被用到;
- 代码只写不读;
// foobar.js
let a = 'aaa'; // 会被清除掉,因为只定义,未使用
export function foo() {}
export function bar() {} // 会被清除掉,因为不会被执行
// index.js
import {foo} from './foobar.js';
foo();
清除无用css代码可使用Purgecss
scope hosting原理:将所有模块代码按照引用顺序放在同一个函数中,重命名变量防止变量名冲突。production 模式下默认开启,只支持 ES6 模块;如果不开启的话,每一个模块都会被不同的函数包裹,导致打出来的包较大;
加快打包速度
- 使用最新版本的 Node 和 webpack。优化做了那么多,一顿操作猛如虎,回头一看,提升的速度,可能没有点下升级的大。但需要注意的是,在升级 webpack 的时候,某些插件可能回不兼容。
- 多进程打包。之前比较流行的Happy Pack,但作者已经没有太大的兴趣继续维护这个库了,并推荐了Thread Loader
module.exports = {
module: {
rules: [
{
test: /\.js$/,
include: path.resolve("src"),
use: [
"thread-loader",
// your expensive loader (e.g babel-loader)
]
}
]
}
}
- 使用TerserPlugin开启并行压缩代码
module.exports = {
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
}),
],
},
};
- 使用HtmlWebpackExternalsPlugin分离基础包,优点是基础包不会被打包进 bundle,但如果基础包增加的话,需要手动添加入口;
new HtmlWebpackExternalsPlugin({
externals: [
{
module: 'jquery',
entry: 'https://unpkg.com/jquery@3.2.1/dist/jquery.min.js',
global: 'jQuery',
},
],
})
- 使用DLLPlugin进行分包,配合DLLReferencePlugin进行对 manifest.json 引用,实现预编译资源模块。
// webpack.dll.js
// package.json 中加入脚本:"dll": "webpack --config webpack.dll.js"
module.exports = {
entry: {
libary: ['react', 'react-dom']
},
output: {
filename: '[name]_[chunkhash].dll.js',
path: path.join(__dirname, 'build/libary'),
libary: '[name]'
},
plugins: [
new webpack.DllPlugin({
name: '[name]_[hash]',
path: path.join(__dirname, 'build/libary/[name].json'),
});
]
};
// webpack.prod.js
module.exports = {
// ...
plugins: [
new webpack.DllReferencePlugin({
manifest: require('./build/libary/libary.json'),
});
],
}
使用缓存提升二次构建速度
- babel-loader 缓存避免在每次执行时,可能产生的、高性能消耗的 Babel 重新编译过程:
loader: 'babel-loader?cacheDirectory' - terser-webpack-plugin 开启压缩缓存:
module.exports = {
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
cache: true,
}),
],
},
};
module.exports = {
context: // ...
entry: // ...
output: // ...
plugins: [
new HardSourceWebpackPlugin()
]
}
缩小构建目标
- 通过使用
exclude和include来减少需要构建的模块:
exclude: 'node_modules'
- 减少文件搜索范围:
- 优化 resolve.modules 配置(减少模块搜索层级);
- 优化 resolve.mainFields 配置;
- 优化 resolve.extensions 配置;
- 合理使用 resolve.alias;
module.exports = {
resolve: {
alias: {
react: path.resolve(__dirname, './node_modules/react/dist/react.min.js'), // 直接精准定位,无需搜索
},
modules: [path.resolve(__dirname, 'node_modules')], // 只在当前目录下搜索
extensions: ['.js'], // 扩展名不宜过长,默认会查询 js、json;extensions 越多搜索时间越长;
mainFields: ['main'], // 入口文件
}
};
动态引入 polyfill
为了让不同的浏览器都能支持 map、set、promise 等语法,我们在项目中通常需要引入 babel-polyfill;但是完全引入的话,代价有些大;况且对于使用高级浏览器的用户来说,平白无故地耗费流量,肯定也是不开心的。为了极致优化的话,可以使用动态 polyfill 服务。这项服务是通过判断 User-Agent 来返回对应的 polyfill;考虑到国内浏览器环境比较复杂,各种魔改,动态 polyfill 是否可以直接应用,还是要打一个问号的。
- 动态
import代码懒加载
这样虽然不能减小包的体积,但是能保证代码只有在被使用的时候才会被加载,实现懒加载的效果。首先,安装babel插件
yarn add --dev @babel/plugin-syntax-dynamic-import
然后配置 .babelrc
{
"plugins": [" @babel/plugin-syntax-dynamic-import"]
}
多页面打包
某些情况下,根据业务需求,需要进行多页面打包。其基本思路是每个页面对应一个 entry 和一个 html-webpack-plugin 配置,但这种实现方式,添加页面时需要手动去修改配置。如果做好约定,可以通过 glob 来自动匹配多文件页面:
const glob = require('glob');
const setMPA = () => {
const entry = {};
const entryFiles = glob.sync(path.join(__dirname, './src/*/index.{js,jsx}'));
const htmlWebpackPlugins = [];
Object.keys(entryFiles).forEach((index) => {
const entryFile = entryFiles[index];
// 这里需要约定好,src/*/ 目录下的 index.js 文件作为入口
const match = entryFile.match(/src\/(.*)\/index\.js/);
const pageName = match && match[1];
entry[pageName] = entryFile;
htmlWebpackPlugins.push(
new HtmlWebpackPlugin({
template: path.join(__dirname, `src/${pageName}/index.html`),
filename: `${pageName}.html`,
chunks: ['vendors', pageName],
inject: true,
minify: {
html5: true,
collapseWhitespace: true,
preserveLineBreaks: false,
minifyCSS: true,
minifyJS: true,
removeComments: true,
},
}),
);
});
return {
entry, // 多入口
htmlWebpackPlugins, // 多页面
};
};
const { entry, htmlWebpackPlugins } = setMPA();
module.exports = {
entry,
output: {
path: path.join(__dirname, 'dist'),
filename: '[name]_[chunkhash:8].js',
},
plugins:[
// 其他插件配置
].concat(htmlWebpackPlugins),
};