Webpack 5 打包原理详解

打开 webpack 5 的产物看一眼:__webpack_require__ 就是一张 ID 到函数的表。顺带说清 Tree Shaking 到底是谁在删代码,以及 5 起不再自动 polyfill Node 核心模块之后该怎么办。

预计
7 分钟

打开任何一个 webpack 5 的产物,前六十行都长得差不多。要认的只有两个名字:__webpack_modules__ 是一张「模块 ID → 函数」的表,__webpack_require__ 是按 ID 从表里取东西的那个函数。剩下的都围着这两样打转。

本文贴的产物都是 webpack 5.109.2 真跑出来的,配置是 mode: 'development'devtool: falseoptimization.minimize: false,两个源文件一共四行。你照着配能得到同一份,行号可能差几行,形状不会变。

模块系统就是一张表加一个函数

webpack 5 · 产物里的 __webpack_require__(去掉了每行的 /******/ 前缀)
// The module cache
const __webpack_module_cache__ = {};
// The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
const cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
// Create a new module (and put it into the cache)
const module = __webpack_module_cache__[moduleId] = {
// no module.id needed
// no module.loaded needed
exports: {}
};
// 省略:moduleId 不在表里时删掉缓存项并抛 MODULE_NOT_FOUND
// Execute the module function
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
// Return the exports of the module
return module.exports;
}

二十行里有三件事值得单独说。

缓存的单位是 exports 对象,不是模块的代码。 同一个模块被 import 十次,模块函数只跑一次,后面九次拿到的是同一个对象引用。所以模块顶层的 let count = 0 是全应用共享的一份 —— 单例模式在打包产物里是默认行为,不需要谁去实现。

高亮的第 12 行和第 21 行之间的顺序,是循环依赖不死循环的原因。 模块对象先放进缓存,然后才执行模块函数。A 引 B、B 又引回 A 的时候,B 里那次 __webpack_require__(A) 会命中缓存,拿到一个 exports 还是空对象的 A,不会再进一次 A 的函数体。这也解释了循环依赖典型的症状:不报错,只是拿到 undefined

模块作用域就是函数作用域。 每个模块被包成一个函数,webpack 的隔离机制只有这一招 —— 没有别的沙箱。

顺带认一个版本:网上大量标着「webpack 5」的文章贴的是 webpack 4 的运行时。 认法很简单,出现 installedModulesmodule.imodule.l = false 的是 4。webpack 5 的缓存叫 __webpack_module_cache__,模块对象上连 idloaded 都不建了 —— 产物注释里 no module.id neededno module.loaded needed 那两行,是 webpack 特意写给你看的。这两行是条件生成的:真有模块读了 module.idmodule.loaded,webpack 就把注释换成 id: moduleId,loaded: false, 这两个字段。也就是说产物里出现哪一种,取决于你的代码用没用到它们。

外壳不再是「把模块表当参数传进去的 IIFE」

webpack 4 的产物外形是 (function(modules){ … })({ "./src/index.js": function(…){…} }),模块表当实参传进去。webpack 5 换了:

webpack 5 · 单入口单 chunk 的产物骨架
/******/ (() => { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ({ /* 模块 ID → 函数,入口不在里面 */ });
/************************************************************************/
/******/ // __webpack_require__ 本体
/******/ // 以及按需生成的运行时片段:.d 定义导出、.r 打 __esModule 标记、.o 是 hasOwnProperty
/************************************************************************/
let __webpack_exports__ = {};
// This entry needs to be wrapped in an IIFE because it needs to be isolated against other modules in the chunk.
(() => {
// ./src/index.js 的代码直接写在这儿
})();
/******/ })()

入口模块这条容易漏:单入口单 chunk 时它不进 __webpack_modules__,webpack 把它内联在运行时后面直接执行。在产物里搜入口文件名,搜到的不是表里的一项。

__webpack_require__ 身上挂的那些单字母属性也不是固定的一套 —— webpack 按这次构建实际用到了什么才生成对应的运行时片段。产物里出现 __webpack_require__.d,是因为有 ESM 导出需要定义 getter;没有异步 chunk,就不会有 .e

Tree Shaking 只负责标记,删代码的是压缩器

这一节的两份产物值得自己跑一遍,因为结论和多数人的印象相反。源文件是这样,unused 没有任何地方 import:

src/math.js
export function add(a, b) { return a + b; }
export function unused(a) { return a * 999; }

mode: 'production'optimization.minimize: false,产物里 unused 原样活着:

production + minimize: false
/******/ (() => { // webpackBootstrap
/******/ "use strict";
;// ./src/math.js
function add(a, b) { return a + b; }
function unused(a) { return a * 999; }
;// ./src/index.js
console.log(add(1, 2));
/******/ })()

把压缩打开,同样两个文件变成一行:

production + 压缩
(()=>{"use strict";console.log(3)})();

所以 Tree Shaking 是两步走:webpack 分析出「unused 这个导出没人用」并在产物里留下标记,真正把那几行文本删掉的是 Terser。只开一半,字节数一个都不会少 —— 有人把 minimize 关掉排查问题,然后拿这份产物去量体积,量到的是个假数。

上面那份 production 产物还顺带露出另一件事:模块函数没了,两个模块被摊平进同一个作用域,;// ./src/math.js 这种注释就是接缝。这是 Scope Hoisting(ModuleConcatenationPlugin),production 默认开。前面整套 __webpack_require__ 查表机制在这里根本没出场 —— 能摊平的模块连查表都省了。

摊不平、也摇不动的情况有两类:

  • 模块不是 ESM。 require() 是运行时求值的函数调用,静态分析不出「谁用了哪个导出」。一个 babel 配置把 import 转成了 require,Tree Shaking 就静默失效,产物变大而不报任何错。
  • 模块有副作用,而你没告诉打包器。 package.json 里的 sideEffects 回答的是另一个问题:没人用这个模块的导出时,光是 import 它会不会产生影响。标成 "sideEffects": false 等于保证「不会」,打包器才敢把整个模块连同那条 import 一起丢掉。全局 CSS、polyfill、往原型上挂方法的模块都是有副作用的,一律标 false 的后果是线上少一段样式,而构建全绿。

5 起不再替你 polyfill Node 核心模块

从 4 升上来撞得最多的一堵墙。代码里有 import path from 'path',webpack 4 会自动塞一份 path-browserify 进去,5 直接报错:

webpack 5.109.2 的原话
ERROR in ./src/poly.js 1:0-24
Module not found: Error: Can't resolve 'path' in '…/src'
BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.
If you want to include a polyfill, you need to:
- add a fallback 'resolve.fallback: { "path": require.resolve("path-browserify") }'
- install 'path-browserify'
If you don't want to include a polyfill, you can use an empty module like this:
resolve.fallback: { "path": false }

报错自己把两条路写出来了。但先问第三个问题:这段代码为什么会跑到浏览器里去。多数时候是某个依赖顺手 import 了 cryptopath,而那条分支在浏览器里压根不执行 —— 这时候 resolve.fallback: { path: false } 比装一个 polyfill 划算,前者是 0 字节,后者可能是几十 KB。

第二次构建让它走磁盘

webpack.config.js · 持久化缓存
module.exports = {
cache: { type: 'filesystem' },
};

缓存默认落在 node_modules/.cache/webpack/ 下面,按 mode 分子目录 —— 我这次跑出来的是 default-development。它缓存的是模块解析和编译的中间结果,所以省掉的是重复的 loader 执行,不是输出那一步。

代价是缓存失效判断变成了你的问题:webpack 认得源文件的变化,认不出「我改了 webpack.config.js」或者「我升级了某个 loader」。构建结果诡异、清掉 .cache 就好了,多半是这个。

插件能拿到的一切都来自那个 compiler 参数

插件是对象不是函数。webpack 只调用它的 apply 方法,把 compiler 递进去;插件之后能做的每件事,都得从这个参数上找。

一个最小的插件
class MyPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
console.log('文件还没写到磁盘上,这是最后一次改产物的机会');
callback();
});
}
}

钩子来自 tapable,三种挂法按回调形态分:tap 同步,tapAsync 给你一个 callbacktapPromise 要你返回 Promise。挂错了会静默地不等你 —— 异步逻辑写在 tap 里,webpack 不会等它跑完就往下走。

而且不是每个钩子三种都给。emitdoneAsyncSeriesHook,三种都行;compilecompilationSyncHook,只有 tap。想在 compilation 上挂 tapAsync 是拿不到那个方法的。

时机上记住四个就够用:compile 开始编译,compilation 建好了这一次编译的对象,emit 文件即将写盘,done 全部结束。想改产物内容只能挂在 emit 之前,等 done 触发时文件已经在磁盘上了。

这套外壳什么时候是负担

上面那一整套 —— 模块表、__webpack_require__、运行时片段 —— 是为了在浏览器里模拟一个模块系统。打应用它是必需品;打一个给别人 import 的库,它就是纯负担:使用者的打包器拿到的不是一堆模块,而是一个自带模块系统的黑盒,摇不动里面的东西。Rollup 打包原理那篇里有同一段代码在 rollup 下的产物,是平铺的几个函数,没有任何外壳,两份对着看差别很直观。

反过来,应用侧的配置复杂度通常不该由你自己扛。Nextjs 是如何实现 production ready 的 react里的 splitChunks 分组,就是把「哪些依赖该进长缓存的包」这个判断预先替使用者做掉了 —— 那是一份可以抄的答案,比从空白配置开始调要省事。