ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Backstage 插件组合系统(Composability System)完全指南:扩展、路由与组件数据的原理与迁移实战

Backstage 插件组合系统(Composability System)完全指南:扩展、路由与组件数据的原理与迁移实战 Backstage 插件组合系统Composability System完全指南扩展、路由与组件数据的原理与迁移实战【免费下载链接】backstageBackstage is an open framework for building developer portals项目地址: https://gitcode.com/GitHub_Trending/ba/backstageBackstage 组合系统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 () h1This 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 ExtensionT { 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: () PromiseT }与{ sync: T }两种形态懒加载出错时会包装为ForwardedErrorFailed 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()渲染。命名模式构建插件时遵循以下命名模式可以更清晰地表达导出符号的意图与用途描述模式示例顶层页面*PageCatalogIndexPage、SettingsPage、LighthousePage实体页签内容Entity*ContentEntityJenkinsContent、EntityKubernetesContent实体概览卡片Entity*CardEntitySentryCard、EntityPagerDutyCard实体条件判断is*AvailableisPagerDutyAvailable、isJenkinsAvailable插件实例*PluginjenkinsPlugin、catalogPlugin工具 API 引用*ApiRefconfigApiRef、catalogApiRef路由系统RouteRef 与 useRouteRefBackstage 的路由系统重度依赖组合系统。它用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等与externalRoutesregisterComponent、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.importPageviewTechDocRouteRef声明defaultTarget: techdocs.docRootpackages/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 RoutesRouteRef支持添加具名、带类型的参数。参数在创建时声明会强制路径中必须出现这些参数并在使用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其余实体渲染MyOtherEntitySwitch 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 /}/ /EntitySwitchEntitySwitch会渲染第一个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*PageCatalogIndexPage、SettingsPage、LighthousePage实体页签内容RouterEntity*ContentEntityJenkinsContent、EntityKubernetesContent实体概览卡片*CardEntity*CardEntitySentryCard、EntityPagerDutyCard实体条件判断isPluginApplicableToEntityis*AvailableisPagerDutyAvailable、isJenkinsAvailable插件实例plugin*PluginjenkinsPlugin、catalogPlugin小结组合系统的三层核心抽象各司其职组件数据为静态元素检查提供结构化通道配合useElementFilter实现EntitySwitch这类声明式组件扩展统一了插件向应用交付内容的边界懒加载、错误边界、插件上下文与分析埋点都由核心自动包装RouteRef 体系通过挂载点 外部绑定 默认目标的多级间接层让互不知情的开源插件也能在应用层被灵活地串接起来。如果你想深入了解其演进方向可以继续阅读 新前端系统扩展、扩展蓝图 与 新路由系统 文档。【免费下载链接】backstageBackstage is an open framework for building developer portals项目地址: https://gitcode.com/GitHub_Trending/ba/backstage创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表