在前两篇:
我们分别拆解了壳层和子应用的设计。壳层是"坞",子应用是拥有独立路由段的"大星",但还有一种插件形态------它不占路由段,却既能提供纯逻辑能力(如鉴权守卫),又能渲染 UI 组件(如区域选择器)。这就是 SDK,星坞三层体系中的"小星"。
如果你用过微前端框架,可能会有这样的困惑:插件要么是纯逻辑,要么是完整页面,中间地带怎么办? 鉴权守卫不需要页面,但需要在每个路由跳转前拦截;区域选择器不是独立业务,却需要在 Header 和面包屑同时渲染 UI。如果强行归入子应用,会引入不必要的路由和加载开销;如果复制到每个子应用,则违背 DRY 原则。SDK 就是解决这个矛盾的轻量形态。
本文的核心思路是:描述符声明 UI 契约,上下文裁剪最小权限,UI 渲染三板斧各取所需。 下面逐个拆解。
SDK 的定位
先明确 SDK 在星坞三层体系中的位置:
| 维度 | App(子应用) | SDK(轻量插件) |
|---|---|---|
| 路由 | 拥有路由段(如 /product/*) |
无独立路由段,不参与路由分发 |
| UI | 渲染完整页面/视图 | 可纯逻辑,也可提供 UI 组件供宿主渲染 |
| 生命周期 | 完整 mount → update → unmount |
activate → deactivate |
| 加载时机 | 路由匹配时按需加载 | 按需或预加载 |
| 独立开发 | 可独立启动开发服务器 | 通常在壳层内调试 |
一句话总结:SDK 是不占路由段的轻量插件,能纯逻辑、能提供 UI、能两者兼有。
SDK 的形态光谱
SDK 并非非此即彼,而是有一个从"纯逻辑"到"含 UI"的形态光谱:
- 纯逻辑 SDK :仅提供 API/拦截器/数据转换,不渲染任何 UI(如
auth-guard) - 含 UI SDK :除 API 外还提供 UI 组件,支持两种互补渲染能力(如
region-selector)
这种设计填补了传统微前端"纯逻辑或纯页面"之间的空白,是星坞相比其他框架的一个亮点。
描述符声明 UI 契约
壳层在加载 SDK 模块之前,需要先知道"这个 SDK 叫什么、有没有 UI 组件、挂载到哪个插槽"。这些信息由 插件描述符(PluginDescriptor) 提供------它和子应用的描述符是同一个类型,但 SDK 有几个专属字段。
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 消费壳层能力,但 SdkContext 是 AppContext 的受约束子集。这不是偷懒少写几行代码,而是有意裁剪------基于最小权限原则。
| 能力 | 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() 动态构建:
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 简洁得多:
| 方法 | 必填 | 说明 |
|---|---|---|
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 实例并发布到 SharedStateBus,deactivate 里清理。子应用通过 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 之外,还要实现 getComponents、render、unrender:
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 通过三种互补方式解决了这一问题:
宿主定位置 · 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:
来看 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------门面不只是转发
前面说了 SdkRegistry 是 PluginRegistry 的门面,但它的门面不是简单的方法转发。在三个关键点增加了业务语义:
| 方法 | 语义 | 设计要点 |
|---|---|---|
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 使用的 Select、Empty 等组件和壳层是同一份实例,样式、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 不信插件 | 完全隔离、无冲突 | 事件冒泡需处理、表单兼容性 |
实际上,首版实现中的两个 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 多了三个关键配置:
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 能在开发模式下正常工作的关键,值得展开说说:
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 组件,避免双实例问题。
小结
- 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