Backstage 插件组合系统(Composability System)完全指南:扩展、路由与组件数据的原理与迁移实战
【免费下载链接】backstageBackstage is an open framework for building developer portals项目地址: https://gitcode.com/GitHub_Trending/ba/backstage
Backstage 组合系统(Composability System)是支撑"把大量开源与自研插件拼装进一个开发者门户"的底层框架。本文基于 docs/plugins/composability.md 全面讲解其三大核心原语——Component Data(组件数据)、Extensions(扩展)与 RouteRef 路由体系,并结合本仓库@backstage/core-plugin-api与@backstage/catalog的实际源码,带你掌握插件如何声明扩展、应用如何绑定路由、EntitySwitch 如何做条件渲染,以及如何将旧式插件迁移到新组合系统。
说明:本页描述的旧版前端组合系统(
createRoutableExtension、createComponentExtension、RouteRef、ExternalRouteRef、组件数据)在新版前端系统中已被替代。若你在使用新系统,请阅读 前端系统扩展文档、扩展蓝图文档 与 路由文档。
组合系统概览
组合系统的核心原则是:插件之间应该有清晰的边界与连接方式。它应当:
- 把某个插件内的崩溃隔离在自身范围内,同时允许插件之间自由导航;
- 让插件只在需要时才被加载(按需懒加载);
- 允许插件为其他插件提供可扩展的"扩展点";
- 以**应用优先(app-first)**的思维构建——应用的简洁与清晰优先于插件与核心 API 的简洁。
组合系统并不是单一的 API 表面,而是一组模式(patterns)、原语(primitives)与 API的集合。其核心概念是extensions(扩展)——由插件导出、供应用使用;同时还有名为component data(组件数据)的原语,让应用结构更具声明性;以及RouteRef系列,负责在页面之间灵活路由,这在聚合不同开源插件时尤为关键。
核心概念:Component Data(组件数据)
组件数据是组合系统引入的一种新原语,为 React 组件提供了一种数据维度。它通过一个 key 将数据挂载到 React 组件上,之后可以从任何由该组件创建的 JSX 元素上,用同一个 key 读取这些数据:
const MyComponent = () => <h1>This is my component</h1>; attachComponentData(MyComponent, 'my.data', 5); const element = <MyComponent />; const myData = getComponentData(element, 'my.data'); // myData === 5组件数据的用途是在渲染之前检查元素上携带的信息。这种"元素检查"模式在 React 生态中相当常见,例如react-router与material-ui都会在渲染前检查子元素的属性。不过在这些库中通常只检查元素类型(type)与 props,而组件数据提供了更结构化的访问方式,并支持同一份数据同时存在多个版本并各自解释,从而简化了演进过程。
源码实现:数据挂在哪、怎么读
在 packages/core-plugin-api/src/extensions/componentData.tsx 中可以看到具体实现:
- 数据通过
componentDataKey = '__backstage_data'直接定义在组件函数/类上(这种方式对react-hot-loader之类的组件包装器兼容性更好),同时保留了一个全局WeakMap作为后备存储; attachComponentData(component, type, data)会先查找已有容器,若 key 重复会直接抛错:"Attempted to attach duplicate data ...";getComponentData(node, type)从 JSX 元素的.type上取出组件类型,再查表取值,找不到时返回undefined。
组件数据的核心用例是通过元素树进行路由与插件发现——React 元素树成为"哪些插件被使用、顶层插件路由是什么"的单一事实来源。但它不限于此,完全可以作为构建新抽象的基础原语。
实战配套:useElementFilter
在EntitySwitch等组件内部,配合组件数据使用的还有useElementFilter钩子(见 packages/core-plugin-api/src/extensions/useElementFilter.tsx)。它类似于React.Children.map,但额外处理了 Fragment 与 Backstage 特有的FeatureFlagged组件遍历,并通过selectByComponentData({ key, withStrictError })与getElements()对元素做声明式过滤与收集,返回值基于输入节点做了 memo 化。
核心概念:Extensions(扩展)
扩展是插件导出给应用使用的东西。最常见的形态是 React 组件,但理论上可以是任意 JavaScript 值。扩展由create*Extension系列函数创建,并用plugin.provide()包装成真正导出的扩展。
扩展类型本身非常简单:
export type Extension<T> = { expose(plugin: BackstagePlugin): T; };扩展的强大之处在于多个角色可以介入其使用过程:创建与插件包装由创建函数的属主控制;Backstage 核心能在扩展被暴露到插件之外时介入;最终由应用控制扩展的使用方式。
两种核心扩展创建函数
核心 API 目前提供两种扩展创建函数:
createComponentExtension:普通的 React 组件,没有特殊要求,例如实体概览页上的卡片。组件基本原样导出,但会被包装以提供错误边界(error boundary)、懒加载(lazy loading)与插件上下文(plugin context)。createRoutableExtension:构建在组件扩展之上,用于任何应在特定路由路径渲染的组件(如顶层页面或实体页签内容)。创建时必须传入一个RouteRef作为mountPoint(挂载点),挂载点是该组件对外部世界的"句柄",其他组件与插件通过它来链接到该路由组件。
核心库目前只有这两种创建函数,未来可能增加。同时一些插件也提供自己的扩展创建方式,例如@backstage/plugin-scaffolder的createScaffolderFieldExtension。扩展也不绑定 React,可以用来建模通用 JS 概念,甚至桥接到其他渲染库或前端框架。
源码实现:扩展到底被包了几层
查看 packages/core-plugin-api/src/extensions/extensions.tsx 的createReactExtension,可以看到expose()返回的组件被层层包装:
Suspense+ 全局Progressfallback:懒加载时显示应用级进度组件;PluginErrorBoundary:把插件内部的渲染错误隔离在边界内(对应"隔离插件崩溃"的设计目标);AnalyticsContext:自动注入pluginId、扩展name与routeRef等分析属性;- 随后
attachComponentData(Result, 'core.plugin', plugin)挂上插件实例、attachComponentData(Result, 'core.extensionName', name)挂上扩展名,并把createRoutableExtension传入的'core.mountPoint'数据一并挂载。
对于createRoutableExtension,包装组件还会在渲染时调用useRouteRef(mountPoint)做路由装配校验:如果挂载点没有在应用元素树中被发现,会抛出明确错误:"Routable extension components may not be rendered by other components and must be directly available as an element within the App provider component."——这正是下文"单元素树约束"的源码级保障。
从插件视角使用扩展
扩展是穿越插件边界的主要方式之一,也是插件向应用提供具体内容的途径,取代了旧的Router或各类*Card导出。
官方建议将导出的扩展放在顶层plugin.ts,或专门的extensions.ts(或.tsx)文件中。该文件不应包含核心实现——如果扩展是 React 组件,建议懒加载真正的组件。组件扩展通过lazy声明开箱即用地支持懒加载:
export const EntityFooCard = plugin.provide( createComponentExtension({ component: { lazy: () => import('./components/FooCard').then(m => m.FooCard), }, }), );路由扩展强制懒加载,因为这是提供组件的唯一方式:
export const FooPage = plugin.provide( createRoutableExtension({ name: 'FooPage', component: () => import('./components/FooPage').then(m => m.FooPage), mountPoint: fooPageRouteRef, }), );源码层面,ComponentLoader类型(见 extensions.tsx)同时支持{ lazy: () => Promise<T> }与{ sync: T }两种形态;懒加载出错时会包装为ForwardedError("Failed lazy loading of the ${name} extension, try to reload the page")。另外从源码可见name参数被用于运行时标识(如分析数据、错误信息),因此官方强烈建议name与导出变量名保持一致——不传name会在控制台打印弃用警告。
在应用中使用扩展:单元素树约束
目前所有扩展都被建模为 React 组件,用法与普通组件一致,但有一个重要区别:所有扩展必须属于一棵从根AppProvider开始的单一 React 元素树。
例如下面的应用代码是错误的:
const AppRoutes = () => ( <Routes> <Route path="/foo" element={<FooPage />} /> <Route path="/bar" element={<BarPage />} /> </Routes> ); const App = () => ( <AppProvider> <AppRouter> <Root> <AppRoutes /> </Root> </AppRouter> </AppProvider> );原因在于:路由发现依赖对元素树的静态检查(组件数据 +useElementFilter遍历),而AppRoutes是一个被调用后才产生元素的中间组件,无法被静态遍历到。修复方式很简单——不要在应用中创建中间组件:
const appRoutes = ( <Routes> <Route path="/foo" element={<FooPage />} /> <Route path="/bar" element={<BarPage />} /> </Routes> ); const App = () => ( <AppProvider> <AppRouter> <Root>{appRoutes}</Root> </AppRouter> </AppProvider> );你可以在 packages/app/src/App.tsx 中看到本仓库示例应用的实际写法:路由元素被收集后传给createApp的features数组,最终由app.createRoot()渲染。
命名模式
构建插件时遵循以下命名模式,可以更清晰地表达导出符号的意图与用途:
| 描述 | 模式 | 示例 |
|---|---|---|
| 顶层页面 | *Page | CatalogIndexPage、SettingsPage、LighthousePage |
| 实体页签内容 | Entity*Content | EntityJenkinsContent、EntityKubernetesContent |
| 实体概览卡片 | Entity*Card | EntitySentryCard、EntityPagerDutyCard |
| 实体条件判断 | is*Available | isPagerDutyAvailable、isJenkinsAvailable |
| 插件实例 | *Plugin | jenkinsPlugin、catalogPlugin |
| 工具 API 引用 | *ApiRef | configApiRef、catalogApiRef |
路由系统:RouteRef 与 useRouteRef
Backstage 的路由系统重度依赖组合系统。它用RouteRef表示应用中的路由目标:运行时它们会被绑定到具体的path,但提供了间接层,帮助那些彼此并不知道对方存在、更不知道对方路径的插件互相路由。
每个RouteRef的具体path是根据应用中的元素树发现的。考虑以下示例:
const appRoutes = ( <Routes> <Route path="/foo" element={<FooPage />} /> <Route path="/bar" element={<BarPage />} /> </Routes> );假设FooPage与BarPage分别是fooPlugin与barPlugin导出的路由扩展。由于FooPage是路由扩展,它有一个RouteRef作为挂载点(即fooPageRouteRef)。在上面的例子中,fooPageRouteRef将与/foo路由关联。
如果需要路由到FooPage,可以使用useRouteRef钩子创建具体链接。useRouteRef只接受一个RouteRef参数,返回一个用于生成 URL 的函数:
const MyComponent = () => { const fooRoute = useRouteRef(fooPageRouteRef); return <a href={fooRoute()}>Link to Foo</a>; };跨插件链接:ExternalRouteRef
假设我们要从BarPage链接到FooPage。我们不想在barPlugin中直接引用fooPageRouteRef——那会制造对fooPlugin的不必要依赖,也让应用失去"把插件绑在一起"的灵活性。解决办法是使用ExternalRouteRef:
- 与普通路由引用一样,它可以传给
useRouteRef生成具体 URL; - 但它不能作为路由组件的挂载点,而是必须由应用通过路由绑定(route bindings)关联到一个目标路由。
在barPlugin内创建ExternalRouteRef时,应使用描述其在插件中角色的中性名称,而不是具体指向哪个插件页面,最终目标由应用决定。例如BarPage想链接头部的外部页面,可以这样声明:
const headerLinkRouteRef = createExternalRouteRef({ id: 'header-link' });在 packages/core-plugin-api/src/routing/ExternalRouteRef.ts 的实现中可以看到它支持id、params、optional与defaultTarget四个选项,且运行时用[routeRefType] = 'external'与普通路由区分。
在应用中绑定外部路由
外部路由的关联由应用控制。插件的每个ExternalRouteRef都应绑定到实际的RouteRef(通常来自另一个插件)。绑定过程在应用启动时执行一次,之后在整个应用生命周期内用于解析具体路由路径。
接上面的例子,让BarPage链接到FooPage,应用里可以这样写:
createApp({ bindRoutes({ bind }) { bind(barPlugin.externalRoutes, { headerLink: fooPlugin.routes.root, }); }, });绑定之后,在barPlugin内使用useRouteRef(headerLinkRouteRef)就能生成指向FooPage实际挂载路径的链接。
注意:应用代码中不应直接导入和使用RouteRef,而是通过插件实例访问插件的路由。这是为了更好的命名空间与可发现性,同时减少插件包中的独立导出数量。路由引用通过createPlugin传入:
// In foo-plugin export const fooPlugin = createPlugin({ routes: { root: fooPageRouteRef, }, ... }) // In bar-plugin export const barPlugin = createPlugin({ externalRoutes: { headerLink: headerLinkRouteRef, }, ... })另外,几乎总是应该把路由引用本身放在单独文件(如顶层routes.ts)中,而不是放在创建插件实例的文件里,以避免插件内部其他部分使用这些路由引用时产生循环依赖。以 plugins/scaffolder/src/routes.ts 为例,scaffolder 插件把所有路由引用集中定义,再由 plugins/scaffolder/src/plugin.tsx 中的scaffolderPlugin通过routes(如root、selectedTemplate、ongoingTask等)与externalRoutes(registerComponent、viewTechDoc)注册。
这种路由间接层对开源插件尤其重要,因为它们需要为集成方式保留灵活性。对于你自己为内部 Backstage 应用开发的插件,可以选择直接导入甚至直接使用具体路由;不过完整使用路由系统仍有好处——它帮你组织结构化路由,并且(下文会看到)还能管理路由参数。
绑定优先级与静态配置绑定
从 packages/core-app-api/src/app/resolveRouteBindings.ts 的源码可以看到外部路由解析遵循三级优先级:
- 代码内
bindRoutes回调(最高优先级),并且支持把值设为false来显式禁用某个外部路由;对非 optional 的路由缺失绑定会直接抛错; - 静态配置
app.routes.bindings(次优先级),如果代码已绑定则跳过; defaultTarget默认目标(最低优先级),仅在未被上述两者处理时生效。
静态配置的方式不需要改应用代码,但无法获得类型安全与编译期校验。静态绑定位于app-config.yaml的app.routes.bindings键下,工作方式与 新前端系统的路由绑定 相同,例如:
app: routes: bindings: bar.headerLink: foo.root外部路由引用的默认目标(Default Targets)
自 Backstage1.28版本起,可以为外部路由引用定义默认目标,工作方式与 新前端系统的默认目标 相同:
export const createComponentExternalRouteRef = createExternalRouteRef({ defaultTarget: 'scaffolder.createComponent', });defaultTarget的字符串格式为标准的<plugin id>.<route id>。这在仓库中有多处真实用例:例如 plugins/scaffolder/src/routes.ts 中registerComponentRouteRef声明defaultTarget: 'catalog-import.importPage',viewTechDocRouteRef声明defaultTarget: 'techdocs.docRoot';packages/app/src/examples/pagesPlugin.tsx 中的externalPageXRouteRef也声明了defaultTarget: 'pages.pageX'。
可选外部路由(Optional External Routes)
创建ExternalRouteRef时可以标记为可选:
const headerLinkRouteRef = createExternalRouteRef({ id: 'header-link', optional: true, });标记为 optional 的外部路由不要求在应用中被绑定,因此可以作为"是否显示某个链接/执行某个动作"的开关。
当对可选外部路由调用useRouteRef时,返回值签名变为RouteFunc | undefined,从而支持如下逻辑:
const MyComponent = () => { const headerLink = useRouteRef(headerLinkRouteRef); return ( <header> My Header {headerLink && <a href={headerLink()}>External Link</a>} </header> ); };源码层面,createExternalRouteRef的optional默认值为false(见 ExternalRouteRef.ts),并作为运行时字段readonly optional保存在实现类中。
参数化路由(Parameterized Routes)
RouteRef支持添加具名、带类型的参数。参数在创建时声明,会强制路径中必须出现这些参数,并在使用useRouteRef时要求传入:
// 创建参数化路由 const myRouteRef = createRouteRef({ id: 'myroute', params: ['name'] }) // 在应用中,MyPage 是以 myRouteRef 为 mountPoint 的路由扩展 <Route path='/my-page/:name' element={<MyPage />}/> // 在组件中使用 const myRoute = useRouteRef(myRouteRef) return ( <div> <a href={myRoute({name: 'a'})}>A</a> <a href={myRoute({name: 'b'})}>B</a> </div> )在 packages/core-plugin-api/src/routing/RouteRef.ts 的createRouteRef实现中可以看到,params会在创建时存入RouteRefImpl的readonly params字段,供运行时校验与 URL 生成使用。
目前还不能创建参数化的ExternalRouteRef,也不能把外部路由绑定到参数化路由上,未来可能会视需要添加。
子路由(SubRouteRefs)
最后一种可创建的路由引用是SubRouteRef:它用于创建相对于某个绝对RouteRef的固定路径。当你有一个页面内部挂在某个路由扩展组件的子路由上、又希望其他插件能路由到该页面时,它非常有用。
例如:
// routes.ts const rootRouteRef = createRouteRef({ id: 'root' }); const detailsRouteRef = createSubRouteRef({ id: 'root-sub', parent: rootRouteRef, path: '/details', }); // plugin.ts export const myPlugin = createPlugin({ routes: { root: rootRouteRef, details: detailsRouteRef, }, }); export const MyPage = myPlugin.provide( createRoutableExtension({ name: 'MyPage', component: () => import('./components/MyPage').then(m => m.MyPage), mountPoint: rootRouteRef, }), ); // components/MyPage.tsx const MyPage = () => ( <Routes> {/* myPlugin.routes.root 会把用户带到这个页面 */} <Route path="/" element={<IndexPage />} /> {/* myPlugin.routes.details 会把用户带到这个页面 */} <Route path="/details" element={<DetailsPage />} /> </Routes> );在 packages/core-plugin-api/src/routing/SubRouteRef.ts 的实现中,createSubRouteRef会在运行时从path里提取:param参数并与父路由参数合并,同时做一系列校验:路径必须以/开头、不能以/结尾、参数不能与父路由重叠、参数名必须合法,否则都会抛错。仓库中也有实际案例:scaffolder 的legacySelectedTemplateRouteRef即通过createSubRouteRef基于rootRouteRef创建了/templates/:templateName子路由(见 plugins/scaffolder/src/routes.ts)。
Catalog 组件:EntitySwitch 与 EntityLayout
为帮助你在应用中组织 catalog 实体页面、并在不同场景下选择渲染内容,@backstage/catalog插件提供了EntitySwitch组件。它通过一组EntitySwitch.Case子元素,最多选择一个要渲染的元素。
例如:让所有 kind 为"Template"的实体渲染MyTemplate,其余实体渲染MyOther:
<EntitySwitch> <EntitySwitch.Case if={isKind('template')}> <MyTemplate /> </EntitySwitch.Case> <EntitySwitch.Case> <MyOther /> </EntitySwitch.Case> </EntitySwitch> // 想要更短的形式: <EntitySwitch> <EntitySwitch.Case if={isKind('template')} children={<MyTemplate />}/> <EntitySwitch.Case children={<MyOther />}/> </EntitySwitch>EntitySwitch会渲染第一个if函数对当前实体返回true的Case的子元素;如果没有任何 Case 匹配,则不渲染任何内容;如果某个 Case 未指定if过滤函数,它始终匹配。if属性就是一个(entity: Entity) => boolean类型的函数,例如isKind可以这样实现:
function isKind(kind: string) { return (entity: Entity) => entity.kind.toLowerCase() === kind.toLowerCase(); }@backstage/catalog插件提供了一组内置条件:isKind、isComponentType、isResourceType、isEntityWith和isNamespace(此外源码中还有isApiType,见 plugins/catalog/src/components/EntitySwitch/conditions.ts)。
EntitySwitch 的源码实现:组件数据的典型应用
plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx 是组件数据 + 元素过滤的教科书式案例:
EntitySwitchCaseComponent本身渲染为null,但在模块加载时执行attachComponentData(EntitySwitchCaseComponent, 'core.backstage.entitySwitch', true);EntitySwitch通过useElementFilter遍历子元素,用selectByComponentData({ key: 'core.backstage.entitySwitch', withStrictError: 'Child of EntitySwitch is not an EntitySwitch.Case' })精确筛选出Case元素——非 Case 子元素会直接触发严格错误;- 之后对每个 Case 执行
condition?.(entity, { apis }),其中apis来自useApiHolder(),因此条件函数除了实体本身还能访问应用 API 持有者; - 条件函数支持异步(返回 Promise),检测到异步条件时会自动切换到
AsyncEntitySwitch分支处理; - 还支持
renderMultipleMatches属性('first'默认只渲染第一个匹配,'all'渲染所有匹配)。
另外,@backstage/catalog插件还导出了新的EntityLayout组件——它是EntityPageLayout的调整版与替代品,详细内容见下文的应用迁移部分。
迁移旧插件:Porting Existing Plugins
将现有插件移植到新组合系统,有几个高层步骤:
- 移除
createPlugin中的router.addRoute/router.registerRoute用法,把页面组件改为导出为路由扩展(routable extension); - 把任何
Router导出改为路由扩展; - 把普通组件导出(如 catalog 概览卡片)改为组件扩展(component extension);
- 停止导出
RouteRef,改为传给createPlugin; - 停止把
RouteRef作为 props 接收或从其他插件导入,改为创建ExternalRouteRef作为替代,并传给createPlugin; - 按照下方命名模式表重命名其他导出符号。
需要注意:移除既有导出与配置对任何插件都是破坏性变更。如果需要向后兼容,应该在新增内容的同时将旧代码标记为 deprecated,之后再择机移除。
迁移命名模式对照表
许多导出命名模式已改变,以避免导入别名并更清晰地表意,请参照下表确定新名称:
| 描述 | 旧模式 | 新模式 | 示例 |
|---|---|---|---|
| 顶层页面 | Router | *Page | CatalogIndexPage、SettingsPage、LighthousePage |
| 实体页签内容 | Router | Entity*Content | EntityJenkinsContent、EntityKubernetesContent |
| 实体概览卡片 | *Card | Entity*Card | EntitySentryCard、EntityPagerDutyCard |
| 实体条件判断 | isPluginApplicableToEntity | is*Available | isPagerDutyAvailable、isJenkinsAvailable |
| 插件实例 | plugin | *Plugin | jenkinsPlugin、catalogPlugin |
小结
组合系统的三层核心抽象各司其职:组件数据为静态元素检查提供结构化通道(配合useElementFilter实现EntitySwitch这类声明式组件);扩展统一了插件向应用交付内容的边界(懒加载、错误边界、插件上下文与分析埋点都由核心自动包装);RouteRef 体系通过"挂载点 + 外部绑定 + 默认目标"的多级间接层,让互不知情的开源插件也能在应用层被灵活地串接起来。如果你想深入了解其演进方向,可以继续阅读 新前端系统扩展、扩展蓝图 与 新路由系统 文档。
【免费下载链接】backstageBackstage is an open framework for building developer portals项目地址: https://gitcode.com/GitHub_Trending/ba/backstage
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考