在 Webpack 的世界里,一切资源皆模块。编译器把入口文件、入口引用的每个资源、甚至运行时注入的代码,统统抽象成依赖图上的节点,而承载这些节点的对象并不只有一种形态。翻开源码目录 lib 会看到 NormalModule、RawModule、ContextModule、DelegatedModule、ExternalModule 等一串实现,其中 NormalModule 和 RawModule 是最基础也最容易混淆的一对:前者是资源文件的标准封装,负责 loader 转换与依赖解析;后者则是一辆跳过所有构建环节的直通车。理解两者的边界,是编写高质量 Webpack 插件、排查构建异常的基本功。

一、继承体系与构造参数:两者从出生就不同
NormalModule 与 RawModule 都继承自同一个基类 Module。这个基类定义了模块在依赖图中的通用行为:identifier 返回模块的唯一标识,build 负责构建,codeGeneration 负责生成最终代码,size 用于统计体积,updateHash 参与哈希计算。两个子类的差异,正是从它们对这套接口的不同实现方式开始的。
先看构造函数。NormalModule 的构造参数多达十几个,包括模块类型 type、上下文路径 context、请求字符串 request、用户请求 userRequest、原始请求 rawRequest、loader 列表 loaders、资源路径 resource、解析器 parser、生成器 generator 等。这一长串参数意味着它天然就是为完整编译流程服务的:NormalModuleFactory 在解析阶段会根据配置中的 rules 匹配 loader,确定模块类型,再把所有信息打包交给构造函数。
RawModule 的构造则朴素得多,核心参数只有三个:source、identifier 和 name。第一个是现成的模块源码字符串,第二个是模块标识,第三个是可读名称。不需要 loader,不需要解析器,甚至不需要文件真实存在于磁盘上。换句话说,NormalModule 描述的是“如何把一个资源编译成模块”,而 RawModule 描述的是“我已经有一段代码,请直接把它当作模块”。
// NormalModule 的构造(简化示意,实际参数更多)
new NormalModule({
type: 'javascript/auto',
resource: '/project/src/index.js',
userRequest: './src/index.js',
rawRequest: './src/index.js',
loaders: [],
parser: javascriptParser,
generator: javascriptGenerator
});
// RawModule 的构造:三个参数就够了
new RawModule(
'module.exports = 42;', // source:现成的源码
'raw-module-42', // identifier:唯一标识
'./answer.js' // name:可读名称
);
把两者的行为差异整理成表格会更直观:
| 对比维度 | NormalModule | RawModule |
|---|---|---|
| 创建方式 | 由 NormalModuleFactory 解析请求后创建 | 直接实例化,传入源码字符串 |
| loader 处理 | 完整执行匹配到的 loader 链 | 完全跳过 |
| AST 解析 | 按模块类型选择 parser 生成语法树 | 不解析,不分析语法 |
| 依赖收集 | 自动提取 import、require 等依赖 | 自身不产生任何依赖 |
| 代码生成 | 由模块类型对应的 Generator 生成 | 原样输出构造时传入的字符串 |
| 模块类型 | js、css、asset 等多种类型 | 固定按 javascript/auto 处理 |
| Source Map | 可结合 devtool 生成映射 | 基本不支持有意义的映射 |
二、NormalModule:一条完整的构建流水线
NormalModule 的 build 方法是整个 Webpack 编译流程的核心。它的执行过程可以概括为三步:第一步执行 loader,把磁盘上的原始资源转换成中间代码;第二步调用 parser 把转换结果解析成 AST;第三步遍历 AST 中的语句,把 import、require、动态 import 等依赖语句收集为 Dependency 对象,挂到模块自身。后续流程中,compilation 会根据这些依赖发起新一轮模块解析,依赖图就是这样一层层生长出来的。
以一个 CSS 文件为例,配置了 style-loader 和 css-loader 之后,NormalModule 在构建时会先让 css-loader 把 CSS 转换成 JS 模块形式的字符串,再让 style-loader 包上一层插入 style 标签的运行时代码,最后由解析器扫描转换结果中的 require 调用,继续收集依赖。整个过程对使用者完全透明,这正是 NormalModule 的价值所在:任何能被 loader 处理的资源,都能以统一的方式进入依赖图。
// NormalModule 构建流程的简化示意
class NormalModule extends Module {
build(options, compilation, resolver, fs, callback) {
// 第一步:执行 loader 链,得到转换后的源码
this.doBuild(options, compilation, resolver, fs, (err) => {
if (err) return callback(err);
// 第二步:按模块类型获取解析器,生成 AST
const parser = this.getParser(this.type);
parser.parse(this._source.source(), {
current: this,
module: this,
compilation: compilation
});
// 第三步:解析过程中收集的依赖已被挂到 this.dependencies
// compilation 会据此继续解析新的模块请求
callback();
});
}
}
值得一提的是,NormalModule 的 identifier 由资源绝对路径、loader 信息、模块类型等共同拼成,这保证了唯一性和稳定性,也让持久化缓存能够通过 identifier 命中历史构建结果。当文件内容与相关配置都没有变化时,模块可以跳过重新构建,这是 Webpack 增量编译效率的基础。
三、RawModule:把现成代码直接送进产物
RawModule 的 build 方法几乎是空的:它不读文件,不跑 loader,不解析语法,只是初始化一些构建元数据就宣告完成。代码生成阶段,它把构造函数里传入的那个字符串包装成 Source 对象直接返回。也就是说,你在构造时给它什么,最终产物里就是什么,一个字节都不会变。
这种设计看似简单,却解决了一个很实际的问题:有些代码根本不是从文件来的。HMR 热更新在生成补丁模块时,需要把运行时拼装的更新代码作为一个模块塞进 chunk;某些构建工具需要把配置序列化成一段 JS 代码注入产物;DLL、外部模块等场景也存在“代码是生成的,不是读来的”需求。这些场景如果强行走 NormalModule,就得先把字符串写到磁盘再让编译器读回来,既别扭又低效。
// RawModule 的核心逻辑(简化示意)
class RawModule extends Module {
constructor(source, identifier, name, readableIdentifier) {
super('javascript/auto', null);
this.sourceStr = source;
this.identifierStr = identifier || 'raw-module';
this.nameStr = name || 'raw-module';
this.readableIdentifierStr = readableIdentifier || this.nameStr;
}
identifier() {
return this.identifierStr;
}
build(options, compilation, resolver, fs, callback) {
// 没有任何编译动作,直接完成
this.buildInfo = {};
this.buildMeta = {};
callback();
}
codeGeneration() {
// 把构造时传入的字符串原样作为产物代码
const sources = new Map();
sources.set('javascript', new RawSource(this.sourceStr));
return { sources };
}
}
需要注意两个隐含限制。其一,RawModule 的模块类型固定为 javascript/auto,传入的代码会被当作 JS 处理,如果你想注入 CSS 或其他资源形态,它帮不上忙。其二,identifier 完全由调用方决定,如果两个不同的 RawModule 使用了相同的 identifier,依赖图和缓存都会出现混乱,这是插件开发中最常见的翻车点之一。
四、使用场景对比与插件实战
NormalModule 的适用场景无需多言:项目中所有真实存在的资源文件,JS、CSS、图片、字体、JSON,只要经过 rules 匹配和 loader 转换,最终都会以 NormalModule 的形态参与编译。它是构建流水线上的主力,绝大多数开发者日常打交道的就是它。
RawModule 的舞台则在标准流程之外,典型的有三类:一是虚拟模块,比如把环境配置、构建元信息序列化成一个模块供业务代码引用;二是运行时代码注入,HMR 的补丁模块就是现成的例子;三是桥接外部产物,把已经生成好的代码片段纳入依赖图统一管理。下面用一个虚拟配置模块的插件演示具体做法:拦截 NormalModuleFactory 的 factorize 钩子,当请求命中约定路径时直接返回 RawModule,绕过文件解析。
const webpack = require('webpack');
const RawModule = webpack.RawModule;
class VirtualConfigPlugin {
apply(compiler) {
compiler.hooks.normalModuleFactory.tap('VirtualConfigPlugin', (factory) => {
factory.hooks.factorize.tapAsync(
'VirtualConfigPlugin',
(resolveData, callback) => {
if (resolveData.request === 'virtual:config') {
// 业务代码里 import 'virtual:config' 即可拿到这份配置
const config = { api: 'https://api.ipipp.com', debug: false };
const source = 'module.exports = ' + JSON.stringify(config) + ';';
const module = new RawModule(source, 'virtual:config', 'virtual:config');
return callback(null, module);
}
// 其他请求走正常流程
callback();
}
);
});
}
}
module.exports = VirtualConfigPlugin;
这个插件生效后,业务代码中的 import 'virtual:config' 会被直接解析为一个内容固定的模块,磁盘上不需要存在任何对应文件。如果改用 NormalModule 实现同样效果,就得借助虚拟文件系统伪装出真实文件,复杂度立刻上升一个量级。反过来,如果模块内容来自真实文件且需要 loader 参与,比如 TS 转译、CSS 处理,那就必须使用 NormalModule,RawModule 完全无法胜任。
五、选型建议与常见踩坑点
选型的判断标准其实只有一条:代码是否需要经过编译。需要 loader 转换、语法分析、依赖收集的,交给 NormalModule;内容已经确定、拿来即用的字符串,用 RawModule。介于两者之间的情况,优先考虑 NormalModule,因为它对缓存、Source Map、模块类型系统的支持都更完整。
使用 RawModule 时有几个坑值得提前记下。第一,identifier 必须全局唯一且跨构建稳定,否则持久化缓存可能返回错误内容,排查起来非常隐蔽。第二,传入的代码必须是最终形态,没有 loader 意味着没有 babel 转译,如果目标运行环境较老,语法层面要自己把关。第三,RawModule 不会自动产生依赖,代码里如果引用了其他模块,需要手动构造 Dependency 并正确挂接,否则产物运行时会报找不到模块。第四,它对 Source Map 基本无能为力,产物报错时定位会直接指向生成字符串,调试体验要提前做好心理预期。
最后补充一点版本层面的差异。Webpack 4 时代,HMR 补丁、部分运行时代码都直接使用 RawModule;到了 Webpack 5,运行时体系被重构成了独立的 RuntimeModule,职责划分更清晰,但 RawModule 作为公开导出的内部类依然保留,插件生态中虚拟模块类方案也依然大量依赖它。无论版本如何演进,理解“完整流水线”与“直通车”这两种模块形态的分野,都能帮助你在阅读源码和编写插件时快速定位问题所在。
NormalModuleRawModuleWebpack插件开发修改时间:2026-09-26 05:39:18