
本文介绍一种通过 Webpack 自定义插件在构建后期提取已过 loader 处理、别名解析和宏替换,但尚未被 Webpack 封装(如 __webpack_require__)的原始 ES 模块源码的方法,并将其按原始目录结构输出为独立 .js 文件。
本文介绍一种通过 webpack 自定义插件在构建后期提取已过 loader 处理、别名解析和宏替换,但尚未被 webpack 封装(如 `__webpack_require__`)的原始 es 模块源码的方法,并将其按原始目录结构输出为独立 `.js` 文件。
在基于 Webpack 的现代前端项目中,常需保留“模块化可执行性”——即让浏览器原生支持的 ES 模块(type="module")能直接加载、运行,而不依赖 Webpack 的运行时或 bundle 封装。尤其在微前端、文档站点嵌入、或渐进式增强场景下,开发者往往希望:
- 保留
import语句的语义(如import { foo } from 'utils'), - 已完成路径别名(
resolve.alias)和宏替换(如process.env.NODE_ENV)等预处理, - 但跳过 Webpack 的模块封装、依赖图合并与运行时注入。
Webpack 官方并未提供“导出纯净处理后源码”的内置能力,但其插件系统(尤其是 compilation.modules 和 Module#originalSource())恰好提供了所需钩子。核心思路是:在 compiler.hooks.done 阶段遍历所有已处理完毕的模块,对类型为 'javascript/esm' 的模块调用 originalSource()?.source() 获取其经过所有 loader 和 resolver 处理后的最终字符串内容,再按原始资源路径(module.resource)写入文件系统。
以下是一个生产可用的 TypeScript 实现插件(兼容 Webpack 5+):
import * as webpack from 'webpack';
import * as path from 'path';
import * as fs from 'fs';
class SourceInterceptorPlugin {
handleModulesRecursively(
modules: Set<webpack.Module>,
sources: Map<string, string>
) {
for (const module of modules) {
// 仅处理原生 ES 模块(跳过 JSON、CSS、动态 import 包裹等)
if (module.type !== 'javascript/esm') continue;
// 递归处理嵌套模块(如某些内联 loader 或 AST 转换生成的子模块)
const innerModules = (module as any)['modules'] as Set<webpack.Module>;
if (innerModules && innerModules.size > 0) {
this.handleModulesRecursively(innerModules, sources);
continue;
}
// 获取原始处理后源码(非 webpack 封装版)
const source = module.originalSource()?.source();
if (!source || typeof source !== 'string') {
console.warn(`[SourceInterceptor] Skipped module: ${module.identifier()}`);
continue;
}
// 确保是 NormalModule(标准 JS 模块),避免 runtime / manifest 模块
if (module instanceof webpack.NormalModule) {
sources.set(module.resource, source);
}
}
}
apply(compiler: webpack.Compiler) {
compiler.hooks.done.tapAsync('SourceInterceptorPlugin', (stats, callback) => {
const compilation = stats.compilation;
const sources = new Map<string, string>();
// 递归收集所有 ESM 模块源码
this.handleModulesRecursively(compilation.modules, sources);
// 输出到配置的 output.path(支持自定义 dist 目录)
const outputDist = compilation.outputOptions.path || process.cwd();
for (const [resourcePath, source] of sources) {
// 保持原始目录结构:/src/utils/foo.js → /dist/src/utils/foo.js
const relPath = path.relative(process.cwd(), resourcePath);
const fullPath = path.join(outputDist, relPath);
// 创建父目录并写入
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, source, 'utf8');
}
console.log(`✅ SourceInterceptorPlugin: emitted ${sources.size} ES modules to ${outputDist}`);
callback();
});
}
}
export default SourceInterceptorPlugin;使用方式(在 webpack.config.js 中):
const SourceInterceptorPlugin = require('./plugins/SourceInterceptorPlugin');
module.exports = {
// ... 其他配置(含 alias、自定义 loader 等)
plugins: [
new SourceInterceptorPlugin(),
],
};⚠️ 重要注意事项:
- 此插件提取的是 loader 链处理后、resolver 解析后、但未被 Webpack 运行时包装 的源码,因此
import语句仍为相对路径或别名(如import 'lodash'),需配合<script type="importmap"></script>或服务端重写规则解析至真实路径; -
module.originalSource()返回的是Source对象(来自webpack-sources库),其.source()方法才给出最终字符串,切勿误用module.source()(它返回的是 Webpack 封装后的代码); - 某些 Webpack 内部模块(如
HarmonyCompatibilityDependency注入的辅助代码)不参与此流程,无需担心污染输出; - 若项目启用
experiments.outputModule: true(输出 ESM bundle),请确保本插件在done钩子执行,此时compilation.modules已完全稳定。
该方案平衡了灵活性与可控性:你无需绕过 Webpack 生态(保留别名、loader、tree-shaking 等优势),又能获得真正“开箱即用”的原生模块文件,为现代 Web 提供轻量、可组合、符合标准的模块交付能力。

















