Webpack 5 打包原理详解
打开 webpack 5 的产物看一眼:__webpack_require__ 就是一张 ID 到函数的表。顺带说清 Tree Shaking 到底是谁在删代码,以及 5 起不再自动 polyfill Node 核心模块之后该怎么办。
打开任何一个 webpack 5 的产物,前六十行都长得差不多。要认的只有两个名字:__webpack_modules__ 是一张「模块 ID → 函数」的表,__webpack_require__ 是按 ID 从表里取东西的那个函数。剩下的都围着这两样打转。
本文贴的产物都是 webpack 5.109.2 真跑出来的,配置是 mode: 'development'、devtool: false、optimization.minimize: false,两个源文件一共四行。你照着配能得到同一份,行号可能差几行,形状不会变。
模块系统就是一张表加一个函数
// The module cacheconst __webpack_module_cache__ = {};
// The require functionfunction __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 的运行时。 认法很简单,出现 installedModules、module.i、module.l = false 的是 4。webpack 5 的缓存叫 __webpack_module_cache__,模块对象上连 id 和 loaded 都不建了 —— 产物注释里 no module.id needed 和 no module.loaded needed 那两行,是 webpack 特意写给你看的。这两行是条件生成的:真有模块读了 module.id 或 module.loaded,webpack 就把注释换成 id: moduleId, 和 loaded: false, 这两个字段。也就是说产物里出现哪一种,取决于你的代码用没用到它们。
外壳不再是「把模块表当参数传进去的 IIFE」
webpack 4 的产物外形是 (function(modules){ … })({ "./src/index.js": function(…){…} }),模块表当实参传进去。webpack 5 换了:
/******/ (() => { // 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:
export function add(a, b) { return a + b; }export function unused(a) { return a * 999; }mode: 'production' 但 optimization.minimize: false,产物里 unused 原样活着:
/******/ (() => { // webpackBootstrap/******/ "use strict";
;// ./src/math.jsfunction add(a, b) { return a + b; }function unused(a) { return a * 999; }
;// ./src/index.js
console.log(add(1, 2));
/******/ })()把压缩打开,同样两个文件变成一行:
(()=>{"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 直接报错:
ERROR in ./src/poly.js 1:0-24Module 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 了 crypto 或 path,而那条分支在浏览器里压根不执行 —— 这时候 resolve.fallback: { path: false } 比装一个 polyfill 划算,前者是 0 字节,后者可能是几十 KB。
第二次构建让它走磁盘
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 给你一个 callback,tapPromise 要你返回 Promise。挂错了会静默地不等你 —— 异步逻辑写在 tap 里,webpack 不会等它跑完就往下走。
而且不是每个钩子三种都给。emit 和 done 是 AsyncSeriesHook,三种都行;compile 和 compilation 是 SyncHook,只有 tap。想在 compilation 上挂 tapAsync 是拿不到那个方法的。
时机上记住四个就够用:compile 开始编译,compilation 建好了这一次编译的对象,emit 文件即将写盘,done 全部结束。想改产物内容只能挂在 emit 之前,等 done 触发时文件已经在磁盘上了。
这套外壳什么时候是负担
上面那一整套 —— 模块表、__webpack_require__、运行时片段 —— 是为了在浏览器里模拟一个模块系统。打应用它是必需品;打一个给别人 import 的库,它就是纯负担:使用者的打包器拿到的不是一堆模块,而是一个自带模块系统的黑盒,摇不动里面的东西。Rollup 打包原理那篇里有同一段代码在 rollup 下的产物,是平铺的几个函数,没有任何外壳,两份对着看差别很直观。
反过来,应用侧的配置复杂度通常不该由你自己扛。Nextjs 是如何实现 production ready 的 react里的 splitChunks 分组,就是把「哪些依赖该进长缓存的包」这个判断预先替使用者做掉了 —— 那是一份可以抄的答案,比从空白配置开始调要省事。