首页
/ webpack 原生 ESM 代码分割实战:以 module-code-splitting 示例剖析 import() 异步按需加载

webpack 原生 ESM 代码分割实战:以 module-code-splitting 示例剖析 import() 异步按需加载

2026-09-07 12:35:59作者:尤峻淳Whitney

本文以本仓库 examples/module-code-splitting 示例为核心,讲解在纯 ES Module(ECMAScript Modules)输出形态下,webpack 如何借助原生 import() 实现代码分割与按需加载。通过逐行分析三个业务源文件、配套的 webpack.config.js,并结合打包产物的运行时代码与优化前后 Stats,你将掌握 ESM 输出场景下 async chunk 的生成方式、被多个调用点共享的动态模块如何只加载一次,以及 __webpack_require__.ei__webpack_esm_ids__ 等运行时机制的真实含义。

示例概览与定位

module-code-splitting 属于仓库 examples/ 目录下的 "Code Splitting" 主题族(相关示例还包括 code-splittingcode-splitting-harmonycode-splitting-native-import-context 等)。它与经典 code-splitting 示例最大的区别在于:入口产物本身是一个 ES Module,异步 chunk 通过浏览器原生的 import() 语句加载,而不是传统的 JSONP <script> 注入或 CommonJS 的 require

目录下的文件组织如下(见 examples/module-code-splitting):

文件 作用
example.js 入口模块,描述业务时序:延迟后动态加载计数器并调用其 API
methods.js 被入口静态依赖的辅助模块,内部同样会动态加载 ./counter
counter.js 唯一被异步加载的业务模块,被 example.jsmethods.js 两处共同引用
webpack.config.js ESM 输出的关键配置(output.moduleexperiments.outputModule、module library type)
index.html <script type="module"> 加载产物的示意页面
template.md 该示例的讲解模板:源码、构建产物与运行 Info 的组装文档

三个源文件的业务逻辑拆解

入口 example.js:异步时序演示

example.js 的完整源码:

import { resetCounter, print } from "./methods";

setTimeout(async () => {
	const counter = await import("./counter");
	print(counter.value);
	counter.increment();
	counter.increment();
	counter.increment();
	print(counter.value);
	await resetCounter();
	print(counter.value);
}, 100);

业务时序非常清晰:

  1. 静态导入 ./methods 中的 resetCounterprint
  2. 100ms 后进入异步流程,通过 await import("./counter") 按需获取计数器模块的命名空间对象;
  3. 依次打印初值 0,自增三次后打印 3,最后调用 resetCounter() 重置后再打印 0

注意 import("./counter") 返回的是一个 Module Namespace 对象(命名空间对象),因此访问其导出需要使用 counter.valuecounter.increment() 的形式,这与静态导入后直接使用具名标识符的写法不同。

methods.js:动态导入的第二个调用点

methods.js

export const resetCounter = async () => {
	(await import("./counter")).reset();
};

export const print = value => console.log(value);

这里的关键点是:example.jsmethods.js 都在动态导入同一个 ./counter。在 webpack 的依赖图视角中,./counter 于是拥有两个 import() 调用点(后文 Stats 中会看到 import() ./counter ... 出现两次),并因此被独立抽到一个单独的异步 chunk 中。

counter.js:共享的有状态模块

counter.js

export let value = 0;
export function increment() {
	value++;
}
export function decrement() {
	value--;
}
export function reset() {
	value = 0;
}

它导出一个可变的 value 以及 incrementdecrementreset 三个操作函数,decrement 在示例运行路径中并未被调用,但会被保留在导出集合中(见 Stats 中的 [exports: decrement, increment, reset, value])。

ESM 输出的核心配置解读

示例的 webpack.config.js 完整如下:

"use strict";

/** @type {import("webpack").Configuration} */
const config = {
	output: {
		module: true,
		library: {
			type: "module"
		}
	},
	optimization: {
		usedExports: true,
		concatenateModules: true
	},
	target: "browserslist: last 2 chrome versions",
	experiments: {
		outputModule: true
	}
};

module.exports = config;

逐项说明这些配置如何共同决定产物的形态:

配置项 取值 作用与影响
output.module true 入口 chunk 输出为 ES Module 文件,内部使用 import/export 语法组织(而非 UMD/IIFE 包裹),并在产物顶部生成 export 声明
output.library.type "module" 声明产物的库格式为 ESM:直接暴露命名导出(export),配合 output.module: true 使用;这也是代码中使用 import/export 的前提
experiments.outputModule true 实验性开关,启用 module 格式输出及相关运行时能力;output.module: true 在底层需要它生效
optimization.usedExports true 启用"已使用导出"分析,便于摇树(配合生产构建时未用到的代码被移除或标记)
optimization.concatenateModules true 启用 Scope Hoisting,把满足条件的模块内联拼接为同一个函数作用域,减小体积、提升执行效率(示例中 example.js + 1 modules 即把入口与 methods.js 拼进了同一段代码)
target "browserslist: last 2 chrome versions" 明确运行目标为现代浏览器,使 webpack 可以放心地在运行时使用原生 import() 等现代特性,而无需向下做兼容垫片

需要说明:示例展示的是把 webpack 自身产物打成可被其他宿主加载的 ESM 库的形态。若只是普通 Web 应用,仅需 output.module: true(或 output.module: false 时开启 experiments.outputModule 的一部分能力)即可获得 ESM 输出;library.type: "module" 与否取决于产物是否需要对外暴露具名导出。本示例中二者并存,因此构建后的入口文件带有 export

如何构建与复现

按照 examples/README.md "Building an Example" 一节的说明,从仓库根目录依次执行:

# 1. 在项目根目录安装依赖
yarn

# 2. 执行环境初始化(setup 脚本)
yarn setup

# 3. 安装 webpack-cli(示例构建所需)
yarn add --dev webpack-cli

# 4. 进入目标示例目录并执行构建
cd examples/module-code-splitting && node build.js

如需一次性构建全部示例,可在根目录运行 npm run build:examples;仓库中的 examples/buildAll.js 会遍历 examples/examples.js 列出的每个示例目录逐一执行 node build.js。构建完成后,产物位于 dist/ 下,template.md 中嵌入的输出片段即构建命令的实际 stdout 与产物快照。

构建产物剖析:Unoptimized 模式

默认(开发/非生产)构建生成两个产物,template.md 的 Info 输出记录了完整文件清单:

asset output.js 5.47 KiB [emitted] [javascript module] (name: main)
asset 1.output.js 1.3 KiB [emitted] [javascript module]
chunk (runtime: main) output.js (main) 420 bytes (javascript) 2.43 KiB (runtime) [entry] [rendered]
  > ./example.js main
  runtime modules 2.43 KiB 4 modules
  ./example.js + 1 modules 420 bytes [built] [code generated]
    [no exports]
    [no exports used]
    entry ./example.js main
    used as library export
chunk (runtime: main) 1.output.js 146 bytes [rendered]
  > ./counter ./methods.js 2:8-27
  > ./counter ./example.js 4:23-42
  ./counter.js 146 bytes [built] [code generated]
    [exports: decrement, increment, reset, value]
    import() ./counter ./example.js + 1 modules ./example.js 4:23-42
    import() ./counter ./example.js + 1 modules ./methods.js 2:8-27
webpack X.X.X compiled successfully

从中可以读出的关键信息:

  • output.js(main 入口 chunk):包含 webpack 运行时(2.43 KiB,4 个 runtime modules)与业务代码。业务部分仅 420 字节且标记为 ./example.js + 1 modules——这正是 concatenateModules(Scope Hoisting)生效的证据:入口与 methods.js 被合并进同一模块函数,入口自身的导出为空([no exports]),只是"作为库的导出入口"(used as library export)。
  • 1.output.js(异步 chunk):只包含 ./counter.js(146 字节)。它被标记了两个调用来源:./methods.js 2:8-27(即 (await import("./counter")).reset())与 ./example.js 4:23-42(即 await import("./counter"))。两个静态入口共同指向同一个异步 chunk,webpack 会保证该 chunk 在依赖图中只生成一份。

ESM 入口产物:原生 import() 驱动的运行时

template.md 展示的 dist/output.js 前段(__webpack_modules__ = {})后是完整的 webpack runtime,其中与本例主题最相关的是 import chunk loading 段。运行时以 __webpack_require__.ei(chunkId, importFn) 作为异步加载入口,核心逻辑(整理自模板输出,去除了装饰性注释边框)如下:

// The module cache
const __webpack_module_cache__ = {};

// The require function
function __webpack_require__(moduleId) {
	const cachedModule = __webpack_module_cache__[moduleId];
	if (cachedModule !== undefined) return cachedModule.exports;
	const module = __webpack_module_cache__[moduleId] = { exports: {} };
	__webpack_modules__moduleId;
	return module.exports;
}
__webpack_require__.m = __webpack_modules__;

/* webpack/runtime/import chunk loading */
(() => {
	const installedChunks = { 0: 0 }; // 0 = chunk loaded

	const installChunk = (data) => {
		let { __webpack_esm_ids__, __webpack_esm_modules__, __webpack_esm_runtime__ } = data;
		var moduleId, chunkId, i = 0;
		for (moduleId in __webpack_esm_modules__) {
			if (__webpack_require__.o(__webpack_esm_modules__, moduleId)) {
				__webpack_require__.m[moduleId] = __webpack_esm_modules__[moduleId];
			}
		}
		if (__webpack_esm_runtime__) __webpack_esm_runtime__(__webpack_require__);
		for (; i < __webpack_esm_ids__.length; i++) {
			chunkId = __webpack_esm_ids__[i];
			if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
				installedChunks[chunkId][0]();
			}
			installedChunks[chunkId] = 0;
		}
	};

	__webpack_require__.ei = (chunkId, importFn) => {
		let promises = [];
		let installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
		if (installedChunkData !== 0) { // 0 means "already installed".
			if (installedChunkData) {
				// a Promise means "currently loading" -> 复用进行中的加载
				promises.push(installedChunkData[1]);
			} else {
				let promise = importFn().then(installChunk, (e) => {
					if (installedChunks[chunkId] !== 0) installedChunks[chunkId] = undefined;
					throw e;
				});
				promise = Promise.race([promise, new Promise((resolve) => (installedChunkData = installedChunks[chunkId] = [resolve]))]);
				promises.push((installedChunkData[1] = promise));
			}
		}
		return Promise.all(promises);
	};
})();

这段运行时的设计要点:

  1. importFn 就是原生 import():模板产物中异步加载被编译为 import(/*! import() */ "./dist/1.output.js")。因为是 ESM 输出且目标为现代浏览器,webpack 不再生成 JSONP 或 <script> 注入逻辑,直接把"加载 chunk"委托给宿主环境的原生动态导入。
  2. installChunk 接收一个"描述对象":解构出 __webpack_esm_ids__(chunk id 列表)、__webpack_esm_modules__(chunk 内的模块映射)、__webpack_esm_runtime__(chunk 级运行时)。模块被逐个写入全局 __webpack_require__.m 后,chunk 被标记为 0(已加载)。这说明异步 chunk 文件的导出契约(如何把 id + modules 元数据交给宿主)由 webpack 运行时与 chunk 文件双方配合完成。
  3. 去重与并发去抖installedChunks 同时表达"未加载 / 加载中(Promise) / 已加载(0)"三种状态。如果两次 import() 落在同一个 chunk 上且第一次仍在进行中,第二次会直接复用第一次的 Promise——这正是示例中 example.jsmethods.js 先后加载同一 ./counter不会重复发起网络请求、模块也只实例化一次的底层保证。
  4. 模块缓存保证状态唯一./counter 无论从哪个入口触发加载,最终都落在同一个 __webpack_module_cache__ 条目上。因此开发模式下 counter.increment() 的三次调用能在同一个 value 上累加,resetCounter() 也能重置同一状态。

业务代码被编译后的形态

template.md 中,业务逻辑被编译为(含 /***/ 分隔注释的原始产物节选):

;// ./methods.js
const resetCounter = async () => {
	(await __webpack_require__.ei(1, () => (import(/*! import() */ "./dist/1.output.js"))).then(() => (__webpack_require__(/*! ./counter */ 1)))).reset();
};

const print = value => console.log(value);

;// ./example.js
setTimeout(async () => {
	const counter = await __webpack_require__.ei(1, () => (import(/*! import() */ "./dist/1.output.js"))).then(() => (__webpack_require__(/*! ./counter */ 1)));
	print(counter.value);
	counter.increment();
	counter.increment();
	counter.increment();
	print(counter.value);
	await resetCounter();
	print(counter.value);
}, 100);

可见 import("./counter") 被规整为固定两段式调用:先 __webpack_require__.ei(chunkId, importFn) 确保 chunk 加载完成,再 __webpack_require__(moduleId) 从模块缓存中取出并执行 ./counter 模块函数,拿到其 exports 作为命名空间对象。./methods.js./example.js 被拼合在同一个模块函数中(注释头 ./example.js + 1 modules),印证了 concatenateModules 的作用。chunk id 与 module id 在该开发输出中均为 1(示例的 output.js 属于 chunk 0)。

生产构建的对比:压缩与确定性 id

使用 --mode production(或等效配置)构建后,产物规模显著下降:

asset output.js 1.12 KiB [emitted] [javascript module] [minimized] (name: main)
asset 481.output.js 222 bytes [emitted] [javascript module] [minimized]
chunk (runtime: main) 481.output.js 146 bytes [rendered]
  > ./counter ./methods.js 2:8-27
  > ./counter ./example.js 4:23-42
  ./counter.js 146 bytes [built] [code generated]
    [exports: decrement, increment, reset, value]
    import() ./counter ./example.js + 1 modules ./example.js 4:23-42
    import() ./counter ./example.js + 1 modules ./methods.js 2:8-27
chunk (runtime: main) output.js (main) 420 bytes (javascript) 2.43 KiB (runtime) [entry] [rendered]
  > ./example.js main
  runtime modules 2.43 KiB 4 modules
  ./example.js + 1 modules 420 bytes [built] [code generated]
    [no exports]
    [no exports used]
    entry ./example.js main
    used as library export
webpack X.X.X compiled successfully

几点对比结论:

  • 文件体积:入口从 5.47 KiB 降至 1.12 KiB,异步 chunk 从 1.3 KiB 降至 222 字节,均带 [minimized] 标记。压缩收益明显——异步 chunk 本身只有 146 字节业务代码,绝大部分开销在运行时公共代码上,而运行时只进入口 chunk。
  • 模块/chunk id 变化:开发构建的 chunk 1 在生产构建中变为 481.output.js(chunk 与 module id 均采用生产模式默认的确定性数值化 id,不受开发模式命名影响,从而保证 hash 稳定)。
  • 依赖关系信息一致import() 的两个调用点、./counter 的导出集合(decrement, increment, reset, value)在两个模式下完全相同,说明代码分割决策不随优化开关变化。

生产产物的完整形态(单行压缩代码,整理自模板)展示了同一套机制在压缩后的骨架:

var e = {};
const t = {};
function o(r) {
	const n = t[r];
	if (void 0 !== n) return n.exports;
	const i = t[r] = { exports: {} };
	return er, i.exports;
}
o.m = e, o.d = (e, t) => { /* ...define property getters... */ },
o.o = (e, t) => Object.hasOwn(e, t),
o.r = e => { /* ...make namespace object... */ },
(() => {
	const e = { 792: 0 };
	const t = t => { /* ...installChunk:写回模块、标记 id、执行 chunk 运行时... */ };
	o.ei = (r, n) => { /* ...去重后的原生 import() 加载与 Promise 复用... */ };
})();
const r = e => console.log(e);
setTimeout(async () => {
	const e = await o.ei(481, () => import("./dist/481.output.js")).then(() => o(481));
	r(e.value), e.increment(), e.increment(), e.increment(), r(e.value),
	await (async () => { (await o.ei(481, () => import("./dist/481.output.js")).then(() => o(481))).reset(); })(),
	r(e.value);
}, 100);

生产版把 resetCounter 内联成了入口异步流程中的 IIFE,两个动态加载点都指向 chunk id 481 与模块 id 481——再次印证"两个调用点共享同一异步 chunk,只加载一次"的分割结果。同时注意产物以 export 结尾(output.module 生效),使其可作为 ESM 库被宿主页面直接 import

运行时的语义衔接与可扩展阅读

如果你希望继续深入理解本示例背后的机制与同类场景,仓库内还有以下直接相关资源:

  • 运行时的辅助函数(__webpack_require__.o 的 hasOwnProperty 简写、__webpack_require__.d 的导出 getter 定义、__webpack_require__.r 的 namespace 标记)在产物头部均有出现,其实现与 lib/RuntimeGlobals.js 中声明的运行时全局一一对应;
  • 经典 JSONP/script 式代码分割对比可看 code-splitting(其 template.md 讲解 require.ensure 与按需 chunk、[README](https://gitcode.com/GitHub_Trending/web/webpack/blob/896506966c25d1032c757c28c7422ec5ffc15705/examples/code-splitting/README.md?utm_source=gitcode_repo_files) 分析 b 模块如何被优化器从 on-demand chunk 中剔除);
  • Harmony 语法 + 代码分割的结合见 code-splitting-harmony;使用 import() 动态拼接上下文(import.meta.webpackContext)的场景见 code-splitting-native-import-context,其中 code-splitting-specify-chunk-name 展示了用 magic comment 指定异步 chunk 名称的写法;
  • 若要观察同源模块在两个入口 chunk 间的拆分与复用策略,extra-async-chunkcommon-chunk-and-vendor-chunk 提供了更多入口/公共 chunk 的对比样本。

小结

module-code-splitting 是一个小而完整的实验样本:它用三个模块、一处静态导入与两处动态导入,精确展示了 ESM 输出形态下 webpack 代码分割的完整链路——import() 调用点如何汇聚成一个独立 async chunk、运行时 __webpack_require__.ei 如何用原生 import() 加模块缓存实现"多调用点共享、单次加载"、以及 output.module/experiments.outputModule/library.type: "module" 三者如何配合产出可被宿主直接 import 的现代格式产物。对照 template.md 中保留的开发与生产两套 Stats 与产物快照,读者可以把抽象的运行机制落到具体的字节与 id 变化上,进而在自己的多页面或库工程中复现同样的按需加载收益。

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