前言
上一篇我们聊了 qiankun 的工作原理和整体项目结构。这一篇开始动手------搭建整个微前端架构的骨架:shell-app 主应用。
主应用做的事情说起来不复杂:
- 安装 qiankun,注册所有子应用
- 告诉 qiankun「哪个路由对应哪个子应用」
- 初始化全局状态,让主应用和子应用之间能通信
- 启动 qiankun
但每一步都有细节值得讲清楚。我们按顺序来。
项目结构回顾
shell-app 的核心目录结构如下:
csharp
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
bash
pnpm add qiankun --filter shell-app
因为用的是 pnpm monorepo,--filter shell-app 确保只在主应用里安装,子应用不需要安装 qiankun。
第二步:设计 micro/index.ts
这是整个主应用里最核心的文件。我们把它拆成三个部分来看。
Part 1:子应用入口地址处理
typescript
/**
* 获取子应用入口地址
* 开发环境使用 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------省略协议头,让浏览器自动继承当前页面的协议(http 或 https),避免混合内容警告。
Part 2:子应用注册配置
typescript
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 给子应用
typescript
const getProps = () => {
return {
globalState: getGlobalStateActions(),
token: tokenManager.getToken(),
userInfo: tokenManager.getUserInfo(),
tokenManager,
};
};
通过 props,主应用可以在子应用加载时把数据「注入」进去。这里传了四样东西:
globalState:qiankun 的全局状态 actions,子应用用它来读写共享状态token和userInfo:认证信息,子应用调用接口时需要tokenManager:token 的存取工具,子应用可以直接用,不需要自己再实现一遍
这是 shared/ 共享包设计的延伸------公共逻辑只写一次,主应用通过 props 分发给所有子应用。
第三步:注册子应用与生命周期钩子
typescript
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 是最快速定位问题的方式。你可以清楚地看到:
csharp
[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
typescript
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 把一切串起来
typescript
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 树。如果顺序搞错------比如先 render 再 registerApps------子应用可能在 DOM 容器还没渲染出来的时候就尝试挂载,导致找不到容器节点,页面一片空白。
全局状态设计:globalState.ts
全局状态是主应用和所有子应用通信的核心通道。我们用 qiankun 的 initGlobalState 封装了一套完整的状态管理方案。
状态结构
typescript
interface GlobalState {
user: UserInfo | null; // 当前用户信息
locale: string; // 语言设置
theme: 'light' | 'dark'; // 主题
siderCollapsed: boolean; // 侧边栏折叠状态
loading: boolean; // 全局加载状态
notifications: Notification[]; // 消息通知队列
}
这些状态有一个共同特点:它们属于「全局关心」的数据------不只是某一个子应用需要,而是多个子应用都可能读取或修改的。纯粹属于某个子应用内部的状态,没必要放进来。
ActionType 枚举
typescript
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」这种大小写不一致的坑。
单例模式保证全局唯一
typescript
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:
typescript
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...