
本文介绍一种通过 Webpack 自定义插件,在构建完成阶段(done 钩子)递归遍历编译模块、调用 originalSource().source() 获取已过 loader 处理但未打包封装的原始 ES 模块代码,并按原始目录结构输出为独立文件的实用方案。
本文介绍一种通过 webpack 自定义插件,在构建完成阶段(`done` 钩子)递归遍历编译模块、调用 `originalsource().source()` 获取已过 loader 处理但未打包封装的原始 es 模块代码,并按原始目录结构输出为独立文件的实用方案。
在基于 Webpack 的现代前端构建流程中,有时我们需要绕过最终的 bundle 封装,直接获取经过 loader(如 Babel、自定义宏处理器、别名解析等)处理后的纯净 ES 模块源码——例如用于服务端动态加载、CDN 分发、或嵌入文档站点中以支持原生 <script type="module"></script> 的“模块化”运行方式。此时,Webpack 的标准 emit 钩子并不适用,因为它操作的是即将写入文件系统的 compilation.assets(即已包装的 chunk),而非原始模块内容。
正确时机应是 compiler.hooks.done:它在完整编译结束、所有模块已解析、转换、优化完毕后触发,此时 compilation.modules 已包含全部处理后的模块实例,且每个模块可通过 originalSource()?.source() 安全获取其最终字符串形式的源码(即跳过 webpack 运行时注入、__webpack_require__ 封装等“fluff”)。
以下是一个生产可用的 TypeScript 插件实现(兼容 Webpack 5+):
import * as path from 'path';
import * as fs from 'fs';
import { Compiler, Stats, Module } from 'webpack';
class SourceInterceptorPlugin {
handleModulesRecursively(modules: Set<Module>, sources: Map<string, string>) {
for (const module of modules) {
// 仅处理 ES 模块(排除 asset、css 等非 JS 模块)
if (module.type !== 'javascript/esm') continue;
// 递归处理子模块(如内联的 dynamic import 或 split chunk 中的嵌套模块)
const innerModules = (module as any)['modules'] as Set<Module> | undefined;
if (innerModules && innerModules.size > 0) {
this.handleModulesRecursively(innerModules, sources);
continue;
}
// 获取原始处理后源码(关键!)
const source = module.originalSource()?.source();
if (!source || typeof source !== 'string') {
console.warn(`[SourceInterceptor] Skipped module: ${module.identifier()}`);
continue;
}
// 仅保留 NormalModule(即真实资源文件,排除 synthesized 模块)
if ('resource' in module && typeof (module as any).resource === 'string') {
sources.set((module as any).resource, source);
}
}
}
apply(compiler: Compiler) {
compiler.hooks.done.tapAsync('SourceInterceptorPlugin', (stats: Stats, callback) => {
const compilation = stats.compilation;
const sources = new Map<string, string>();
// 从根模块集合开始遍历
this.handleModulesRecursively(compilation.modules, sources);
// 输出路径:复用 webpack output.path,保持相对结构
const outputDist = compilation.outputOptions.path || process.cwd();
for (const [resPath, source] of sources) {
const relPath = path.relative(compiler.context, resPath);
const fullPath = path.join(outputDist, relPath);
// 确保目录存在
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, source, 'utf8');
}
console.log(`✅ SourceInterceptorPlugin: extracted ${sources.size} modules to ${outputDist}`);
callback();
});
}
}
export default SourceInterceptorPlugin;使用方式(webpack.config.js):
const SourceInterceptorPlugin = require('./plugins/SourceInterceptorPlugin');
module.exports = {
// ... 其他配置
plugins: [
new SourceInterceptorPlugin(),
],
};⚠️ 重要注意事项:
- 此插件获取的是 loader 链处理后的源码,不包含
node_modules中依赖的实际内容(即import _ from 'lodash'仍保留原样),因此需配合resolve.alias或浏览器原生<script type="importmap"></script>实现运行时路径映射; - 若项目使用
experiments.topLevelAwait或output.module: true,模块类型可能为'javascript/dynamic'或'javascript/esm',建议根据实际module.type调整过滤逻辑; -
originalSource()返回null时(如某些 asset 模块或未启用 source map 的 loader),需做空值防护; - 不建议在开发模式(
mode: 'development')下启用此插件,因频繁重编译会大量 IO 写入;可结合stats.compilation.options.mode === 'production'做条件启用。
该方案轻量、稳定,无需修改现有 loader 或 alias 配置,完美契合“保留 webpack 构建能力,同时导出纯净模块”的核心诉求。

















