在上一篇《万星入坞:我们如何用三层插件体系干掉巨石应用》中,我们拆解了星坞框架的壳层(Shell)设计------启动流程、核心模块、开发模式与构建部署。壳层是"坞",但"坞"里没有"星"就只是个空壳。
这篇就来说说:子应用(App)如何设计,才能优雅地"入坞"?
如果你从巨石应用拆出子应用,最直觉的做法可能是:按路由拆几个独立仓库,各自打包,再用 iframe 或 qiankun 拼起来。这么做能跑,但很快就会碰到一堆问题------React 实例不一致导致 Hooks 崩溃、子应用之间无法共享状态、公共依赖重复打包、独立开发时缺少壳层上下文导致白屏......这些问题不是"能不能跑"的问题,而是"能不能用"的问题。
星坞的子应用设计,核心思路是:描述符即契约,上下文即边界,生命周期即协议。 下面逐个拆解。
子应用的定位
先明确子应用在星坞三层体系中的位置:
| 维度 | App(子应用) | SDK(轻量插件) |
|---|---|---|
| 路由 | 拥有路由段(如 /product/*) |
无独立路由段 |
| UI | 渲染完整页面/视图 | 可纯逻辑,也可提供 UI 组件 |
| 生命周期 | 完整 mount → update → unmount |
activate → deactivate |
| 加载时机 | 路由匹配时按需加载 | 按需或预加载 |
| 独立开发 | 可独立启动开发服务器 | 通常在壳层内调试 |
一句话总结:子应用是拥有独立路由段和完整 UI 树的业务模块,是用户能"看见"的功能单元。
描述符即契约
壳层在加载子应用模块之前,需要先知道"这个子应用叫什么、路由前缀是什么、有没有权限声明"。这些信息由插件描述符(PluginDescriptor)提供------它是子应用与壳层之间的静态契约。
描述符的核心字段如下:
ts
// 子应用描述符(Shell config/apps.json 使用)
interface AppDescriptor {
name: string; // 唯一标识,如 'product'
version: string; // semver 版本号
entry: string; // ESM 入口路径
routePrefix: string; // 路由前缀,如 '/product'
dependencies?: string[]; // 依赖的 SDK,如 ['region-selector']
navItem?: NavItem; // 导航菜单配置
configSchema?: Record<string, unknown>; // 配置 Schema
integrity?: string; // SRI 校验哈希
}
一个实际的描述符声明:
ts
// packages/apps/product/plugin.config.ts
const descriptor: PluginDescriptor = {
name: 'product',
type: 'app',
version: '1.0.0',
entry: './src/index.tsx',
routePrefix: '/product',
dependencies: ['region-selector'],
navItem: {
key: 'product',
label: '商品管理',
icon: '📦',
order: 100,
children: [
{ key: 'product.list', label: '商品列表' },
{ key: 'product.detail', label: '商品详情' },
],
},
configSchema: {
type: 'object',
properties: {
defaultRegion: { type: 'string' },
maxProducts: { type: 'number' },
},
},
};
这里有个关键设计:App 专有字段与 SDK 专有字段互斥 。routePrefix 和 navItem 仅对 type: 'app' 有意义,exports 和 uiComponents 仅对 type: 'sdk' 有意义------类型系统确保这种互斥,防止配置串用。
描述符有三个声明位置,各有用途:
| 位置 | 格式 | 用途 |
|---|---|---|
plugin.config.ts |
PluginDescriptor | 子应用本地声明,开发时使用 |
shell/config/apps.json |
AppDescriptor | Shell 运行时配置,生产环境使用 |
dev-main.tsx 内联 |
PluginDescriptor | 独立开发模式 mock |
为什么描述符要外置到 JSON?因为壳层可以在不加载模块的情况下做出决策 :路由分发、权限检查、菜单生成只需读取描述符,无需 import() 子应用模块。CI 发布时将描述符写入配置中心,壳层拉取后即可完成路由注册,无需重新构建。
AppContext ------ 子应用的"生命线"
子应用入坞后,如何与壳层交互?答案是通过 AppContext------子应用与框架交互的唯一合法通道。
AppContext 的完整接口:
ts
interface AppContext {
descriptor: PluginDescriptor; // 插件描述符
router: {
params: Record<string, string>; // 路由参数(如 { productId: 'xxx' })
query: Record<string, string>; // URL 查询参数
navigate: (to: string, options?: NavigateOptions) => void;
beforeLeave: (guard: () => boolean | Promise<boolean>) => void;
};
config: TypedConfig; // 类型安全配置中心
sharedState: SharedStateBus; // 共享状态总线
sdk: SdkRegistry; // SDK 注册表
infra: {
monitor: Monitor; // 监控上报
i18n: I18n; // 国际化
net: NetClient; // 网络请求
permission: PermissionChecker; // 权限校验
};
container: HTMLElement; // 渲染容器
}
为什么一定要走 AppContext,不能直接 import Shell 的模块?
- 显式优于隐式 :子应用能做什么,完全由 AppContext 的字段决定。不需要的子应用(如 SDK)自然不会获得
router、container等能力 - 沙箱基础:AppContext 是受限上下文策略的实现基础------框架控制上下文的构造,可以按需裁剪能力
- 测试友好:子应用只依赖 AppContext 接口而非 Shell 实现细节,测试时只需构造 Mock 上下文
壳层构造 AppContext 的过程在 AppOutlet 组件中,每个子应用获得的上下文都是基于自身描述符和当前路由状态动态构建的:
ts
// packages/shell/src/layout/AppOutlet.tsx
function buildAppContext(shell, descriptor, el, location, navigate): AppContext {
return {
descriptor,
router: {
params: routeParamsFor(location.pathname, descriptor.routePrefix || ''),
query: Object.fromEntries(new URLSearchParams(location.search)),
navigate: (to, options) => navigate(to, { replace: options?.replace }),
beforeLeave: (guard) => shell.lifecycle.registerRouteGuard(descriptor.name, guard),
},
config: shell.configCenter.forPlugin(descriptor.name), // 作用域隔离
sharedState: shell.sharedState,
sdk: shell.sdkRegistry,
infra: { monitor, i18n, net, permission },
container: el,
};
}
注意 config: shell.configCenter.forPlugin(descriptor.name)------这行代码让子应用只能读写自己的配置命名空间,无法触碰其他插件的配置。能力边界在构造时就划清了。
生命周期 ------ 子应用的"入坞协议"
子应用入坞不是"加载了就行",它需要遵循一套生命周期协议,壳层才能正确地挂载、更新、卸载它。
生命周期时序
| 阶段 | 钩子 | 类型 | 说明 |
|---|---|---|---|
| 准备挂载 | beforeMount |
通知型 | 权限检查、配置读取 |
| 挂载 | mount |
必须实现 | React 渲染到容器 |
| 挂载完成 | afterMount |
通知型 | 性能打点、数据预取 |
| 路由更新 | update |
响应型 | URL 参数变化时重新拉取数据 |
| 即将卸载 | beforeUnmount |
可中断 | 返回 false 阻止卸载 |
| 卸载 | unmount |
必须实现 | 清理副作用、卸载 React Root |
这里有几个关键设计点:
beforeUnmount 可中断 :这是为了处理"表单未保存"这类场景。子应用返回 false,壳层就停止卸载流程,弹出确认对话框。其他钩子都是通知型的,只有 beforeUnmount 拥有"一票否决权"。
钩子超时熔断 :每个钩子有 10 秒超时限制。如果某个子应用的 beforeMount 卡死了,超时后壳层会 reject 并释放串行锁,防止整个应用的路由跳转被卡死。
ESM 模块驱逐 :子应用 unmount 后可选择性驱逐模块缓存(evictOnUnmount),释放内存。下次进入时重新 import()------对于大型子应用,这能显著减少内存占用。
子应用如何实现生命周期
入口文件的核心职责是导出 AppLifecycle 对象 ,供壳层 PluginRegistry 在 resolve 阶段提取:
tsx
// packages/apps/product/src/index.tsx
import type { AppLifecycle, AppContext } from '@xingwu/types';
import { createRoot, type Root } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from '@/App';
// WeakMap 管理挂载句柄,不泄漏到全局
const rootByContainer = new WeakMap<HTMLElement, Root>();
const lifecycle: AppLifecycle = {
async mount(ctx: AppContext) {
// 兜底:如果同一容器已有 Root,先卸载再重建
const existing = rootByContainer.get(ctx.container);
if (existing) {
existing.unmount();
rootByContainer.delete(ctx.container);
}
const root = createRoot(ctx.container);
rootByContainer.set(ctx.container, root);
// Shell 与子应用是不同 React Root,Router Context 不共享,必须自带 Router
const basename = ctx.descriptor.routePrefix || '/product';
root.render(
<ConfigProvider locale={zhCN}>
<AntdApp>
<BrowserRouter basename={basename}>
<App ctx={ctx} />
</BrowserRouter>
</AntdApp>
</ConfigProvider>,
);
},
async unmount(ctx: AppContext) {
const root = rootByContainer.get(ctx.container);
root?.unmount();
rootByContainer.delete(ctx.container);
},
onError(error) {
return <Result status="error" title="商品管理模块出错" subTitle={error.message} />;
},
};
export default lifecycle;
这里有个值得细说的设计:为什么用 WeakMap<HTMLElement, Root> 管理句柄?
ReactDOM.Root 这种非序列化句柄,有三个存放选择:
| 方案 | 问题 |
|---|---|
挂到 window |
违反沙箱约束,全局污染 |
放入 SharedStateBus |
违反「受控共享」约束,非序列化对象不应跨插件传递 |
WeakMap<HTMLElement, Root> |
✅ 以容器节点为 key,既保证 mount → unmount 配对清理,又不泄漏到全局 |
子应用路由 ------ 壳层管段,子应用自治
路由是子应用最核心的"领地"。壳层只关心路由段的顶层匹配 (/product 开头的交给 product 子应用),子应用在分配到的路由段内完全自治。
子应用的路由定义:
tsx
// packages/apps/product/src/App.tsx
export function App({ ctx }: { ctx: AppContext }) {
return (
<div>
<Space size="middle" split={<Typography.Text type="secondary">|</Typography.Text>}>
<Link to=".">商品列表</Link>
<Link to="detail/demo-product-001">示例详情</Link>
</Space>
<Routes>
<Route index element={<ProductList ctx={ctx} />} />
<Route path="detail/:productId" element={<ProductDetail ctx={ctx} />} />
</Routes>
</div>
);
}
壳层在挂载子应用时,会自动将 BrowserRouter 的 basename 设为描述符中的 routePrefix。这意味着子应用内部可以用相对路径写路由(to="detail/xxx"),不需要硬编码前缀。
有个容易踩的坑:Shell 与子应用是不同的 React Root,Router Context 不共享。 子应用必须自带 <BrowserRouter>,否则 <Link>、useNavigate 等路由 Hook 都会失效。这也是为什么 mount 里要包裹一层 <BrowserRouter basename={basename}>。
壳层如何挂载子应用
壳层的 AppOutlet 组件是子应用挂载的"调度中心",它的核心逻辑是:同 App 走 update,切换 App 先卸旧再挂新。
对应的代码实现:
tsx
// packages/shell/src/layout/AppOutlet.tsx(简化)
export function AppOutlet({ shell }: { shell: Shell }) {
const location = useLocation();
const navigate = useNavigate();
const mountRef = useRef<HTMLDivElement>(null);
const mountedAppRef = useRef<string | null>(null);
useEffect(() => {
const descriptor = shell.registry.findByRoute(location.pathname);
if (!descriptor) return;
const el = mountRef.current;
if (!el) return;
const ctx = buildAppContext(shell, descriptor, el, location, navigate);
// 同 App → update,切换 App → 先卸后挂
const isSameAppMounted =
mountedAppRef.current === descriptor.name &&
shell.lifecycle.getActiveApp() === descriptor.name;
if (isSameAppMounted) {
await shell.lifecycle.updateApp(descriptor.name, ctx);
} else {
await shell.lifecycle.mountApp(descriptor.name, el, ctx);
mountedAppRef.current = descriptor.name;
}
}, [location.pathname, location.search]);
return <div ref={mountRef} id="app-area" className="min-h-full" />;
}
LifecycleManager 通过串行锁保证任意时刻最多一个子应用处于 active,避免 mount/unmount 竞态:
ts
// mountApp/unmountApp 都通过串行锁排队执行
private runAppLifecycleExclusive<T>(fn: () => Promise<T>): Promise<T> {
const next = this.appLifecycleLock.then(fn);
this.appLifecycleLock = next.then(() => undefined, () => undefined);
return next;
}
子应用中使用 SDK
子应用不直接 import SDK 模块,而是通过 ctx.sdk 延迟绑定。这种设计带来三个优势:
- 解耦部署:子应用和 SDK 可以独立部署,子应用不需要在构建时将 SDK 打包
- 版本协商 :壳层统一管理 SDK 版本,子应用只声明依赖(
dependencies: ['region-selector']),不锁定具体版本 - 按需加载:SDK 只在子应用首次需要时才加载,避免加载不使用的 SDK
实际使用示例------商品列表页加载区域选择器 SDK:
tsx
// packages/apps/product/src/pages/ProductList.tsx
export function ProductList({ ctx }: { ctx: AppContext }) {
useEffect(() => {
const loadRegion = async () => {
try {
const regionApi = await ctx.sdk.load<RegionSelectorApi>('region-selector');
const region = regionApi.getCurrentRegion();
console.info(`[Product] Current region: ${region.name}`);
} catch (e) {
console.warn('[Product] region-selector SDK not available:', e);
}
};
void loadRegion();
}, [ctx]);
// ...
}
如果需要 SDK 的 UI 组件,则通过 getComponent 获取:
tsx
const RegionPicker = ctx.sdk.getComponent('region-selector', 'RegionPicker');
// 在 JSX 中使用
{RegionPicker && <RegionPicker />}
独立开发 ------ 不依赖壳层也能跑
子应用开发中最痛的一点是:每次改个按钮样式都要启动整个壳层? 在星坞里不需要。
每个子应用都有两个入口文件:
| 入口 | 用途 | 加载方式 |
|---|---|---|
src/index.tsx |
生产/联调 | Shell 通过 import(entry) 动态加载 |
src/dev-main.tsx |
独立开发 | Vite dev server 直接使用,模拟完整 AppContext |
独立开发入口的核心是构造 Mock AppContext------模拟 Shell 会提供的所有能力:
tsx
// packages/apps/product/src/dev-main.tsx(关键部分)
class DevSharedState implements SharedStateBus {
private map = new Map<string, unknown>();
getState<T>(key: string): T | undefined { return this.map.get(key) as T | undefined; }
setState<T>(key: string, value: T | ((prev: T) => T)): void { /* ... */ }
subscribe<T>(): () => void { return () => {}; }
}
const devSdk: SdkRegistry = {
has: (name) => name === 'region-selector',
load: async <T,>(name: string): Promise<T> => {
if (name === 'region-selector') {
return {
getAvailableRegions: () => [{ id: 'cn-east', name: '华东' }],
getCurrentRegion: () => ({ id: 'cn-east', name: '华东(独立开发)' }),
} as T;
}
throw new Error(`SDK "${name}" 未在独立开发模式 mock`);
},
// 其他方法省略...
};
function buildDevContext(navigate, container): AppContext {
return {
descriptor: { name: 'product', type: 'app', routePrefix: '/product', ... },
router: { params: {}, query: {}, navigate, beforeLeave: () => {} },
config: devConfig,
sharedState: devSharedState,
sdk: devSdk,
infra: { monitor: noopMonitor, i18n: noopI18n, net: noopNet, permission: noopPerm },
container,
};
}
Mock 的设计原则是:接口对齐,实现最简 。SharedStateBus 用内存 Map 实现,SdkRegistry 只 mock 当前子应用依赖的 SDK,Monitor/I18n 全部空实现。这样开发者只需 pnpm dev 就能启动子应用,不依赖 Shell。
独立开发模式下还会显示一个醒目的黄色 Banner,提醒开发者当前是独立模式:
⚠️ 独立开发模式:路由 basename 为
/product。联调请同时启动 Shell(端口 3000)并保留本服务在 5174。
子应用构建 ------ External 化与模块共享
子应用的构建配置是整个框架"运行时模块共享"的关键一环。
ts
// packages/apps/product/vite.config.ts
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@pages': path.resolve(__dirname, 'src/pages'),
'@styles': path.resolve(__dirname, '../../../styles'),
},
},
server: {
port: 5174, // 固定端口,Shell 联调时按此端口 import
strictPort: true,
cors: true, // 允许 Shell 跨域 import
},
build: {
lib: {
entry: 'src/index.tsx',
formats: ['es'], // 仅输出 ESM
fileName: 'product',
},
rollupOptions: {
external: ['react', 'react-dom', 'react-router-dom', '@xingwu/types'],
},
},
});
为什么要把 react 等标为 external?
不是为了减小包体积(虽然确实有此副作用),而是为了运行时模块共享:
| 依赖 | external 的原因 |
|---|---|
react / react-dom |
Hooks 要求同一实例,否则状态管理崩溃 |
react-router-dom |
路由上下文需要共享,否则 <Link> 失效 |
@xingwu/types |
接口定义需要在壳层和子应用之间保持类型一致性 |
这些依赖由壳层通过两种方式统一提供:
开发模式下,createSharedReactPlugin 将 react 系裸导入重定向到虚拟模块,从 window.__REACT_SHARED__ 获取壳层提供的 React 单实例。生产模式下,Import Maps 将裸导入映射到壳层已经加载的模块 URL。
子应用标准结构
一个完整的子应用目录长这样:
bash
packages/apps/product/
├── package.json # 独立 package,声明 @xingwu/types 依赖
├── tsconfig.json # 继承基座 tsconfig.base.json
├── vite.config.ts # Vite 配置(lib 模式构建 + external)
├── index.html # 独立开发模式的 HTML 入口
├── plugin.config.ts # 插件描述符声明
└── src/
├── index.tsx # 生产入口:导出 AppLifecycle
├── dev-main.tsx # 独立开发入口:模拟 AppContext
├── App.tsx # 子应用根组件(路由定义)
└── pages/
├── ProductList.tsx # 商品列表页
└── ProductDetail.tsx # 商品详情页
开发一个新的子应用,只需要按照这个结构创建目录,实现以下三件事:
- 声明描述符 (
plugin.config.ts)------ 告诉壳层"我是谁" - 实现生命周期 (
src/index.tsx)------ 告诉壳层"怎么挂载我" - 写业务代码 (
src/App.tsx+src/pages/)------ 通过 AppContext 消费壳层能力
踩坑实录
写子应用的过程中踩过几个坑,记录一下,希望大家别再踩。
坑1:忘记自带 BrowserRouter
现象 :子应用内部的 <Link> 点击无反应,useNavigate 返回的 navigate 函数调用后 URL 变了但页面不跳转。
原因 :Shell 和子应用是不同的 React Root,Router Context 不共享。子应用如果不包裹 <BrowserRouter>,所有路由 Hook 都在"裸奔"。
修复 :在 mount 中用 <BrowserRouter basename={routePrefix}> 包裹子应用根组件。
坑2:react-dom/client 未拦截导致 Hooks 崩溃
现象 :联调模式下子应用加载后控制台报 Invalid hook call。
原因 :createSharedReactPlugin 漏掉了 react-dom/client 的拦截。Shell 通过 import() 动态加载子应用时,react-dom/client 落到 Vite 的 CJS→ESM 预构建路径,预构建转换无法正确暴露 createRoot 命名导出,导致拿到的是另一份 react-dom 实例。
修复 :单独拦截 react-dom/client,重定向到 virtual:shared-react-dom-client。
坑3:WeakMap 里忘了清理 Root
现象:反复切换子应用后内存持续增长。
原因 :unmount 里只调了 root.unmount() 但没从 WeakMap 中 delete,导致旧的 Root 引用无法被 GC 回收。
修复 :unmount 中同时执行 rootByContainer.delete(ctx.container)。
小结
- 描述符即契约 :
PluginDescriptor让壳层在不加载模块的情况下就能完成路由注册、菜单生成、权限检查------这是"配置驱动"的基础 - AppContext 即边界 :子应用与框架的唯一交互通道,能力边界在构造时就划清了;
config.forPlugin()的作用域隔离让插件无法越界读写配置 - 生命周期即协议 :
mount/unmount是最小契约,beforeUnmount提供中断能力,超时熔断防止死锁,ESM 驱逐回收内存------渐进增强,按需使用 - 独立开发是刚需 :两个入口文件(
index.tsx+dev-main.tsx)的设计,让子应用开发既能在独立模式下快速迭代,又能在联调模式下与壳层无缝协作 - External 化是运行时共享的前提:不是"能省几个 KB"的问题,而是"React Hooks 能不能正常工作"的问题
子应用入坞,看似只是一个 mount 函数的事,实际上涉及契约声明、能力边界、生命周期协议、路由自治、模块共享、独立开发六个维度的设计。每个维度都有各自的取舍------而这些取舍的出发点只有一个:让子应用的开发体验尽量接近独立应用,同时享受插件化框架带来的架构红利。
如果你也在设计微前端的子应用体系,希望这些实践能给你一些参考。
子应用完整示例:apps/product