Skip to main content

可组合系统

摘要

本页介绍了可组合性系统,该系统有助于将众多插件的内容整合到一个 Backstage 应用程序中。

可组合性系统的核心原则是,插件应具有清晰的边界和连接。 它应隔离插件内部的崩溃,但允许在它们之间进行导航。 它应允许只在需要时加载插件,并使插件能够为其他插件提供扩展点。 可组合性系统也是以应用程序优先的思维方式构建的,它优先考虑应用程序的简洁性和清晰度,而不是插件和核心应用程序接口的简洁性和清晰度。

可组合性系统并不是一个单一的应用程序接口,而是一系列模式、基元和应用程序接口的集合。 其核心概念是扩展还有一种称为组件数据的基元,有助于使应用程序的结构更具声明性。 此外还有RouteRef可帮助以灵活的方式在页面之间进行路由,这一点在整合不同的开源插件时尤为重要。

概念

本节将简要介绍有助于支持可组合性系统的所有概念。

组件数据

组件数据是一种可组合性原语,是为 React 组件提供新数据维度的一种方式。 数据使用一个键附加到 React 组件,然后可以从使用这些组件创建的任何 JSX 元素中读取,使用相同的键,如下例所示:

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虽然在这些库中通常只检查元素类型和道具,而我们的组件数据增加了更多的结构化访问,并通过允许同时使用和解释一块数据的多个不同版本来简化演化。

组件数据的用例之一是支持通过应用程序中的元素发现路由和插件。 通过这种方式,我们可以让应用程序中的 React 元素树成为真相的来源,既可以知道使用了哪些插件,也可以知道应用程序中的所有顶级插件路由。 不过,组件数据的使用并不局限于这些用例,因为它还可以用作创建新抽象的基元。

扩展

扩展是插件输出到应用程序中使用的内容。 最典型的是 React 组件,但实际上它们可以是任何类型的 JavaScript 值。 它们是通过以下方式创建的create*Extension函数,并用plugin.provide()以创建实际导出的扩展名。

扩展类型很简单:

export type Extension<T> = {
expose(plugin: BackstagePlugin): T;
};

扩展的威力来自于不同角色对其使用的钩挂能力。 创建和插件包装由创建函数的所有者控制,Backstage核心能够钩挂到在插件外公开扩展的过程,最终由应用程序控制扩展的使用。

Backstage 核心 API 目前提供两种不同类型的扩展创建器、createComponentExtensioncreateRoutableExtension组件扩展是没有特殊要求的普通 React 组件,例如实体概述页面的卡片。 组件或多或少会按原样导出,但会封装以提供错误边界、懒加载和插件上下文等功能。

可路由扩展建立在组件扩展之上,可用于任何应在特定路由路径上呈现的组件,如顶层页面或实体页面标签内容。 创建可路由扩展时,需要提供一个RouteRef作为mountPoint挂载点将是该组件对外的句柄,其他组件和插件如果希望链接到可路由组件,就会使用该挂载点。

到目前为止,核心库中只有两个扩展创建功能,但未来可能会增加更多。 还有一些插件通过自己的扩展提供了扩展功能的方法,比如createScaffolderFieldExtension@backstage/plugin-scaffolder扩展也不与 React 绑定,既可用于为通用 JavaScript 概念建模,也可能是连接 React 以外的呈现库和 Web 框架的桥梁。

从插件的角度看扩展功能

扩展是穿越插件边界的主要方法之一,也是插件提供具体内容供应用程序使用的方式。 它们取代了现有的组件导出概念,例如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,
}),
);

在应用程序中使用扩展

目前,所有扩展都是以 React 组件为模型的。 这些扩展的使用方法与任何 React 组件的常规使用方法相同,但有一个重要区别。 所有扩展都必须是一棵 React 元素树的一部分,这棵元素树从根部AppProvider.

例如,以下应用程序代码会工作:

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

const App = () => (
<AppProvider>
<AppRouter>
<Root>
<AppRoutes />
</Root>
</AppRouter>
</AppProvider>
);

但这种情况很容易解决!只需确保不在应用程序中创建任何中间组件,例如像这样:

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

const App = () => (
<AppProvider>
<AppRouter>
<Root>{appRoutes}</Root>
</AppRouter>
</AppProvider>
);

命名模式

在构建插件时,有几种命名模式需要遵守,这有助于明确导出的意图和用法。

| 说明 | 模式 | 示例 | | --------------------- | ---------------- | ---------------------------------------------------- | | 顶层页面 | | 首页*Page|CatalogIndexPage,SettingsPage,LighthousePage| 实体选项卡内容Entity*Content|EntityJenkinsContent,EntityKubernetesContent| 实体概述卡Entity*Card|EntitySentryCard,EntityPagerDutyCard| 实体条件is*Available|isPagerDutyAvailable,isJenkinsAvailable| 插件实例*Plugin|jenkinsPlugin,catalogPlugin| 实用程序 API 参考*ApiRef|configApiRef,catalogApiRef|

路由系统

Backstage 的路由系统在很大程度上依赖于可组合系统。 它使用RouteRef表示应用程序中的路由目标,这些目标在运行时将绑定到具体的path但它提供了一种间接方式,帮助将不同的插件混合在一起,否则这些插件就不知道如何互相连接。

混凝土path对于每个RouteRef让我们看看下面的例子:

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>;
};

现在,让我们假设要从BarPageFooPage我们不想参考fooPageRouteRef直接从我们的barPlugin因为这样会对fooPlugin此外,它在允许应用程序将插件绑定在一起方面也没有提供多少灵活性,链接反而是由插件本身决定的。 为了解决这个问题,我们使用了ExternalRouteRef和普通路由引用一样,它们可以传递给useRouteRef来创建具体的 URL,但这些 URL 不能用作可路由组件的挂载点,而必须通过应用程序中的路由绑定与目标路由相关联。

我们创建一个新的ExternalRouteRefbarPlugin,使用一个中性的名称来描述它在插件中的作用,而不是它可能链接到的特定插件页面,让应用程序决定最终目标。 如果BarPage例如,如果要在页眉中链接到外部页面,可以声明一个ExternalRouteRef与此类似:

const headerLinkRouteRef = createExternalRouteRef({ id: 'header-link' });

在应用程序中绑定外部路由

外部路由的关联由应用程序控制。ExternalRouteRef应绑定到一个实际的RouteRef绑定过程在应用程序启动时进行一次,然后在应用程序的整个生命周期内使用,以帮助解决具体的路由路径问题。

以上面的例子为例BarPage连接到FooPage我们可能会在应用程序中做这样的事情:

createApp({
bindRoutes({ bind }) {
bind(barPlugin.externalRoutes, {
headerLink: fooPlugin.routes.root,
});
},
});

鉴于上述绑定,使用useRouteRef(headerLinkRouteRef)barPlugin会让我们创建一个指向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这是为了避免在使用同一插件其他部分的路由引用时出现循环导入。

另一点需要注意的是,路由中的这种间接性对于需要灵活集成的开源插件特别有用。 对于为自己的Backstage程序内部构建的插件,可以选择直接导入的方式,甚至直接使用具体的路由。 不过,即使在内部插件中使用完整的路由系统也有一些好处。 它可以帮助你构建路由,而且正如你将进一步看到的那样,它还可以帮助你管理路由参数。

可选外部路由

创建一个ExternalRouteRef可以将其标记为可选项:

const headerLinkRouteRef = createExternalRouteRef({
id: 'header-link',
optional: true,
});

标记为 "可选 "的外部路由无需在应用程序中绑定,因此可用作是否显示特定链接或是否应采取行动的开关。

呼叫时useRouteRef的返回签名改为RouteFunc | undefined,允许这样的逻辑:

const MyComponent = () => {
const headerLink = useRouteRef(headerLinkRouteRef);

return (
<header>
My Header
{headerLink && <a href={headerLink()}>External Link</a>}
</header>
);
};

参数化路由

的一个特点RouteRef参数是在创建时声明的,它将强制要求应用程序路径中包含参数,并在使用useRouteRef.

下面是创建和使用参数化路由的示例:

// Creation of a parameterized route
const myRouteRef = createRouteRef({
id: 'myroute',
params: ['name']
})

// In the app, where MyPage is a routable extension with myRouteRef set as mountPoint
<Route path='/my-page/:name' element={<MyPage />}/>

// Usage within a component
const myRoute = useRouteRef(myRouteRef)
return (
<div>
<a href={myRoute({name: 'a'})}>A</a>
<a href={myRoute({name: 'b'})}>B</a>
</div>
)

目前还无法将参数化的ExternalRouteRef或将外部路由绑定到参数化路由上,但今后如有需要,也可添加此功能。

###子路线

可以创建的最后一种路由反射是SubRouteRef可用于创建一个路由 ref,其固定路径相对于绝对路径的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 will take the user to this page */}
<Route path="/" element={<IndexPage />} />

{/* myPlugin.routes.details will take the user to this page */}
<Route path="/details" element={<DetailsPage />} />
</Routes>
);

目录组件

为了帮助您构建应用程序中的目录实体页面,并选择在不同场景中呈现的内容,您可以使用@backstage/catalog插件提供了一个EntitySwitch它的工作原理是通过一个EntitySwitch.Case孩子们

例如,如果您希望所有类型为"Template"将以MyTemplate组件,而所有其他实体都将以MyOther组件,您可以采取以下措施:

<EntitySwitch>
<EntitySwitch.Case if={isKind('template')}>
<MyTemplate />
</EntitySwitch.Case>

<EntitySwitch.Case>
<MyOther />
</EntitySwitch.Case>
</EntitySwitch>

// Shorter form if desired:
<EntitySwitch>
<EntitySwitch.Case if={isKind('template')} children={<MyTemplate />}/>
<EntitySwitch.Case children={<MyOther />}/>
</EntitySwitch>

EntitySwitch组件将呈现第一个EntitySwitch.Case返回true的函数时,将所选实体传递给if如果所有情况都不匹配,则不会渲染子代。if过滤器函数,它将始终匹配。if属性只是(entity: Entity) => boolean例如isKind可以这样实现:

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

@backstage/catalog插件提供了几个内置条件、isKind,isComponentType,isResourceType,isEntityWithisNamespace.

除了EntitySwitch组件,目录插件还会导出一个新的EntityLayout组件的调整和替代版本。EntityPageLayout组件,下文的应用程序迁移部分将对其进行更深入的介绍。

本文档的其余部分将介绍如何将旧版应用程序迁移到上述新的可组合性系统。

移植现有插件

将现有插件移植到新的可组合性系统有几个高级步骤:

  • createPlugin 中移除 router.addRouterouter.registerRoute,并将页面组件作为可路由扩展导出。 停止导出 RouteRefs 并将其传递给 createPlugin 。 * 停止将 RouteRefs 作为道具或从其他插件导入,而是创建一个 ExternalRouteRef 作为替代,并将其传递给 createPlugin

请注意,删除现有导出和配置是对任何插件的破坏性更改。 如果需要向后兼容性,则在添加新内容时应废弃现有代码,然后在以后删除。

命名模式

为避免导入别名并明确意图,已更改了许多导出命名模式。 请参考下表制定新名称:

| 说明 | 现有模式 | 新模式 | 示例 | | -------------------- | ---------------------------- | ----------------- | ---------------------------------------------------- | | 顶层页面 | | 首页Router|\*Page|CatalogIndexPage,SettingsPage,LighthousePage| 实体选项卡内容Router|Entity\*Content|EntityJenkinsContent,EntityKubernetesContent| 实体概述卡\*Card|Entity\*Card|EntitySentryCard,EntityPagerDutyCard| 实体条件isPluginApplicableToEntity|is\*Available|isPagerDutyAvailable,isJenkinsAvailable| 插件实例plugin|\*Plugin|jenkinsPlugin,catalogPlugin|