
本文介绍一种通过自定义 Webpack 插件,在构建完成阶段(done 钩子)递归遍历已处理模块、提取原始 JavaScript 源码并按目录结构输出为独立文件的实用方案,适用于需保留模块化能力但绕过 Webpack 打包逻辑的场景。
本文介绍一种通过自定义 webpack 插件,在构建完成阶段(`done` 钩子)递归遍历已处理模块、提取原始 javascript 源码并按目录结构输出为独立文件的实用方案,适用于需保留模块化能力但绕过 webpack 打包逻辑的场景。
在现代前端工程中,Webpack 不仅承担打包职责,还集成了丰富的源码预处理能力(如宏替换、路径别名解析、自定义 loader 转换等)。有时我们希望复用这些处理结果——即获取经 loader 和 resolve 机制处理后、但尚未被 Webpack 封装成 __webpack_require__ 形式的“纯净 ES 模块源码”,并以原始目录结构落地为 .js 文件,从而支持浏览器原生 import 或配合 <script type="importmap"></script> 动态加载。
上述需求无法通过 emit 钩子直接满足,因为此时模块已进入 chunk 生成阶段,源码可能已被封装或混淆。更可靠的方式是监听 compiler.hooks.done,在完整构建结束后访问 stats.compilation.modules,从中提取已转换完毕的模块源码。
以下是一个生产可用的 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) {
// 过滤非 ESM 模块(如 JSON、CSS 等)
if (module.type !== 'javascript/esm') continue;
// 递归处理嵌套模块(如由 `ModuleConcatenationPlugin` 合并的模块)
const innerModules = (module as any)['modules'] as Set<module> | undefined;
if (innerModules && innerModules.size > 0) {
this.handleModulesRecursively(innerModules, sources);
continue;
}
// 获取原始处理后源码(非打包后代码)
const originalSource = module.originalSource();
const source = originalSource?.source();
if (!source || typeof source !== 'string') {
console.warn(`[SourceInterceptor] Skipped module: ${module.identifier()}`);
continue;
}
// 仅保留 NormalModule(即真实资源文件),避免 runtime / synthetic 模块
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, callback) => {
const compilation = stats.compilation;
const sources = new Map<string string>();
// 递归收集所有 ESM 模块源码
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(process.cwd(), resPath);
const fullPath = path.join(outputDist, relPath);
// 确保目录存在
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, source, 'utf8');
}
console.log(`[SourceInterceptor] Extracted ${sources.size} modules to ${outputDist}`);
callback();
});
}
}
export default SourceInterceptorPlugin;</string></module></string></module>
使用方式
在 webpack.config.js 中引入并注册插件:
const SourceInterceptorPlugin = require('./plugins/SourceInterceptorPlugin');
module.exports = {
// ... 其他配置
plugins: [
new SourceInterceptorPlugin(),
],
};
注意事项与局限性
- ✅ 保留预处理逻辑:该插件获取的是经过所有 loader(含自定义宏替换)、alias 解析、resolve 规则处理后的源码,完全复用现有构建流水线。
- ⚠️ 不解析第三方依赖路径:输出文件中仍保留
import 'lodash'或import '@/utils'等语句,未自动替换为node_modules/lodash/index.js或绝对路径。建议结合resolve.alias+ 浏览器importmap实现运行时映射。 - ⚠️ 不处理动态
import()表达式:若项目大量使用import('./foo.js'),其路径解析逻辑需额外适配(当前插件仅处理静态import)。 - ? 不兼容 Webpack 4:
originalSource()和模块类型判断基于 Webpack 5+ 的内部 API;如需降级,需改用module.source()并自行过滤 runtime 代码。
该方案轻量、侵入性低,无需修改现有 loader 或配置,即可将 Webpack 强大的模块解析能力“导出”为浏览器可直接消费的原生模块集合,是构建渐进式迁移、微前端子应用或文档示例沙箱的理想基础设施。










