在前两篇:
我们分别拆解了壳层和子应用的设计。壳层是"坞",子应用是拥有独立路由段的"大星",但还有一种插件形态——它不占路由段,却既能提供纯逻辑能力(如鉴权守卫),又能渲染 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 消费壳层能力,但 SdkContext 是 AppContext 的受约束子集。这不是偷懒少写几行代码,而是有意裁剪——基于最小权限原则。
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 的构建
SdkContext 由 SdkRegistry.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 实例并发布到 SharedStateBus,deactivate 里清理。子应用通过 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 之外,还要实现 getComponents、render、unrender:
// 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——门面不只是转发
前面说了 SdkRegistry 是 PluginRegistry 的门面,但它的门面不是简单的方法转发。在三个关键点增加了业务语义:
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 使用的 Select、Empty 等组件和壳层是同一份实例,样式、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 多了三个关键配置:
createSharedReactPlugin():开发模式下拦截 react 系裸导入,从window.__REACT_SHARED__获取宿主 React 实例resolve.dedupe:确保 Vite 始终使用同一份 React 模块实例optimizeDeps.disabled: true:禁用依赖预构建,让共享 React 插件能拦截所有裸导入
其中 optimizeDeps.disabled: true 最容易被忽略。如果不禁用预构建,Vite 会把 react 预构建成一份 ESM 缓存,createSharedReactPlugin 的 resolveId 钩子根本不会触发——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 组件,避免双实例问题。
小结
- SDK 填补了"纯逻辑或纯页面"之间的空白——描述符声明 UI 契约(
uiComponents),上下文裁剪最小权限(SdkContext是AppContext的受约束子集),UI 渲染三板斧(壳层插槽 / 子应用引用 / 仅 API)各取所需 - 布局权与渲染权分离是 SDK UI 机制的核心哲学——宿主决定"UI 出现在哪"(
SdkSlotHost+data-xingwu-slot),SDK 决定"插槽里画什么"(render内组件映射与createRoot) - 门面模式不只是方法转发——
SdkRegistry在get(API 语义)、getComponent(组件缓存)、load(预加载 = resolve + activate)三个关键点增加了业务语义 - 共享实例是含 UI SDK 的命门——React 双实例、antd 双实例、
react-dom/client预构建陷阱,每一个都能让你 debug 一个下午 - 样式隔离是信任与成本的权衡——
css-modules(L1)→css-in-js(L2)→shadow-dom(L3),渐进策略让框架不强迫所有 SDK 使用最严格隔离
如果你也在做微前端的插件化设计,希望 SDK 的"轻量但不止于逻辑"思路能给你一些启发。
SDK完整示例传送门:sdks