如何解构单体前端应用——前端应用的微服务式拆分
刷新页面?路由拆分?No,动态加载组件。
本文分为以下四部分:
- 前端微服务化思想介绍
- 微前端的设计理念
- 实战微前端架构设计
- 基于 Mooa 进行前端微服务化
前端微服化
对于前端微服化来说,有这么一些方案:
- Web Component 显然可以一个很优秀的基础架构。然而,我们并不可能去大量地复写已有的应用。
- iFrame。你是说真的吗?
- 另外一个微前端框架 Single-SPA,显然是一个更好的方式。然而,它并非 Production Ready。
- 通过路由来切分应用,而这个跳转会影响用户体验。
- 等等。
因此,当我们考虑前端微服务化的时候,我们希望:
- 独立部署
- 独立开发
- 技术无关
- 不影响用户体验
独立开发
在过去的几星期里,我花费了大量的时间在学习 Single-SPA 的代码。但是,我发现它在开发和部署上真的太麻烦了,完全达不到独立部署地标准。按 Single-SPA 的设计,我需要在入口文件中声名我的应用,然后才能去构建:
declareChildApplication('inferno', () => import('src/inferno/inferno.app.js'), pathPrefix('/inferno'));
同时,在我的应用里,我还需要去指定我的生命周期。这就意味着,当我开发了一个新的应用时,必须更新两份代码:主工程和应用。这时我们还极可能在同一个源码里工作。
当出现多个团队的时候,在同一份源码里工作,显然变得相当的不可靠——比如说,对方团队使用的是 Tab,而我们使用的是 2 个空格,隔壁的老王用的是 4 个空格。
独立部署
一个单体的前端应用最大的问题是,构建出来的 js、css 文件相当的巨大。而微前端则意味着,这个文件被独立地拆分成多个文件,它们便可以独立去部署应用。
我们真的需要技术无关吗?
等等,我们是否真的需要技术无关?如果我们不需要技术无关的话,微前端问题就很容易解决了。
事实上,对于大部分的公司和团队来说,技术无关只是一个无关痛痒的话术。当一家公司的几个创始人使用了 Java,那么极有可能在未来的选型上继续使用 Java。除非,一些额外的服务来使用 Python 来实现人工智能。因此,在大部分的情况下,仍然是技术栈唯一。
对于前端项目来说,更是如此:一个部门里基本上只会选用一个框架。
于是,我们选择了 Angular。
不影响用户体验
使用路由跳转来进行前端微服务化,是一种很简单、高效的切分方式。然而,路由跳转地过程中,会有一个白屏的过程。在这个过程中,跳转前的应用和将要跳转的应用,都失去了对页面的控制权。如果这个应用出了问题,那么用户就会一脸懵逼。
理想的情况下,它应该可以被控制。
微前端的设计理念
设计理念一:中心化路由
互联网本质是去中心化的吗?不,DNS 决定了它不是。TAB,决定了它不是。
微服务从本质上来说,它应该是去中心化的。但是,它又不能是完全的去中心化。对于一个微服务来说,它需要一个服务注册中心:
服务提供方要注册通告服务地址,服务的调用方要能发现目标服务。
对于一个前端应用来说,这个东西就是路由。
从页面上来说,只有我们在网页上添加一个菜单链接,用户才能知道某个页面是可以使用的。
而从代码上来说,那就是我们需要有一个地方来管理我们的应用:**发现存在哪些应用,哪个应用使用哪个路由。
管理好我们的路由,实际上就是管理好我们的应用。
设计理念二:标识化应用
在设计一个微前端框架的时候,为每个项目取一个名字的问题纠结了我很久——怎么去规范化这个东西。直到,我再一次想到了康威定律:
系统设计(产品结构等同组织形式,每个设计系统的组织,其产生的设计等同于组织之间的沟通结构。
换句人话说,就是同一个组织下,不可能有两个项目的名称是一样的。
所以,这个问题很简单就解决了。
设计理念三:生命周期
Single-SPA 设计了一个基本的生命周期(虽然它没有统一管理),它包含了五种状态:
- load,决定加载哪个应用,并绑定生命周期
- bootstrap,获取静态资源
- mount,安装应用,如创建 DOM 节点
- unload,删除应用的生命周期
- unmount,卸载应用,如删除 DOM 节点
于是,我在设计上基本上沿用了这个生命周期。显然,诸如 load 之类对于我的设计是多余的。
设计理念四:独立部署与配置自动化
从某种意义上来说,整个每系统是围绕着应用配置进行的。如果应用的配置能自动化,那么整个系统就自动化。
当我们只开发一个新的组件,那么我们只需要更新我们的组件,并更新配置即可。而这个配置本身也应该是能自动生成的。
实战微前端架构设计
基于以上的前提,系统的工作流程如下所示:
整体的工程流程如下所示:
- 主工程在运行的时候,会去服务器获取最新的应用配置。
- 主工程在获取到配置后,将一一创建应用,并为应用绑定生命周期。
- 当主工程监测到路由变化的时候,将寻找是否有对应的路由匹配到应用。
- 当匹配对对应应用时,则加载相应的应用。
故而,其对应的结构下图所示:
整体的流程如下图所示:
独立部署与配置自动化
我们做的部署策略如下:我们的应用使用的配置文件叫 apps.json
,由主工程去获取这个配置。每次部署的时候,我们只需要将 apps.json
指向最新的配置文件即可。配置的文件类如下所示:
- 96a7907e5488b6bb.json
- 6ff3bfaaa2cd39ea.json
- dcd074685c97ab9b.json
一个应用的配置如下所示:
{
"name": "help",
"selector": "help-root",
"baseScriptUrl": "/assets/help",
"styles": [
"styles.bundle.css"
],
"prefix": "help",
"scripts": [
"inline.bundle.js",
"polyfills.bundle.js",
"main.bundle.js"
]
}
这里的 selector
对应于应用所需要的 DOM 节点,prefix 则是用于 URL 路由上。这些都是自动从 index.html
文件和 package.json
中获取生成的。
应用间路由——事件
由于现在的应用变成了两部分:主工程和应用部分。就会出现一个问题:只有一个工程能捕获路由变化。当由主工程去改变应用的二级路由时,就无法有效地传达到子应用。在这时,只能通过事件的方式去通知子应用,子应用也需要监测是否是当前应用的路由。
if (event.detail.app.name === appName) {
let urlPrefix = 'app'
if (urlPrefix) {
urlPrefix = `/${window.mooa.option.urlPrefix}/`
}
router.navigate([event.detail.url.replace(urlPrefix + appName, '')])
}
相似的,当我们需要从应用 A 跳转到应用 B 时,我们也需要这样的一个机制:
window.addEventListener('mooa.routing.navigate', function(event: CustomEvent) {
const opts = event.detail
if (opts) {
navigateAppByName(opts)
}
})
剩下的诸如 Loading 动画也是类似的。