
1. 这不是配置文档是模块化运行时的“心脏解剖图”很多人第一次接触 webpack是从webpack.config.js里那一堆entry、output、loader、plugin开始的。配好了能打包报错了就百度搜错误码改完再npm run build—— 这很真实但离真正理解 webpack 差了整整一个运行时的距离。我说的“webpack模块化实现原理”不是讲怎么写 loader也不是教你怎么优化 splitChunks而是带你掀开打包产物的.js文件逐行读它生成的代码看清那个被封装在__webpack_require__里的模块加载引擎是怎么一帧一帧运转的。它不依赖 Node.js 的require也不用浏览器原生的import()而是一套完全自包含、可预测、可调试的模块沙盒系统。关键词就三个模块注册表modules、模块缓存installedModules、统一 require 函数webpack_require。这三者构成的三角结构就是所有 webpack 打包产物的底层骨架。无论你用的是 Vue 单文件组件、React 的 lazy Suspense还是纯 ES6 的import { foo } from ./utils最终都会被编译成对这个三角结构的一次调用。它解决的核心问题非常朴素如何让成百上千个分散的 JS 文件在浏览器里按需加载、避免重复执行、支持循环引用、还能隔离作用域答案不在 Webpack CLI 里而在你dist/main.js开头那几百行看起来像“黑盒”的启动代码中。这篇文章适合三类人一是已经会配 webpack 但总在 runtime 报错时抓瞎的中级前端二是想搞懂现代前端工程化底层逻辑的架构学习者三是正在从 Parcel/Vite 切回 webpack 做深度定制的工程化同学。你不需要会写 plugin但得愿意打开 Chrome DevTools 的 Sources 面板点开main.js把光标停在__webpack_require__.d那一行慢慢往下读。2. 模块化设计的底层动机与不可妥协的约束2.1 为什么不能直接用浏览器原生 import这是所有初学者最容易忽略的前提。ESMECMAScript Module确实是标准但它有硬性限制import必须是顶层语句不能动态拼接路径import()返回 Promise无法同步获取导出值更重要的是浏览器原生 ESM 不支持模块 ID 映射、路径别名、内容重写比如把.vue编译成 JS、运行时条件加载等工程化刚需。举个具体例子你在src/index.js里写了import utils from /utils其中是 webpack alias 指向src/。浏览器根本不知道/utils是什么它只认相对路径或绝对 URL。webpack 必须在构建阶段就把/utils替换成./utils.js再把utils.js的内容提取出来和index.js一起塞进同一个闭包里——这个过程叫“静态分析 内联注入”而它的执行载体就是__webpack_require__运行时。2.2 CommonJS 的包袱与 webpack 的取舍Node.js 的 CommonJSCJS是同步的、动态的、支持require(a flag)这种字符串拼接的。但浏览器没有fs.readFileSync所以 CJS 无法直接运行。webpack 早期版本曾尝试模拟 CJS 运行时结果发现两个致命问题一是循环引用处理极其脆弱A require BB require A两者 exports 都是空对象后续赋值会丢失二是module.exports {}和exports.xxx yyy混用时exports被重新赋值后指向失效。webpack 最终选择了一条中间路线语法层兼容 CJS运行时层重构模型。它把每个模块都包装成一个函数形如(function(module, __webpack_exports__, __webpack_require__) { ... })然后由__webpack_require__统一调度执行顺序。这样既保留了require(./a)的写法习惯又彻底规避了 CJS 的同步 I/O 依赖和循环引用陷阱。关键在于webpack 不是“模拟” CJS而是用函数闭包 状态机的方式“重实现”了一套更可控的模块协议。2.3 模块注册表modules所有代码的“户籍档案”当你执行npx webpack --modedevelopmentwebpack 会从 entry 开始递归解析所有import/require语句为每个遇到的模块生成一个唯一的数字 ID从 0 开始递增并把该模块的源码已处理过 loader封装成一个函数存入一个数组// 简化示意实际 modules 是一个稀疏数组 var modules [ function(module, __webpack_exports__, __webpack_require__) { // entry 模块src/index.js __webpack_require__.d(__webpack_exports__, default, function() { return main; }); var main function() { console.log(hello); }; }, function(module, __webpack_exports__, __webpack_require__) { // src/utils.js 模块 __webpack_require__.d(__webpack_exports__, add, function() { return add; }); var add function(a, b) { return a b; }; } ];这个modules数组就是模块的“户籍档案”。ID 就是身份证号函数体就是该模块的全部行为定义。它不关心模块是.js、.ts还是.vue只要能被 loader 转成 JS 函数就有一席之地。注意modules在打包时就已确定运行时只读。这意味着 webpack 的模块图是静态的、可预测的——这也是它能做 Tree Shaking 的前提没被__webpack_require__调用过的模块 ID对应的函数永远不会被执行自然可以安全删除。2.4 模块缓存installedModules避免重复执行的“内存保险柜”如果每次__webpack_require__(1)都重新执行modules[1]函数那utils.js里的var cache {}就永远清零状态无法保持。webpack 用一个简单的对象installedModules来解决var installedModules {}; function __webpack_require__(moduleId) { if (installedModules[moduleId]) { return installedModules[moduleId].exports; } var module installedModules[moduleId] { i: moduleId, l: false, // loaded 标志 exports: {} }; modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); module.l true; return module.exports; }看懂了吗installedModules就是一个以 moduleId 为 key 的 Map。第一次调用__webpack_require__(1)发现installedModules[1]不存在就新建一个{ i: 1, l: false, exports: {} }然后用modules[1]函数去填充它的exports第二次再调用直接返回缓存的exports对象。这个设计精妙在三点第一exports是引用传递模块内部对exports.xxx的赋值会直接反映在缓存对象上第二l: false标志确保模块函数只执行一次哪怕有循环引用A require BB require AA 在执行到一半时去 require BB 执行完返回A 继续执行不会死循环第三installedModules是闭包变量外部无法篡改保证了模块状态的私密性。2.5 统一 require 函数webpack_require模块系统的“中央处理器”__webpack_require__是整个模块化体系的唯一入口和调度中心。它不只是个函数更是一个微型运行时环境。我们拆解它最核心的几段逻辑模块定位if (installedModules[moduleId]) return ...是缓存检查模块初始化var module installedModules[moduleId] {...}创建新模块记录模块执行modules[moduleId].call(...)是真正的“加载”动作把module、module.exports、__webpack_require__作为参数传进去导出绑定__webpack_require__.d是一个辅助函数用于定义export的 getter确保export const foo 1和export default bar都能被正确访问动态导入支持__webpack_require__.e处理import()它返回 Promise负责加载 chunk 文件并触发__webpack_require__加载新模块。最关键的是__webpack_require__本身是闭包内的私有函数外部代码无法覆盖或 monkey patch 它。这意味着你写的任何import或require最终都逃不开这个函数的调度。它就像操作系统内核所有用户态模块你的业务代码都必须通过系统调用__webpack_require__来申请资源其他模块。这种强控制力是 webpack 能做代码分割、懒加载、热更新HMR的基础——因为所有模块加载行为都被它收口了。3. 从源码到运行时一个模块加载的完整生命周期3.1 构建阶段AST 解析与模块 ID 分配假设你有如下简单项目// src/index.js import { add } from ./math; console.log(add(1, 2)); // src/math.js export const add (a, b) a b;webpack 启动后第一步是 AST抽象语法树解析。它用acorn或babel/parser把index.js解析成树状结构遍历所有ImportDeclaration节点识别出import { add } from ./math。此时webpack 还不知道./math对应哪个文件于是进行模块解析resolve根据resolve.alias、resolve.extensions默认.js,.json、resolve.modules默认node_modules等规则找到src/math.js的绝对路径。接着它为src/index.js分配 ID 0为src/math.js分配 ID 1。这个 ID 分配不是随机的而是按依赖图的拓扑排序topological sort决定的被依赖的模块 ID 总是小于依赖它的模块。所以math.jsID 1一定在index.jsID 0之前被处理。这保证了运行时__webpack_require__(0)执行时modules[1]已经存在。3.2 转换阶段Loader 链与模块函数封装拿到src/math.js的源码后webpack 按照module.rules匹配 loader。假设你配置了babel-loader那么源码会先经过 Babel 编译把export const add ...转成Object.defineProperty(__webpack_exports__, add, {...})再交给 webpack 封装。封装后的modules[1]长这样// modules[1] - src/math.js function(module, __webpack_exports__, __webpack_require__) { use strict; __webpack_require__.r(__webpack_exports__); // 标记为 ES Module /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, add, function() { return add; }); const add (a, b) a b; }注意__webpack_require__.d这个调用它不是立即执行add而是定义了一个 getter当外部代码访问__webpack_exports__.add时才执行return add。这就是为什么export const foo expensiveFn()不会在模块加载时执行expensiveFn而是在首次访问foo时才执行。这种惰性求值lazy evaluation是 ESM 语义的关键保障。3.3 打包阶段启动代码注入与模块注册表生成所有模块处理完后webpack 会生成一个“启动块bootstrap code”也就是你dist/main.js开头几百行。它包含installedModules和modules的初始化声明__webpack_require__函数定义__webpack_require__.d、.r、.nnamed export 兼容等辅助函数最后一行__webpack_require__(0)即启动 ID 0 的 entry 模块。整个main.js的结构是线性的、自包含的。它不依赖任何外部库连Promise都是自己 polyfill 的如果目标环境不支持。你可以把它理解成一个“JS 操作系统镜像”内核__webpack_require__、驱动loader 处理后的模块函数、根进程entry 模块全部打包进一个文件。当你在浏览器里执行它__webpack_require__(0)被调用modules[0]开始执行它内部的__webpack_require__(1)被触发modules[1]执行……就这样一个模块链被逐级点亮。3.4 运行阶段从 require 调用到 exports 返回的逐帧解析我们用 Chrome DevTools 实际走一遍__webpack_require__(1)的执行栈断点打在__webpack_require__(1)调用处比如index.js里的import { add } from ./math编译后的位置进入__webpack_require__函数moduleId是 1检查installedModules[1]为空进入初始化分支创建module { i: 1, l: false, exports: {} }存入installedModules[1]执行modules[1].call(module.exports, module, module.exports, __webpack_require__)此时栈顶是modules[1]函数this是module.exports即{}module参数是刚创建的对象__webpack_require__是闭包函数函数体内执行__webpack_require__.d(...)给module.exports添加一个名为add的 getter然后执行const add ...但注意add是局部变量不会污染module.exports函数执行完毕module.l true返回module.exports回到modules[0]现在它拿到了一个对象{ add: [Getter] }可以安全地调用add(1,2)。整个过程耗时不到 0.1ms但每一步都精准可控。你可以在 DevTools 的 Scope 面板里实时看到module.exports如何从空对象变成带 getter 的对象看到installedModules[1]如何从undefined变成一个完整对象。这不是魔法是精心设计的状态机。3.5 动态导入import()Chunk 加载与模块注册的二次启动import(./async-module.js)是 webpack 的高级特性它会把async-module.js及其依赖单独打包成一个chunk比如1.js并在运行时加载。这个过程由__webpack_require__.eensure函数完成// __webpack_require__.e 的简化逻辑 __webpack_require__.e function requireEnsure(chunkId) { var promises []; var installedChunkData installedChunks[chunkId]; if (installedChunkData ! 0) { // 0 表示已加载 if (installedChunkData) { promises.push(installedChunkData[2]); } else { var promise new Promise(function(resolve, reject) { installedChunkData installedChunks[chunkId] [resolve, reject]; }); promises.push(installedChunkData[2] promise); // 创建 script 标签加载 1.js var script document.createElement(script); script.src __webpack_require__.p chunkId .js; document.head.appendChild(script); } } return Promise.all(promises); };当1.js加载完成它会执行自己的启动代码里面包含__webpack_require__.oobject has own property等新函数并把它的modules数组 merge 到主modules中实际是通过__webpack_require__.m扩展。然后__webpack_require__.e(1)的 Promise resolve你就可以then里调用__webpack_require__(moduleIdInChunk1)了。整个过程是异步的、可中断的、可重试的但模块注册和缓存机制与同步require完全一致。这才是真正的“按需加载”不是加载整个 bundle而是加载一个独立的模块单元并将其无缝接入现有的模块系统。4. 核心机制深度拆解d、r、n、o 等辅助函数的实战意义4.1__webpack_require__.dES Module 导出的基石__webpack_require__.d是定义导出属性的核心函数。它的签名是d(exports, name, getter)。为什么不用exports[name] value因为 ESM 要求export是 live binding活绑定即导出的值如果在源模块内被修改导入方看到的也必须是最新值。exports[name] value是值拷贝做不到活绑定。而d用的是Object.defineProperty__webpack_require__.d function(exports, name, getter) { if (!__webpack_require__.o(exports, name)) { Object.defineProperty(exports, name, { enumerable: true, get: getter }); } };get: getter确保每次访问exports.add都会重新执行getter()函数从而拿到add的当前值。如果你在math.js里写let add 1; setTimeout(() add 2, 1000); export { add };那么导入方import { add } from ./math后一秒后访问add就会得到2。这就是活绑定的力量。__webpack_require__.d还做了防重写保护if (!__webpack_require__.o(exports, name))避免多次export同名变量时覆盖。4.2__webpack_require__.r标记 ES Module 的“身份证”__webpack_require__.r的作用是给exports对象添加一个__esModule属性exports.__esModule true。这看起来多余实则关键。它解决了 CJS 和 ESM 互操作的难题。比如你用import React from react而react包是 CJS 输出的module.exports { createElement: ... }没有__esModule标志。webpack 为了兼容会自动把React当作 default 导出来处理。但如果react包自己加了__esModule: truewebpack 就知道这是标准 ESMimport React from react就是正确的而import * as React from react才是命名空间导入。这个标志是 webpack 内部约定的“模块类型指纹”没有它跨生态导入就会混乱。4.3__webpack_require__.ndefault 导出的兼容性补丁__webpack_require__.n是为了解决export default在 CJS 环境下的降级问题。ESM 的export default foo编译后是exports.default foo但 CJS 的module.exports foo并没有default属性。所以__webpack_require__.n会判断如果模块有__esModule就直接返回module.exports否则返回一个函数function() { return module.exports; }并把这个函数的default属性设为module.exports。这样无论源模块是 ESM 还是 CJSimport xxx from xxx都能拿到正确的值。它是 webpack 兼容性策略中最隐蔽也最重要的一环。4.4__webpack_require__.o属性检测的“安检门”__webpack_require__.o就是Object.prototype.hasOwnProperty.call(obj, prop)的简写。它被大量用于安全检查比如__webpack_require__.d里防止重复定义导出__webpack_require__.r里检查是否已标记__esModule。为什么不用原生obj.hasOwnProperty(prop)因为obj可能是null或undefined或者obj自己重写了hasOwnProperty方法虽然不推荐但可能。__webpack_require__.o是一个健壮的、不会被污染的检测工具体现了 webpack 运行时对边界情况的极致防御。4.5__webpack_require__.ppublicPath 的运行时锚点__webpack_require__.p存储的是output.publicPath的值比如/static/。它被用在__webpack_require__.e里拼接 chunk 脚本的 URLscript.src __webpack_require__.p chunkId .js。这个设计允许你在运行时动态修改__webpack_require__.p比如 CDN 切换、多环境部署。你甚至可以在index.html里script__webpack_require__.p https://cdn.example.com/;/script然后加载main.js所有动态 import 都会从 CDN 加载。p是 webpack 运行时与部署环境的唯一桥梁它的存在让打包产物真正具备了环境无关性。5. 实操验证与避坑指南在真实项目中观察和调试5.1 如何在 DevTools 里亲手“解剖”你的 main.js别只看 webpack 文档直接动手。步骤如下创建一个最简项目mkdir wp-test cd wp-test npm init -y npm install webpack webpack-cli --save-dev写src/index.jsimport { log } from ./util; log(hello);和src/util.jsexport const log console.log;npx webpack --modedevelopment --devtoolnone禁用 source map看原始代码打开dist/main.js搜索__webpack_require__你会看到完整的运行时代码在 Chrome 里打开空白页粘贴main.js全部内容按 F12切到 Console输入__webpack_require__(0)回车——它会执行输出hello再输入installedModules回车看到{0: {…}, 1: {…}}输入modules[1]回车看到那个封装函数在modules[1]函数体里加debugger;重新打包再执行就能断点调试模块执行过程。这个过程比读一百篇博客都管用。你会亲眼看到installedModules如何增长modules数组如何被调用__webpack_require__如何流转。这是理解原理的最快路径。5.2 常见 runtime 错误的根源与修复错误信息根本原因修复方案Uncaught TypeError: __webpack_require__ is not a functionmain.js被错误地当作普通 script 加载未执行启动代码或output.libraryTarget配置错误导致__webpack_require__未暴露检查 HTML 中script srcmain.js是否放在 body 底部确认output.libraryTarget是var默认而非umd或windowUncaught Error: Module not found: Error: Cant resolve ./xxx构建时模块解析失败但 runtime 报错是因为modules数组里根本没有对应 ID 的函数检查resolve.alias路径是否正确确认src/xxx.js文件真实存在用webpack --display-modules查看实际解析路径Cannot read property call of undefined__webpack_require__(X)调用时modules[X]是undefined说明该模块未被打包进当前 chunk检查optimization.splitChunks配置确认X对应的模块没有被错误地分到另一个 chunk或检查import()的路径是否写错Maximum call stack size exceeded循环引用过于复杂__webpack_require__的缓存机制未能及时截断重构代码避免 A→B→A 这样的直接循环改用import()动态加载打破循环或用/* webpackMode: eager */强制同步加载这些错误都不是 webpack 的 bug而是模块系统在特定边界条件下的必然表现。理解了modules和installedModules的关系你就能一眼看出问题在哪。5.3 三个必知的调试技巧技巧一强制模块重新执行。有时你想测试模块初始化逻辑但installedModules缓存了结果。可以在 Console 里执行delete installedModules[1]; __webpack_require__(1);强制重跑modules[1]。这比重启页面快得多。技巧二查看模块依赖图。在main.js里搜索__webpack_require__.e它后面跟着的数字就是 chunk ID。再搜索webpackJsonp旧版或__webpack_require__.f新版能看到 chunk 加载的完整流程。结合 Network 面板你能清晰看到哪个 import() 触发了哪个 chunk 加载。技巧三模拟模块缺失。临时注释掉modules[1]的定义然后执行__webpack_require__(0)你会看到Cannot read property call of undefined。这个实验让你深刻理解modules数组是模块系统的“宪法”缺一个 ID整个链条就断了。5.4 生产环境的隐藏细节minify 后的模块系统开发模式下main.js是可读的但生产模式--modeproduction会压缩。__webpack_require__会被缩成rmodules变成minstalledModules变成o。但结构完全不变。你可以用terser的--mangle-props keep_fnames选项保留函数名或者直接在压缩后代码里搜索function r(依然能找到运行时核心。记住压缩只是改名和删空格不改变逻辑。r(0)还是启动入口o[1]还是模块缓存。原理是永恒的语法是暂时的。5.5 与 Vite 的本质区别为什么 Vite 不需要这套系统这是很多人的困惑。Vite 在开发模式下用原生 ESMimport直接由浏览器加载不需要__webpack_require__。但请注意Vite 的生产构建vite build默认用 rolluprollup 的输出也是基于__webpack_require__类似的运行时叫require或importwrapper只是名字不同。Vite 的优势在于开发时跳过了打包利用了浏览器的 ESM 能力而 webpack 的优势在于构建时的强控制力能做更复杂的转换比如 CSS in JS、SVG as Component。它们不是谁取代谁而是针对不同场景的最优解。理解 webpack 的模块原理反而能让你更懂 Vite 的 HMR 是如何在不刷新页面的情况下精准替换一个模块的exports的。6. 原理延伸Tree Shaking、Scope Hoisting 与模块系统的共生关系6.1 Tree Shaking 为什么只对 ESM 有效Tree Shaking 的本质是“静态可达性分析”。webpack 在构建时会扫描modules数组找出所有被__webpack_require__字符串字面量调用的模块 ID比如__webpack_require__(1)然后标记这些 ID 为“活跃”。所有未被标记的模块其对应的modules[X]函数就会被删除。但这个分析有个前提__webpack_require__的参数必须是常量数字不能是变量let id 1; __webpack_require__(id)会导致整个modules数组都被认为是活跃的Tree Shaking 失效。而 ESM 的import语句是静态的import { foo } from ./bar在 AST 层就能确定bar模块一定会被加载CJS 的require(xxx)是动态的xxx可能是任何表达式。所以 webpack 只对 ESM 做 Tree Shaking对 CJS 则保守处理。这就是为什么export和import语法是 Tree Shaking 的基础设施而__webpack_require__是它的执行引擎。6.2 Scope Hoisting把模块函数“焊接”在一起Scope Hoisting作用域提升是 webpack 4 的优化功能它试图把多个模块函数合并成一个大函数消除__webpack_require__调用开销。比如// modules[0] function(module, exports, req) { req(1); // 调用 modules[1] } // modules[1] function(module, exports, req) { console.log(hi); }Scope Hoisting 后变成// 合并后的一个函数 function() { console.log(hi); // 直接内联不再 require }这听起来很美但它有严格前提模块之间不能有循环引用不能有eval不能有require.ensure等动态加载。因为一旦合并__webpack_require__的缓存和隔离机制就失效了。Scope Hoisting 不是替代__webpack_require__而是它的激进优化模式。当你看到optimization.concatenateModules: true就知道 webpack 正在尝试把模块系统“压扁”但底层依然是__webpack_require__在兜底。6.3 模块联邦Module Federation跨 bundle 的 require 扩展模块联邦是 webpack 5 的重磅特性它让__webpack_require__能跨不同的 bundle 加载模块。比如host-app的main.js可以__webpack_require__(remote-app/Component)而Component来自另一个独立部署的remote-app.js。这背后是__webpack_require__的增强它不仅查本地modules数组还会查远程remote-app暴露的模块注册表。模块联邦没有推翻原有模型而是把modules从单机数组扩展成了分布式注册中心。__webpack_require__依然是那个中央处理器只是它的“管辖范围”变大了。理解了基础__webpack_require__模块联邦的源码读起来就毫无压力。6.4 自定义 Runtime你可以重写webpack_require吗理论上可以但极度不推荐。webpack 提供了runtime配置项允许你指定一个自定义的运行时入口。但你要重写__webpack_require__就必须同时重写modules、installedModules、__webpack_require__.d等所有配套函数还要保证与 webpack 的插件生态如 SplitChunksPlugin、HotModuleReplacementPlugin兼容。这相当于重写操作系统内核。绝大多数需求用DefinePlugin注入全局变量、用ProvidePlugin自动引入模块、用NormalModuleReplacementPlugin替换模块就足够了。__webpack_require__是 webpack 的圣杯敬而远之善用即可。6.5 未来展望ESM 的原生支持会淘汰 webpack 吗不会。ESM 是标准webpack 是工具。就像 TCP/IP 是标准Wireshark 是工具一样。浏览器原生 ESM 解决了“如何加载”但没解决“如何转换”TS、JSX、CSS、“如何优化”Code Splitting、Preload、“如何集成”与后端 API、微前端。webpack 的价值不在import语法而在它构建出的那个可控、可预测、可调试的模块宇宙。只要前端工程化还有“转换”和“优化”的需求__webpack_require__这套模型就有存在的意义。它可能被新的工具如 rspack用 Rust 重写但核心思想——模块注册、缓存、统一加载——不会变。我在某公司做前端基建时曾遇到一个线上 bug某个页面白屏Console 报Cannot read property default of undefined。团队排查了两天最后我直接打开线上main.js搜索__webpack_require__(定位到出问题的模块 ID再查modules[ID]发现它被SplitChunksPlugin错误地分到了一个异步 chunk而主 chunk 里没有它的定义。我们加了cacheGroups规则强制保留在主 chunk问题立刻解决。这件事让我坚信工具可以配置但原理必须亲手验证。你不需要记住所有函数名但要能在main.js里顺着__webpack_require__的调用链一层层跟下去直到看到那个exports对象是如何被创建、被填充、被返回的。这才是前端工程师的硬功夫。