把一个 Vite + Vue3 应用塞进 qiankun (React + Umi3) 主站:十个坑的复盘

32 阅读10分钟

把一个 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:

<script type="module" crossorigin src="/assets/index-xxx.js"></script>

eval 一段含 import / export 的代码,直接 SyntaxError

更麻烦的是退路也被堵死了:想在运行时自己 document.head.appendChild(script) 插一个 type="module" 的标签让浏览器原生加载?qiankun 的 dynamicAppend 补丁劫持了 head / bodyappendChild,会把动态插入的 script 再拿去 eval,绕回同一个错误。

解法:构建后把 ESM 入口标签换成一段不含 ESM 语法的普通脚本(qiankun 可以 eval 它),由它去原生加载真正的入口:

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)

关键在最后一行:补丁只打在 headbody 上,documentElement 是漏网之鱼。

入口模块加载完后把生命周期挂到 window,桥接脚本再把它转交给 qiankun:

// 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.Vueundefined

解法:qiankun 模式下把 CDN 块整个从 HTML 里删掉(用注释标记包裹,插件正则移除),这些依赖改为打进包里。体积会涨,但这是当前架构下唯一干净的做法。

例外是那些由公司基础设施提供、以全局变量形式暴露的内部 SDK——主站自己也在真实 window 上加载了同一份,子站直接读全局变量反而能取到。


坑四:分页组件样式丢了

表格能渲染,分页控件样式全乱。

原因是三层叠加:

  1. 项目用 unplugin-vue-components 按需引入样式,它只扫 .vue 模板里直接写的组件
  2. 表格用的是上层业务组件库封装的高阶 CRUD 表格,其内部渲染的分页组件不在扫描范围内,拿不到按需样式
  3. 独立站没暴露这个问题,因为 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/(微应用)两个路径下:

location ^~ /static-xxx/ {
  proxy_pass http://127.0.0.1:9000/;
}

一份产物能同时在两个路径下正常工作,靠的是 webpack 的运行时能力:

if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}

Vite 没有等价物,base 在构建时就烤进产物了。结论:必须构建两份

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。这个隐含依赖要显式写进脚本里:

"build:qiankun": "pnpm --filter <app> build && pnpm --filter <app> build:qiankun"

坑八:部署阶段的两个翻车

其一,pnpm monorepo 里脚本找不到。 流水线在仓库根执行 pnpm build:qiankun,而这个脚本当时只存在于子包。pnpm 在根目录找不到同名脚本会退化成递归执行所有 workspace 包,第一个没有该脚本的包就报错:

ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL  Command "build:qiankun" not found
检查脚本退出状态码:254

在根 package.json 补一条转发脚本即可。

其二,nginx 的 alias + try_files 会返回 500 而不是 404。 最初这样写:

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

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 子应用的运行时模板自动调用:

// plugin-qiankun/src/slave/lifecycles.ts.tpl
callback: () => {
  if (props?.autoSetLoading && typeof props?.setLoading === 'function') {
    props.setLoading(false)
  }
}

我们是 Vue 子应用,没有这份模板,mount(props) 里拿到了 setLoading 却从没调过。在挂载完成后补一行即可。

这类「框架默认帮你做了、换个技术栈就没人做」的隐式契约,在跨栈微前端里会反复出现,只能靠读源码发现。


坑十:本地环境的两个低级但费时的问题

Windows 文件占用。 预览服务开着的时候跑构建,Vite 清空输出目录会失败:

EPERM: operation not permitted, lstat dist\assets\index-xxx.js
  at emptyDir (...)

把构建和预览合并成一条命令(先构建后起服务),顺序天然错开,问题消失。

IPv4 / IPv6 绑定不匹配。 vite preview 默认只绑 [::1],而主站 dev 代理目标写的是 127.0.0.1:<port>,连接直接被拒:

TCP    [::1]:2444    LISTENING     ← 只有 IPv6
127.0.0.1 -> HTTP 000
localhost -> HTTP 200

这个坑之所以耗时,是因为我一直用 localhost 验证,它解析到 ::1 所以永远返回 200,把问题完美掩盖了,直到沿着主站的真实链路(IPv4)验证才暴露。教训很直接:验证要走真实调用方的链路,不要走自己顺手的那条。


如果重来

  1. 先确认参考案例的构建工具。抄一个 webpack 子应用的接入方式去做 Vite 子应用,前三个坑是必然的。
  2. 把「两份产物」当作前提而不是意外。Vite 的 base 写死在产物里,独立站和微应用无法共用一份包,这个约束应该在方案阶段就定下来,而不是部署时才发现。
  3. 跨栈接入要读框架源码autoSetLoading 这种隐式契约不会写在文档里。
  4. 每一层都要能独立验证。这次排查链路很长(浏览器 → 代理工具 → 主站 dev → devServer 代理 → 子站预览 → nginx),中间任何一环断了现象都是「一直转圈」。把每层的自查命令固化下来,比逐层猜快得多。

相关文档