首页
/ webpack require.context 目录上下文详解:动态 require 的打包原理与实战剖析

webpack require.context 目录上下文详解:动态 require 的打包原理与实战剖析

2026-09-07 11:16:51作者:瞿蔚英Wynne

在 webpack 中,当代码里的模块路径不是完整静态字符串、而是一个"目录 + 动态变量"的表达式时(如 require("./templates/" + name)),webpack 无法在编译期确定具体要加载哪个文件。本文以开源仓库 webpack 官方示例 examples/require.context 为骨架,完整还原该类场景下的产物形态,并结合 lib/ContextModule.jslib/dependencies/RequireContextDependencyParserPlugin.js 等源码揭示其底层实现。读完你既能看懂动态 require 的打包产物,也能掌握 require.context 的参数语义、mode 取值,以及它如何与 Code Splitting 组合实现按需加载。

一、示例场景:运行期才决定要哪个模板

官方示例的入口文件 examples/require.context/example.js 只有几行:

function getTemplate(templateName) {
	return require("./templates/"+templateName);
}
console.log(getTemplate("a"));
console.log(getTemplate("b"));

函数 getTemplate 接收的参数是运行期才传入的字符串,require 的参数是 "./templates/" + templateName 这样一个动态拼接表达式。webpack 编译器不可能在构建时静态分析出它到底引用了 a.jsb.js 还是某个不存在的文件——因此 webpack 采用"目录上下文(Context)"策略:把该目录下所有符合匹配条件的文件全部打包进来,并生成一张运行时查找表,真正加载哪个模块留到浏览器执行 getTemplate(...) 那一刻再决定。

示例的模板目录 examples/require.context/templates 下有三个文件,每个文件的代码模式完全一致(示例 README 称之为 "All templates are of this pattern"):

module.exports = function() {
	return "This text was generated by template A";
}

templates/a.jstemplates/b.jstemplates/c.js 各自导出一个函数,函数执行后返回一句话。它们彼此独立、互不引用,非常适合用来观察"上下文模块"如何把它们统一收编进产物。

说明:示例目录中的 README.md 是仓库通过 template.md 模板自动生成/渲染得到的构建报告文档,其中占位符 _{{example.js}}__{{dist/output.js}}__{{stdout}}__{{production:stdout}}_ 会被真实入口源码、产物与构建输出替换。因此该 README 里的每段产物代码与统计数字,都是该示例在对应 webpack 版本下的真实运行结果。

二、产物剖析:上下文模块(Context Module)长什么样

1. 一张把模块名映射到模块 id 的查找表

在示例 README 展示的 dist/output.js 中,webpack 把三个模板连同上下文模块一起打包。模块 1 就是上下文模块本身,它内部先维护一张 map

const map = {
	"./a": 2,
	"./a.js": 2,
	"./b": 3,
	"./b.js": 3,
	"./c": 4,
	"./c.js": 4
};

注意这张表同时收录了带扩展名和不带扩展名两种写法("./a""./a.js" 都指向模块 2),这是因为 webpack 在解析目录上下文时按规则系统补全/匹配了扩展名。模块 2、3、4 则依次是 templates/a.jstemplates/b.jstemplates/c.js 三个普通模块,各自仍是熟悉的 CommonJS 形态(产物注释里会标记 unknown exports (runtime-defined)CommonJS bailout: module.exports is used directly)。

2. 上下文加载函数与它暴露的 API

map 之上是上下文模块的加载函数,这正是整个机制的核心:

function webpackContext(req) {
	const id = webpackContextResolve(req);
	return __webpack_require__(id);
}
function webpackContextResolve(req) {
	if(!__webpack_require__.o(map, req)) {
		const e = new Error("Cannot find module '" + req + "'");
		e.code = 'MODULE_NOT_FOUND';
		throw e;
	}
	return map[req];
}
webpackContext.keys = function webpackContextKeys() {
	return Object.keys(map);
};
webpackContext.resolve = webpackContextResolve;
module.exports = webpackContext;
webpackContext.id = 1;

拆开看,这里有四个值得注意的 API:

  • webpackContext(req):核心加载函数,先通过 resolve 把请求字符串换算成模块 id,再调用运行时模块加载器 __webpack_require__(id) 真正执行模块;
  • webpackContextResolve(req):先借助运行时工具 __webpack_require__.o(即 Object.prototype.hasOwnProperty 的简写,见产物中的 runtime 片段)检查 req 是否在 map 中,找不到就抛出带有 code = 'MODULE_NOT_FOUND' 的运行时错误——这与 Node.js 原生模块解析失败的语义保持一致;
  • webpackContext.keys:返回上下文中所有可解析模块名的数组,实践中常用于"枚举目录内所有匹配文件"(例如批量注册路由或自动加载图标);
  • webpackContext.resolve:仅做字符串到模块 id 的解析、不加载模块。

3. 入口代码被改写

产物末尾的入口模块展示了改写结果(见 README):示例中手写的 require("./templates/"+templateName) 被编译成:

function getTemplate(templateName) {
	return __webpack_require__(1)("./"+templateName);
}

也就是说,原本的目录拼接表达式被等价替换为"加载模块 1(上下文模块),并以运行期参数调用它"。模块 1 的产物注释头也清楚标明了它的构建语义:

!*** ./templates/ sync ^\.\/.*$ ***!

这里的 sync 指同步加载模式,^\.\/.*$ 是匹配模板文件的正则。

4. 配套运行时代码

产物中还包含了两个基础运行时片段(见 README):模块缓存对象 __webpack_module_cache__ 与加载函数 __webpack_require__,以及 __webpack_require__.o 这个 hasOwnProperty 简写。前者保证同一模块只执行一次并缓存其 exports,后者被 webpackContextResolve 用来做映射表成员检查。这些都印证了上下文模块本身"只负责调度、不承担缓存"的职责划分——模块实例化与缓存完全复用全局运行时。

三、底层实现:解析插件如何识别 require.context

上述"上下文模块"的生成有两个触发入口:一是本文示例的动态拼接 require 表达式,二是显式 API require.context。两者的产物形态相同。它们的解析入口位于 lib/dependencies/RequireContextDependencyParserPlugin.js,其 apply 方法在 parser.hooks.call.for("require.context") 上注册了处理器,并给出了三个默认值(RequireContextDependencyParserPlugin.js#L22-L26):

let regExp = /^\.\/.*$/;
let recursive = true;
let mode = "sync";

随后按参数个数(expr.arguments.length)逐级解析,从源码可以清晰还原 require.context 的四参签名(L27-L65):

参数位置 含义 默认值 解析方式
第 1 个 目录路径,如 "./templates" 必填 必须是静态字符串(isString()
第 2 个 recursive:是否递归匹配子目录 true 必须是布尔值
第 3 个 regExp:匹配文件的过滤正则 /^\.\/.*$/ 必须是正则表达式字面量
第 4 个 mode:上下文加载模式 "sync" 必须是字符串

任何一个参数不符合对应类型,解析插件都会直接 return(放弃处理);全部校验通过后,它创建 RequireContextDependency,把 request / recursive / regExp / mode 连同 category: "commonjs" 一起记录为依赖,交给后续的 lib/ContextModuleFactory.jslib/ContextModule.js 完成模块收集与生成。这也解释了为什么示例产物头的注释是 ./templates/ sync ^\.\/.*$:目录为 ./templates,模式 sync,默认正则 ^\.\/.*$ 匹配该目录下的所有相对路径文件。

mode 参数的可选值

上面产物中的 sync 只是 mode 的一种取值。在 lib/ContextModule.js 中可以看到针对不同 mode 的分支处理(如 ContextModule.js#L581"sync" / "eager" 的处理、L599"weak" 的处理、L607"lazy" 的处理),完整的 mode 语义如下:

mode 语义
sync 默认值,模块同步打包进当前 chunk,调用时立即同步加载(对应示例产物形态)
eager 同样同步加载,但会在构建期生成异步 chunk 的占位/引用,使模块仍可被单独提取并支持错误处理语义
lazy 上下文内每个模块生成独立的异步 chunk,只有运行时真正 require 到才按需加载,天然配合 Code Splitting
weak 上下文模块尝试从运行时已加载的模块中解析,若目标尚未加载则直接失败(不发起新的加载),通常配合外部化使用

实际使用的完整调用形式是:

require.context(directory, useSubdirectories = true, regExp = /^\.\/.*$/, mode = 'sync');

结合解析插件源码可以看到:若省略后三个参数,useSubdirectoriesregExpmode 分别以上表中的默认值生效——即默认递归匹配目录下所有文件并以同步方式打包,这正是绝大多数 require.context 入门用法的实际含义。

四、构建统计解读:同一示例的两种模式对比

示例 README 末尾的 # Info 章节给出了该示例的两次构建统计(格式遵循 webpack stats 输出),可用来说明上下文模块对产物体积的影响。

Unoptimized(开发/未压缩)模式:

asset output.js 3.75 KiB [emitted] (name: main)
chunk (runtime: main) output.js (main) 603 bytes (javascript) 89 bytes (runtime) [entry] [rendered]
  > ./example.js main
  dependent modules 457 bytes [dependent] 4 modules
  runtime modules 89 bytes 1 module
  ./example.js 146 bytes [built] [code generated]
    [used exports unknown]
    entry ./example.js main
webpack X.X.X compiled successfully

Production mode(压缩)模式:

asset output.js 830 bytes [emitted] [minimized] (name: main)
...
  ./example.js 146 bytes [built] [code generated]
    [no exports used]
webpack X.X.X compiled successfully

对比中可以提取几个关键事实:

  • 主 chunk 共包含 4 个 dependent modules(457 bytes),正好对应 1 个上下文模块 + 3 个模板模块,加上 146 bytes 的入口 example.js,合计 603 bytes 的 javascript 主体;
  • 另有 89 bytes / 1 module 的 runtime modules(即前面剖析的 __webpack_require__ 缓存与 hasOwnProperty 简写);
  • 生产模式经过压缩与 tree shaking 分析后,产物从 3.75 KiB 降到 830 bytes,入口模块的标记也从 [used exports unknown](无法静态确定导出使用情况)变为 [no exports used]——因为模板模块走的是运行期动态查找,其导出在编译期被判定为无静态使用。

五、与 Code Splitting 组合:按需加载模板

动态 require/require.context 最常见的进阶用法,是把"目录内文件统一收编"与"按需异步加载"结合起来。示例 README 结尾将读者导向同仓库的组合示例 code-splitted-require.context

该示例的入口 examples/code-splitted-require.context/example.js 演示了如何用 require.ensure(老版本 webpack 的按需加载写法)把上下文加载放进异步回调:

function getTemplate(templateName, callback) {
	require.ensure([], function(require) {
		callback(require("../require.context/templates/"+templateName)());
	});
}
getTemplate("a", function(a) {
	console.log(a);
});
getTemplate("b", function(b) {
	console.log(b);
});

注意这里它甚至直接复用了 require.context 示例中的 templates 目录(路径写成 "../require.context/templates/"),先说明两点关联:

  1. 两个示例共用同一套模板源码code-splitted-require.context 的模板内容与 require.context 示例完全一致,读者可以在两篇 README 的产物之间做横向对比,观察同一个目录上下文在"同步打进主 chunk"与"拆成异步 chunk"两种策略下的差异;
  2. 异步化带来的产物变化:在 code-splitted-require.context 的 README 中,产物不再是单一 output.js,而是出现按需加载的额外 chunk,模板模块从主 chunk 中剥离,只有运行时真正请求到某个模板时才发起异步加载。

在今天的 webpack 中,require.ensure 已被更通用的动态 import() 取代,而上文表格里的 mode: "lazy" 正是"上下文 + 按需加载"的直接表达——每条匹配路径会被单独拆成异步 chunk,是 require.context 面向现代 Code Splitting 的首选形态。

六、小结:理解上下文机制的四条主线

回到仓库中这个最小示例,require.context 的核心心智模型可以归纳为四点:

  1. 静态收集:面对无法静态解析的动态 require,webpack 按"目录 + 正则"把匹配文件全部纳入打包(收集逻辑见 lib/ContextModuleFactory.js,模块实现在 lib/ContextModule.js);
  2. 运行时寻址:上下文模块内部用一张"请求名 → 模块 id"的 map 做解析,找不到即抛出 MODULE_NOT_FOUND
  3. API 完整:除加载本身外还提供 keys(枚举)、resolve(仅解析)等能力,便于做路由注册、目录扫描类功能;
  4. 可组合:通过与 mode: "lazy" 或动态 import()/require.ensure 配合,可在"目录批量收编"之上叠加按需加载。

如果需要继续深入,建议按以下顺序阅读仓库源码与示例:

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