[译]一种基于模块联邦的插件前端
原文:https://malcolmkee.com/blog/a-plugin-based-frontend-with-module-federation/
在谈及模块联邦及其独立构建和部署的特性(通常称为微前端)时,一个常见的问题是,“为什么这比使用iframe更好?”虽然这的确是一个问题,特别是当只使用模块联邦拼接多个UI时,其好处可能不会立即显现的时候;答案就在于它无缝集成多个前端应用程序,并允许组件和函数调用一起工作的能力。这就是为什么模块联邦是目前构建微前端应用程序的最佳技术。
在本文中,我将为前端应用程序提供一个利用模块联邦的插件架构。该架构允许开发人员在既有应用程序中添加、删除或更新功能,而无需对应用程序进行任何更改。得益于模块联邦实现的无缝集成,该插件架构才成为可能。
插件架构是什么?
插件架构(plugin architecture)是一种软件架构,它允许 第三方开发者 通过编写插件来扩展现有软件的功能。
在插件系统中,“core”软件提供了 一组定义好的接口、API或钩子,以使开发人员在不修改核心软件的前提下添加新特性或修改应用程序的行为。这种方法促进了模块化,因为插件可以独立于核心软件开发,并且可以被轻松添加或删除以自定义应用程序。
插件系统通常用于需要大量定制的系统。例如,流行的软件,如浏览器,文本编辑器,构建工具和内容管理系统(CMS)都使用插件系统,使开发人员能够向软件添加新功能。VS Code 是一个流行的代码编辑器,它的扩展市场就是一个插件系统的例子。类似地,流行的 CMS WordPress 使用插件系统,使用户能够向其网站添加新功能。
以模块联邦实现的插件系统
模块联邦的一种典型模式包括一个单体应用程序(host),它从多个较小的应用程序(remote)中导入代码。host和remote都可以独立构建和部署,并且可以使用模块联邦在运行时将它们缝合在一起。
将插件系统应用于模块联邦,可以使host应用程序或者说"core",在添加、更新或移除充当插件的remotes 时保持不变。唯一的约束是所有remote必须遵循一组定义好的接口或钩子。
举个例子,假设所有remote应用都必须按照以下约定导出单个远程模块/register
:
// src/register.tsx
import { register } from '@company/core-plugin';
import * as React from 'react';
const OrdersPage = () => <h1>Orders</h1>;
export default register({
routes: [
{
path: 'orders',
element: <OrdersPage />,
},
],
});
来自包@company/core-plugin
的register
函数是一个身份函数,用于强制类型安全:
import { RouteObject } from 'react-router-dom';
export interface Plugin {
routes: Array<RouteObject>;
}
export const register = (plugin: Plugin) => plugin;
通过所有remote都使用该接口暴露的register
模块,host就可以渲染已在所有remote上注册的全部路由:
//app.tsx in host
import { Plugin } from '@company/core-plugin';
import * as React from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { createRoot } from 'react-dom/client';
const getAllRemotes = () =>
Promise.all([
import('microfrontend1/register'),
import('microfrontend2/register'),
import('microfrontend3/register'),
]) as Promise<Array<{ default: Plugin }>>;
getAllRemotes()
.then((mods) => mods.map((mod) => mod.default))
.then((remotes) => {
const router = createBrowserRouter(remotes.map((remote) => remote.routes).flat());
createRoot(document.getElementById('app')!).render(<RouterProvider router={router} />);
});
如下例所示,每当在remote中增添新的路由,则host中无需改变单独的代码,只消在下次加载时便会自动出现了。
// src/register.tsx
import { register } from '@company/core-plugin';
import * as React from 'react';
const OrdersPage = () => <h1>Orders</h1>;
const OrdersDetailsPage = () => <h1>Orders Details</h1>;
export default register({
routes: [
{
path: 'orders',
element: <OrdersPage />,
},
{
path: 'orders/:orderId',
element: <OrdersDetailsPage />,
},
],
});
可能的插件API
在模块联邦中的插件架构有了基本了解之后,你就可以通过创建更多的API或钩子来提高host的可扩展性了。下面是一些支持常见用例的插件API。请记住,它们不是详尽的,也不是必需的。可以根据你的用例来决定其取舍,或者也可以创建自己的API。
register
的 routes
选项
这个选项在前面的部分中讨论过,是一个路由定义数组,通常可以从你使用的路由器库中扩展(在我的例子中,我重用了react-router-dom
中的RouteObject
接口)。它还可以包括子导航,比如在你的应用中要用tabs之类的时候。host将在构造其路由之前合并来自所有remote的路由定义。
从理论上讲,多个remote的路由可能会相互冲突,例如使用'*'
这类过度贪婪的路径,当检测到这种情况时,你应该通过 linting 或控制台错误消息来缓解。
register
的 navItems
选项
也就是一个导航项目列表;你的host应用可能带有导航,此属性允许remote向其中添加/删除项目。该属性的可能定义为:
interface NavItem {
path: string;
label: string;
/** 用去嵌套导航的章节或者说组 */
section: string;
/** 排序用 */
order: number;
/** 图标 */
icon: React.ReactNode;
/** 假设又区分了多种导航 */
location: 'header' | 'footer' | 'sidebar';
}
结合了 <Slot />
组件的 register
之 fills
选项
如果需要将组件从一个remote嵌入到另一个remote,这两个API可以帮上忙。
想象一个客户票证界面,它显示多个部分,如客户个人信息和过往订单等。客户票据界面由一个团队维护,而客户个人信息和订单由另一个团队开发,每个团队都维护着自己的remote应用。
要将客户个人信息和过往订单嵌入到客户票证界面中,我们可以使用以下元素:
-
在客户票证界面(在 customer-support-app 那个 remote 应用里编写)中,渲染一个
<Slot id="customerTicketScreen" />
组件。就其本身而言,它什么也没有显示。 -
在客户个人数据和订单两个 remote 应用中,为
register
提供fills
选项
// src/register.tsx
export default register({
fills: [
{
slotId: 'customerTicketScreen', // 匹配在 support 中由 Slot 提供的 id
component: PersonalInfoSection,
},
],
});
- 在 host 中,使用 React context 注入所有按
slotId
分组的 fills。在Slot
组件中,读取 context 的值,并按照slotId
与id
匹配,渲染所有 fills。
usePluginEventEmitter
和 usePluginEventListener
让来自多个 remote 的组件在同一个界面上共存,那么它们不可避免地要相互通信。usePluginEventEmitter
和 usePluginEventListener
就是用于让组件发出/监听事件的自定义钩子。
从原理上来讲,这类钩子可以使用 mitt
事件总线或 window.dispatch(CustomEvent)
这样的自定义事件来实现。
总结
一个使用模块联邦的基于插件的前端架构,是创建复杂应用程序的强大方法,这样的应用允许来自多个项目的UI组件无缝集成。通过使用插件系统,开发人员可以在不修改host应用的前提下扩展其功能。
同时,虽然这种方法带来诸多便利,留意其潜在的挑战和走好钢丝也是很重要的。例如,如果要在多应用间复用工具函数或类,插件系统可能并不适用,反而 npm 包是个更好的选择。尽管有这些潜在限制,经过细心计划和实现,基于插件的前端架构还是可以为构建复杂应用提供一个灵活和可扩展的平台。