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

在前两篇:

我们分别拆解了壳层和子应用的设计。壳层是"坞",子应用是拥有独立路由段的"大星",但还有一种插件形态------它不占路由段,却既能提供纯逻辑能力(如鉴权守卫),又能渲染 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 描述符

ts 复制代码
// 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 描述符

ts 复制代码
// 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() 动态构建:

ts 复制代码
// 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

ts 复制代码
// 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 守卫等纯逻辑能力:

ts 复制代码
// 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

ts 复制代码
// 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
宿主定位置 · SDK 定内容"] UI -->|"方式二:子应用显式引用"| App2["子应用:getComponent
自行放入 JSX"]

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

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

tsx 复制代码
// 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 的实现精髓:

tsx 复制代码
// 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 识别插槽,映射到对应组件:

tsx 复制代码
// 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 树:

tsx 复制代码
// 子应用内部
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 的组件缓存:

ts 复制代码
// 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,只调用逻辑能力:

ts 复制代码
// 子应用内部
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 组件:

ts 复制代码
// 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 组件:

tsx 复制代码
// 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、主题完全共享。壳层在启动时注入:

ts 复制代码
// 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 构建

ts 复制代码
// 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 构建

ts 复制代码
// 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 能在开发模式下正常工作的关键,值得展开说说:

ts 复制代码
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 命名导出,运行时会报:

javascript 复制代码
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

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

含 UI SDK

ruby 复制代码
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

相关推荐
枫叶林FYL1 小时前
【强化学习】3 双系统持续强化学习:快速迁移与元知识整合架构手册
人工智能·机器学习·架构
AI科技星1 小时前
哥德巴赫猜想1+1基于平行素数对等腰梯形网格拓扑与素数渐近密度的大偶数满填充完备性证明
人工智能·线性代数·架构·概率论·学习方法
小短腿的代码世界2 小时前
信号路由风暴:Qt算法交易系统的高频信号分发架构
qt·算法·架构
这个DBA有点耶2 小时前
SQL改写实战:子查询、CTE、窗口函数性能对比
数据库·mysql·性能优化
2301_780789662 小时前
手游遇到攻击为什么要用SDK游戏盾手游遇到攻击为什么要用 SDK 游戏盾?
安全·web安全·游戏·架构·kubernetes·ddos
中小企业实战军师刘孙亮3 小时前
小微企业生存发展指南:从求稳到扩张的实战策略-佛山鼎策创局破局增长咨询
架构·产品运营·音视频·制造·业界资讯
Gauss松鼠会3 小时前
GaussDB(DWS) 日常维护命令
服务器·数据库·postgresql·性能优化·gaussdb·经验总结
sanduo1123 小时前
什么是优秀的部署架构?
架构
国科安芯4 小时前
ASP7A84AS与主流架构兼容替代及系统级电源完整性解决方案的深度研究
单片机·嵌入式硬件·架构