首页
/ 深入 webpack harmony 示例:ES Module 从源码到打包产物的完整编译解析

深入 webpack harmony 示例:ES Module 从源码到打包产物的完整编译解析

2026-09-07 14:31:13作者:凤尚柏Louis

本篇指南以本仓库 examples/harmony 示例为核心,讲解 webpack 如何把采用 ES Module(webpack 内部称之为 “harmony modules”)语法编写的多个源文件,编译、链接并打包成一个浏览器可执行的 bundle,同时演示基于 import() 的按需异步加载(Code Splitting)。读完本文,你将能读懂 webpack 生成的 harmony 模块产物结构、模块级静态分析注释([provided] / [used exports])、运行时助手函数(__webpack_require__.d / .r / .e)的含义,并理解同一源码在 development 与 production 两种模式下产物与模块合并策略的差异。

一、harmony 示例的组成与"文档模板"生成机制

examples/harmony/ 目录下的全部源文件如下:

文件 作用
examples/harmony/example.js 入口模块:以命名导入方式消费同步模块,并触发一个异步模块的动态加载
examples/harmony/increment.js 中间层模块:从 math 导入 add,再对外导出 increment
examples/harmony/math.js 叶子模块:导出一个可接收任意参数的 add 求和函数
examples/harmony/async-loaded.js 仅通过 import() 异步加载的模块,导出 answer = 42
examples/harmony/webpack.config.js 示例专用最小配置,仅固化 chunk 命名策略
examples/harmony/README.md 由模板渲染生成的最终文档(含产物源码与构建统计)
examples/harmony/template.md 文档模板:通过占位符嵌入源文件、构建产物与 stats 输出

这里有一个值得注意的工程细节:template.md 本身并不直接书写源码和输出内容,而是使用 _{{example.js}}__{{stdout}}_ 这类占位符引用磁盘上的真实文件与实际构建结果。仓库的 examples/build-common.js 会读取每个示例目录下的 template.md,依次用三种模式跑真实构建(见 examples/build-common.js):

const compilations = [
	["--mode production --env production", "production"],
	["--mode development --env development --devtool none", "development"],
	["--mode none --env none --output-pathinfo verbose", ""]
];

每次编译完成后由 examples/template-common.jsreplaceResults 将输出回填到模板中,最终写为 README.md。因此 examples/harmony/README.md 里呈现的代码与 stats,都是这一示例在仓库真实版本下的实况记录,具有天然的可复现性与时效性。全仓库的示例均可通过 examples/examples.js 的递归扫描与 examples/buildAll.js 批量构建。

webpack.config.js 中只做了一件事:optimization.chunkIds: "deterministic",其注释说明了原因——“保持不同模式(例如仅构建时)下的文件名一致”,这使得后文 development / production 两套 stats 中的 655.output.js 命名可以互相对照。

二、模块依赖链:一条最典型的 ESM 使用链路

示例的入口 examples/harmony/example.js 完整代码如下:

import { increment as inc } from './increment';
var a = 1;
inc(a); // 2

// async loading
import("./async-loaded").then(function(asyncLoaded) {
	console.log(asyncLoaded);
});

它在同一份文件里覆盖了 ES Module 的两种最常见形态:

  1. 静态(顶层)导入import { increment as inc } from './increment',使用了 as 别名语法,且在调用处 inc(a) 直接以局部标识符使用;
  2. 动态导入import("./async-loaded") 返回 Promise,配合 .then() 在运行期按需加载,这是 webpack Code Splitting 最基础的写法。

被同步引用的中间模块 examples/harmony/increment.js

import { add } from './math';
export function increment(val) {
    return add(val, 1);
};

叶子模块 examples/harmony/math.js

export function add() {
	var sum = 0, i = 0, args = arguments, l = args.length;
	while (i < l) {
		sum += args[i++];
	}
	return sum;
}

examples/harmony/async-loaded.js 只做一件事:

export var answer = 42;

依赖拓扑非常清晰:

example.js (entry)
├── increment.js ──┬── math.js   (同步依赖,合并进主 chunk)
└── async-loaded.js              (动态依赖,切分为独立异步 chunk)

math.jsadd 故意写成遍历 arguments 的可变参数形式,不引用任何 webpack 运行时依赖,属于纯函数模块,是观察 ESM 编译与后续 production 模式模块合并的理想素材。

三、逐段拆解构建产物 dist/output.js:harmony 模块如何被翻译

template.md 的第三节标题即为 dist/output.js,它展示的是入口 chunk 的真实产物。产物主体是一个 IIFE 包裹的 webpackBootstrap:

/******/ (() => { // webpackBootstrap
/******/ 	"use strict";
/******/ 	var __webpack_modules__ = ([
/* 0 */,
/* 1 */
/*!**********************!*\
  !*** ./increment.js ***!
  \**********************/
/*! namespace exports */
/*! export increment [provided] [no usage info] [missing usage info prevents renaming] */
/*! other exports [not provided] [no usage info] */
/*! runtime requirements: __webpack_require__, __webpack_require__.r, __webpack_exports__, __webpack_require__.d, __webpack_require__.* */
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   increment: () => (/* binding */ increment)
/* harmony export */ });
/* harmony import */ var _math__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./math */ 2);

function increment(val) {
    return (0,_math__WEBPACK_IMPORTED_MODULE_0__.add)(val, 1);
};

从这段代码能观察到 webpack 对 ESM 编译的几个核心事实:

  • 模块表 __webpack_modules__ 是一个数组/* 0 */ 空位保留给运行时占位(通常编号 0 会被保留),./increment.js 占据 /* 1 */./math.js 占据 /* 2 */
  • 每个 harmony 模块以 CommonJS 风格的三参函数承载(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {...},ES Module 语义在内部被翻译为对 exports 对象的读写;
  • 导出被转换成 getter:先调用 __webpack_require__.r(__webpack_exports__)exports 打上 __esModuleSymbol.toStringTag 标记(见产物中 runtime 段 make namespace object),再用 __webpack_require__.d 通过 Object.defineProperty 定义可枚举的惰性 getter increment: () => (/* binding */ increment),保持导出与模块内变量的实时绑定关系,这与原生 ESM 的 live binding 语义一致;
  • 命名导入被改写为长限定名访问import { add } from './math' 被编译成 var _math__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./math */ 2),调用点 add(val, 1) 变成 (0,_math__WEBPACK_IMPORTED_MODULE_0__.add)(val, 1)——外层括号保证 add 被调用时其 thisundefined,忠实还原 ESM 导入函数调用时无 this 绑定的语义;
  • 模块头部的注释区块是静态分析元信息namespace exportsexport increment [provided] [no usage info]other exports [not provided] 等标注,来自构建 CLI 中追加的 --stats-reasons --stats-used-exports --stats-provided-exports(见 examples/build-common.js)。[provided] 表示导出存在且已登记,[no usage info] 表示当前分析尚未收集到消费方信息(入口场景下属正常),[not provided] 则标记了该模块未提供的导出(防止外部错误访问)。

3.1 入口 chunk 的执行代码:同步调用 + 异步加载并存

在 webpack runtime 与模块表之后,产物末尾是入口模块自身的执行片段:

let __webpack_exports__ = {};
// This entry needs to be wrapped in an IIFE because it needs to be isolated against other modules in the chunk.
(() => {
/*!********************!*\
  !*** ./example.js ***!
  \********************/
/*! namespace exports */
/*! exports [not provided] [no usage info] */
/*! runtime requirements: __webpack_require__, __webpack_require__.r, __webpack_exports__, __webpack_require__.e, __webpack_require__.* */
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _increment__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./increment */ 1);

var a = 1;
(0,_increment__WEBPACK_IMPORTED_MODULE_0__.increment)(a); // 2

// async loading
__webpack_require__.e(/*! import() */ 655).then(() => (__webpack_require__(/*! ./async-loaded */ 3))).then(function(asyncLoaded) {
	console.log(asyncLoaded);
});

})();

这里能清楚看到静态 import 与动态 import() 的产物差异:

  • 静态导入被内联为模块表项 __webpack_require__(/*! ./increment */ 1),运行时同步返回其 exports;
  • 动态导入则被改写为 __webpack_require__.e(655).then(() => __webpack_require__(3)):先用 runtime 的 ensure chunk__webpack_require__.e)去加载编号为 655 的异步 chunk,加载完成后再 __webpack_require__(3) 取回其中的 ./async-loaded 模块。整个 .then() 链保持了源码中 Promise 风格的调用约定;
  • 入口被单独包进一个 IIFE,注释明确解释了原因:需要与 chunk 中其他模块隔离作用域。

3.2 加载异步 chunk 的 runtime 链路

产物中与按需加载配套的 runtime 代码逐项印证了 chunk 的加载机制(完整代码见 examples/harmony/README.md 中的 <details> runtime 区块):

  • __webpack_require__.e = (chunkId) => Promise.all(...)ensure chunk 的统一入口,负责聚合各 chunk 加载方式(jsonp、import 脚本、模块联邦等)返回的 Promise;
  • __webpack_require__.u = (chunkId) => (chunkId + ".output.js"):chunk 文件名解析函数,因此异步 chunk 实际请求的是 655.output.js
  • __webpack_require__.l:通过创建 <script> 标签(script.src = url)加载远程脚本,并处理 120 秒超时、错误/加载完成回调去重与内存清理;
  • __webpack_require__.f.j + webpackJsonpCallback:JSONP 方式的 chunk 加载实现,installedChunks0 / Promise / undefined 三个状态区分“已加载 / 加载中 / 未加载”,加载成功后把新模块合并进 __webpack_require__.m 模块表并 resolve 对应 Promise;
  • __webpack_require__.p = "dist/":publicPath,与构建命令行参数 --output-public-path "dist/"examples/build-common.js)对应。

3.3 异步 chunk 产物 655.output.js

依据 Info 节的构建统计,异步 chunk 文件为 655.output.js,其来源与导出被标注为:

chunk (runtime: main) 655.output.js 24 bytes [rendered]
  > ./async-loaded ./example.js 6:0-24
  ./async-loaded.js 24 bytes [built] [code generated]
    [exports: answer]
    [used exports unknown]
    import() ./async-loaded ./example.js 6:0-24

> ./async-loaded ./example.js 6:0-24 是该 chunk 的“起源”记录——即入口文件第 6 行第 0–24 列处的 import() 调用。由于异步 chunk 的内容要到真正被加载时才执行,webpack 对它的导出使用信息无法预先确定,因此标注为 [used exports unknown];而同步依赖的 math.js 标注同样是 [used exports unknown],因为它经由 increment.js 间接引用,是否还有别的消费方未知。

四、同一源码、两种模式:Info 小节解读

template.md 最后的 Info 节给出了两个版本的构建统计,分别是未优化(development)与 production 模式。

Unoptimized(development)

asset output.js 11.3 KiB [emitted] (name: main)
asset 655.output.js 761 bytes [emitted]
chunk (runtime: main) 655.output.js 24 bytes [rendered]
  > ./async-loaded ./example.js 6:0-24
  ./async-loaded.js 24 bytes [built] [code generated]
    [exports: answer]
    [used exports unknown]
    import() ./async-loaded ./example.js 6:0-24
chunk (runtime: main) output.js (main) 400 bytes (javascript) 5.34 KiB (runtime) [entry] [rendered]
  > ./example.js main
  runtime modules 5.34 KiB 8 modules
  dependent modules 225 bytes [dependent] 2 modules
  ./example.js 175 bytes [built] [code generated]
    [no exports]
    [used exports unknown]
    entry ./example.js main
webpack X.X.X compiled successfully

要点:

  • 主 bundle output.js 11.3 KiB,其中业务 JavaScript 仅 400 字节,其余约 5.34 KiB 是 8 个 runtime 模块(模块缓存、JSONP chunk 加载等运行时基础代码);
  • 同步依赖的 increment.jsmath.js 被归入 dependent modules 225 bytes [dependent] 2 modules
  • 入口 example.js 标注 [no exports],因为它只导入而不导出任何内容,[used exports unknown] 说明出口侧尚未形成可用的裁剪结论,这正是 development 模式不做激进优化的表现。

Production mode

asset output.js 2.01 KiB [emitted] [minimized] (name: main)
asset 655.output.js 121 bytes [emitted] [minimized]
chunk (runtime: main) 655.output.js 24 bytes [rendered]
  > ./async-loaded ./example.js 6:0-24
  ./async-loaded.js 24 bytes [built] [code generated]
    [exports: answer]
    import() ./async-loaded ./example.js + 2 modules ./example.js 6:0-24
chunk (runtime: main) output.js (main) 400 bytes (javascript) 5.34 KiB (runtime) [entry] [rendered]
  > ./example.js main
  runtime modules 5.34 KiB 8 modules
  ./example.js + 2 modules 400 bytes [built] [code generated]
    [no exports]
    [no exports used]
    entry ./example.js main
webpack X.X.X compiled successfully

与 development 模式对比,production 的关键差异一目了然:

对比维度 Unoptimized(development) Production
output.js 体积 11.3 KiB 2.01 KiB(minimized)
655.output.js 体积 761 bytes 121 bytes(minimized)
同步模块的 stats 呈现 dependent modules 225 bytes [dependent] 2 modules ./example.js + 2 modules 400 bytes(已合并且可被摇树)
导出使用标注 [used exports unknown] [no exports used]
异步 chunk 构建来源 ./async-loaded.js ./async-loaded ./example.js + 2 modules(合并且穷举了引用链)

两处 [no exports used] 是 production 模式下 tree shaking 可以安全工作的信号:当 webpack 确认入口不需要对外导出、模块间引用链完整可达时,未被使用的导出(包括 async-loaded 中并未被消费的 answer)才有资格被压缩器移除。再叠加 scope hoisting(./example.js + 2 modules 的呈现方式表明多个模块已被提升合并且重命名空间化,这正是产品代码从 11.3 KiB 骤降到 2.01 KiB 的重要原因之一,配合压缩后主 chunk 约压缩 82%)。用户侧业务 JavaScript 均为 400 bytes 的一致数值,也印证了 chunkIds: "deterministic" 让两种模式下文件名保持稳定的配置意图。

五、动手重现该示例

整个示例构建链路依赖 webpack-cli 与本仓库源码,且 README.md 是由脚本实时渲染生成的:

  1. 在仓库根目录安装依赖并确保能解析到 webpack-cliexamples/build-common.js 中做了显式的 require.resolve("webpack-cli") 校验,缺失时会抛出 Please install webpack-cli at root.);
  2. 依次执行 examples/buildAll.js 中的模式对每个含 template.md 的示例目录执行 node build.js 构建;
  3. 每个示例实际执行的底层命令由 examples/build-common.js 拼装,例如 harmony 示例会以 node ../bin/webpack.js 附带 --entry ./example.js --output-filename output.js --stats-reasons --stats-used-exports --stats-provided-exports --output-public-path "dist/" --mode production/development 等参数跑三遍(对应 examples/build-common.js 的参数常量),并把 stdout 清洗掉时间戳、版本号等易变信息(替换为 XXXX:XX:XXwebpack X.X.X)后回填模板。

动手实验建议:修改 example.js 中的导入(例如去掉 as inc 别名改为直接导入、把 math 的调用挪到 async-loaded.js 中),观察 examples/harmony/template.md 渲染出的产物中对应注释(runtime requirements[provided] / [used exports])与 chunk 归属如何随之变化,是理解 webpack ESM 编译与依赖图分析最直接的方式。

结语

examples/harmony 示例以最小但完整的模块图覆盖了 webpack 处理 ES Module 的全部关键链路:命名导入导出与别名的编译、live binding 的 getter 化翻译、静态与动态导入的差异化处理、异步 chunk 的切分与 JSONP 加载,以及 development / production 两种模式下模块分析信息([provided] / [used exports unknown] / [no exports used])与体积变化的对比。配合其 README.md 中保存的真实产物与 template.md 的模板化生成机制,它是理解“ESM 源码 → webpack 中间表示 → 可执行 bundle”这一过程的最短可行路径,也是研究 tree shaking 与 Code Splitting 行为的绝佳出发点。

登录后查看全文
热门项目推荐
相关项目推荐