qiankun 微前端实战(二):主应用搭建 — 安装、注册与全局状态

1 阅读8分钟

前言

上一篇我们聊了 qiankun 的工作原理和整体项目结构。这一篇开始动手——搭建整个微前端架构的骨架:shell-app 主应用

主应用做的事情说起来不复杂:

  1. 安装 qiankun,注册所有子应用
  2. 告诉 qiankun「哪个路由对应哪个子应用」
  3. 初始化全局状态,让主应用和子应用之间能通信
  4. 启动 qiankun

但每一步都有细节值得讲清楚。我们按顺序来。


项目结构回顾

shell-app 的核心目录结构如下:

shell-app/
└── src/
    ├── main.tsx          # 入口文件,qiankun 在这里启动
    ├── App.tsx           # 根组件,提供路由和布局
    ├── micro/            # qiankun 微前端配置(核心)
    │   └── index.ts      # 注册子应用 + 启动配置
    ├── utils/
    │   ├── globalState.ts  # 全局状态管理
    │   ├── auth.ts         # 登录/登出工具
    │   └── storage.ts      # token 存储工具
    └── styles/
        └── global.css

micro/ 目录是这篇文章的主角——所有 qiankun 相关的配置都集中在这里,而不是散落在入口文件或路由配置里。这是一个值得养成的习惯,方便后续维护和扩展。


第一步:安装 qiankun

pnpm add qiankun --filter shell-app

因为用的是 pnpm monorepo,--filter shell-app 确保只在主应用里安装,子应用不需要安装 qiankun。


第二步:设计 micro/index.ts

这是整个主应用里最核心的文件。我们把它拆成三个部分来看。

Part 1:子应用入口地址处理

/**
 * 获取子应用入口地址
 * 开发环境使用 localhost,生产环境使用环境变量配置的 URL
 */
const getEntry = (localPort: number, envKey: string): string => {
  const prodEntry = (import.meta as any).env[envKey];
  if (prodEntry) {
    return prodEntry;
  }
  return `//localhost:${localPort}`;
};

这个函数的作用是让同一份代码在开发和生产环境都能正常工作。开发时各子应用跑在本地不同端口,生产时地址从环境变量读取。

注意这里用的是 //localhost:3001 而不是 http://localhost:3001——省略协议头,让浏览器自动继承当前页面的协议(httphttps),避免混合内容警告。

Part 2:子应用注册配置

const microApps: MicroApp[] = [
  {
    name: 'app-user',
    entry: getEntry(3001, 'VITE_APP_USER_ENTRY'),
    container: '#subapp-user',
    activeRule: (location: Location) => location.pathname.startsWith('/user'),
    props: getProps(),
  },
  {
    name: 'app-product',
    entry: getEntry(3002, 'VITE_APP_PRODUCT_ENTRY'),
    container: '#subapp-product',
    activeRule: (location: Location) => location.pathname.startsWith('/product'),
    props: getProps(),
  },
  {
    name: 'app-order',
    entry: getEntry(3003, 'VITE_APP_ORDER_ENTRY'),
    container: '#subapp-order',
    activeRule: (location: Location) => location.pathname.startsWith('/order'),
    props: getProps(),
  },
  {
    name: 'app-dashboard',
    entry: getEntry(3004, 'VITE_APP_DASHBOARD_ENTRY'),
    container: '#subapp-dashboard',
    activeRule: (location: Location) => location.pathname.startsWith('/dashboard'),
    props: getProps(),
  },
];

每个子应用配置有四个核心字段,缺一不可:

字段作用常见错误
name子应用唯一标识必须与子应用自身声明的名字一致
entry子应用入口 HTML 地址忘记配置 CORS,导致加载失败
container渲染到哪个 DOM 节点DOM 节点不存在时子应用渲染空白
activeRule什么路由下激活路径前缀冲突导致多个子应用同时触发

activeRule 这里用的是函数形式而不是字符串,好处是更灵活——可以写复杂的匹配逻辑,比如同时匹配多个路径前缀。

Part 3:传递 props 给子应用

const getProps = () => {
  return {
    globalState: getGlobalStateActions(),
    token: tokenManager.getToken(),
    userInfo: tokenManager.getUserInfo(),
    tokenManager,
  };
};

通过 props,主应用可以在子应用加载时把数据「注入」进去。这里传了四样东西:

  • globalState:qiankun 的全局状态 actions,子应用用它来读写共享状态
  • tokenuserInfo:认证信息,子应用调用接口时需要
  • tokenManager:token 的存取工具,子应用可以直接用,不需要自己再实现一遍

这是 shared/ 共享包设计的延伸——公共逻辑只写一次,主应用通过 props 分发给所有子应用。


第三步:注册子应用与生命周期钩子

export const registerApps = () => {
  registerMicroApps(microApps, {
    beforeLoad: [
      (app) => {
        console.log('[qiankun] Loading app...', app.name);
      },
    ],
    beforeMount: [
      (app) => {
        console.log('[qiankun] Mounting app...', app.name);
      },
    ],
    afterMount: [
      (app) => {
        console.log('[qiankun] Mounted app...', app.name);
      },
    ],
    beforeUnmount: [
      (app) => {
        console.log('[qiankun] Unmounting app...', app.name);
      },
    ],
    afterUnmount: [
      (app) => {
        console.log('[qiankun] Unmounted app...', app.name);
      },
    ],
  });
};

registerMicroApps 的第二个参数是全局生命周期钩子,对所有子应用生效。这跟子应用自己暴露的 bootstrap/mount/unmount 不同——那是子应用内部的钩子,这里是主应用侧的钩子。

这五个钩子在调试阶段非常有用。当你发现某个子应用加载空白、或者切换子应用时页面异常,在这里打 log 是最快速定位问题的方式。你可以清楚地看到:

[qiankun] Loading app... app-user
[qiankun] Mounting app... app-user
[qiankun] Mounted app... app-user
[qiankun] Unmounting app... app-user
[qiankun] Unmounted app... app-user

如果某一行没有打印,问题就出在那个阶段。


第四步:启动 qiankun

export const startMicroApps = () => {
  start({
    sandbox: {
      strictStyleIsolation: false,
      experimentalStyleIsolation: true,
    },
    prefetch: 'all',
    singular: false,
  });
};

这三个配置值得单独说明:

sandbox.strictStyleIsolation: false

严格样式隔离使用 Shadow DOM 实现,隔离效果最好,但会导致一些 UI 库(比如 Ant Design、Element Plus)的弹窗、下拉框样式失效——因为这些组件会把 DOM 挂载到 document.body,在 Shadow DOM 外面,拿不到组件的样式。开发阶段还会引发 Vite HMR 报错。所以这里关掉了。

sandbox.experimentalStyleIsolation: true

实验性样式隔离的原理是给子应用的所有 CSS 选择器加上一个属性前缀(类似 CSS Scoped),既能防止样式污染,又不影响 UI 库的弹窗组件。代价是「实验性」——在某些边缘场景可能有问题,但对大多数项目来说够用。

singular: false

默认值是 true,表示同一时间只能有一个子应用处于激活状态。改成 false 之后,多个子应用可以同时存在于页面上。这在本项目里是必要的——ERP 系统的布局中,不同区域可能同时展示不同子应用的内容。

prefetch: 'all'

主应用启动后,在浏览器空闲时预加载所有子应用的资源。用户切换子应用时就不需要等待加载,体验更流畅。


第五步:入口文件 main.tsx 把一切串起来

import { initGlobalState, ActionType } from './utils/globalState';
import { registerApps, startMicroApps } from './micro';
import { logout } from './utils/auth';

// 1. 初始化全局状态
const actions = initGlobalState();

// 2. 监听全局状态变化,处理子应用发来的 action
actions.onGlobalStateChange((state: any, prevState: any) => {

  // 处理退出登录
  if (state.action === ActionType.LOGOUT || state.action === 'LOGOUT') {
    logout();
    window.location.href = '/login';
    actions.setGlobalState({ action: null });
  }

  // 处理跨应用路由跳转
  if (state.action && state.action.startsWith('NAVIGATE_')) {
    const targetPath = state.payload?.path;
    if (targetPath) {
      window.history.pushState(null, '', targetPath);
      actions.setGlobalState({ action: null });
    }
  }
}, true);

// 3. 注册子应用
registerApps();

// 4. 启动 qiankun
startMicroApps();

// 5. 渲染 React 主应用
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);

注意这里的顺序很重要

initGlobalState → registerApps → startMicroApps → ReactDOM.render

必须先初始化全局状态、注册子应用,再启动 qiankun,最后才渲染 React 树。如果顺序搞错——比如先 renderregisterApps——子应用可能在 DOM 容器还没渲染出来的时候就尝试挂载,导致找不到容器节点,页面一片空白。


全局状态设计:globalState.ts

全局状态是主应用和所有子应用通信的核心通道。我们用 qiankun 的 initGlobalState 封装了一套完整的状态管理方案。

状态结构

interface GlobalState {
  user: UserInfo | null;       // 当前用户信息
  locale: string;              // 语言设置
  theme: 'light' | 'dark';    // 主题
  siderCollapsed: boolean;     // 侧边栏折叠状态
  loading: boolean;            // 全局加载状态
  notifications: Notification[]; // 消息通知队列
}

这些状态有一个共同特点:它们属于「全局关心」的数据——不只是某一个子应用需要,而是多个子应用都可能读取或修改的。纯粹属于某个子应用内部的状态,没必要放进来。

ActionType 枚举

export enum ActionType {
  SET_USER = 'SET_USER',
  LOGOUT = 'LOGOUT',
  NAVIGATE_TO = 'NAVIGATE_TO',
  SET_THEME = 'SET_THEME',
  TOGGLE_THEME = 'TOGGLE_THEME',
  SET_LOCALE = 'SET_LOCALE',
  SET_SIDER_COLLAPSED = 'SET_SIDER_COLLAPSED',
  SET_LOADING = 'SET_LOADING',
  ADD_NOTIFICATION = 'ADD_NOTIFICATION',
  REMOVE_NOTIFICATION = 'REMOVE_NOTIFICATION',
}

用枚举定义所有 action 类型,而不是在各处写魔法字符串,有两个好处:一是 TypeScript 类型提示,不会写错;二是集中管理,子应用和主应用用同一份枚举,不会出现「主应用监听 LOGOUT,子应用发出 logout」这种大小写不一致的坑。

单例模式保证全局唯一

let actions: MicroAppStateActions | null = null;

export const initGlobalState = (state?: Partial<GlobalState>) => {
  if (!actions) {
    actions = qiankunInitGlobalState({ ...initialState, ...state });
  }
  return actions;
};

用模块级变量 actions 保证 initGlobalState 无论被调用多少次,始终返回同一个实例。这很关键——如果每次都创建新实例,子应用收到的 globalState 和主应用自己监听的就不是同一个,状态同步会失效。


一个值得关注的细节:action 字段的约定

main.tsx 里,主应用监听全局状态变化时,判断的是 state.action

if (state.action === ActionType.LOGOUT) {
  logout();
  actions.setGlobalState({ action: null }); // 处理完立刻清空
}

这里用了一个约定:子应用需要触发某个行为时,不直接调用主应用的函数,而是往全局状态里写一个 action 字段。主应用监听到这个字段变化后,执行对应的逻辑,然后把 action 清空。

这个模式类似 Redux 的 dispatch,好处是子应用完全不需要知道主应用的内部实现,只需要约定好 ActionType 枚举就够了。处理完之后立刻把 action 设为 null,也是为了防止状态变化被重复触发。


小结

这一篇我们完成了主应用的核心搭建:

  • micro/index.ts:子应用注册、入口地址处理、props 注入、生命周期钩子
  • startMicroApps:沙箱配置、预加载、多应用并存
  • globalState.ts:全局状态结构设计、ActionType 枚举、单例模式
  • main.tsx:把一切串起来,并且顺序很重要

主应用骨架立起来了。下一篇,我们开始改造第一个子应用——React 子应用,让它能被 qiankun 正确加载。


下一篇:[第 3 篇 — 接入 React 子应用:UMD 打包与生命周期改造](即将发布)

觉得有帮助的话,欢迎点赞收藏,也欢迎去 GitHub 给项目点个 Star ⭐ 项目地址:github.com/nacheal/erp…