webpack 模块级代码分割(module code splitting)实战:基于原生 ES Module 输出的异步 import 与按需加载
导读
本文围绕 webpack 官方示例 examples/module-code-splitting 展开,演示了当构建产物本身就是原生 ES Module(output.module / experiments.outputModule)时,如何借助动态 import() 把 counter.js 这样的模块拆成独立的异步 chunk,并按需加载、共享模块实例。读完本文,你将掌握 output.module: true 与 library.type: "module" 的配置方式、源码 import() 与 export 在运行时的真实改写形态(__webpack_require__.ei 可分析式 chunk 导入)、开发与生产两种模式下的产物差异,以及如何用统计信息验证代码分割是否生效。
示例整体结构:一个"计数模块"的按需加载
示例目录 examples/module-code-splitting 下共有 6 个文件,全部文件均可直接阅读:
| 文件 | 作用 |
|---|---|
| example.js | 入口模块,延迟 100ms 后异步加载 ./counter |
| methods.js | 导出 resetCounter(内部再次 import ./counter)与 print |
| counter.js | 被异步加载的共享状态模块,维护 value 并提供 increment/decrement/reset |
| webpack.config.js | 启用"模块输出"(module output)的核心配置 |
| index.html | 以 <script type="module"> 加载产物 dist/main.mjs 的演示页 |
| README.md | 构建后由模板自动生成的完整输出与打包统计 |
入口:定时器触发按需加载
example.js 在启动 100ms 后进入异步流程,关键点有两个:
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);
import("./counter")是 webpack 的代码分割入口点:它不是一个同步静态导入,而是产生一个运行时才加载的异步 chunk,只有当定时器回调真正执行时模块才被请求与求值。counter拿到的是该模块的 namespace 对象,因此能访问value、increment、reset等导出。
被复用的异步加载逻辑
methods.js 展示了同样依赖 counter 的另一个异步导出:
export const resetCounter = async () => {
(await import("./counter")).reset();
};
export const print = value => console.log(value);
注意 example.js 与 methods.js 都通过 import("./counter") 引用同一个模块。webpack 会把它们合并到同一个异步 chunk,而不是为每个调用点生成一份拷贝——这就是代码分割与共享去重的基础。
被分割的目标模块
counter.js 是一个带内部状态的 ES Module:
export let value = 0;
export function increment() {
value++;
}
export function decrement() {
value--;
}
export function reset() {
value = 0;
}
它拥有 4 个导出(value/increment/decrement/reset)。由于模块内部 value 是 let 可变绑定,increment() 的副作用在任何 import 该模块的调用方之间都是共享的——这正是本示例想验证的行为:异步 chunk 加载一次之后,模块缓存全局生效,多次 import() 返回同一个模块实例。
关键配置:让产物本身就是原生 ES Module
要让 webpack 输出可供浏览器 <script type="module"> 直接使用的 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:产物以 ES Module 语法(import/export)输出,而不是 webpack 传统的 IIFE/CommonJS 包装;output.library.type: "module":声明产物本身作为"库"导出的方式就是原生 ESM。在 WebpackOptions 类型定义中它属于 declarations/WebpackOptions.d.ts 所描述的 library 类型体系中的module分支,与assign/var/commonjs/umd等传统目标并列;experiments.outputModule: true:启用"模块输出"实验特性,output.module与上述 library 类型必须同时满足实验条件(后文给出的产物output.js首行即var e={}的模块注册表,随后是真正的export/namespace 处理,而非module.exports,证实了这一点);target: "browserslist: last 2 chrome versions":因为产物依赖浏览器原生import()来加载异步 chunk,所以目标必须是支持动态 import 的现代环境;optimization.usedExports: true:启用"使用的导出"分析,让 tree-shaking 有机会在产物中省略未使用的导出(详见后文源码产物分析);optimization.concatenateModules: true:在非异步依赖允许的范围内做作用域提升(scope hoisting),把可静态合并的模块拍平进同一作用域,减小体积。
加载方式
examples/module-code-splitting/index.html 中产物以 .mjs 命名并通过原生 <script type="module"> 引入:
<script src="./dist/main.mjs" type="module"></script>
补充说明:实际构建时文件名由 webpack 输出规范决定。示例构建统计中主 chunk 显示为
output.js、异步 chunk 显示为1.output.js(生产模式为481.output.js);demo 页面使用.mjs以强调其为 ESM。若自定义文件名模板,可参考 webpack 的[name]/[contenthash]等占位符机制。
如何重新构建本示例
在仓库根目录下,使用 examples 的构建脚本即可复现 README 中的产物与统计信息。webpack 官方 examples 统一由 examples/examples.js 发现包含 template.md 的示例目录、由 examples/buildAll.js 批量执行构建,而产物统计经过 examples/template-common.js 中的 replaceBase 处理(裁剪时间、格式化 runtime 区块等)后回填到 README。因此 README.md 中的 dist/output.js、Info 统计等小节,均来自对该示例实际执行 webpack 的真实输出。
从源码看"模块输出 + 异步 import"的运行时代码
本示例与传统 webpack 输出最本质的差异,在于异步 chunk 的加载方式。传统模式会注入基于 <script> 标签或 importScripts 的 chunk 加载运行时;而原生 ESM 输出下,webpack 复用了宿主环境的动态 import()。
__webpack_require__.ei:可分析式的单 chunk import
看 examples/module-code-splitting/README.md 里折叠的 runtime 区块,核心是这一小段:
// object to store loaded and loading chunks
const installedChunks = {
0: 0
};
const installChunk = (data) => {
let {__webpack_esm_ids__, __webpack_esm_modules__, __webpack_esm_runtime__} = data;
// add "modules" to the modules object,
// then flag all "ids" as loaded and fire callback
...
};
__webpack_require__.ei = (chunkId, importFn) => {
let promises = [];
let installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
if(installedChunkData !== 0) { // 0 means "already installed".
// a Promise means "currently loading".
if(installedChunkData) {
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);
};
这段逻辑的语义在源码中有着明确的落点:__webpack_require__.ei 是 webpack 运行时的全局键之一,对应 lib/RuntimeGlobals.js 中定义的 analyzableChunkImport:
analyzable single-chunk
import()that keepsensureChunktiming and deduplication:对已安装的 chunk 立即 resolve,否则执行字面的import()并将其安装(installed)。
也就是说:
installedChunks中记录每个 chunk 的加载状态:0表示已安装,[resolve, Promise]表示加载中,undefined表示未加载;- 对同一 chunk 的并发请求会被去重(复用同一 Promise,不会重复
import()); importFn是源码中import("./dist/1.output.js")的字面保留,这也是为什么它被注释为/*! import() */——webpack 让运行时的代码分割逻辑与宿主 ESM 的 import 机制直接对接;installChunk(data)读取异步 chunk 暴露的三个字段__webpack_esm_ids__ / __webpack_esm_modules__ / __webpack_esm_runtime__,把其中模块注册进__webpack_require__.m,再逐一把 chunk id 标记为已加载并触发等待中的 Promise。
这也解释了为什么此示例构建后 installedChunks 只注册了主 chunk 0,而 async chunk 不靠 <script> 注入,而是作为独立 ESM 文件被运行时动态 import() 拉取。
产物逐行解析:静态导入被内联、动态导入被改写
开发模式产物
examples/module-code-splitting/README.md 中展示了非压缩主 chunk output.js 的业务代码部分:
/*!********************************!*\
!*** ./example.js + 1 modules ***!
\********************************/
/*! namespace exports */
/*! runtime requirements: __webpack_require__.ei, __webpack_require__ */
;// ./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);
可以从产物中观察到三个重要事实:
- 静态导入被作用域提升(concatenation)合并:
methods.js与example.js被标注为./example.js + 1 modules打包进同一 chunk,import { resetCounter, print }变成了同作用域内的直接函数引用——这是concatenateModules: true的效果; - 每个动态 import 都变成两步:先
__webpack_require__.ei(chunkId, () => import(...))确保 chunk 安装,再__webpack_require__(moduleId)真正取模块。chunkId1、moduleId1都是./counter的数值编号; - 两个源码调用点被统一映射:
example.js与methods.js中的import("./counter")在产物中指向同一个 chunk 加载 + 同一个模块 id,证明它们共享同一个异步 chunk。
生产模式产物
压缩后 examples/module-code-splitting/README.md 只剩一行单行代码,但仍能辨认出完整骨架:
var e={};const t={};function o(r){...}o.m=e,o.d=(e,t)=>{...},o.o=(e,t)=>Object.hasOwn(e,t),o.r=e=>{...},
(()=>{const e={792:0},t=t=>{let{__webpack_esm_ids__:r,__webpack_esm_modules__:n,__webpack_esm_runtime__:i}=t;...};o.ei=(r,n)=>{...}})();
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);
注意生产模式下模块 id 从可读的递增编号变成了由 hash 决定的 792、481 等数值(这也是统计信息里异步 chunk 名为 481.output.js 的原因)。同时 print 被内联成箭头函数 const r=e=>console.log(e),resetCounter 被内联成 async IIFE。压缩后的主产物仅 1.12 KiB、异步 chunk 仅 222 bytes(见下文统计对比)。
构建统计:用 Info 验证代码分割结果
README 的 Info 小节记录了两个模式的真实构建统计,可以直接验证"counter 被打成独立 chunk、只在需要时才被加载"这一行为。
未优化(Unoptimized / 开发模式)
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(主入口,5.47 KiB)与1.output.js(异步 chunk,1.3 KiB)两个独立 asset; - chunk 归属:
./example.js + 1 modules属于入口 chunk;./counter.js单独位于1.output.js; - 分割依据(reason):
import() ./counter出现在./example.js 4:23-42与./methods.js 2:8-27——恰好对应源码里两处动态 import 的行列位置,直接证明了拆分来自这两次import(); - 导出信息:
./counter.js的可用导出为[exports: decrement, increment, reset, value],为后续 tree-shaking 提供依据; - 入口标注:主 chunk 被
used as library export,呼应了library.type: "module"的配置。
生产模式
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
对比可见:
- 异步 chunk 文件名为
481.output.js(哈希化模块 id 落盘),主文件压缩后仅剩1.12 KiB,异步文件222 bytes; - 分割关系保持不变:
./counter.js仍然只在1.output.js/481.output.js中,reason 行与开发模式一致; - 模块的 JS 逻辑体积(146 bytes)与开发模式相同——压缩只作用于文件外壳与运行时,模块本身的语义单元没有变化;
runtime modules 2.43 KiB 4 modules表明运行时本身独立成模块计入体积,它是每个入口都需要的"基础设施"。
运行行为推演:模块只求值一次
综合源码与产物,可以推演该示例在浏览器中的实际执行顺序(index.html 加载 main.mjs 后):
- 主模块立即执行,注册
setTimeout回调,100ms 内不会触发./counter的加载; - 100ms 后回调执行,
__webpack_require__.ei判断 chunk1未安装,于是import("./dist/1.output.js")拉取异步 chunk 并调用installChunk安装; __webpack_require__(1)从模块缓存返回./counter的 namespace,value为0,打印0;- 三次
increment()使value变为3,打印3; resetCounter()再次经__webpack_require__.ei加载同一 chunk——此时它已被标记为已安装(0),因此不再发起网络请求,直接返回,随后调用reset()归零,最后打印0。
输出序列为 0 → 3 → 0。该行为正是"一次安装、全局共享模块状态"的体现,也是 RuntimeGlobals.analyzableChunkImport 中 "deduplication"(去重)与 "resolves immediately for an installed chunk" 的运行时语义。
小结与延伸阅读
- 核心结论一:
import("./counter")的代码分割并不受output.module: true影响,被引用模块会进入独立异步 chunk;不同的是该 chunk 由浏览器原生import()加载,运行时通过__webpack_require__.ei完成去重与状态跟踪。 - 核心结论二:共享的异步模块(
counter.js)无论被多少调用点import(),都只生成一份 chunk 与一份模块实例。 - 核心结论三:模块输出模式需要在
output.module、output.library.type: "module"、experiments.outputModule、现代target上同时就位,并配合usedExports/concatenateModules才能得到文中所展示的精简产物。
如果想在仓库中继续深入研究,推荐以下几个入口:
- 对比无需模块输出的同类示例:examples/module-code-splitting 之外,examples/code-splitting 展示了传统环境下的
import()与require.ensure差异,examples/harmony 聚焦 ES Module 静态导入体系; - 阅读 webpack.config.js 所用的各配置项在 schemas/WebpackOptions.json 中的字段定义与默认值;
- 在 lib/RuntimeGlobals.js 中继续检索
analyzableChunkImport的注释,理解该全局键所服务的 ESM 加载场景; - 参考 examples 的自动化生成机制(examples/examples.js、examples/template-common.js),理解 README 中产物与统计信息均为构建脚本自动生成,从而可以放心地将其当作真实输出佐证。
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