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
posted @ 2026-09-24 20:01  HuangBingQuan  阅读(13)  评论(0)    收藏  举报