资讯动态

React扩展机制实战:基于Manifest与Slot的可插拔架构

发布时间:2026/10/15 11:06:40 来源:尧图企业网站定制
你们有没有遇到过这种情况应用的功能一多每一次新加业务模块都要去路由表里加一条路由、去菜单配置里加一个入口、去权限表里加一个权限点再手动关联对应组件。一开始几个页面还好等模块增长到几十个以后改起来就非常痛苦而且风险极高——你只是加个功能却可能动到整个应用的启动流程。这一篇“React扩展(二)”要聊的就是怎么摆脱这种状态把应用从“一块完整的大石板”拆成“一个宿主 一堆扩展”让新增功能变成往目录里放一个文件夹就能完成的事。这个系列的第一篇主要解决了宿主应用的基础架构和模块拆分这一篇我会把重心放在扩展机制本身扩展如何描述自己、宿主如何加载它、运行时怎么挂载、扩展之间怎么通信、以及工程化落地时会踩的坑。适合正在做中后台系统、低代码平台或者想把前端应用开放成可插拔架构的工程师参考。1. 从“追加页面”到“长出页面”扩展系统的边界与问题界定1.1 扩展在React应用里到底指什么先说清楚概念。我这里说的“扩展”不是指脚手架层面加一个npm包也不是把功能组件抽出来复用而是指一套运行时机制宿主应用在运行期间能够加载一组事先约定好的独立模块按规则把它们渲染到指定位置并允许这些模块使用宿主提供的公共能力。换句话说扩展是可独立开发、独立构建、独立部署的React模块。宿主不需要知道扩展内部的实现细节只需要知道“这个扩展要在哪个位置渲染、它需要哪些能力”。扩展和宿主之间通过契约连接而不是通过代码内部的import连接。大多数团队其实已经在做类似的事只是没做成体系。比如公共组件库、动态路由配置、微前端子应用这些都算扩展的雏形。但它们各自解决一个局部问题组件库解决复用动态路由解决入口注册微前端解决独立部署。如果想统一收口就需要一套明确的扩展机制。1.2 三种“伪扩展”为什么走不远在动手设计前我建议先排掉几个看着像扩展、实际脆弱的做法。第一种是用配置项 条件渲染来模拟扩展。界面上一堆if (config.enableX) return X/看起来能开关功能但每加一个扩展都要改宿主的代码然后重新发布整个应用。这种方案只适合数量固定、变化很慢的功能开关不适合快速增长的业务模块。第二种是把所有扩展都打进同一个bundle只在路由层面做代码分割。问题在于扩展和宿主之间不存在物理边界任何一个扩展的依赖升级都可能影响宿主反向也一样。团队之间协同开发时这种耦合早晚会变成牵一发动全身的噩梦。第三种是iframe嵌入。隔离性确实好但通信成本高、双滚动条、文案交互不同步而且很多中后台场景根本接受不了iframe带来的体验降级。真正可用的扩展机制至少要满足三点扩展代码和宿主代码物理隔离扩展可以按需加载且不污染全局扩展能声明自己需要的能力而不是直接import宿主的内部模块。这样才值得做成体系。1.3 本篇示例的定位接下来我会用一个模拟的中后台工作台项目做载体逐步搭建这套扩展机制。项目不算复杂一个宿主外壳包含左侧菜单、顶部栏、主内容区一组扩展注册到不同的挂载区。重点不是把这个项目做得精美而是把扩展从声明到加载、渲染、通信的完整链路走通。后面所有代码示例都基于React 18 TypeScript打包环境以Vite为主。Vite的import.meta.glob对扩展收集很友好但我会在工程化章节顺带讲一下Webpack版怎么做思路是通用的。2. 扩展的自我描述Manifest契约与模块加载约定扩展机制的第一步是让宿主知道“它是什么、能干什么、放在哪”。这一步通常由一个扩展描述文件完成我习惯叫它ExtensionManifest。2.1 Manifest字段应该怎么设计Manifest不需要很复杂但不能含糊。下面是我在实际项目里反复调整后沉淀下来的一套最小可用结构// shared/types/manifest.ts export interface ExtensionManifest { // 扩展的唯一标识建议使用反向域名风格 id: string; // 展示给用户看的名称 name: string; // 版本号宿主升级时用来做冲突检测 version: string; // 这个扩展想挂载到哪些插槽 slots: string[]; // 扩展组件的加载函数返回一个 React 组件 load: () Promise{ default: React.ComponentTypeExtensionProps }; // 可选的初始化/销毁钩子 activate?: (ctx: ExtensionContext) void; deactivate?: (ctx: ExtensionContext) void; // 排序权重控制同一插槽里的渲染顺序 priority?: number; }id、slots、load三个字段是核心。slots这个设计是整个机制的关键——扩展不关心自己被渲染到页面哪个具体坐标只声明“我要参与哪个区域”具体位置由宿主决定。ExtensionProps是宿主渲染扩展时传入的props统一约定// shared/types/runtime.ts export interface ExtensionProps { slot: string; context: ExtensionContext; // 宿主传入的其他运行时数据 params?: Recordstring, unknown; }2.2 load函数与按需加载load函数必须是一个返回组件的异步函数这样宿主才能用React.lazy来做代码分割。扩展代码可以独立打包成异步chunk在需要时动态加载而不是跟随宿主首屏一起下载。一个最简单的扩展长这样// extensions/quick-create/manifest.tsx import type { ExtensionManifest } from ../../shared/types; const QuickCreate: React.FCExtensionProps ({ context }) { const { core } context; return ( button onClick{() core.commands.open(quick-create)} 新建任务 /button ); }; export const manifest: ExtensionManifest { id: com.example.quick-create, name: 快捷创建, version: 1.0.0, slots: [topbar-actions], load: () Promise.resolve({ default: QuickCreate }), priority: 10, };这里有个很容易忽略的细节load直接Promise.resolve一个组件和用React.lazy加载独立模块的区别在哪区别在于构建方式。如果扩展和宿主在同一个仓库、同一个构建流程里load只是返回一个函数如果扩展是独立构建、独立发布的load里可以做一个动态importload: () import(./QuickCreate)宿主只需要依赖Manifest契约完全不关心扩展内部是怎么打包的。这就是物理边界的意义。2.3 自动收集与依赖排序实际项目里扩展少则几十多则上百不可能手动一个一个注册。我的做法是让构建工具自动扫描指定目录下的Manifest。Vite环境用import.meta.glob非常舒服// host/registry.ts const manifestModules import.meta.glob{ manifest: ExtensionManifest }(../extensions/*/manifest.tsx); export async function collectExtensions(): PromiseExtensionManifest[] { const manifests: ExtensionManifest[] []; for (const path in manifestModules) { const module await manifestModules[path](); manifests.push(module.manifest); } // 扩展之间的排序规则优先级高在前同优先级按 id 排序保证稳定 return manifests.sort((a, b) (b.priority ?? 0) - (a.priority ?? 0) || a.id.localeCompare(b.id)); }import.meta.glob的好处是构建时可静态分析能直接生成精确的懒加载映射不需要在运行时扫描文件系统。Webpack对应的是require.context用法也类似const manifestContext require.context(../extensions, true, /manifest\.tsx$/); const manifests manifestContext.keys().map((path) { const module manifestContext(path); return module.manifest; });到这里宿主已经知道有哪些扩展、它们想挂到哪。下一步要解决的就是扩展到底以什么方式进入React组件树。3. 扩展运行时Slot插槽机制与组件挂载Manifest只是契约真正落地要靠运行时。我通常用ExtensionSlot组件来承接扩展的渲染宿主只需要在页面上写一个ExtensionSlot nametopbar-actions /这个位置就会自动渲染所有声明挂载到该插槽的扩展。3.1 ExtensionSlot的核心实现Slot本身不复杂关键是处理“异步加载、错误隔离、空状态、重新加载”这几件事。// host/components/ExtensionSlot.tsx import { Suspense, ErrorBoundary, useState, useEffect } from react; import { collectExtensions } from ../registry; export function ExtensionSlot({ name }: { name: string }) { const [extensions, setExtensions] useStateExtensionManifest[]([]); useEffect(() { let cancelled false; collectExtensions().then((list) { if (cancelled) return; setExtensions(list.filter((item) item.slots.includes(name))); }); return () { cancelled true; }; }, [name]); return ( ErrorBoundary slot{name} Suspense fallback{div classNameslot-loading模块加载中.../div} {extensions.map((ext) ( ExtensionRenderer key{ext.id} manifest{ext} slot{name} / ))} /Suspense /ErrorBoundary ); }collectExtensions虽然在registry里已经收集过一次但Slot组件里会重新调用这看起来有点重复。实际项目中我会做成一个registryStore缓存Manifest列表避免每次渲染都重复扫描和加载。下面是用useSyncExternalStore管理注册表的模式// host/registryStore.ts import { useSyncExternalStore } from react; let cachedManifests: ExtensionManifest[] | null null; const listeners new Set() void(); export function getManifests() { if (!cachedManifests) { throw new Promise((resolve) { collectExtensions().then((list) { cachedManifests list; listeners.forEach((l) l()); resolve(undefined); }); }); } return cachedManifests; } function subscribe(listener: () void) { listeners.add(listener); return () listeners.delete(listener); } export function useManifests() { return useSyncExternalStore(subscribe, getManifests); }这里抛出一个Promise是故意的配合useSyncExternalStore的文档推荐做法解决注册表是异步初始化的数据源在同步模式下拿不到值的问题。这个写法比“useState useEffect 手动setState”要干净得多。3.2 ExtensionRenderer和错误边界因为manifest.load()返回的是Promise我需要把“异步加载组件”和“传入插槽参数”拆成两层。ExtensionRenderer只负责加载组件ExtensionComponent负责调用load()并渲染// host/components/ExtensionRenderer.tsx import React from react; function ExtensionComponent({ manifest, slot }: { manifest: ExtensionManifest; slot: string }) { const LazyComp React.lazy(() manifest.load().then((module) ({ default: module.default })) ); return LazyComp slot{slot} context{createHostContext(manifest)} /; }不过这里有一个会被文档坑到的地方React.lazy接受的是一个返回Promise{ default: Component }的函数而manifest.load()恰好也是这个形状。但你如果直接写React.lazy(manifest.load)等Manifest变化时想重新加载就麻烦了。所以我留了ExtensionComponent这一层让每次渲染都基于最新的Manifest生成一个新的lazy组件这样load函数更新后Slot能立即拿到新组件。错误边界是扩展机制里最容易偷懒的部分。任何扩展抛错都不应该拖垮整个宿主页面。一个很粗糙但够用的错误边界// host/components/ErrorBoundary.tsx export class ErrorBoundary extends React.Component{ slot: string; children: React.ReactNode } { state { hasError: false }; static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error: Error, info: React.ErrorInfo) { console.error([slot:${this.props.slot}] 扩展渲染失败, error, info); } render() { if (this.state.hasError) { return div classNameslot-error这个区域的功能加载失败了请刷新重试/div; } return this.props.children; } }所有Slot都要被ErrorBoundary包住。这不是防御性过度设计是必须品。你永远不知道一个扩展会在什么环境、什么时刻、因为什么外部条件炸掉。3.3 为什么不做独立DOM树和Shadow DOM讨论Slot实现时很多人会问扩展为什么一定要嵌在宿主React组件树里而不是像iframe那样独立渲染到DOM节点甚至套一层Shadow DOM搞样式隔离这两种方案我都实际试过简单说结论React组件树内渲染是默认首选Shadow DOM可以针对部分场景增强但不能全局套用。组件树内渲染最大的好处是状态和上下文自然共享。扩展可以使用宿主提供的Provider、路由、事件总线不需要做跨树通信。缺点是隔离性较弱尤其是CSS样式但这个问题可以通过约定类名前缀、CSS Modules、设计变量注入来解决不必为此付出全部隔离的代价。Shadow DOM的隔离效果确实更好插件的样式、事件都进不去宿主的DOM。但代价也很直接React事件合成机制基于document事件委托Shadow DOM内部的事件需要手动重新分发很多第三方组件库尤其是用了createPortal向body挂载的在Shadow内部会行为异常滚动条、弹层、焦点管理都会有额外兼容成本。如果是做一个非常独立的编辑器或图表插件Shadow DOM值得考虑但作为平台级通用机制它的坑远大于收益。方案对比如下方案隔离性状态共享实现成本适用场景React组件树内渲染弱直接低绝大多数业务扩展独立root Shadow DOM强需通信高编辑器、图表等独立重组件iframe最强最弱中三方完全不可信场景4. 核心能力注入让扩展与宿主状态打通扩展仅仅能渲染还不够真正的价值在于它能读取宿主数据、触发宿主动作、和其他扩展协作。这一步我称之为“能力注入”实现载体就是第三章节代码里提到的createHostContext。4.1 Bridge对象的设计宿主给扩展的上下文不能把整个应用都塞进去那是灾难。我倾向于暴露一个“能力面”只给扩展需要用到的能力。// host/createHostContext.ts import type { RuntimeContext } from ../shared/types; export interface HostBridge { user: { id: string; name: string; roles: string[] }; navigate: (path: string) void; commands: { open: (name: string, payload?: unknown) void; onCommand: (handler: (name: string, payload?: unknown) void) () void; }; query: (resource: string, params?: Recordstring, unknown) Promiseunknown; subscribe: (key: string, listener: () void) () void; getState: T(key: string) T | undefined; } export function createHostContext(manifest: ExtensionManifest): RuntimeContext { return { manifest, core: { user: currentUser, navigate: (path) router.navigate(path), commands: commandBus, query: (resource, params) apiClient.query(resource, params), subscribe: (key, listener) storeBridge.subscribe(key, listener), getState: (key) storeBridge.getState(key), }, }; }这里最需要克制的是不要把store实例直接给扩展而是给getState、subscribe这种最小方法集合。这样扩展永远拿不到内部store的整体结构未来宿主重构内部状态时不至于“革自己的命”还要考虑所有外部扩展的兼容。4.2 使用useSyncExternalStore让扩展组件响应宿主状态扩展组件本身是React组件它需要响应宿主的状态变化。但在运行时扩展不是从宿主React tree里拿Context虽然它在tree里但不应该依赖宿主内部的特定Provider那样耦合太重。所以让扩展自己订阅宿主状态是最干净的方式。// extensions/task-list/useWidgetState.ts import { useSyncExternalStore } from react; import { ExtensionContext } from ../../../shared/types; export function useWidgetStateT(ctx: ExtensionContext, key: string): T | undefined { const { core } ctx; return useSyncExternalStore( (cb) core.subscribe(key, cb), () core.getStateT(key), () core.getStateT(key) ); }扩展内部就可以自然使用Hook来拉取宿主数据// extensions/task-list/index.tsx const TaskList ({ context }: ExtensionProps) { const tasks useWidgetStateTask[](context, task-list); const { core } context; const handleRefresh () { core.query(tasks, { page: 1 }).then(() { // 宿主会更新全局 task 状态插件通过订阅自动拿到最新数据 }); }; // ... };这个模式让扩展在不触碰宿主内部实现的前提下获得了和宿主组件一致的响应式数据体验。它本质上是把外部store暴露成一份可以被订阅的公开状态层。4.3 扩展之间的通信命令总线扩展之间有时候需要协作比如“创建任务”扩展完成操作后希望通知“任务列表”扩展去刷新数据。直接让两个扩展互相引用是最大的坑——它们之间不该有静态依赖。我的做法是给宿主运行时注入一个简单的命令总线// host/commandBus.ts type CommandHandler (name: string, payload?: unknown) void; const handlers new SetCommandHandler(); export const commandBus { open(name: string, payload?: unknown) { handlers.forEach((h) h(name, payload)); }, onCommand(handler: CommandHandler) { handlers.add(handler); return () handlers.delete(handler); }, };“创建任务”扩展完成创建后core.commands.open(task-created, { taskId });“任务列表”扩展在自己的组件挂载时订阅useEffect(() { return core.commands.onCommand((name) { if (name task-created) refreshList(); }); }, []);这种基于事件总线的通信不需要任何两个扩展互相认识。宿主也不需要知道具体的业务事件只负责承载一个发布订阅通道。命令总线的事件名最好做个规范例如统一用domain:action格式比如task:created、task:updated避免扩展自己发明命名风格。5. 工程化落地打包约束、开发调试与热更新扩展机制走到这一步架构已经没有大问题了。真正让人头疼的往往是工程化扩展代码该按什么标准构建、开发时怎么调试、如何支持扩展之间隔离和热更新。5.1 扩展的构建产物与外置依赖如果扩展和宿主在同一个仓库、同一个构建流程构建约束非常简单。一旦要把扩展独立出来开发就要在构建层面处理一个大坑重复的React实例。两个React实例存在时useState、useContext、Hooks全部错乱最典型的错误就是Invalid hook call。解决办法是构建扩展时把react、react-dom标记为外部依赖// vite.config.ts import { defineConfig } from vite; import react from vitejs/plugin-react; export default defineConfig({ build: { lib: { entry: ./manifest.tsx, formats: [es] }, rollupOptions: { external: [react, react-dom], output: { chunkFileNames: assets/[name].js }, }, }, });宿主加载独立构建的扩展时扩展内部的import React from react会被解析到宿主bundle里的React实例上。这也是我前面说load() { return import(./QuickCreate) }的意义——宿主加载的是扩展构建产物React来自宿主两者天然共用同一实例。同理宿主依赖的UI组件库、状态管理库如果扩展也要用都建议做external。否则扩展自己打包一份就又陷入“多实例陷阱”。5.2 开发模式下的热更新平时开发扩展最理想的状态是改写扩展代码宿主页面不刷新就出现新效果。同一仓库下Vite的HMR能直接覆盖不需要任何额外处理。但如果是独立扩展项目就需要给manifest的load函数加一个缓存破坏策略// 开发环境专用避免浏览器缓存旧的扩展chunk load: () import(./QuickCreate?ts${Date.now()})生产环境绝对不推荐用这种带时间戳的写法它会让每次加载都重新下载。可以把当前环境判断放在构建期用环境变量控制。5.3 版本漂移、循环依赖与重复依赖的排查清单扩展开发上了规模之后出现频率最高的问题基本集中在一张表里问题现象根因排查方法扩展里调用React Hook报错多个React实例检查extension产物是否external了react扩展间引用了彼此的代码静态依赖交叉架构约束禁止扩展import其他扩展同名组件样式错乱全局样式冲突用CSS Modules或scope前缀旧缓存导致新代码不生效浏览器缓存/构建缓存开发环境时间戳、生产环境内容hash扩展处理事件不触发事件未监听/命令名不一致事件总线打印日志按命名规范核对加载扩展导致主包变大扩展内部动态import未生效查看构建产物chunk大小其中扩展之间静态依赖交叉是最隐蔽的。它不会立刻报错但会让扩展关系网变成一团乱麻。我定的规矩是扩展只能依赖宿主提供的Bridge不得import同级的任何其他扩展代码。需要协作走命令总线需要数据走宿主状态层永远不直接引用对方内部函数。这样以后单独上线某个扩展只需要验证它和宿主的关系而不用验证它和十几个平行扩展的关系。5.4 调试工具注册表面板最后提一个非常提升开发体验的小工具注册表面板。在宿主页面提供一个仅开发环境可见的浮窗展示当前收集到的所有扩展信息id、版本、命中哪些slot、加载状态、错误信息、排序权重。这样一个扩展“没出现在页面上”的问题就能立刻判断出是Manifest没收到、slot没匹配上、还是组件渲染报错。实现思路很简单在RegistryStore里维护一份“加载日志”每次Slot渲染扩展时往里追加一条记录开发面板订阅这份记录并渲染成列表。这也是调试扩展机制的“放大镜”比打开控制台一个个查要直观得多。6. 能跑以后边界与细节的收敛我把扩展机制从设计到落地的大致链路走完了。代码本身不算复杂真正的复杂度来自边界控制——哪些能力可以暴露给扩展、哪些资源不允许扩展碰、扩展之间协作的上限在哪。这些边界如果一开始不划定后面一定会以最难看的方式长出来。在我自己的项目里这套机制跑过一段时matter后最大的收益反而不是“新模块上线快了”而是重构变安全了。宿主内部怎么改只要不破坏Bridge的签名扩展完全不受影响。同样的扩展怎么改宿主也不关心只要它对外依然符合Manifest契约。各自具边界以后团队协作的半径一下就变大了。最后分享一个我实际养成的习惯新需求来了先按扩展的姿势去写即使它目前只有一个slot、一个组件。等这个需求稳定了再把它内联进宿主核心代码或者继续留在扩展层。这个“先扩展、后收敛”的判断比一开始就决定它该待在哪层要靠谱得多。扩展机制的真正价值不在于把每个东西都做成插件而在于让你任何时候都能选择“不把所有东西都硬编码在同一个模块里”。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑