webpack 原生 ESM 代码分割实战:以 module-code-splitting 示例剖析 import() 异步按需加载
本文以本仓库 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-splitting、code-splitting-harmony、code-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.js 与 methods.js 两处共同引用 |
| webpack.config.js | ESM 输出的关键配置(output.module、experiments.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);
业务时序非常清晰:
- 静态导入
./methods中的resetCounter与print; - 100ms 后进入异步流程,通过
await import("./counter")按需获取计数器模块的命名空间对象; - 依次打印初值
0,自增三次后打印3,最后调用resetCounter()重置后再打印0。
注意 import("./counter") 返回的是一个 Module Namespace 对象(命名空间对象),因此访问其导出需要使用 counter.value、counter.increment() 的形式,这与静态导入后直接使用具名标识符的写法不同。
methods.js:动态导入的第二个调用点
export const resetCounter = async () => {
(await import("./counter")).reset();
};
export const print = value => console.log(value);
这里的关键点是:example.js 与 methods.js 都在动态导入同一个 ./counter。在 webpack 的依赖图视角中,./counter 于是拥有两个 import() 调用点(后文 Stats 中会看到 import() ./counter ... 出现两次),并因此被独立抽到一个单独的异步 chunk 中。
counter.js:共享的有状态模块
export let value = 0;
export function increment() {
value++;
}
export function decrement() {
value--;
}
export function reset() {
value = 0;
}
它导出一个可变的 value 以及 increment、decrement、reset 三个操作函数,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);
};
})();
这段运行时的设计要点:
importFn就是原生import():模板产物中异步加载被编译为import(/*! import() */ "./dist/1.output.js")。因为是 ESM 输出且目标为现代浏览器,webpack 不再生成 JSONP 或<script>注入逻辑,直接把"加载 chunk"委托给宿主环境的原生动态导入。installChunk接收一个"描述对象":解构出__webpack_esm_ids__(chunk id 列表)、__webpack_esm_modules__(chunk 内的模块映射)、__webpack_esm_runtime__(chunk 级运行时)。模块被逐个写入全局__webpack_require__.m后,chunk 被标记为0(已加载)。这说明异步 chunk 文件的导出契约(如何把id + modules元数据交给宿主)由 webpack 运行时与 chunk 文件双方配合完成。- 去重与并发去抖:
installedChunks同时表达"未加载 / 加载中(Promise) / 已加载(0)"三种状态。如果两次import()落在同一个 chunk 上且第一次仍在进行中,第二次会直接复用第一次的 Promise——这正是示例中example.js与methods.js先后加载同一./counter时不会重复发起网络请求、模块也只实例化一次的底层保证。 - 模块缓存保证状态唯一:
./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-chunk 与 common-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 变化上,进而在自己的多页面或库工程中复现同样的按需加载收益。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00