Backstage 插件组合系统(Composability System)完全指南:扩展、路由与组件数据的原理与迁移实战
2026/9/12 15:20:25 网站建设 项目流程

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 如何做条件渲染,以及如何将旧式插件迁移到新组合系统。

说明:本页描述的旧版前端组合系统(createRoutableExtensioncreateComponentExtensionRouteRefExternalRouteRef、组件数据)在新版前端系统中已被替代。若你在使用新系统,请阅读 前端系统扩展文档、扩展蓝图文档 与 路由文档。

组合系统概览

组合系统的核心原则是:插件之间应该有清晰的边界与连接方式。它应当:

  • 把某个插件内的崩溃隔离在自身范围内,同时允许插件之间自由导航;
  • 让插件只在需要时才被加载(按需懒加载);
  • 允许插件为其他插件提供可扩展的"扩展点";
  • 以**应用优先(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-routermaterial-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-scaffoldercreateScaffolderFieldExtension。扩展也不绑定 React,可以用来建模通用 JS 概念,甚至桥接到其他渲染库或前端框架。

源码实现:扩展到底被包了几层

查看 packages/core-plugin-api/src/extensions/extensions.tsx 的createReactExtension,可以看到expose()返回的组件被层层包装:

  1. Suspense+ 全局Progressfallback:懒加载时显示应用级进度组件;
  2. PluginErrorBoundary:把插件内部的渲染错误隔离在边界内(对应"隔离插件崩溃"的设计目标);
  3. AnalyticsContext:自动注入pluginId、扩展namerouteRef等分析属性;
  4. 随后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 中看到本仓库示例应用的实际写法:路由元素被收集后传给createAppfeatures数组,最终由app.createRoot()渲染。

命名模式

构建插件时遵循以下命名模式,可以更清晰地表达导出符号的意图与用途:

描述模式示例
顶层页面*PageCatalogIndexPageSettingsPageLighthousePage
实体页签内容Entity*ContentEntityJenkinsContentEntityKubernetesContent
实体概览卡片Entity*CardEntitySentryCardEntityPagerDutyCard
实体条件判断is*AvailableisPagerDutyAvailableisJenkinsAvailable
插件实例*PluginjenkinsPlugincatalogPlugin
工具 API 引用*ApiRefconfigApiRefcatalogApiRef

路由系统:RouteRef 与 useRouteRef

Backstage 的路由系统重度依赖组合系统。它用RouteRef表示应用中的路由目标:运行时它们会被绑定到具体的path,但提供了间接层,帮助那些彼此并不知道对方存在、更不知道对方路径的插件互相路由。

每个RouteRef具体path是根据应用中的元素树发现的。考虑以下示例:

const appRoutes = ( <Routes> <Route path="/foo" element={<FooPage />} /> <Route path="/bar" element={<BarPage />} /> </Routes> );

假设FooPageBarPage分别是fooPluginbarPlugin导出的路由扩展。由于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 的实现中可以看到它支持idparamsoptionaldefaultTarget四个选项,且运行时用[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(如rootselectedTemplateongoingTask等)与externalRoutesregisterComponentviewTechDoc)注册。

这种路由间接层对开源插件尤其重要,因为它们需要为集成方式保留灵活性。对于你自己为内部 Backstage 应用开发的插件,可以选择直接导入甚至直接使用具体路由;不过完整使用路由系统仍有好处——它帮你组织结构化路由,并且(下文会看到)还能管理路由参数。

绑定优先级与静态配置绑定

从 packages/core-app-api/src/app/resolveRouteBindings.ts 的源码可以看到外部路由解析遵循三级优先级

  1. 代码内bindRoutes回调(最高优先级),并且支持把值设为false来显式禁用某个外部路由;对非 optional 的路由缺失绑定会直接抛错;
  2. 静态配置app.routes.bindings(次优先级),如果代码已绑定则跳过;
  3. defaultTarget默认目标(最低优先级),仅在未被上述两者处理时生效。

静态配置的方式不需要改应用代码,但无法获得类型安全与编译期校验。静态绑定位于app-config.yamlapp.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> ); };

源码层面,createExternalRouteRefoptional默认值为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会在创建时存入RouteRefImplreadonly 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函数对当前实体返回trueCase的子元素;如果没有任何 Case 匹配,则不渲染任何内容;如果某个 Case 未指定if过滤函数,它始终匹配if属性就是一个(entity: Entity) => boolean类型的函数,例如isKind可以这样实现:

function isKind(kind: string) { return (entity: Entity) => entity.kind.toLowerCase() === kind.toLowerCase(); }

@backstage/catalog插件提供了一组内置条件:isKindisComponentTypeisResourceTypeisEntityWithisNamespace(此外源码中还有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

将现有插件移植到新组合系统,有几个高层步骤:

  1. 移除createPlugin中的router.addRoute/router.registerRoute用法,把页面组件改为导出为路由扩展(routable extension);
  2. 把任何Router导出改为路由扩展;
  3. 把普通组件导出(如 catalog 概览卡片)改为组件扩展(component extension);
  4. 停止导出RouteRef,改为传给createPlugin
  5. 停止RouteRef作为 props 接收或从其他插件导入,改为创建ExternalRouteRef作为替代,并传给createPlugin
  6. 按照下方命名模式表重命名其他导出符号。

需要注意:移除既有导出与配置对任何插件都是破坏性变更。如果需要向后兼容,应该在新增内容的同时将旧代码标记为 deprecated,之后再择机移除。

迁移命名模式对照表

许多导出命名模式已改变,以避免导入别名并更清晰地表意,请参照下表确定新名称:

描述旧模式新模式示例
顶层页面Router*PageCatalogIndexPageSettingsPageLighthousePage
实体页签内容RouterEntity*ContentEntityJenkinsContentEntityKubernetesContent
实体概览卡片*CardEntity*CardEntitySentryCardEntityPagerDutyCard
实体条件判断isPluginApplicableToEntityis*AvailableisPagerDutyAvailableisJenkinsAvailable
插件实例plugin*PluginjenkinsPlugincatalogPlugin

小结

组合系统的三层核心抽象各司其职:组件数据为静态元素检查提供结构化通道(配合useElementFilter实现EntitySwitch这类声明式组件);扩展统一了插件向应用交付内容的边界(懒加载、错误边界、插件上下文与分析埋点都由核心自动包装);RouteRef 体系通过"挂载点 + 外部绑定 + 默认目标"的多级间接层,让互不知情的开源插件也能在应用层被灵活地串接起来。如果你想深入了解其演进方向,可以继续阅读 新前端系统扩展、扩展蓝图 与 新路由系统 文档。

【免费下载链接】backstageBackstage is an open framework for building developer portals项目地址: https://gitcode.com/GitHub_Trending/ba/backstage

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询