接手一个老项目,webpack.config.js 三百多行,动一下 loader 的顺序整个构建就崩,报错信息还看不出是哪一层出的问题。这种时候光会抄配置是不够的,你得知道每个字段作用在构建流程的哪一段。这篇是我当年把 webpack 从头过一遍时留下的笔记,后来陆续补过几轮,把版本演进、四个核心概念、loader 链、代码分割、CSS 处理、HMR、SourceMap、长缓存和多页面配置串成了一条线。
原文写的时候基线是 webpack 3,现在线上主流已经是 webpack 5。老写法我一个字没删,历史记录本身有价值,看老项目也用得上;每处过时的地方我另起一小段写清楚现在该怎么做,以及为什么变了。
这篇偏「概念与配置项全景」,也就是每个字段是什么、怎么填。如果你要的是「从零跑通一套能用的开发环境」那种手把手流程,看这篇的姊妹篇 webpack4 定制前端开发环境,那边是按 Step 走的实战流水线。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- webpack 从 V1 到 V3 每一代补上了什么能力,怎么判断一份老配置停在哪一代
- Entry、Output、Loader、Plugin 四个核心概念,以及 Chunk / Bundle / Module 的区别
- Babel、TypeScript 在 webpack 里的接法,polyfill 的两种垫片选型
CommonsChunkPlugin提取公共代码,以及它在 webpack 4 之后被谁取代require.ensure与import()两种代码分割写法的差异- CSS 的完整处理链,
style-loader/css-loader/ 预处理器 / PostCSS / 提取成文件 - JS 与 CSS 两条 Tree shaking 路线,以及为什么有些库摇不动
- 图片、字体、第三方库这三类文件的处理套路
- dev server、proxy、HMR、SourceMap、ESLint 这套开发期能力怎么配
- 长缓存优化和多页面应用的两种组织方式
# 一、webpack 的版本演进
# 1.1 版本更迭
先看一眼从 0.x 到 3.x 这条时间线。知道每个大版本是什么时候出来的,以后看到一份陌生的老配置,扫一眼用了哪些插件就能判断它停在哪一代。

下面这张是各个大版本之间的能力变化,重点看每一代往上加了什么。

# 1.2 功能进化
把上面两张图里的信息拆开说,每一代的关键词其实很清楚。
Webpack V1
- 编译、打包
HMR(模块热更新)- 代码分割
- 文件处理
V1 把「模块化 + 打包」这件事做成了,HMR 和代码分割在那个年代是相当超前的能力。
Webpack V2
Tree ShakingES module- 动态
Import - 新的文档
V2 最重要的动作是原生支持 ES module。有了静态的 import / export,Tree Shaking 才有分析的基础,这是后面所有产物体积优化的地基。
Webpack V3
Scope Hoisting(作用域提升)Magic Comments(配合动态import使用)
V3 的 Scope Hoisting 把能合并的模块塞进同一个函数作用域,减少了运行时的闭包包裹,产物小一点、启动快一点。Magic Comments 就是 import() 里那段 /* webpackChunkName */ 注释,后面代码分割那节会用到。
版本迁移
V1 -> V2
迁移指南 https://doc.webpack-china.org/guides/migrating/
V2 -> V3
更新升级即可
顺着上面聊一下现在的情况。V3 之后 webpack 4 引入了 mode 和 optimization.splitChunks,webpack 5 又带来了持久化缓存、Asset Modules 和 Module Federation。也就是说这篇里凡是出现 webpack.optimize.* 系列内置插件的地方,在 webpack 5 上都已经不是推荐写法了,我会在对应章节挨个说明。如果你要的是 webpack 5 那一套构建性能配置,我单独写过一篇 Webpack 5 构建性能优化实战。
# 二、webpack 核心概念
webpack 的配置项看着多,骨架其实就四块,Entry 说从哪进,Output 说往哪出,Loader 管「这类文件怎么翻译成模块」,Plugin 管「构建过程中还要顺手干点什么」。剩下几十个字段都是挂在这四块上的细节。先把这四块的分工关系理顺,后面看任何一份配置都不会慌。
# 2.1 Entry
- 代码的入口
- 打包的入口
- 单个或多个
Entry 是整张依赖图的根。webpack 从这里开始读文件、解析 import、递归往下走,最后得到一张完整的模块依赖图。一个入口对应一条独立的依赖链,多入口就是多张图(有交集的部分后面靠代码分割去合并)。
写法上有四种,我建议直接用键值对那种,理由在下面。
最简写法,一个字符串就是一个入口,webpack 会给它一个默认名字 main。
module.exports = {
entry: 'index.js'
}
传数组表示「把这几个文件打进同一个 bundle」,它们仍然算一个入口,只是这个入口有多个起始文件。常见用途是把 polyfill 塞在业务代码前面。
module.exports = {
entry: ['index.js','vendor.js']
}
键值对写法,index 这个 key 会成为 chunk 的名字,后面 output.filename 里的 [name] 占位符取的就是它。
module.exports = {
entry: {
index:'index.js'
}
}
前两种能力合起来,多个入口、其中某个入口还有多个起始文件。
module.exports = {
entry: {
index:['index.js','app.js'],
vendor: 'vendor.js'
}
}
为什么建议一律用键值对写法?因为字符串和数组写法拿不到 chunk 名,产物文件全叫 main,一旦要做多入口或者按 chunk 拆包,你得回头把配置改一遍。一开始就写成对象,后面加入口只是多一行的事。
# 2.2 Output
- 打包成的文件(
bundle) - 一个或多个
- 自定义规则
- 配合
CDN
Output 决定产物落在哪、叫什么名字。它和 Entry 是对应关系,几个入口原则上就会产出几个 bundle,再加上代码分割出来的若干 chunk 文件。
单入口的时候写死文件名就行。
module.exports = {
entry: 'index.js',
output: {
filename: 'index.min.js'
}
}
多入口就必须用占位符,不然两个入口会往同一个文件名上写。[name] 取的是 entry 的 key,[hash:5] 是本次构建的哈希取前 5 位,用来做浏览器缓存的破缓存标记。
module.exports = {
entry: {
index: 'index.js',
vendor: 'vendor.js'
},
output: {
filename: '[name].min[hash:5].js'
}
}
这里有个坑要注意,[hash] 是「整次构建」的哈希,只要有任意一个文件变了,所有产物的文件名都会一起变,缓存全部失效。真要做长期缓存得换成 [chunkhash],第六节讲长缓存优化时会专门展开。
output.publicPath 这个字段这里先提一句,它决定运行时去哪个前缀下取异步 chunk 和静态资源,把它指向 CDN 域名就是最常见的「配合 CDN」的做法。
# 2.3 Loaders
- 处理文件
- 转化为模块
webpack 自己只认 JS。CSS、图片、字体、TypeScript 这些它一概不懂,Loader 干的就是翻译工作,把非 JS 的东西转成 webpack 能塞进依赖图的模块。
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: 'css-loader'
}
]
}
}
test 是匹配条件,use 是命中之后交给谁处理。一条 rule 就是一句「凡是长这样的文件,走这条链」。
# 2.3.1 常用Loader
按职责分成三类记就行。
编译相关
babel-loaderts-loader
样式相关
style-loadercss-loaderless-loaderpostcss-loader
文件相关
file-loaderurl-loader
文件这一类现在有变化。webpack 5 内置了 Asset Modules,file-loader、url-loader、raw-loader 这三个都不用装了,直接在 rule 里写 type: 'asset/resource'(等价 file-loader)、type: 'asset/inline'(等价 url-loader 内联)或者 type: 'asset'(按体积自动二选一)。老项目升级 webpack 5 的时候,这几个 loader 记得从 package.json 里删掉,留着反而容易和内置行为打架。
# 2.4 Plugins
- 参与打包整个过程
- 打包优化和压缩
- 配置编译时的变量
- 极其灵活
Loader 只在「文件转模块」这一步起作用,Plugin 的口子大得多。webpack 内部跑的是一套基于 tapable 的钩子系统,从初始化、编译、生成 chunk 一直到写文件,每个环节都暴露了钩子,Plugin 挂在哪个钩子上就能在哪一步插手。想深入了解常用插件都干了什么,可以看 webpack 常用插件汇总。
module.exports = {
plugins: [
new webpack.optimize.UglifyJsPlugin()
]
}
# 2.4.1 常用plugins
优化相关
CommonsChunkPluginUglifyJsWebpackPlugin
功能相关
ExtractTextWebpackPlugin提取cssHtmlWebpackPlugin生成HTML模板HotModuleReplacementPlugin热模块替换CopyWebpackPlugin拷贝文件
这份清单里有三个已经换代了,先记个账,后面对应章节会细说。CommonsChunkPlugin 在 webpack 4 被 optimization.splitChunks 取代;UglifyJsWebpackPlugin 换成了 terser-webpack-plugin(Uglify 不支持 ES2015+ 语法,Terser 是它的 fork);ExtractTextWebpackPlugin 换成了 mini-css-extract-plugin。HtmlWebpackPlugin、HotModuleReplacementPlugin、CopyWebpackPlugin 这三个到现在还在用,只是版本和参数有调整。
# 2.5 名词
Chunk打包过程分割的代码块Bundle打包后的文件Module
这三个词在文档和报错信息里混着出现,分不清会很难受,我一开始也被绕过。Module 是源码层面的一个文件,一个 .js 一个 .css 都是 Module;Chunk 是构建过程中的中间产物,webpack 把若干 Module 按依赖关系归成一组,这组就是一个 Chunk;Bundle 是最终落盘的那个文件。