普通视图

发现新文章,点击刷新页面。
昨天 — 2026年5月23日首页

万星入坞·其三:SDK 轻量组件如何优雅地"点亮"

作者 码云之上
2026年5月22日 17:19

在前两篇:

我们分别拆解了壳层和子应用的设计。壳层是"坞",子应用是拥有独立路由段的"大星",但还有一种插件形态——它不占路由段,却既能提供纯逻辑能力(如鉴权守卫),又能渲染 UI 组件(如区域选择器)。这就是 SDK,星坞三层体系中的"小星"。

如果你用过微前端框架,可能会有这样的困惑:插件要么是纯逻辑,要么是完整页面,中间地带怎么办? 鉴权守卫不需要页面,但需要在每个路由跳转前拦截;区域选择器不是独立业务,却需要在 Header 和面包屑同时渲染 UI。如果强行归入子应用,会引入不必要的路由和加载开销;如果复制到每个子应用,则违背 DRY 原则。SDK 就是解决这个矛盾的轻量形态。

本文的核心思路是:描述符声明 UI 契约,上下文裁剪最小权限,UI 渲染三板斧各取所需。 下面逐个拆解。


SDK 的定位

先明确 SDK 在星坞三层体系中的位置:

graph LR
  Shell["Shell 壳层"] -->|"提供 SdkContext"| SDK["SDK 轻量插件"]
  Shell -->|"提供 AppContext"| App["App 子应用"]
  App -->|"ctx.sdk.load()"| SDK
  SDK -.->|"禁止直接 import"| App
维度 App(子应用) SDK(轻量插件)
路由 拥有路由段(如 /product/* 无独立路由段,不参与路由分发
UI 渲染完整页面/视图 可纯逻辑,也可提供 UI 组件供宿主渲染
生命周期 完整 mount → update → unmount activate → deactivate
加载时机 路由匹配时按需加载 按需或预加载
独立开发 可独立启动开发服务器 通常在壳层内调试

一句话总结:SDK 是不占路由段的轻量插件,能纯逻辑、能提供 UI、能两者兼有。

SDK 的形态光谱

SDK 并非非此即彼,而是有一个从"纯逻辑"到"含 UI"的形态光谱:

graph LR
  Pure["纯逻辑"] --> Mixed["含 UI 组件"]
  Pure --- Auth["auth-guard\n鉴权拦截\n无 UI"]
  Pure --- I18n["i18n-provider\n翻译包\n无 UI"]
  Mixed --- Region["region-selector\n区域选择器\n逻辑 + UI"]
  Mixed --- Audit["audit-log\n审计日志面板\n逻辑 + UI"]

  style Pure fill:#fff3cd
  style Mixed fill:#d4edda
  • 纯逻辑 SDK:仅提供 API/拦截器/数据转换,不渲染任何 UI(如 auth-guard
  • 含 UI SDK:除 API 外还提供 UI 组件,支持两种互补渲染能力(如 region-selector

这种设计填补了传统微前端"纯逻辑或纯页面"之间的空白,是星坞相比其他框架的一个亮点。


描述符声明 UI 契约

壳层在加载 SDK 模块之前,需要先知道"这个 SDK 叫什么、有没有 UI 组件、挂载到哪个插槽"。这些信息由 插件描述符(PluginDescriptor) 提供——它和子应用的描述符是同一个类型,但 SDK 有几个专属字段。

graph TD
  Desc["SDK 描述符\nPluginDescriptor"] -->|"壳层读取"| Preload["预加载判断\npreload"]
  Desc -->|"壳层读取"| UI["UI 组件注册\nuiComponents"]
  Desc -->|"壳层读取"| Style["样式隔离策略\nstyleStrategy"]
  Desc -->|"运行时"| Entry["import(entry)\n加载模块"]
  Desc -->|"子应用读取"| Export["导出声明\nexports"]

  style Desc fill:#e8f4fd
  style Entry fill:#d4edda

SDK 专有字段

字段 必填 说明
preload 是否预加载(SDK 独有,App 按路由加载无需此字段)
exports 导出的 API 声明列表
uiComponents UI 组件声明数组(纯逻辑 SDK 无此字段)
styleStrategy 样式隔离策略:css-modules(默认)/ css-in-js / shadow-dom

其中 uiComponents 是 SDK 与宿主之间的静态 UI 契约,作用类似 React 的 propTypes

子字段 说明
name 组件唯一标识,需与 getComponents() 返回的 key 对应
slot 期望的挂载位置,壳层 SdkSlotHost 据此决定渲染位置
propsSchema 组件 props 的 JSON Schema 约束,宿主侧可据此生成 TypeScript 类型
description 组件用途描述,方便文档生成

来看两个实际例子。

纯逻辑 SDK 描述符

// packages/sdks/auth-guard/plugin.config.ts
const descriptor: PluginDescriptor = {
  name: 'auth-guard',
  type: 'sdk',
  version: '1.2.0',
  entry: './src/index.ts',
  preload: true,                    // 预加载——鉴权守卫必须首屏就绪
  exports: ['AuthGuardApi'],        // 声明导出的 API
  configSchema: {
    type: 'object',
    properties: {
      enableSessionGuard: { type: 'boolean', default: true },
      enableOwnerGuard: { type: 'boolean', default: true },
    },
  },
};

没有 uiComponents,壳层就知道这个 SDK 不需要渲染 UI。

含 UI SDK 描述符

// packages/sdks/region-selector/plugin.config.ts
const descriptor: PluginDescriptor = {
  name: 'region-selector',
  type: 'sdk',
  version: '2.1.0',
  entry: './src/index.tsx',
  preload: true,
  exports: ['RegionSelectorApi'],
  uiComponents: [
    {
      name: 'RegionPicker',
      description: '区域选择器下拉组件',
      slot: 'header-slot',           // 挂载到 Header 插槽
      propsSchema: { type: 'object', properties: { regions: { type: 'array' }, onChange: { typeof: 'function' } } },
    },
    {
      name: 'RegionBreadcrumb',
      description: '区域面包屑导航',
      slot: 'breadcrumb',            // 挂载到面包屑插槽
    },
  ],
  styleStrategy: 'css-modules',
  configSchema: {
    type: 'object',
    properties: {
      defaultRegion: { type: 'string' },
    },
  },
};

注意两个 uiComponents 声明了不同的 slot——壳层据此知道 RegionPicker 渲染到 Header,RegionBreadcrumb 渲染到面包屑。声明时绑定,无需运行时协商。


上下文裁剪——最小权限

SDK 通过 SdkContext 消费壳层能力,但 SdkContextAppContext受约束子集。这不是偷懒少写几行代码,而是有意裁剪——基于最小权限原则。

graph TB
  subgraph AppCtx["AppContext"]
    A1["descriptor"]
    A2["config"]
    A3["sharedState"]
    A4["router"]
    A5["sdk"]
    A6["infra.net"]
    A7["infra.permission"]
    A8["infra.monitor"]
    A9["infra.i18n"]
    A10["container"]
  end

  subgraph SdkCtx["SdkContext"]
    S1["descriptor ✅"]
    S2["config ✅"]
    S3["sharedState ✅"]
    S4["router ❌"]
    S5["sdk ❌"]
    S6["infra.net ❌"]
    S7["infra.permission ❌"]
    S8["infra.monitor ✅"]
    S9["infra.i18n ✅"]
    S10["ui ✅(仅含 UI SDK)"]
  end

  style S4 fill:#f8d7da
  style S5 fill:#f8d7da
  style S6 fill:#f8d7da
  style S7 fill:#f8d7da
  style S10 fill:#d4edda
能力 AppContext SdkContext 裁剪原因
路由 router SDK 不参与路由分发,不应干预导航
SDK 引用 sdk 避免循环依赖(A → B → A)
网络请求 infra.net 避免不可控网络行为,应通过 API 封装
权限检查 infra.permission 权限是 App 层关注点
监控 错误上报是基础能力
国际化 SDK 可能需要翻译
UI 能力 ui SDK 独有:getSlot / requestRerender

为什么 SDK 不能引用其他 SDK?想象一下:SDK A 加载 SDK B,SDK B 又加载 SDK A——循环依赖一形成,加载顺序就崩了。所以 SdkContext 故意拿掉了 sdk 字段,SDK 之间只能通过 SharedStateBus 间接通信。

SdkContext 的构建

SdkContextSdkRegistry.buildSdkContext() 动态构建:

// packages/shell/src/sdk-registry.ts
private buildSdkContext(name: string): SdkContext {
  const descriptor = this.registry.getDescriptor(name);
  const hasUi = (descriptor.uiComponents?.length ?? 0) > 0;

  return {
    descriptor,
    config: this.deps.configCenter.forPlugin(name),  // 插件级配置作用域
    sharedState: this.deps.sharedState,
    infra: { monitor: this.deps.monitor, i18n: this.deps.i18n },
    ui: hasUi
      ? {
          getSlot(slotName) {
            const decl = descriptor.uiComponents?.find(c => c.slot === slotName);
            return decl ? { name: slotName, type: 'slot' } : undefined;
          },
          requestRerender: (componentName) => {
            this.emitRerender(name, componentName);
          },
        }
      : undefined,  // 纯逻辑 SDK 拿不到 ui 对象
  };
}

注意最后那个 ui: hasUi ? ... : undefined——只有声明了 uiComponents 的 SDK 才能拿到 ui 对象。纯逻辑 SDK 试图调用 ctx.ui.requestRerender() 会直接报 Cannot read properties of undefined,从源头上杜绝误用。


生命周期——简洁即克制

SDK 的生命周期比 App 简洁得多:

stateDiagram-v2
  state App {
    [*] --> BeforeMount: 路由匹配
    BeforeMount --> Mount: 钩子通过
    Mount --> AfterMount: 挂载完成
    AfterMount --> Update: 路由参数变化
    Update --> Update: 参数再次变化
    AfterMount --> BeforeUnmount: 路由离开
    BeforeUnmount --> Unmount: 钩子通过 / 超时熔断
    Unmount --> [*]
  }

  state SDK {
    [*] --> Activate: 预加载 / 按需加载
    Activate --> Render: 壳层调用 renderTo()
    Render --> Active: 活跃使用中
    Active --> Rerender: requestRerender
    Rerender --> Active
    Active --> Unrender: 插槽卸载 / SDK 停用
    Render --> Active
    Unrender --> Deactivate: 壳层卸载
    Activate --> Active: 纯逻辑 SDK
    Active --> Deactivate: 壳层卸载
    Deactivate --> [*]
  }
方法 必填 说明
activate(ctx) 初始化并发布 API 到 SharedStateBus
deactivate(ctx) 清理共享状态与副作用
onError(error, ctx) 错误上报
getComponents(ctx) 返回 UI 组件映射
render(container, ctx) SDK 自主将 UI 渲染到宿主 DOM
unrender(container, ctx) 卸载 React Root,与 render 成对

为什么 SDK 没有 update?因为它不参与路由分发,不会因 URL 变化触发框架级更新。为什么没有 beforeUnmount?因为 SDK 的 deactivate 是壳层主动调用的(不是用户行为触发的),不存在"表单未保存"这类需要中断的场景。

简洁即克制——SDK 只保留必要的生命周期,不多不少。


SDK 入口实战

理论说完了,来看两个 SDK 的入口实现。

纯逻辑 SDK:auth-guard

// packages/sdks/auth-guard/src/index.ts
const lifecycle: SdkLifecycle = {
  async activate(ctx: SdkContext) {
    const api = new AuthGuardApi(ctx);
    ctx.sharedState.setState('auth-guard.api', api);   // 发布 API
    ctx.sharedState.setState('auth-guard.ready', true);
  },
  async deactivate(ctx: SdkContext) {
    ctx.sharedState.setState('auth-guard.api', undefined);  // 清理 API
    ctx.sharedState.setState('auth-guard.ready', undefined);
  },
  onError(error, ctx) {
    ctx.infra.monitor.reportError('sdk-auth-guard-error', error);
  },
};

export default lifecycle;
export { AuthGuardApi } from '@/api';

整个入口就这么简洁——activate 里创建 API 实例并发布到 SharedStateBusdeactivate 里清理。子应用通过 ctx.sdk.load('auth-guard') 即可拿到 AuthGuardApi 实例。

AuthGuardApi 内部提供 Session 守卫、Owner 守卫等纯逻辑能力:

// packages/sdks/auth-guard/src/api.ts
export class AuthGuardApi {
  private sessionGuardEnabled: boolean;
  private ownerGuardEnabled: boolean;

  constructor(ctx: SdkContext) {
    const config = ctx.config.get<{ enableSessionGuard?: boolean }>('auth-guard') || {};
    this.sessionGuardEnabled = config.enableSessionGuard ?? true;
  }

  async checkSession(): Promise<boolean> {
    if (!this.sessionGuardEnabled) return true;
    return document.cookie.includes('session_id');
  }

  async checkAll(): Promise<{ session: boolean; owner: boolean }> {
    const [session, owner] = await Promise.all([this.checkSession(), this.checkOwner()]);
    return { session, owner };
  }
}

注意 API 的构造函数接收 SdkContext,通过 ctx.config 读取配置——这就是描述符中 configSchema 的作用:SDK 在 activate 阶段拿到配置,行为由配置驱动,而非硬编码。

含 UI SDK:region-selector

含 UI 的 SDK 在 activate / deactivate 之外,还要实现 getComponentsrenderunrender

// packages/sdks/region-selector/src/index.tsx
const lifecycle: SdkLifecycle = {
  async activate(ctx: SdkContext) {
    const regions = ctx.config.get<Array<{ id: string; name: string }>>('regions') || [
      { id: 'cn-east', name: '华东' },
      { id: 'cn-south', name: '华南' },
      { id: 'cn-north', name: '华北' },
      { id: 'cn-west', name: '西南' },
    ];
    const api = new RegionSelectorApi(regions, ctx);
    ctx.sharedState.setState('region-selector.api', api);
  },
  async deactivate(ctx: SdkContext) {
    ctx.sharedState.setState('region-selector.api', undefined);
  },
  onError(error, ctx) {
    ctx.infra.monitor.reportError('sdk-region-selector-error', error);
  },
  getComponents(_ctx: SdkContext) {
    return { RegionPicker, RegionBreadcrumb };   // 组件映射
  },
  render(container, ctx) {
    return renderSdkUi(container, ctx);          // 自主渲染
  },
  unrender(container) {
    return unrenderSdkUi(container);             // 自主卸载
  },
};

export default lifecycle;
export { RegionSelectorApi, RegionPicker, RegionBreadcrumb };

三种能力各司其职:

能力 方法 消费方 场景
API 发布 activate 内写入 SharedStateBus SdkRegistry.get() 纯逻辑交互
组件映射 getComponents() SdkRegistry.getComponent() 子应用显式引用
自主渲染 render(container, ctx) 壳层 SdkSlotHost 壳层插槽渲染

三种能力互不冲突,SDK 可按需组合——纯逻辑 SDK 只实现 activate / deactivate,含 UI 的 SDK 可以同时实现 render(壳层插槽)和 getComponents(子应用复用)。


UI 渲染三板斧

SDK 的 UI 渲染是本文的重头戏。传统微前端方案中,插件要么是纯逻辑,要么是纯页面,无法表达"提供可复用 UI 片段"的需求。星坞的 SDK 通过三种互补方式解决了这一问题:

flowchart LR
  subgraph sdk_internal ["SDK 内部"]
    Logic["逻辑能力 Api"]
    UI["UI 组件 Components"]
  end

  Logic -->|"方式三:仅消费 API"| App1["子应用:直接调用 Api"]
  UI -->|"方式一:壳层插槽自主渲染"| Slot["SdkSlotHost<br/>宿主定位置 · SDK 定内容"]
  UI -->|"方式二:子应用显式引用"| App2["子应用:getComponent<br/>自行放入 JSX"]

方式一:壳层插槽自主渲染(推荐)

壳层在布局中预留 SdkSlotHost,SDK 通过 render(container, ctx) 将 UI 渲染到宿主提供的 DOM。宿主决定"UI 出现在哪",SDK 决定"插槽里画什么"。

// Shell 布局中预留插槽
<SdkSlotHost shell={shell} sdkName="region-selector" slot="header-slot" />

SdkSlotHost 是一个精巧的 React 组件,内部管理三个 effect:

graph TD
  Mount["Effect 1:挂载/卸载"] -->|"sdkName 或 slot 变化"| RenderTo["sdkRegistry.renderTo()"]
  RenderTo -->|"cleanup"| UnrenderFrom["sdkRegistry.unrenderFrom()"]

  Rerender["Effect 2:requestRerender 订阅"] -->|"SDK 内部状态变更"| Incr["renderVersion++"]
  Incr --> Refresh["Effect 3:原地刷新"]

  style RenderTo fill:#d4edda
  style Refresh fill:#fff3cd

来看 SdkSlotHost 的实现精髓:

// packages/shell/src/layout/SdkSlotHost.tsx
export function SdkSlotHost({ shell, sdkName, slot, className }: SdkSlotHostProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [renderVersion, setRenderVersion] = useState(0);

  // Effect 1:订阅 SDK 的 requestRerender 通知
  useEffect(() => {
    return shell.sdkRegistry.onRerender(sdkName, () => {
      // 触发条件:SDK 在 render 阶段调用 requestRerender
      // 与正常路径差异:同步 setState 会导致 effect cleanup 在 React 渲染中 unmount Root
      // 修复原因:推迟到微任务,刷新走独立 effect,不触发卸载 cleanup
      queueMicrotask(() => {
        setRenderVersion((v) => v + 1);
      });
    });
  }, [shell, sdkName]);

  // Effect 2:挂载 / 卸载(仅随插槽或 SDK 变化)
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    let cancelled = false;

    void (async () => {
      await shell.sdkRegistry.renderTo(sdkName, el, { slot });
    })();

    return () => {
      cancelled = true;
      // 推迟卸载,避免在 React commit 阶段同步 unmount 嵌套 Root
      queueMicrotask(() => {
        void shell.sdkRegistry.unrenderFrom(sdkName, container).catch(console.error);
      });
    };
  }, [shell, sdkName, slot]);

  // Effect 3:requestRerender 触发的原地刷新
  useEffect(() => {
    if (renderVersion === 0) return;
    const el = containerRef.current;
    if (!el) return;
    void shell.sdkRegistry.renderTo(sdkName, el, { slot });
  }, [renderVersion, shell, sdkName, slot]);

  return <div ref={containerRef} className={className} data-xingwu-slot={slot} />;
}

这里有两个容易踩坑的设计决策:

踩坑 1:queueMicrotask 推迟 state 更新。SDK 调用 requestRerender 时可能正处于 React 渲染流程中,如果同步 setState,会导致 effect cleanup 在渲染中被触发,尝试 unmount 一个正在渲染的 React Root——直接崩溃。推迟到微任务后,刷新走独立的 effect,与当前渲染互不干扰。

踩坑 2:卸载也用 queueMicrotask。React 在 commit 阶段同步执行 effect cleanup,如果此时同步调用 root.unmount(),等于在 React 内部渲染流程中卸载另一个 Root——同样会崩溃。

SDK 侧的 render 实现

SDK 侧的 render 实现通过 container.dataset.xingwuSlot 识别插槽,映射到对应组件:

// packages/sdks/region-selector/src/sdkRender.tsx
const roots = new WeakMap<HTMLElement, Root>();
const regionListeners = new WeakMap<HTMLElement, () => void>();

function renderIntoContainer(container: HTMLElement, ctx: SdkContext): void {
  const slot = container.dataset.xingwuSlot ?? '';   // 读取宿主标记的 slot
  const api = ctx.sharedState.getState<RegionSelectorApi>('region-selector.api');
  if (!api) return;

  const regions = api.getAvailableRegions();
  const currentRegion = api.getCurrentRegion();

  let element: ReactNode = null;
  if (slot === 'header-slot') {
    element = <RegionPicker regions={regions} currentRegion={currentRegion}
               onChange={(region) => api.setCurrentRegion(region.id)} />;
  } else if (slot === 'breadcrumb') {
    element = <RegionBreadcrumb regions={regions} currentRegion={currentRegion} />;
  }
  if (!element) return;

  let root = roots.get(container);
  if (!root) {
    root = createRoot(container);      // 复用已有 Root
    roots.set(container, root);
  }
  root.render(element);

  // 订阅区域变更,通知宿主重新渲染
  regionListeners.get(container)?.();
  const unsub = api.onRegionsUpdated(() => {
    ctx.ui?.requestRerender(slotComponentName(slot));   // 触发 SdkSlotHost 刷新
  });
  regionListeners.set(container, unsub);
}

这段代码体现了几个关键设计:

  • WeakMap 管理 Root:用 WeakMap<HTMLElement, Root> 而不是 Map,当 DOM 元素被移除时 Root 引用自动释放,不会内存泄漏
  • slot → 组件映射:SDK 内部决定哪个 slot 渲染哪个组件,宿主只负责提供 DOM 和标记 slot 名称
  • requestRerender 闭环:SDK 监听 API 状态变更 → 调用 ctx.ui.requestRerender()SdkSlotHost 收到通知 → 递增 renderVersion → 触发刷新 effect → 重新调用 renderTo → SDK 的 render 读取最新 API 状态 → root.render 更新 UI

方式二:子应用显式引用

子应用通过 ctx.sdk.getComponent('region-selector', 'RegionPicker') 获取组件,自行放入 JSX 树:

// 子应用内部
const RegionPicker = ctx.sdk.getComponent<typeof import('xingwu-sdk-region-selector').RegionPicker>(
  'region-selector', 'RegionPicker'
);

// 自行控制位置和 props
<RegionPicker regions={regions} currentRegion={current} onChange={handleRegionChange} />

这种方式适用于需要精细控制位置与 props 的场景——比如子应用想把区域选择器放在自己的侧边栏里,而不是壳层 Header。

getComponent 背后是 SdkRegistry 的组件缓存:

// packages/shell/src/sdk-registry.ts
getComponent<T>(sdkName: string, componentName: string): T | undefined {
  const cached = this.componentCache.get(sdkName);
  if (cached?.[componentName]) return cached[componentName] as T;

  // 降级:从 PluginInstance 中提取
  const instance = this.registry.getInstance(sdkName);
  return instance?.uiComponents?.[componentName] as T | undefined;
}

组件缓存在 activate 后一次性提取,避免每次 getComponent() 重新调用 getComponents()

方式三:仅消费 API

不渲染 UI,只调用逻辑能力:

// 子应用内部
const api = await ctx.sdk.load<RegionSelectorApi>('region-selector');
const currentRegion = api.getCurrentRegion();

适用于不需要 UI 交互、只需数据的场景——比如商品列表读取当前区域作为查询条件。


SdkRegistry——门面不只是转发

前面说了 SdkRegistryPluginRegistry 的门面,但它的门面不是简单的方法转发。在三个关键点增加了业务语义:

graph TD
  PR["PluginRegistry\n(全量 API)"] -->|"门面裁剪"| SR["SdkRegistry\n(消费侧子集)"]

  SR -->|"get()"| Bus["SharedStateBus\n读取 {name}.api"]
  SR -->|"getComponent()"| Cache["componentCache\nactivate 后一次性缓存"]
  SR -->|"load()"| Full["resolve + activate\n预加载 = 首屏即用"]
  SR -->|"renderTo()"| Render["load + renderSdk\n注入 data-xingwu-slot"]
  SR -->|"reload()"| Reload["deactivate → activate\n灰度切换"]

  style SR fill:#e8f4fd
方法 语义 设计要点
get(name) 获取已激活 SDK 的 API 不返回模块导出,而是从 SharedStateBus 读取 {name}.api
load(name) 加载并激活 SDK resolve + activateSdk + 缓存组件,确保返回可用 API
preload(names) 批量预加载 预加载 = resolve + activate,首屏即可用
reload(name) 灰度重载 deactivate → activate,重建组件缓存,不清除描述符
getComponent(sdk, name) 获取 UI 组件 优先读缓存,降级读 PluginInstance
renderTo(sdk, container, { slot }) SDK 自主渲染 load 确保激活,再 renderSdk
unrenderFrom(sdk, container) 卸载 SDK UI 调用 lifecycle.unrender
onRerender(sdk, callback) 订阅重渲染 SDK requestRerender 触发

reload 的设计值得一提。当灰度策略切换 SDK 版本时,不需要重新加载描述符——只需 deactivate 旧实例、activate 新实例、重建组件缓存。因为描述符中的 uiComponents 契约不变(同一 SDK 的不同版本),只有模块实现变了。


宿主 UI 共享——SDK 的"借船出海"

含 UI 的 SDK 有一个特殊挑战:它不能直接 import antd,否则会和壳层的 antd 产生双实例问题。 和 React 双实例问题类似,两份 antd 的 Context 无法共享,样式也会重复加载。

星坞的解法是"借船出海"——SDK 从壳层注入的全局对象中借用 UI 组件:

// packages/sdks/region-selector/src/shims/host-antd.ts
export interface HostAntdSubset {
  Breadcrumb: typeof import('antd').Breadcrumb;
  Button: typeof import('antd').Button;
  Empty: typeof import('antd').Empty;
  Select: typeof import('antd').Select;
  Space: typeof import('antd').Space;
  Typography: typeof import('antd').Typography;
}

export function getHostAntd(): HostAntdSubset {
  const mod = window.__ANTD_SHARED__?.antd;
  if (!mod) {
    throw new Error('[region-selector] 未找到 window.__ANTD_SHARED__.antd。请由 Shell 先注入后再加载本 SDK。');
  }
  return mod;
}

SDK 的组件通过 useMemo(() => getHostAntd(), []) 获取宿主 antd 组件:

// packages/sdks/region-selector/src/components/RegionPicker.tsx
export function RegionPicker({ regions, currentRegion, onChange }: RegionPickerProps) {
  const { Empty, Select } = useMemo(() => getHostAntd(), []);
  const { GlobalOutlined } = useMemo(() => getHostIcons(), []);

  if (!regions.length) {
    return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无可用区域" />;
  }

  return (
    <Select value={currentRegion?.id} options={...}
      suffixIcon={<GlobalOutlined />} onChange={...} />
  );
}

这样 SDK 使用的 SelectEmpty 等组件和壳层是同一份实例,样式、Context、主题完全共享。壳层在启动时注入:

// packages/shell/src/main.tsx
(window as any).__ANTD_SHARED__ = {
  antd: { Breadcrumb, Button, Dropdown, Empty, Select, Space, Typography },
  icons: { GlobalOutlined },
};

踩坑 3:Shim 不可省略。如果 SDK 直接 import { Select } from 'antd',Vite 构建时会把 antd 打进 SDK 产物(因为 antd 不在 external 列表中),导致 SDK 体积膨胀且出现双实例问题。Shim 层强制 SDK 从全局获取,既保证了实例唯一,又减小了产物体积。


样式隔离——渐进策略

样式隔离不是一个技术问题,而是信任与成本的权衡。星坞不强制所有 SDK 使用最严格的隔离策略,而是通过 styleStrategy 让 SDK 自行声明:

策略 适用场景 优点 缺点
css-modules(默认) L1 受信内部插件 零运行时开销、构建时哈希 全局选择器需注意
css-in-js L2 半信插件,需主题注入 运行时动态、与宿主主题集成 运行时开销
shadow-dom L3 不信插件 完全隔离、无冲突 事件冒泡需处理、表单兼容性
graph LR
  L1["L1 受信\n内部 Monorepo"] -->|"css-modules"| Zero["零运行时开销"]
  L2["L2 半信\n跨团队"] -->|"css-in-js"| Theme["主题集成"]
  L3["L3 不信\n第三方"] -->|"shadow-dom"| Strict["严格隔离"]

  style L1 fill:#d4edda
  style L2 fill:#fff3cd
  style L3 fill:#f8d7da

实际上,首版实现中的两个 SDK(auth-guard 纯逻辑、region-selector 含 UI)都使用 css-modules——它们都在内部 Monorepo 中,构建时哈希足以避免无意冲突。未来接入第三方插件时,再按需升级隔离策略。


构建配置——与子应用同源不同流

SDK 的构建配置与子应用类似,但有几个差异点值得关注。

纯逻辑 SDK 构建

// packages/sdks/auth-guard/vite.config.ts
export default defineConfig({
  resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
  server: { port: 5175, cors: true },
  build: {
    lib: { entry: 'src/index.ts', formats: ['es'], fileName: 'auth-guard' },
    rollupOptions: { external: ['@xingwu/types'] },  // 只需 external types
  },
});

纯逻辑 SDK 不引入 React,external 列表只需 @xingwu/types

含 UI SDK 构建

// packages/sdks/region-selector/vite.config.ts
export default defineConfig({
  plugins: [createSharedReactPlugin(), react()],
  resolve: {
    alias: { '@': '...', '@components': '...', '@styles': '...' },
    dedupe: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
  },
  optimizeDeps: { disabled: true },   // 禁用预构建,确保共享 React 插件生效
  css: { postcss: { plugins: [tailwindcss(...), autoprefixer()] } },
  server: { port: 5176, strictPort: true, host: true, cors: true },
  build: {
    lib: { entry: 'src/index.tsx', formats: ['es'], fileName: 'region-selector' },
    rollupOptions: { external: ['react', 'react-dom', 'react-dom/client', '@xingwu/types'] },
  },
});

与纯逻辑 SDK 相比,含 UI SDK 多了三个关键配置:

  1. createSharedReactPlugin():开发模式下拦截 react 系裸导入,从 window.__REACT_SHARED__ 获取宿主 React 实例
  2. resolve.dedupe:确保 Vite 始终使用同一份 React 模块实例
  3. optimizeDeps.disabled: true:禁用依赖预构建,让共享 React 插件能拦截所有裸导入

其中 optimizeDeps.disabled: true 最容易被忽略。如果不禁用预构建,Vite 会把 react 预构建成一份 ESM 缓存,createSharedReactPluginresolveId 钩子根本不会触发——SDK 拿到的是 Vite 缓存里的另一份 React,Hooks 照崩不误。

共享 React 插件的核心逻辑

这个插件是含 UI SDK 能在开发模式下正常工作的关键,值得展开说说:

function createSharedReactPlugin(): Plugin {
  const virtualReact = '\0virtual:shared-react';
  const virtualReactDOMClient = '\0virtual:shared-react-dom-client';
  // ... 其他虚拟模块

  return {
    name: 'use-shared-react',
    enforce: 'pre',

    resolveId(source) {
      if (!this.meta.watchMode) return null;  // 生产构建不走虚拟模块
      if (source === 'react') return virtualReact;
      if (source === 'react-dom/client') return virtualReactDOMClient;
      // ...
    },

    load(id) {
      if (id === virtualReact) {
        return `const R = window.__REACT_SHARED__?.React;
if (!R) throw new Error('[SDK] Shared React not found.');
export default R;
export const useState = R.useState;
export const useEffect = R.useEffect;
// ... 逐一导出 Hooks
`;
      }
      if (id === virtualReactDOMClient) {
        return `const RD = window.__REACT_SHARED__?.ReactDOM;
if (!RD) throw new Error('[SDK] Shared ReactDOM not found.');
export const createRoot = RD.createRoot;
export const hydrateRoot = RD.hydrateRoot;
`;
      }
      // ...
    },
  };
}

react-dom/client 必须单独拦截。 这是最容易踩坑的地方——如果不单独拦截,Shell 通过 import() 动态加载 SDK 时,react-dom/client 会落到 Vite 的 CJS→ESM 预构建路径,而预构建转换无法正确暴露 createRoot 命名导出,运行时会报:

SyntaxError: The requested module '.../react-dom/client.js' does not provide an export named 'createRoot'

这个坑笔者踩了整整一个下午才定位到。排查思路是:在浏览器 DevTools 的 Network 面板中查看 SDK 加载的 react-dom/client 实际 URL——如果是 /@fs/... 开头,说明走了 Vite 预构建路径,共享 React 插件没有拦截到。


目录结构一览

最后给一个 SDK 的标准目录结构,方便新 SDK 快速搭建。

纯逻辑 SDK

packages/sdks/auth-guard/
├── package.json
├── tsconfig.json
├── vite.config.ts
├── plugin.config.ts           # 描述符声明
└── src/
    ├── index.ts               # SdkLifecycle 入口
    └── api.ts                 # 对外暴露的 API

含 UI SDK

packages/sdks/region-selector/
├── package.json
├── tsconfig.json
├── vite.config.ts             # 含 createSharedReactPlugin + Tailwind
├── plugin.config.ts           # 描述符声明(含 uiComponents)
└── src/
    ├── index.tsx              # SdkLifecycle 入口 + 具名导出
    ├── api.ts                 # 对外暴露的 API
    ├── sdkRender.tsx          # render / unrender 实现
    ├── components/
    │   ├── RegionPicker.tsx
    │   ├── RegionPicker.module.css
    │   ├── RegionBreadcrumb.tsx
    │   └── RegionBreadcrumb.module.css
    └── shims/
        ├── host-antd.ts       # 从 window.__ANTD_SHARED__ 借用宿主 antd
        └── host-icons.ts      # 从 window.__ANTD_SHARED__ 借用宿主图标

注意 shims/ 目录——这是含 UI SDK 独有的,用于从宿主借用 UI 组件,避免双实例问题。


小结

  1. SDK 填补了"纯逻辑或纯页面"之间的空白——描述符声明 UI 契约(uiComponents),上下文裁剪最小权限(SdkContextAppContext 的受约束子集),UI 渲染三板斧(壳层插槽 / 子应用引用 / 仅 API)各取所需
  2. 布局权与渲染权分离是 SDK UI 机制的核心哲学——宿主决定"UI 出现在哪"(SdkSlotHost + data-xingwu-slot),SDK 决定"插槽里画什么"(render 内组件映射与 createRoot
  3. 门面模式不只是方法转发——SdkRegistryget(API 语义)、getComponent(组件缓存)、load(预加载 = resolve + activate)三个关键点增加了业务语义
  4. 共享实例是含 UI SDK 的命门——React 双实例、antd 双实例、react-dom/client 预构建陷阱,每一个都能让你 debug 一个下午
  5. 样式隔离是信任与成本的权衡——css-modules(L1)→ css-in-js(L2)→ shadow-dom(L3),渐进策略让框架不强迫所有 SDK 使用最严格隔离

如果你也在做微前端的插件化设计,希望 SDK 的"轻量但不止于逻辑"思路能给你一些启发。

SDK完整示例传送门:sdks

昨天以前首页

万星入坞·其二:子应用如何优雅地"入坞"

作者 码云之上
2026年5月19日 19:32

在上一篇《万星入坞:我们如何用三层插件体系干掉巨石应用》中,我们拆解了星坞框架的壳层(Shell)设计——启动流程、核心模块、开发模式与构建部署。壳层是"坞",但"坞"里没有"星"就只是个空壳。
这篇就来说说:子应用(App)如何设计,才能优雅地"入坞"?

如果你从巨石应用拆出子应用,最直觉的做法可能是:按路由拆几个独立仓库,各自打包,再用 iframe 或 qiankun 拼起来。这么做能跑,但很快就会碰到一堆问题——React 实例不一致导致 Hooks 崩溃、子应用之间无法共享状态、公共依赖重复打包、独立开发时缺少壳层上下文导致白屏……这些问题不是"能不能跑"的问题,而是"能不能用"的问题。

星坞的子应用设计,核心思路是:描述符即契约,上下文即边界,生命周期即协议。 下面逐个拆解。


子应用的定位

先明确子应用在星坞三层体系中的位置:

graph LR
  Shell["Shell 壳层"] -->|"提供 AppContext"| App["App 子应用"]
  Shell -->|"提供 SdkContext"| SDK["SDK 轻量插件"]
  App -->|"ctx.sdk.load()"| SDK
  SDK -.->|"禁止直接 import"| App
维度 App(子应用) SDK(轻量插件)
路由 拥有路由段(如 /product/* 无独立路由段
UI 渲染完整页面/视图 可纯逻辑,也可提供 UI 组件
生命周期 完整 mount → update → unmount activate → deactivate
加载时机 路由匹配时按需加载 按需或预加载
独立开发 可独立启动开发服务器 通常在壳层内调试

一句话总结:子应用是拥有独立路由段和完整 UI 树的业务模块,是用户能"看见"的功能单元。


描述符即契约

壳层在加载子应用模块之前,需要先知道"这个子应用叫什么、路由前缀是什么、有没有权限声明"。这些信息由插件描述符(PluginDescriptor)提供——它是子应用与壳层之间的静态契约

graph TD
  Desc["插件描述符\nPluginDescriptor"] -->|壳层读取| Route["路由注册\nroutePrefix"]
  Desc -->|壳层读取| Menu["菜单生成\nnavItem"]
  Desc -->|壳层读取| Perm["权限检查\npermission"]
  Desc -->|壳层读取| Dep["依赖解析\ndependencies"]
  Desc -->|运行时| Entry["import(entry)\n加载模块"]

  style Desc fill:#e8f4fd
  style Entry fill:#d4edda

描述符的核心字段如下:

// 子应用描述符(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 校验哈希
}

一个实际的描述符声明:

// 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 专有字段互斥routePrefixnavItem 仅对 type: 'app' 有意义,exportsuiComponents 仅对 type: 'sdk' 有意义——类型系统确保这种互斥,防止配置串用。

描述符有三个声明位置,各有用途:

位置 格式 用途
plugin.config.ts PluginDescriptor 子应用本地声明,开发时使用
shell/config/apps.json AppDescriptor Shell 运行时配置,生产环境使用
dev-main.tsx 内联 PluginDescriptor 独立开发模式 mock

为什么描述符要外置到 JSON?因为壳层可以在不加载模块的情况下做出决策:路由分发、权限检查、菜单生成只需读取描述符,无需 import() 子应用模块。CI 发布时将描述符写入配置中心,壳层拉取后即可完成路由注册,无需重新构建。


AppContext —— 子应用的"生命线"

子应用入坞后,如何与壳层交互?答案是通过 AppContext——子应用与框架交互的唯一合法通道

graph TD
  Shell["Shell 壳层"] -->|构造| Ctx["AppContext"]
  Ctx --> Router["router\n导航 · 参数 · 守卫"]
  Ctx --> Config["config\n类型安全配置"]
  Ctx --> State["sharedState\n跨插件状态"]
  Ctx --> SDK["sdk\n加载 SDK 能力"]
  Ctx --> Infra["infra\n监控 · 国际化 · 网络 · 权限"]
  Ctx --> Container["container\n渲染容器 DOM"]

  style Ctx fill:#e8f4fd

AppContext 的完整接口:

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 的模块?

  1. 显式优于隐式:子应用能做什么,完全由 AppContext 的字段决定。不需要的子应用(如 SDK)自然不会获得 routercontainer 等能力
  2. 沙箱基础:AppContext 是受限上下文策略的实现基础——框架控制上下文的构造,可以按需裁剪能力
  3. 测试友好:子应用只依赖 AppContext 接口而非 Shell 实现细节,测试时只需构造 Mock 上下文

壳层构造 AppContext 的过程在 AppOutlet 组件中,每个子应用获得的上下文都是基于自身描述符和当前路由状态动态构建的:

// 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)——这行代码让子应用只能读写自己的配置命名空间,无法触碰其他插件的配置。能力边界在构造时就划清了。


生命周期 —— 子应用的"入坞协议"

子应用入坞不是"加载了就行",它需要遵循一套生命周期协议,壳层才能正确地挂载、更新、卸载它。

生命周期时序

stateDiagram-v2
  [*] --> Registered: 描述符写入注册表
  Registered --> BeforeMount: 路由匹配 + 权限通过 + import() 加载
  BeforeMount --> Mount: 钩子通过
  Mount --> AfterMount: 渲染完成
  AfterMount --> Update: 路由参数变化
  Update --> Update: 参数再次变化
  Update --> BeforeUnmount: 导航到其他子应用
  AfterMount --> BeforeUnmount: 导航到其他子应用
  BeforeUnmount --> Unmount: 钩子通过 / 超时熔断
  BeforeUnmount --> AfterMount: 返回 false 阻止离开
  Unmount --> [*]
阶段 钩子 类型 说明
准备挂载 beforeMount 通知型 权限检查、配置读取
挂载 mount 必须实现 React 渲染到容器
挂载完成 afterMount 通知型 性能打点、数据预取
路由更新 update 响应型 URL 参数变化时重新拉取数据
即将卸载 beforeUnmount 可中断 返回 false 阻止卸载
卸载 unmount 必须实现 清理副作用、卸载 React Root

这里有几个关键设计点:

beforeUnmount 可中断:这是为了处理"表单未保存"这类场景。子应用返回 false,壳层就停止卸载流程,弹出确认对话框。其他钩子都是通知型的,只有 beforeUnmount 拥有"一票否决权"。

钩子超时熔断:每个钩子有 10 秒超时限制。如果某个子应用的 beforeMount 卡死了,超时后壳层会 reject 并释放串行锁,防止整个应用的路由跳转被卡死。

ESM 模块驱逐:子应用 unmount 后可选择性驱逐模块缓存(evictOnUnmount),释放内存。下次进入时重新 import()——对于大型子应用,这能显著减少内存占用。

子应用如何实现生命周期

入口文件的核心职责是导出 AppLifecycle 对象,供壳层 PluginRegistryresolve 阶段提取:

// 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 子应用),子应用在分配到的路由段内完全自治

graph TD
  URL["URL: /product/detail/P001"] --> ShellRouter["Shell 路由"]
  ShellRouter -->|"匹配 routePrefix\n/product"| FindApp["查找 product 子应用"]
  FindApp --> MountApp["mount → 渲染子应用"]
  MountApp --> AppRouter["子应用内部路由"]
  AppRouter -->|"detail/:productId"| DetailPage["ProductDetail 页面"]

  URL2["URL: /order/list"] --> ShellRouter2["Shell 路由"]
  ShellRouter2 -->|"匹配 routePrefix\n/order"| FindApp2["查找 order 子应用"]

子应用的路由定义:

// 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>
  );
}

壳层在挂载子应用时,会自动将 BrowserRouterbasename 设为描述符中的 routePrefix。这意味着子应用内部可以用相对路径写路由(to="detail/xxx"),不需要硬编码前缀。

有个容易踩的坑:Shell 与子应用是不同的 React Root,Router Context 不共享。 子应用必须自带 <BrowserRouter>,否则 <Link>useNavigate 等路由 Hook 都会失效。这也是为什么 mount 里要包裹一层 <BrowserRouter basename={basename}>


壳层如何挂载子应用

壳层的 AppOutlet 组件是子应用挂载的"调度中心",它的核心逻辑是:同 App 走 update,切换 App 先卸旧再挂新。

graph TD
  RouteChange["路由变化"] --> FindApp["findByRoute(pathname)"]
  FindApp --> HasApp{"找到子应用?"}
  HasApp -->|否| Skip["跳过"]
  HasApp -->|是| CheckPerm["权限校验"]
  CheckPerm --> PermResult{"有权限?"}
  PermResult -->|否| Error["渲染错误提示"]
  PermResult -->|是| SameApp{"同一 App?"}
  SameApp -->|是| Update["lifecycle.updateApp()\n更新路由参数"]
  SameApp -->|否| UnmountOld["卸载旧 App"] --> MountNew["lifecycle.mountApp()\n挂载新 App"]

对应的代码实现:

// 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 竞态:

// 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 延迟绑定。这种设计带来三个优势:

graph LR
  App["子应用"] -->|"ctx.sdk.load('region-selector')"| Load["按需加载 SDK"]
  Load --> Activate["activate SDK"]
  Activate --> API["获取 Api\ngetCurrentRegion()"]
  Activate --> UI["获取 UI 组件\ngetComponent()"]
  1. 解耦部署:子应用和 SDK 可以独立部署,子应用不需要在构建时将 SDK 打包
  2. 版本协商:壳层统一管理 SDK 版本,子应用只声明依赖(dependencies: ['region-selector']),不锁定具体版本
  3. 按需加载:SDK 只在子应用首次需要时才加载,避免加载不使用的 SDK

实际使用示例——商品列表页加载区域选择器 SDK:

// 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 获取:

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
graph TD
  subgraph DevMode["独立开发模式"]
    DevEntry["dev-main.tsx"] --> MockCtx["构造 Mock AppContext"]
    MockCtx --> DevApp["App 组件\n无需 Shell"]
  end

  subgraph ProdMode["生产/联调模式"]
    ProdEntry["index.tsx"] --> ExportLC["导出 AppLifecycle"]
    ShellLoad["Shell import(entry)"] --> ExportLC
    ShellLoad --> BuildCtx["构造真实 AppContext"]
    BuildCtx --> ProdApp["App 组件"]
  end

  DevApp -.->|"联调时切换"| ShellLoad

独立开发入口的核心是构造 Mock AppContext——模拟 Shell 会提供的所有能力:

// 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 化与模块共享

子应用的构建配置是整个框架"运行时模块共享"的关键一环。

// 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 接口定义需要在壳层和子应用之间保持类型一致性

这些依赖由壳层通过两种方式统一提供:

graph TD
  subgraph Dev["开发模式"]
    DevServer["Vite Dev Server"] -->|"createSharedReactPlugin\n拦截裸导入"| VirtualMod["虚拟模块\nvirtual:shared-react"]
    VirtualMod --> GlobalReact["window.__REACT_SHARED__"]
  end

  subgraph Prod["生产模式"]
    Browser["浏览器"] -->|"Import Maps"| EntryURL["entry URL\n指向 CDN 资源"]
  end

开发模式下,createSharedReactPlugin 将 react 系裸导入重定向到虚拟模块,从 window.__REACT_SHARED__ 获取壳层提供的 React 单实例。生产模式下,Import Maps 将裸导入映射到壳层已经加载的模块 URL。


子应用标准结构

一个完整的子应用目录长这样:

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 # 商品详情页

开发一个新的子应用,只需要按照这个结构创建目录,实现以下三件事:

  1. 声明描述符plugin.config.ts)—— 告诉壳层"我是谁"
  2. 实现生命周期src/index.tsx)—— 告诉壳层"怎么挂载我"
  3. 写业务代码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)


小结

  1. 描述符即契约PluginDescriptor 让壳层在不加载模块的情况下就能完成路由注册、菜单生成、权限检查——这是"配置驱动"的基础
  2. AppContext 即边界:子应用与框架的唯一交互通道,能力边界在构造时就划清了;config.forPlugin() 的作用域隔离让插件无法越界读写配置
  3. 生命周期即协议mount/unmount 是最小契约,beforeUnmount 提供中断能力,超时熔断防止死锁,ESM 驱逐回收内存——渐进增强,按需使用
  4. 独立开发是刚需:两个入口文件(index.tsx + dev-main.tsx)的设计,让子应用开发既能在独立模式下快速迭代,又能在联调模式下与壳层无缝协作
  5. External 化是运行时共享的前提:不是"能省几个 KB"的问题,而是"React Hooks 能不能正常工作"的问题

子应用入坞,看似只是一个 mount 函数的事,实际上涉及契约声明、能力边界、生命周期协议、路由自治、模块共享、独立开发六个维度的设计。每个维度都有各自的取舍——而这些取舍的出发点只有一个:让子应用的开发体验尽量接近独立应用,同时享受插件化框架带来的架构红利。

如果你也在设计微前端的子应用体系,希望这些实践能给你一些参考。

子应用完整示例:apps/product

❌
❌