基于Angular的微前端shell
如上一章所述,有几种方法可以实现基于 SPA 的微前端。本章介绍如何通过七个步骤实现一个按需加载微前端的 shell。除了在有关微前端和 Web 组件的章节中,我们没有将 Web component 用于宏观架构,只使用了按需加载和按需引导的普通 SPA,实际上我们决定将它应用于微观架构。
我们可以使用 Shadow DOM 隔离不同的应用程序以简化实现。从诞生之日起,Angular 就设计其组件支持 Web Component。
在案例中我们的目标是将两简单的应用 client-a 和 client-b 加载到 shell 中。它们会共享一个 Widget:
你可以在作者的 Github 仓库中找到此源码
第一步:确定你需要这么做
确保此方法符合你的体系架构目标。如上一章所述,微前端也有缺点,你需要对它们有足够的了解。
第二步:实现单页应用
首先将你的子应该实现为一个普通的 angular 应用。
在微服务架构中,每个部分都有一个单独的代码库以尽可能地使它们解耦。在实际中会看到很多基于 monorepos 的微前端。当然,何时应用微前端是一个值得讨论的话题,但与之相比更重要的是找到适合目标系统的架构,并意识到这种架构可能带来的后果。
如果我们使用 monorepo,则必须确保 lint 规则发挥其作用,而不是将微前端彼此耦合。前面已经提到过,Nrwl's Nx为此提供了一个出色的解决方案:它可以限制哪些库可以相互访问。Nx 可以检测到 monorepo 的哪些部分受到更改的影响,并且仅重新编译、测试这些受变化影响的部分。
为了简化微前端的路由,建议将微应用名称作为路由前缀。下面是 client-a 应用的路由代码:
@NgModule({
imports: [
ReactiveFormsModule,
BrowserModule,
RouterModule.forRoot([
{ path: 'client-a/page1', component: Page1Component },
{ path: 'client-a/page2', component: Page2Component },
{ path: '**', component: EmptyComponent}
], { useHash: true })
],
[...]
})
export class AppModule {
[...]
}
第三步:暴露共享的 Widget
暴露那些你需要作为 Web Component 或 Custom Element 使用的 Widgets。注意,从微服务的角度来看,你应该尽量避免微前端之间的代码共享,因为这会导致体系结构之间的耦合。
可以通过 Angular Element 把 Angular 组件作为自定义元素暴露给外部使用。也可以到作者的 Angular Element 和 lazy and external Angular Elements学习相关内容。
第四步:编译单页应用
Webpack 和 Angular CLI 都使用全局数组来注册包,这使得应用程序不同 chunks(lazy chunks)之间能够相互查找。但是,如果我们将多个 SPA 加载到一个页面中,整个过程就会乱掉,无法正常运行。
我们有两种解决方案:
将所有的内容都放到一个 bundle 中,这样那个全局数组就显得多余。
重全名那个全局数组。
我们使用了第一种方案,顾名思义,微前端体积应该很小。打包成一个 bundle 可使按需加载变得更加容易。稍后我们会介绍在微前端应用间共享 RxJS 或 Angular 之类的库。
为了调整 CLI 以产生一个捆绑包,这里使用一个工具ngx-build-plus,该工具提供了 --single-bundle 选项:
ng add ngx-build-plus
ng build --prod--single-bundle
在 monorepo 中,还必须加入 project 参数:
ng add ngx-build-plus --project myProject
ng build --prod --single-bundle --project myProject
第五步:创建可按需加载 bundle 的 shell 应用
按需加载 bundle 非常简单。只需要一些原始的 JavaScript 代码即可,主要就是动态创建 script 标签和作为子应用程序的根元素的标签:
//add script tag
const script = document.createElement("script");
script.src = "[...]/client-a/main.js";
document.body.appendChild(script);
//add app
const frontend = document.createElement("client-a");
const content = document.getElementById("content");
content.appendChild(frontend);
当然,你可以把这些都放到一个指令中,另外需要一点代码来控制相应的微应用是否显示:
frontend["visible"] = false;
第六步:微前端之间的通信
通常情况下,我们应该减少微应用之间的通信,这样有利用降低耦合。
实现方式有很多,在这里使用了比较简单的一种方式:参数查询。这种方法有几个优点:
微前端的加载顺序无关紧要。只要加载后就可以从 URL 中获取当前参数。
可以使用 deep link。
和 web 的运作方式一致
实现起来很简单
在 Angular 中设置 URL 参数是很简单的,如下所示:
this.router.navigate([""], {
queryParamsHandling: "merge",
queryParams: { id: 17 }
});
merge 选项保存现有的 URL 参数。如果已经有一个 id 参数,路由会将其覆盖。
Angular 也提供了监听路由参数变化的方法:
route.queryParams.subscribe(params => {
console.debug("params", params);
});
其它方法:
如果将微前端包装到 Web 组件中,可以使用属性和事件与 shell 应用进行通信。
shell 应用可以设置一个全局消息队列,
typescript(windowasany).messageBus=newBehaviorSubject(null);
,shell 和 微应用都可以向这个队列中发布消息,同时也可以监听。使用浏览器提供的自定义事件:
// sender
const customer = { id: 17, ... };
window.raiseEvent(new CustomEvent('CustomerSelected', { details: customer }));
// receiver
window.addEventListener('CustomerSelected', (e) => { ... })
第七步:在微前端应用间共享库
由于我们有几个独立的微前端应用,它们每个都有独立的依赖库,例如 Angular 或 RxJS。从微服务的角度来看,这是完美的,因为它允许每个微前端团队选择任何版本的库或框架,他们甚至可以决定是否以及何时更新到较新版本。
但是,从性能和加载时间的角度来看,这种结构是有问题的,因为它导致 bundle 中大量的重复性代码。例如,最终的 bundle 中可能包含好几个版本的 Angular 源码:
幸运的是,已经有解决方案:Webpack externals
Externals 允许通过将库加载到全局命名空间来共享库。这种方法在 jQuery(提供全局\$对象)时代很流行,有时也适用于简单的 react 和 vue 应用程序。
我们可以对大多数库使用 UMD bundles。只需告诉 webpack 不要将它们与每个微前端打包在一起,而是在全局命名空间中引用它:
要将 Webpack externals 与 Angular CLI 一起使用,您可以利用ngx-build-plus,它里面已经附带了 schematic 用于帮助完成这种修改。
前面已经介绍过如何安装:
ng add ngx-build-plus
然后调用下面的 schematic
ng g ngx-build-plus:externals
不要忘了,在 monorepo 中需要额外的 project 参数
ng add ngx-build-plus --project myProject
ng g ngx-build-plus:externals --project myProject
这种方法也提供了 npm build::externals 命令 。对于默认项目,也有一个脚本 build:externals。运行此脚本后查看 index.html,会发现直接加载了 Angular 的包:
还可以优化这一过程,将它们放在一个 bundle 中。在生成的 webpack.externals.js 中,可以看到一个包名和全局变量的映射。
const webpack = require("webpack");
module.exports = {
externals: {
rxjs: "rxjs",
"@angular/core": "ng.core",
"@angular/common": "ng.common",
"@angular/common/http": "ng.common.http",
"@angular/platform-browser": "ng.platformBrowser",
"@angular/platform-browser-dynamic": "ng.platformBrowserDynamic",
"@angular/compiler": "ng.compiler",
"@angular/elements": "ng.elements",
"@angular/router": "ng.router",
"@angular/forms": "ng.forms"
}
};
使用此方法使生成的包在需要@angular/core 时引用全局变量 ng.core。因此,@angular/core 不再需要包含在 bundle 中。
请注意,这不是 Angular 的默认模式,可能存在一些风险。
结论
有了正确的思路,为微前端实现 shell 应用并不困难。但是,这只是实现微前端的一种方法,并且有其优点和缺点。在实现之前,请确保它符合你的架构目标。
相关文献
Evans, Domain-Driven Design: Tackling Complexity in the Heart of SoftwareWlaschin, Domain Modeling Made FunctionalGhosh, Functional and Reactive Domain ModelingNrwl, Monorepo-style Angular developmentJackson, Micro FrontendsBurleson, Push-based Architectures using RxJS + FacadesBurleson, NgRx + Facades: Better State ManagementSteyer, Web Components with Angular Elements (article series, 5 parts)作者
Manfred Steyer 是一位培训师,顾问和程序设计师,主要关注 Angular。
他在社区的工作得到 Google 认可,授予其成为 Google Developer Expert(GDE)。另外,Manfred 是 Angular 团队中的“受信任的协作者”。他以这个身份为 Angular CLI 实现了差异化加载。
Manfred 曾在 O’Reilly 出版书籍 。也为 German Java Magazine, windows.developer 和 Heise 等杂志撰写文章。
他会定期在有关 Angular 的会议和博客上发表演讲。
在此之前,他负责项目团队主要从事构建 web 业务的相关项目。此外,他在应用科学大学教授了几个有关软件工程的课程。
Manfred 在全职工作同时进行兼职和远程学习,主学位为计算机科学硕士,同时拥有 IT 和 IT Marketing 学位。
您可以在Twitter, Facebook上关注他,并在 http://www.softwarearchitekt.at找到他的博客。