把一个 Vite + Vue3 应用塞进 qiankun (React + Umi3) 主站:十个坑的复盘
背景
手上有两个前端应用:
- 主站 :React + Umi 3 +
@umijs/plugin-qiankun(qiankun 2.x),一个存量多年的中后台 - 子站:Vue 3 + Vite 6 + vite-ssg,新写的独立站,有自己的域名
需求是把子站的一整个功能域嵌进主站的一个菜单里,同时子站自己的域名要继续独立可用------也就是同一份代码要同时以「独立站」和「微应用」两种形态运行。
主站里已经有一个跑通多年的子应用,照着抄就行------这是最初的判断,事实证明它恰恰是最大的误导:那个子应用是 webpack 构建的,而 webpack 和 Vite 在微前端场景下的差异,几乎覆盖了后面所有的坑。
下面按踩坑顺序记录。文中约定几个占位名:微应用注册名 sub-app,主站分配的路由前缀 /sub-app,静态资源前缀 /static-sub-app/,接口转发前缀 /sub-api。
坑一:qiankun 执行不了 Vite 的 ESM 产物
qiankun 2.x 通过 import-html-entry 加载子应用:fetch 入口 HTML,把里面的 <script> 抠出来,用 eval 执行。
webpack 的 UMD 产物是一个自执行函数,eval 没问题。Vite 的产物是 ESM:
html
<script type="module" crossorigin src="/assets/index-xxx.js"></script>
eval 一段含 import / export 的代码,直接 SyntaxError。
更麻烦的是退路也被堵死了:想在运行时自己 document.head.appendChild(script) 插一个 type="module" 的标签让浏览器原生加载?qiankun 的 dynamicAppend 补丁劫持了 head / body 的 appendChild,会把动态插入的 script 再拿去 eval,绕回同一个错误。
解法 :构建后把 ESM 入口标签换成一段不含 ESM 语法的普通脚本(qiankun 可以 eval 它),由它去原生加载真正的入口:
js
var entry = document.createElement('script')
entry.type = 'module'
entry.src = '/static-sub-app/assets/index-xxx.js'
// 挂到 <html> 上,绕开 qiankun 对 head / body appendChild 的劫持
document.documentElement.appendChild(entry)
关键在最后一行:补丁只打在 head 和 body 上,documentElement 是漏网之鱼。
入口模块加载完后把生命周期挂到 window,桥接脚本再把它转交给 qiankun:
js
// ESM 入口(跑在真实 window 上)
window.__SUB_APP_LIFECYCLES__ = { bootstrap, mount, unmount }
window.dispatchEvent(new Event('sub-app:lifecycles-ready'))
// 桥接脚本(跑在沙箱里)
sandboxWindow['sub-app'] = {
bootstrap: delegate('bootstrap'),
mount: delegate('mount'),
unmount: delegate('unmount'),
}
沙箱读属性会穿透到真实 window,所以桥接脚本取得到那个键;写属性则会被拦在代理对象上------这个不对称是下一个坑的根源。
整套逻辑放在一个只在 --mode qiankun 启用的 Vite 插件里,用两个 transformIndexHtml 钩子完成:pre 阶段换入口文件,post 阶段替换产物里的脚本标签。
坑二:vite-ssg 会自己把应用挂上去
ViteSSG() 在浏览器端会立刻把应用挂到 #app,既拿不到 qiankun 传进来的容器,也无法响应 unmount。而且路由 base 在两种形态下不一样:独立站是 /,嵌入主站是 /sub-app(而静态资源前缀又是另一个值 /static-sub-app/),原入口里这两者共用 import.meta.env.BASE_URL,改不动。
解法 :不复用,另写一个 qiankun 专用入口,用原生 createApp + createWebHistory(routerBase),应用实例在 mount 里创建、在 unmount 里销毁。构建时由插件把 HTML 的入口从默认的 main.ts 换成它。
顺带一提,这个入口里刻意不初始化监控 / 埋点 SDK:入口跑在真实 window 上,而主站已经初始化过同一批全局 SDK,重复初始化会互相覆盖。
坑三:沙箱里的 CDN 全局变量,入口取不到
独立站的 index.html 里用 CDN 加载了 vue / vue-router / pinia / element-plus,配合一个 Vite 插件把 import xxx from 'vue' 映射成 window.Vue。
嵌入主站后这套彻底失效,原因是两边跑在不同的 window 上:
- CDN 脚本由 qiankun 在沙箱里 eval,全局变量落在代理 window 上
- ESM 入口由浏览器原生加载,读到的是真实 window
于是 window.Vue 是 undefined。
解法:qiankun 模式下把 CDN 块整个从 HTML 里删掉(用注释标记包裹,插件正则移除),这些依赖改为打进包里。体积会涨,但这是当前架构下唯一干净的做法。
例外是那些由公司基础设施提供、以全局变量形式暴露的内部 SDK------主站自己也在真实 window 上加载了同一份,子站直接读全局变量反而能取到。
坑四:分页组件样式丢了
表格能渲染,分页控件样式全乱。
原因是三层叠加:
- 项目用
unplugin-vue-components按需引入样式,它只扫.vue模板里直接写的组件 - 表格用的是上层业务组件库封装的高阶 CRUD 表格,其内部渲染的分页组件不在扫描范围内,拿不到按需样式
- 独立站没暴露这个问题,因为 CDN 里那份 element-plus 的全量 CSS 顺手把它兜住了------而 qiankun 模式恰好把 CDN 块删了(坑三)
解法 :qiankun 入口整包引入组件库主题源码(theme-chalk/src/index.scss)。CSS 从零散 chunk 变成一个 417KB 的大包,但样式完整且和按需模式共用同一套 SCSS 变量,主题一致。
这个坑的教训是:「独立站没问题」不能作为「嵌入后也没问题」的证据,两种形态的依赖来源可能完全不同。
坑五:布局叠加出来的双重留白
主站 ProLayout 的内容区默认 margin: 24px,子站自己的布局又有 padding: 20px,叠起来四周各 44px,视觉上很空。
解法 :用 ProLayout 官方的 disableContentMargin,只在微应用路由下关掉主站留白。
连带塌方 :主站顶部公告条是靠 top: -25px + margin: 0 -24px 这组负值嵌进那 24px 留白里做通栏的。留白一取消,公告条被顶出内容区(看不见了),原地还留下一块空白。这两个值必须跟着一起改成 0。
后来还尝试过让公告条只占内容区(像主站自有页面那样),做法是子站用 ResizeObserver 把侧栏实时宽度写进 :root 的 CSS 变量、主站公告条按它 margin-left。技术上跑通了,但公告条左边会空出一块------因为主站公告排在微应用容器之上 ,而子站侧栏在容器之内,两者不在同一行,不像主站自有页面那样正好是侧栏的白色顶部。最后按「收益不抵复杂度」撤回了,保留通栏。
坑六:/api 前缀撞车
子站所有接口都是 /api/*。嵌入后请求从主站域名发出,而主站域名下的 /api 是另一套后端(按 Host 分流,不提供子站的接口)。
解法 :加一层前缀。qiankun 模式的环境变量里设 VITE_API_BASE_URL=/sub-api,axios 用它做 baseURL;主站/网关把 /sub-api/* 转发到子站后端并剥掉前缀还原成 /api/*,changeOrigin 把 Host 改成子站域名作为后端分流依据。独立站该变量为空,行为不变。
注意这条前缀省不掉:哪怕静态资源直接从子站域名加载,JS 仍然运行在主站页面里,XHR 还是发往主站域名。
坑七:Vite 的 base 是构建时写死的
这是整件事里最有结构性影响的一条,也是照抄 webpack 子应用最大的陷阱。
那个存量 webpack 子应用只部署一份产物 ,nginx 同时挂在 /(独立站)和 /static-xxx/(微应用)两个路径下:
nginx
location ^~ /static-xxx/ {
proxy_pass http://127.0.0.1:9000/;
}
一份产物能同时在两个路径下正常工作,靠的是 webpack 的运行时能力:
js
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}
Vite 没有等价物,base 在构建时就烤进产物了。结论:必须构建两份。
text
dist/ # 独立站产物,base=/
├── index.html
├── assets/
└── static-sub-app/ # qiankun 产物,base=/static-sub-app/
├── index.html # 含桥接脚本
└── assets/
把 qiankun 产物嵌套 在 dist 里、目录名与 URL 前缀同名,是踩了一圈之后的选择:nginx 复用同一个 root 就能命中,不需要 alias;CI 的 zip ./dist 也一个字不用改。
代价是构建顺序不能反------独立站构建会清空整个 dist,必须先它后 qiankun。这个隐含依赖要显式写进脚本里:
json
"build:qiankun": "pnpm --filter <app> build && pnpm --filter <app> build:qiankun"
坑八:部署阶段的两个翻车
其一,pnpm monorepo 里脚本找不到。 流水线在仓库根执行 pnpm build:qiankun,而这个脚本当时只存在于子包。pnpm 在根目录找不到同名脚本会退化成递归执行所有 workspace 包,第一个没有该脚本的包就报错:
arduino
ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL Command "build:qiankun" not found
检查脚本退出状态码:254
在根 package.json 补一条转发脚本即可。
其二,nginx 的 alias + try_files 会返回 500 而不是 404。 最初这样写:
nginx
location ^~ /static-sub-app/ {
alias /home/service/app/<app>/dist-qiankun/;
try_files $uri $uri/ /static-sub-app/index.html; # ← 陷阱
}
文件找不到时回落到 /static-sub-app/index.html,这个 URI 又重新命中同一个 location,形成内部重定向循环,nginx 直接吐 500------该前缀下连 favicon 都是 500,排查时一度以为是网关挂了。
改成让目录名与 URL 前缀同名、复用默认 root,并且兜底用 =404:
nginx
location ^~ /static-sub-app/ {
expires -1;
try_files $uri $uri/index.html =404;
}
^~ 不能省:否则该前缀下的 .js / .css 会被上面按扩展名匹配的正则 location 抢走。
坑九:autoSetLoading 是个需要子应用配合的契约
页面渲染出来了,但主站的 loading 一直转。
翻 @umijs/plugin-qiankun 的源码才发现,主站只在生命周期 promise 失败时 才 setLoading(false),成功路径上一次都不调。真正关掉 loading 的是它塞给子应用的 setLoading,由 umi 子应用的运行时模板自动调用:
ts
// plugin-qiankun/src/slave/lifecycles.ts.tpl
callback: () => {
if (props?.autoSetLoading && typeof props?.setLoading === 'function') {
props.setLoading(false)
}
}
我们是 Vue 子应用,没有这份模板,mount(props) 里拿到了 setLoading 却从没调过。在挂载完成后补一行即可。
这类「框架默认帮你做了、换个技术栈就没人做」的隐式契约,在跨栈微前端里会反复出现,只能靠读源码发现。
坑十:本地环境的两个低级但费时的问题
Windows 文件占用。 预览服务开着的时候跑构建,Vite 清空输出目录会失败:
perl
EPERM: operation not permitted, lstat dist\assets\index-xxx.js
at emptyDir (...)
把构建和预览合并成一条命令(先构建后起服务),顺序天然错开,问题消失。
IPv4 / IPv6 绑定不匹配。 vite preview 默认只绑 [::1],而主站 dev 代理目标写的是 127.0.0.1:<port>,连接直接被拒:
ruby
TCP [::1]:2444 LISTENING ← 只有 IPv6
127.0.0.1 -> HTTP 000
localhost -> HTTP 200
这个坑之所以耗时,是因为我一直用 localhost 验证 ,它解析到 ::1 所以永远返回 200,把问题完美掩盖了,直到沿着主站的真实链路(IPv4)验证才暴露。教训很直接:验证要走真实调用方的链路,不要走自己顺手的那条。
如果重来
- 先确认参考案例的构建工具。抄一个 webpack 子应用的接入方式去做 Vite 子应用,前三个坑是必然的。
- 把「两份产物」当作前提而不是意外。Vite 的 base 写死在产物里,独立站和微应用无法共用一份包,这个约束应该在方案阶段就定下来,而不是部署时才发现。
- 跨栈接入要读框架源码 。
autoSetLoading这种隐式契约不会写在文档里。 - 每一层都要能独立验证。这次排查链路很长(浏览器 → 代理工具 → 主站 dev → devServer 代理 → 子站预览 → nginx),中间任何一环断了现象都是「一直转圈」。把每层的自查命令固化下来,比逐层猜快得多。
相关文档
- 架构与实现细节:
05-micro-frontend.md - 本地开发流程与端口:
06-local-dev-qiankun.md