解决 @ct/china-tower-tech-ui 在 Vite 构建中的 CJS-Vue 冲突

2790 字
14 分钟
解决 @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 构建失败

根因#

  1. @ct/china-tower-tech-ui 是纯 CJS 库
  • 只有 main 入口(./lib/index.js),没有 module(ESM)入口
  • 使用 webpack 打包,externals 配置将 vue 排除
  • 打包后的代码中使用 require("vue") 引用 Vue
  1. CDN 插件将 vue 设为 Rollup external
  • 项目使用 @ct/sl-vite-plugin-cdn 插件,打包时将 vue 标记为 external
  • 目的是不将 vue 打包进产物,运行时由主应用通过 CDN 提供 window.Vue
  1. @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-ui2.0.12 升级到最新版本 2.1.5

Terminal window
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_PACKAGESSHIM_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 插件做两件事:

  1. 打包时:加入 rollupOptions.external,即 不打包,Rollup 不会将这些模块的代码写入产物
  2. 打包时:通过 externalGlobals 插件将这些包映射到全局变量(如 vuewindow.Vue

SHIM_ONLY_PACKAGES 中的包只做 alias shim(开发时指向 window.Vue 等),但 不会 被设为 external

所以把 vueCDN_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.mjsbuild.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-uivue 的引用,将其重定向到一个自定义虚拟模块:

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,怎么拦截 loadresolveId,只要文件里有 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 exportcommonjs 插件优先管理虚拟模块
五、resolveId 重定向劫持 vue 的解析路径commonjs 插件不走 resolveId 钩子
六、transform 替换在源码层面抹掉 require(“vue”)成功,绕开了 commonjs 的处理逻辑

最终方案代码#

vite.config.mjs
// 自定义插件:解决 @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() 的匹配条件

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

解决 @ct/china-tower-tech-ui 在 Vite 构建中的 CJS-Vue 冲突
https://boke.hackerdream.xyz/posts/vite-cjs-vue-conflict-fix/
作者
晴天
发布于
2026-04-09
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录