• 从落地到"半停用":qiankun 微前端在一个 Vue 3 + Vite 项目中的完整实践与反思


    从落地到"半停用":qiankun 微前端在一个 Vue 3 + Vite 项目中的完整实践与反思

    本文基于一个真实企业项目——某工业监测管理平台(已对业务信息做脱敏处理)的微前端改造实践整理而成。系统由管理后台(admin)与大屏展示端(client)两个独立 Vue 3 应用组成,曾用 qiankun 集成,后转为独立部署。这篇文章既讲"怎么接的",也讲"为什么后来不用了",希望能给正在选型微前端的团队一些参考。

    一、项目背景

    系统包含两个独立前端应用:

    应用 定位 技术栈
    admin 管理后台主应用,基于 JeecgBoot Vue3 二次开发 Vue 3.4 + TypeScript + Vite 5 + Ant Design Vue 4 + Pinia
    client 大屏展示应用 Vue 3.2 + JavaScript + Vite 4 + Three.js / 地图 SDK / ECharts

    两个应用由不同时期、不同风格的团队开发:admin 是标准的中后台技术栈,client 则是重渲染、重 WebSocket、全屏运行的大屏应用。

    最初的诉求很典型:

    1. 用户在 admin 里点一个菜单,希望能无刷新进入大屏,而不是跳到一个新站点;
    2. 登录态(token)要共享,不能让用户再登一次;
    3. 两个应用保持独立仓库目录、独立构建部署节奏。

    于是自然想到了 qiankun——国内最主流的微前端框架,基于 single-spa,通过 HTML Entry + JS 沙箱实现子应用接入,对技术栈几乎无侵入。

    二、主应用侧的实现

    2.1 环境变量驱动的微应用注册清单

    qiankun 的第一步是 registerMicroApps。我们没有把子应用列表硬编码,而是约定了一个环境变量前缀 VITE_APP_SUB_,启动时动态扫描生成注册清单:

    // admin/src/qiankun/apps.ts
    const _apps: object[] = [];
    for (const key in import.meta.env) {
      if (key.includes('VITE_APP_SUB_')) {
        const name = key.split('VITE_APP_SUB_')[1];
        const obj = {
          name,                                  // 微应用名称,全局唯一
          entry: import.meta.env[key],           // 微应用入口地址
          container: '#content',                 // 挂载节点
          activeRule: name,                      // 激活路由前缀
        };
        _apps.push(obj);
      }
    }
    export const apps = _apps;
    

    对应的 .env.development:

    # 命名必须以 VITE_APP_SUB_ 开头,client 为子应用项目名称,也是路由父路径
    VITE_APP_SUB_client = '//localhost:3010'
    

    这样做的好处:

    • 新增子应用零代码改动——加一行环境变量即可;
    • 环境隔离天然成立——开发指向 //localhost:3010,生产指向部署域名;
    • name 同时充当 activeRule,约定"子应用名即路由前缀",即访问 /client/** 时激活 client 子应用。

    2.2 注册与启动

    // admin/src/qiankun/index.ts(节选)
    import { registerMicroApps, start, runAfterFirstMounted, addGlobalUncaughtErrorHandler } from 'qiankun';
    import { apps } from './apps';
    import { getProps, initGlState } from './state';
    
    function genActiveRule(routerPrefix) {
      return (location) => location.pathname.startsWith(routerPrefix);
    }
    
    function filterApps() {
      apps.forEach((item) => {
        item.props = getProps();                 // 主应用下发给子应用的数据
        item.activeRule = genActiveRule('/' + item.activeRule);
      });
      return apps;
    }
    
    function registerApps() {
      const _apps = filterApps();
      registerMicroApps(_apps, {
        beforeLoad: [(loadApp) => console.log('before load', loadApp)],
        beforeMount: [(mountApp) => console.log('before mount', mountApp)],
        afterMount: [(mountApp) => console.log('after mount', mountApp)],
        afterUnmount: [(unloadApp) => console.log('after unload', unloadApp)],
      });
      runAfterFirstMounted(() => console.log('开启监控'));
      addGlobalUncaughtErrorHandler((event) => console.log(event));
      initGlState();
      start({});
    }
    
    export default registerApps;
    

    几个细节值得展开:

    activeRule 用函数而非字符串。 genActiveRule 返回一个 location => boolean 的函数,比字符串匹配灵活——后续如果要支持 /client 和 /client/xxx 之外的复杂规则(比如 hash 模式、多前缀),改函数即可。

    生命周期钩子是埋点/监控的天然切面。 beforeLoad(资源加载前)、beforeMount/afterMount(挂载前后)、afterUnmount(卸载后)可以用来做加载耗时上报、子应用切换埋点。runAfterFirstMounted 则专门用于首个子应用挂载后开启监控脚本——避免监控脚本在子应用加载前就跑起来,统计到一堆空白时间。

    addGlobalUncaughtErrorHandler 兜底。 微前端场景下,子应用的未捕获异常会冒泡到主应用,统一在这里上报,避免大屏里 Three.js 的渲染异常把整个后台搞崩而毫无感知。

    2.3 数据通信:props 下发 + 全局状态两条通道

    qiankun 的主子通信我们用了两条通道,分别解决不同的问题。

    通道一:props 直传——解决"登录态与上下文共享"

    // admin/src/qiankun/state.ts(节选)
    import { initGlobalState } from 'qiankun';
    import { store } from '/@/store';
    import { router } from '/@/router';
    import { getToken } from '/@/utils/auth';
    
    export function getProps() {
      return {
        data: {
          publicPath: '/',
          token: getToken(),   // 登录态
          store,               // 主应用 Pinia 实例
          router,              // 主应用路由实例
        },
      };
    }
    

    子应用在 mount(props) 生命周期里直接拿到 token,无需再走一遍 SSO/登录流程;拿到 store 和 router 实例,则可以做一些深度联动(比如大屏里点击设备跳回后台对应详情页)。

    注意:直接传 store/router 实例是"强耦合"方案,要求子应用与主应用的 Pinia / Vue Router 版本兼容。这在"同一团队维护的两个应用"里可接受,但如果你追求子应用完全技术栈无关,应该只传纯数据。

    通道二:initGlobalState——解决"双向响应式通信"

    export function initGlState(info = { userName: 'admin' }) {
      const actions = initGlobalState(info);
      actions.setGlobalState(info);
      actions.onGlobalStateChange((newState, prev) => {
        console.info('newState', newState);
        console.info('prev', prev);
      });
      return actions;
    }
    

    qiankun 内置的 GlobalState 是一个观察者模式的全局状态池:主应用 setGlobalState,子应用通过 props 里的 onGlobalStateChange 监听;反过来子应用也可以 setGlobalState 通知主应用。适合做主题切换、用户信息变更这类低频、双向的信号同步。

    2.4 挂载容器:藏在布局组件里的 #content

    子应用挂载点 #content 放在主应用布局的内容区:

    
    
    

    同时配合 JeecgBoot 的动态路由机制,在后端菜单里把某个菜单项的 component 配置为 LayoutsContent:

    // admin/src/router/helper/routeHelper.ts
    LayoutMap.set('LAYOUT', LAYOUT);
    LayoutMap.set('IFRAME', IFRAME);
    // 微前端 qiankun
    LayoutMap.set('LayoutsContent', LayoutContent);
    

    这样"进入大屏"就变成了一个正常的菜单路由,点击菜单 → URL 变为 /client/... → qiankun 的 activeRule 命中 → 子应用挂载到 #content。整个体验是"后台里的一个页面",而不是"跳去了另一个网站"。

    2.5 防重复启动:window.qiankunStarted

    启动代码放在布局组件的 onMounted 里,而布局组件可能因路由/权限刷新被多次挂载,start() 重复调用会报错。用一个全局标志位防重:

    onMounted(() => {
      if (openQianKun == 'true') {
        if (!window.qiankunStarted) {
          window.qiankunStarted = true;
          registerApps();
        }
      }
    });
    

    这是 qiankun + Vue 项目的一个经典坑:注册和启动必须且只能执行一次。更优雅的做法是放到 main.ts 的初始化阶段,但放在布局 onMounted 里可以确保挂载容器已存在,各有取舍。

    三、子应用侧:Vite 是最大的坑

    3.1 为什么 Vite 项目接 qiankun 特别麻烦

    qiankun(底层 import-html-entry)的工作方式是:抓取子应用 entry 的 HTML → 解析出内联/外链的 script 和 style → 用 eval(在沙箱上下文中)执行脚本,从而拿到子应用导出的 bootstrap / mount / unmount 生命周期。

    问题来了:Vite 开发模式的产物是原生 ESM(