解决 @ct/china-tower-tech-ui 在 Vite 构建中的 CJS-Vue 冲突
解决 @ct/china-tower-tech-ui 在 Vite 构建中的 CJS-Vue 冲突
问题背景
项目使用 Vite 5 + Vue 2.7 构建微前端子应用,依赖了 @ct/china-tower-tech-ui(一个基于 Element-UI 二次封装的 CJS 组件库)。在执行 pnpm build 时,构建失败并报错:
"default" is not exported by "vue?commonjs-external",imported by "@ct/china-tower-tech-ui/lib/index.js"问题分析
错误链路拆解
@ct/china-tower-tech-ui (webpack 打包的 CJS 库) → require("vue") → @rollup/plugin-commonjs 转换为 import from "\0vue?commonjs-external" → 该虚拟模块没有 default export ❌ → Rollup 构建失败根因
@ct/china-tower-tech-ui是纯 CJS 库
- 只有
main入口(./lib/index.js),没有module(ESM)入口 - 使用 webpack 打包,
externals配置将vue排除 - 打包后的代码中使用
require("vue")引用 Vue
- CDN 插件将
vue设为 Rollup external
- 项目使用
@ct/sl-vite-plugin-cdn插件,打包时将vue标记为external - 目的是不将
vue打包进产物,运行时由主应用通过 CDN 提供window.Vue
@rollup/plugin-commonjs的虚拟模块缺少 default export
- commonjs 插件发现
require("vue"),但vue已是 external - 它生成虚拟模块
\0vue?commonjs-external来桥接 CJS require - 但这个虚拟模块没有
default导出,而 CJS 库期望拿到 Vue 构造函数
为什么开发环境不报错?
开发时 Vite 使用 esbuild 预构建依赖,不走 Rollup 的 commonjs 转换流程。只有 vite build(生产构建)才会触发此问题。
尝试过的方案
方案一:升级组件库版本 ❌
做了什么
将 @ct/china-tower-tech-ui 从 2.0.12 升级到最新版本 2.1.5:
pnpm add @ct/china-tower-tech-ui@2.1.5为什么这样处理
猜测旧版本的组件库可能存在打包缺陷,新版本可能已经修复了 CJS/ESM 兼容性问题,比如提供了 module 字段(ESM 入口)或者改动了 externals 配置。这是最直接、成本最低的尝试——如果新版库本身解决了问题,就不需要任何额外的配置。
结果
升级后重新执行 pnpm build,报错完全一样。检查 2.1.5 版本的 package.json:
{ "name": "@ct/china-tower-tech-ui", "version": "2.1.5", "main": "./lib/index.js" // 注意:没有 "module" 字段}新版库仍然是纯 CJS 格式,没有 ESM 入口。说明该库的所有版本都使用相同的 webpack 打包策略(externals 中排除 vue),升级无法解决问题。
方案二:从 CDN_PACKAGES 中移除 vue ❌
做了什么
修改 vite.config.mjs 中的 CDN_PACKAGES 和 SHIM_ONLY_PACKAGES 配置:
// 修改前const CDN_PACKAGES = ['vue', 'vuex', 'vue-router', 'element-ui', 'lodash', 'dayjs', 'crypto-js']const SHIM_ONLY_PACKAGES = ['echarts','@ct/ct_map_ol','element-ui','lodash']
// 修改后const CDN_PACKAGES = ['vuex', 'vue-router', 'element-ui', 'lodash', 'dayjs', 'crypto-js']const SHIM_ONLY_PACKAGES = ['echarts','@ct/ct_map_ol','element-ui','lodash', 'vue']这个配置是干什么的
CDN_PACKAGES 中的包会被 @ct/sl-vite-plugin-cdn 插件做两件事:
- 打包时:加入
rollupOptions.external,即 不打包,Rollup 不会将这些模块的代码写入产物 - 打包时:通过
externalGlobals插件将这些包映射到全局变量(如vue→window.Vue)
SHIM_ONLY_PACKAGES 中的包只做 alias shim(开发时指向 window.Vue 等),但 不会 被设为 external。
所以把 vue 从 CDN_PACKAGES 移到 SHIM_ONLY_PACKAGES,意味着:
- 开发时:
import Vue from 'vue'仍然指向window.Vue(通过 shim 文件) - 打包时:
vue不再是 external,会被 Rollup 正常打包进产物
为什么这样处理
如果 vue 不被标记为 external,那 @rollup/plugin-commonjs 就不会把它当作 commonjs-external 处理,也就不会生成 \0vue?commonjs-external 这个虚拟模块。require("vue") 会正常解析到 vue 的 ESM 入口(vue/dist/vue.runtime.esm.js),从而拿到 default export。这是从根源上消灭 external 导致的虚拟模块冲突。
结果
构建成功了,没有报错。但代价是 vue 被完整打包进了产物。查看产物大小:
dist/js/index-C3Zaxq99.js.gz 6257.21kb / gzip: 1758.56kb最大的 chunk 从约 3590kb 增大到 6257kb(gzip 后从 943kb 增大到 1758kb),增加了约 80%。这不符合微前端子应用的架构设计——子应用应该复用主应用的 vue,不应该自己带一份。
方案三:配置 commonjsOptions.requireReturnsDefault ❌
做了什么
在 vite.config.mjs 的 build.rollupOptions 中添加 commonjsOptions 配置:
rollupOptions: { maxParallelFileOps: maxParallelFileOps, commonjsOptions: { // 尝试 1 requireReturnsDefault: 'auto', // 尝试 2 requireReturnsDefault: 'preferred', // 尝试 3 requireReturnsDefault: true, // 尝试 4(函数形式) requireReturnsDefault: (id) => id === 'vue', },}这个配置是干什么的
commonjsOptions 是 Vite 传递给 @rollup/plugin-commonjs 的配置项。其中 requireReturnsDefault 控制当 CJS 代码 require() 一个 ESM 模块时,返回值是什么:
| 值 | 行为 |
|---|---|
false(默认) | 返回整个命名空间对象 { default: Vue, ref, reactive, ... } |
'preferred' | 如果目标模块有 default export,只返回 default |
'auto' | 启发式判断,如果模块只有 default export 则只返回它 |
true | 总是只返回 default export |
为什么这样处理
既然报错是 "default" is not exported,那配置 requireReturnsDefault 让 commonjs 插件自动处理 default export 的获取,理论上可以让 require("vue") 正确拿到 Vue 构造函数。
结果
全部无效。原因是 requireReturnsDefault 只对 被 commonjs 插件正常处理的模块 生效。而 vue 已经被 CDN 插件设为 external,commonjs 插件不会去转换它,而是直接生成一个 \0vue?commonjs-external 的虚拟模块引用。requireReturnsDefault 对这个虚拟模块 不起作用。
方案四:自定义插件在 load 钩子拦截虚拟模块 ❌
做了什么
编写自定义 Vite 插件,在 load 钩子中拦截 \0vue?commonjs-external 虚拟模块,手动提供 default export:
function fixCjsVueExternal() { return { name: 'fix-cjs-vue-external', enforce: 'post', // 在 commonjs 插件之后执行 load(id) { if (id.endsWith('\0vue?commonjs-external')) { return `export { default } from "vue"; export * from "vue";`; } }, };}这个配置是干什么的
Vite 插件的 load 钩子负责根据模块 ID 返回文件内容。通过 enforce: 'post' 让它在 commonjs 插件之后执行,期望它能覆盖 commonjs 插件生成的虚拟模块内容,手动补上 export { default } from "vue" 这行代码,让 Rollup 能找到 default export。
为什么这样处理
既然 commonjs 插件生成的虚拟模块没有 default export,那就在另一个插件的 load 钩子中”修补”这个虚拟模块,给它加上正确的导出。这是一个补丁思路——不修改源头,只修结果。
结果
无效。@rollup/plugin-commonjs 插件自己管理它生成的所有虚拟模块(\0commonjsHelpers.js、\0vue?commonjs-external 等)的 load 逻辑。即使自定义插件的 load 钩子返回了内容,commonjs 插件也会优先拦截这些 ID,自定义插件的 load 根本不会被调用。
方案五:自定义插件在 resolveId 钩子重定向 vue ❌
做了什么
编写自定义 Vite 插件,在 resolveId 阶段拦截来自 @ct/china-tower-tech-ui 对 vue 的引用,将其重定向到一个自定义虚拟模块:
function fixCjsVueExternal() { const virtualId = '\0vue-cjs-shim'; return { name: 'fix-cjs-vue-external', enforce: 'pre', // 在 commonjs 插件之前执行 resolveId(source, importer) { // 当 china-tower-tech-ui 库 require("vue") 时,拦截并指向自定义模块 if (source === 'vue' && importer && importer.includes('china-tower-tech-ui')) { return virtualId; } }, load(id) { if (id === virtualId) { return `import Vue from 'vue';export default Vue;export { Vue };export * from 'vue';`; } }, };}这个配置是干什么的
Vite 插件的 resolveId 钩子负责将模块标识符(如 vue)解析为唯一的模块 ID。通过在 commonjs 插件之前(enforce: 'pre')返回一个自定义 ID,期望后续 require("vue") 都会被解析到这个自定义虚拟模块,而不是走到 commonjs 插件的 \0vue?commonjs-external 路径。
load 钩子则为这个虚拟模块提供内容:从 vue 重新导出所有必要的导出,包括 default。
为什么这样处理
既然 commonjs 插件处理 require("vue") 时会生成有问题的虚拟模块,那就在 commonjs 插件之前就把 vue 的解析路径劫持掉,让它走我们自定义的、有正确 default export 的虚拟模块。
结果
无效。@rollup/plugin-commonjs 内部有自己的一套模块解析机制。它在将 CJS 的 require() 转换为 ESM import 时,不经过 Vite 的 resolveId 钩子链。即使我们的插件先注册了 resolveId,commonjs 插件也会按照自己的逻辑将 require("vue") 解析为 \0vue?commonjs-external。
方案六:transform 阶段源码替换 ✅
做了什么
编写自定义 Vite 插件,在 transform 钩子中直接将源码里的 require("vue") 替换为 window.Vue:
function fixCjsVueExternal() { return { name: 'fix-cjs-vue-external', enforce: 'pre', transform(code, id) { // 在 commonjs 插件处理之前,替换 @ct/china-tower-tech-ui 中的 require("vue") if (id.includes('china-tower-tech-ui') && code.includes('require("vue")')) { const patched = code.replace( /require\("vue"\)/g, '(typeof window!=="undefined"&&window.Vue?window.Vue:{})' ); return { code: patched, map: null }; } }, };}在 plugins 数组中注册:
plugins: [ vue(), fixCjsVueExternal(), // 必须在 commonjs 插件之前运行 createHtmlPlugin({ /* ... */ }), // ... 其他插件]这个配置是干什么的
enforce: 'pre':让插件在 Vite 内置插件(包括@rollup/plugin-commonjs)之前执行transform(code, id):接收文件源码和文件路径 ID,返回修改后的代码- 正则替换:在源码层面将
require("vue")替换为(typeof window!=="undefined"&&window.Vue?window.Vue:{}) - 条件过滤:只对
@ct/china-tower-tech-ui库的文件生效,不影响项目自身代码
为什么这样处理
前面的方案都失败了,核心原因是 @rollup/plugin-commonjs 的内部逻辑不受外部控制。无论怎么配置 commonjsOptions,怎么拦截 load 或 resolveId,只要文件里有 require("vue") 调用,commonjs 插件就会按照自己的规则生成 \0vue?commonjs-external。
唯一能绕开这个逻辑的方式是:在 commonjs 插件看到源码之前,就把 require("vue") 这句话从源码里抹掉。
transform 钩子(enforce: 'pre')正是这个时机——它在文件被读取后、任何插件处理之前介入。替换之后,commonjs 插件扫描文件时找不到 require("vue") 调用,自然不会去生成虚拟模块。
为什么替换为 window.Vue 而不是其他方式
vue是 Rollup external,意味着打包时不包含 vue 的代码- 运行时,
window.Vue由主应用的 CDN script(vue.min.js)提供 @ct/china-tower-tech-ui的 webpack externals 配置原本就期望vue是一个全局变量- 所以替换为
window.Vue是 最符合该库原始设计意图 的方式 - 加上
typeof window!=="undefined"判断是为了 SSR 兼容(虽然本项目不做 SSR,但加上是好的实践)
结果
构建成功,且:
vue仍然是 external,没有被打包进产物- 包体积没有增加
- 兼容现有的 CDN 插件架构
- 开发和生产环境均可正常工作
方案对比总结
| 方案 | 思路 | 失败/成功原因 | 包体积影响 |
|---|---|---|---|
| 一、升级库版本 | 期望新版提供 ESM 入口 | 库仍是纯 CJS,无 module 字段 | 无 |
| 二、移除 vue 的 external | 让 vue 不被标记为 external | 可行但 vue 被完整打包 | +80% |
| 三、commonjsOptions | 配置 requireReturnsDefault | 对 external 模块无效 | 无 |
| 四、load 钩子拦截 | 修补虚拟模块的 default export | commonjs 插件优先管理虚拟模块 | 无 |
| 五、resolveId 重定向 | 劫持 vue 的解析路径 | commonjs 插件不走 resolveId 钩子 | 无 |
| 六、transform 替换 | 在源码层面抹掉 require(“vue”) | 成功,绕开了 commonjs 的处理逻辑 | 无 |
最终方案代码
// 自定义插件:解决 @ct/china-tower-tech-ui 的 CJS require("vue") 与 Rollup external 的冲突function fixCjsVueExternal() { return { name: 'fix-cjs-vue-external', enforce: 'pre', transform(code, id) { // 在 commonjs 插件处理之前,替换 @ct/china-tower-tech-ui 中的 require("vue") if (id.includes('china-tower-tech-ui') && code.includes('require("vue")')) { const patched = code.replace( /require\("vue"\)/g, '(typeof window!=="undefined"&&window.Vue?window.Vue:{})' ); return { code: patched, map: null }; } }, };}
export default defineConfig(async ({ mode }) => { // ... return { plugins: [ vue(), fixCjsVueExternal(), // 必须在 commonjs 插件之前运行 // ... 其他插件 ], };});注意事项
- 替换的正则
/require\("vue"\)/g需要匹配该库中实际的写法(单引号还是双引号)。如果库使用require('vue')(单引号),正则需改为/require\('vue'\)/g - 如果未来组件库升级提供了 ESM 入口(
package.json中出现module字段),此插件可以安全移除 - 如果其他 CJS 库也存在同样的
require("vue")问题,可以复用此插件模式,只需修改id.includes()的匹配条件
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!