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

0 阅读16分钟

在前两篇:

我们分别拆解了壳层和子应用的设计。壳层是"坞",子应用是拥有独立路由段的"大星",但还有一种插件形态——它不占路由段,却既能提供纯逻辑能力(如鉴权守卫),又能渲染 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 → unmountactivate → 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 声明列表
uiComponentsUI 组件声明数组(纯逻辑 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
能力AppContextSdkContext裁剪原因
路由routerSDK 不参与路由分发,不应干预导航
SDK 引用sdk避免循环依赖(A → B → A)
网络请求infra.net避免不可控网络行为,应通过 API 封装
权限检查infra.permission权限是 App 层关注点
监控错误上报是基础能力
国际化SDK 可能需要翻译
UI 能力uiSDK 独有: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 内写入 SharedStateBusSdkRegistry.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)加载并激活 SDKresolve + 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-jsL2 半信插件,需主题注入运行时动态、与宿主主题集成运行时开销
shadow-domL3 不信插件完全隔离、无冲突事件冒泡需处理、表单兼容性
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