首页
/ webpack 模块级代码分割(module code splitting)实战:基于原生 ES Module 输出的异步 import 与按需加载

webpack 模块级代码分割(module code splitting)实战:基于原生 ES Module 输出的异步 import 与按需加载

2026-09-07 18:43:39作者:温艾琴Wonderful

导读

本文围绕 webpack 官方示例 examples/module-code-splitting 展开,演示了当构建产物本身就是原生 ES Module(output.module / experiments.outputModule)时,如何借助动态 import()counter.js 这样的模块拆成独立的异步 chunk,并按需加载、共享模块实例。读完本文,你将掌握 output.module: truelibrary.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 对象,因此能访问 valueincrementreset 等导出。

被复用的异步加载逻辑

methods.js 展示了同样依赖 counter 的另一个异步导出:

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

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

注意 example.jsmethods.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)。由于模块内部 valuelet 可变绑定,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.jsInfo 统计等小节,均来自对该示例实际执行 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 keeps ensureChunk timing 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);

可以从产物中观察到三个重要事实:

  1. 静态导入被作用域提升(concatenation)合并methods.jsexample.js 被标注为 ./example.js + 1 modules 打包进同一 chunk,import { resetCounter, print } 变成了同作用域内的直接函数引用——这是 concatenateModules: true 的效果;
  2. 每个动态 import 都变成两步:先 __webpack_require__.ei(chunkId, () => import(...)) 确保 chunk 安装,再 __webpack_require__(moduleId) 真正取模块。chunkId 1、moduleId 1 都是 ./counter 的数值编号;
  3. 两个源码调用点被统一映射example.jsmethods.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 决定的 792481 等数值(这也是统计信息里异步 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 后):

  1. 主模块立即执行,注册 setTimeout 回调,100ms 内不会触发 ./counter 的加载;
  2. 100ms 后回调执行,__webpack_require__.ei 判断 chunk 1 未安装,于是 import("./dist/1.output.js") 拉取异步 chunk 并调用 installChunk 安装;
  3. __webpack_require__(1) 从模块缓存返回 ./counter 的 namespace,value0,打印 0
  4. 三次 increment() 使 value 变为 3,打印 3
  5. 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.moduleoutput.library.type: "module"experiments.outputModule、现代 target 上同时就位,并配合 usedExports/concatenateModules 才能得到文中所展示的精简产物。

如果想在仓库中继续深入研究,推荐以下几个入口:

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388