DLL 预构建(Webpack DllPlugin)深度解析
一句话总结:DLL 预构建是把"几乎不变的第三方依赖"提前单独打包,让日常编译跳过它们,从而加速开发环境的一项老技术。它不适合生产环境,且已被 Webpack 5 持久化缓存和 Vite/Rspack 等新一代工具取代——但它的思想以"依赖预构建"的形式活在了 Vite 里。
1. 它解决什么问题?
开发过程中,每一次修改代码,构建工具都要重新编译。
痛点: react、vue、lodash、echarts 这类第三方库:
- 代码量大(动辄几万行)
- 在开发过程中几乎 100% 不会变动
如果每次保存代码都要重新解析、打包这些庞然大物,dev server 的冷启动和热更新(HMR)都会慢得难以忍受。
思路: 既然这些库"万年不变",那就提前打包一次,之后每次编译直接跳过它们。
2. 工作原理
DLL 方案分两步走,核心是 两个插件 + 一份清单:
第一次构建(DLL 构建):
第三方库入口 ──DllPlugin──> vendor.dll.js (打包好的库)
└> vendor.manifest.json (模块映射清单)
日常开发构建(主构建):
业务代码 ──DllReferencePlugin──> 读取 manifest.json
└─ 命中清单的模块 → 直接引用 dll 产物,跳过编译
└─ 未命中的模块 → 正常编译
最后:HTML 中通过 <script> 手动注入 vendor.dll.js
(通常配合 add-asset-html-webpack-plugin 自动注入)
- DllPlugin:把第三方库打成一个全局变量暴露的 bundle,并生成
manifest.json(记录"模块路径 → DLL 内部模块 ID"的映射)。 - DllReferencePlugin:主构建时,Webpack 遇到
import 'react'先查清单,命中就直接映射到 DLL 里的模块,不再解析 node_modules 源码。
3. 为什么它只属于开发环境?
✅ 开发环境的收益
| 场景 | 收益 |
|---|---|
| dev server 冷启动 | 第三方库只打包一次,启动提速明显 |
| HMR 热更新 | 每次保存跳过几万行库代码的解析,更新更快 |
❌ 生产环境的三大硬伤
1)Tree Shaking 失效 → 体积变大
DLL 在独立的构建上下文中打包,它不知道业务实际用了库的哪些部分,只能按入口全量打入;且打包成单文件后模块边界丢失,主构建也无法再做摇树优化。
生产环境追求极致体积:只用 lodash 的 1%,Tree Shaking 可以删掉剩下 99%——用了 DLL,这个优化就彻底失效了。
2)缓存粒度太粗
把所有第三方库打进一个巨大的 dll.js:升级其中一个小依赖 → 整包 hash 变化 → 所有用户重新下载整个文件。
注:这其实是"任何单一大 vendor 包"的通病,并非 DLL 独有(早期它反而靠"vendor 不变"蹭缓存)。真正的差距在于:现代
SplitChunksPlugin可以按包、按路由细粒度拆分 + contenthash,缓存利用率远高于 DLL 的单体大包。
3)生产构建是低频事件
生产打包通常在 CI/CD 中跑一次,属于"低频事件"。我们愿意用"多花几分钟编译"换"更小的线上体积 + 更好的缓存策略"。
附:开发环境使用 DLL 的代价(也是它口碑下滑的原因):
- 多维护一套 DLL 构建配置
- 新增/升级依赖必须手动重跑 DLL 构建,忘了会报各种莫名错误
- 需要额外插件往 HTML 注入
<script>,构建链路变复杂
4. 为什么现在被淘汰了?
DLL 技术风靡于 Webpack 3 及以前的时代。如今它的生态位已被更优方案覆盖:
| 方案 | 原理 | 对比 DLL |
|---|---|---|
| Webpack 5 持久化缓存 | cache: { type: 'filesystem' } |
二次构建速度不输 DLL,且零配置 |
| Vite | esbuild(Go)做依赖预构建 optimizeDeps |
启动秒级,开箱即用 |
| Rspack | Rust 实现整个打包器 | 构建速度快一个数量级 |
一个值得注意的点:思想没有消亡
Vite 的依赖预构建本质上就是"自动化、零配置的 DLL":
- 同样把
node_modules中不变的依赖提前打包(用 esbuild,快 10-100 倍) - 同样生成一份"预构建产物 + 映射"供开发服务器直接使用
- 但它自动发现依赖、自动缓存、hash 失效自动重构建,完全不需要手动维护 manifest 和注入 script
所以准确的说法是:手动的 DLL 配置被淘汰了,但"依赖预构建"的思想被继承并发扬光大了。
5. DLL 预构建总结
** 什么是 DLL 预构建?为什么现在很少用了?**
DLL 是 Webpack 的 DllPlugin 方案,把 react、lodash 这类不变的第三方库提前单独打包成 dll 文件和 manifest 清单,开发时通过 DllReferencePlugin 跳过对这些库的重复编译,主要解决开发环境的构建速度问题。它不适合生产环境,因为会破坏 Tree Shaking 导致体积变大,单体大包的缓存粒度也太粗。现在少用是因为:Webpack 5 的文件系统持久化缓存零配置就能达到类似效果,而 Vite 用 esbuild 做的依赖预构建,本质上就是自动化、零配置的 DLL,速度还快得多。
6. 参考配置(存档用)
// webpack.dll.config.js —— 单独执行一次:webpack --config webpack.dll.config.js
const path = require('path');
const webpack = require('webpack');
module.exports = {
mode: 'production',
entry: {
vendor: ['react', 'react-dom', 'lodash'], // 需要预构建的库
},
output: {
path: path.join(__dirname, 'dist/dll'),
filename: '[name].dll.js',
library: '[name]_dll', // 暴露的全局变量名
},
plugins: [
new webpack.DllPlugin({
name: '[name]_dll',
path: path.join(__dirname, 'dist/dll', '[name].manifest.json'), // 生成清单
}),
],
};
// webpack.config.js —— 开发环境主配置中引用
const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin');
module.exports = {
plugins: [
new webpack.DllReferencePlugin({
manifest: require('./dist/dll/vendor.manifest.json'), // 查清单,命中则跳过编译
}),
new AddAssetHtmlPlugin({
filepath: path.resolve(__dirname, 'dist/dll/vendor.dll.js'), // 自动注入 <script>
}),
],
};
7. FAQ:为什么要配置两个文件?
很多人第一次看到 DLL 方案都会疑惑:为什么要写 webpack.dll.config.js 和 webpack.config.js 两份配置、用两个插件?
核心答案:DLL 的提速原理就是"把库的构建和业务的构建拆开",所以必须有两个配置、两个插件——一个负责"生产清单",一个负责"消费清单"。
第一次构建(低频,仅升级依赖时跑) webpack --config webpack.dll.config.js
┌──────────────────────────────────────────────┐
│ entry: ['react', 'react-dom', 'lodash'] │
│ ↓ DllPlugin(写清单的人) │
│ 产出①: vendor.dll.js ← 库代码本体 │
│ 产出②: vendor.manifest.json ← 模块映射清单 │
└──────────────────────────────────────────────┘
第 N 次构建(高频,每次开发/HMR 都跑) webpack --config webpack.config.js
┌──────────────────────────────────────────────┐
│ 业务源码中 import 'react' │
│ ↓ DllReferencePlugin(读清单的人) │
│ 查 manifest:命中 → 跳过编译, │
│ 映射到 vendor.dll.js 里的模块│
│ ↓ AddAssetHtmlPlugin │
│ 把 vendor.dll.js 注入 HTML 的 <script> │
└──────────────────────────────────────────────┘
- DllPlugin 管"写":打包库的同时,把"模块路径 → DLL 内部模块 ID"的对应关系记进 manifest
- DllReferencePlugin 管"读":业务编译时遇到
import 'react',先查 manifest,命中就直接指向上次打好的产物,不再碰 node_modules 源码
为什么不能合并成一次构建?
如果两个插件放进同一次构建,意味着每次编译还是要把 react、lodash 重新打一遍——DLL 的加速点就彻底消失了。它的收益恰恰来自:
| DLL 构建 | 主构建 | |
|---|---|---|
| 执行频率 | 极低频(升级依赖才跑一次) | 高频(每次保存/HMR) |
| 编译内容 | 几万行第三方库 | 只有业务代码 |
拆开,库只编译一次;合并,每次都编译。"两个配置"不是冗余设计,而是这个方案能提速的原因本身。
类比:每天煮面前先熬一锅高汤放冰箱(DLL 构建),之后每次煮面直接舀一勺(主构建)。熬汤和煮面必须是两件事——如果每天煮面都重新熬汤,"提前熬汤"就没有意义了。
两个补充认知
1)这就是 DLL 维护成本的根源。 两个独立构建的产物(vendor.dll.js、manifest、HTML 里注入的 script)必须版本一致。重跑了 DLL 但 HTML 还引用旧文件,运行时就会出现 vendor_dll is not defined 之类的错误。这种"两个构建要靠人肉保持同步"的心智负担,正是它被 Webpack 5 缓存和 Vite 淘汰的原因之一。
2)技术上能写在一个文件里,但没有意义。 Webpack 支持 module.exports = [dllConfig, appConfig] 数组形式,但那只是"两个构建写在同一个文件",依然会跑两遍完整的构建流程,提速效果归零。所以社区惯例就是拆成两个文件、两条命令。
8. FAQ:方案中涉及的插件分别解决什么问题?
DLL 相关的讨论中一共会出现四个插件。其中三个属于 DLL 体系,一个(DefinePlugin)是顺带引入的"局外人"。
DefinePlugin —— 编译时的"全局查找替换"(局外人)
身份:不属于 DLL 体系。它是 mode: 'production' 内部自动开启的预设插件之一——聊"DLL 构建为什么用 production 模式"时必然会碰到它,因为替换 process.env.NODE_ENV 这个活的具体执行者就是它。
干什么:在编译时把代码中的标识符原样替换为指定字面量。最经典的用法:
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify('production'),
});
解决什么问题:浏览器里没有 process.env,但 react 等库是 Node 风格写法,靠 process.env.NODE_ENV 区分环境:
// react/index.js
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/react.production.min.js');
} else {
module.exports = require('./cjs/react.development.js');
}
替换后条件变成 if ("production" === 'production')——常量条件让 webpack 能在编译期静态求值,死代码分支直接不打包。这就是"react 的 dev 代码整块消失"的底层机制。
一句话:把"运行时的环境判断"提前到"编译时"完成,为死代码消除创造条件。mode: 'production' 本质上就是 DefinePlugin + TerserPlugin 等一组预设的快捷方式。
为什么在配置文件里看不到 DefinePlugin?
因为 mode 是"自动挡"——它是预设开关,DefinePlugin 是被折叠在幕后的执行者之一。各档位暗中开启的内容:
| mode | 暗中开启的内容 |
|---|---|
production |
DefinePlugin(NODE_ENV→production)、TerserPlugin 压缩、tree-shaking 相关(sideEffects/usedExports)、ModuleConcatenationPlugin(作用域提升)、确定性模块 id |
development |
DefinePlugin(NODE_ENV→development)、可读的模块命名(路径名代替数字 id)、eval 风格 source map |
none |
什么都不开,全部自己配 |
什么时候才会亲手写它:需要注入自己的全局常量时,例如:
new webpack.DefinePlugin({
'process.env.BASE_API': JSON.stringify('https://api.example.com'),
__APP_VERSION__: JSON.stringify('1.2.0'),
});
经典坑:值必须 JSON.stringify。 DefinePlugin 是原样文本替换:
// ❌ 'process.env.NODE_ENV': 'production'
// 替换后:if (production === 'production') ← production 是未定义变量,运行时报错
// ✅ 'process.env.NODE_ENV': JSON.stringify('production') (即 '"production"')
// 替换后:if ("production" === 'production') ← 字符串字面量,正确
DLL 体系三插件:用"缺了它会怎样"理解
| 插件 | 所属构建 | 干什么 | 缺了它会怎样 |
|---|---|---|---|
| DllPlugin | DLL 构建 | 打包库的同时生成 manifest.json 清单 | 主构建无从得知哪些模块已预打包,清单体系不成立 |
| DllReferencePlugin | 主构建 | 读清单,命中的模块跳过编译、映射到 dll 产物 | 库被正常重新打包,DLL 白做,提速归零 |
| AddAssetHtmlPlugin | 主构建 | 把 vendor.dll.js 以 <script> 注入生成的 HTML |
浏览器压根没加载 dll 文件,运行时报 vendor_dll is not defined |
为什么 AddAssetHtmlPlugin 是必要的细节:html-webpack-plugin 默认只注入本次构建产出的 chunk,而 vendor.dll.js 是"上一次构建"(DLL 构建)的产物,不在本次构建的文件列表里,必须靠它手动塞进 HTML。
四个插件串成完整链路
DefinePlugin → 决定库以什么形态(dev/prod)进入产物
DllPlugin → 打包库 + 写清单(manifest.json)
DllReferencePlugin → 主构建读清单,命中则跳过编译
AddAssetHtmlPlugin → 把 dll 产物注入 HTML,让浏览器加载到
9. 总结对比表
| 维度 | 开发环境 | 生产环境 |
|---|---|---|
| 核心目标 | 编译快、HMR 快 | 体积小、缓存好 |
| DLL 收益 | ✅ 跳过第三方库重复编译 | ❌ 无收益 |
| DLL 代价 | 配置维护成本 | Tree Shaking 失效、缓存粒度粗 |
| 现代替代 | Webpack5 cache / Vite / Rspack | SplitChunks + contenthash |

浙公网安备 33010602011771号