微前端MicroFrontEnds小计(持续)

  • start date: 2018-10-27 15:00:23

微前端,MicroFrontend,是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立运行、独立开发、独立部署。

1. *微服务

从定义中我们可知,微前端是类似于微服务的架构,那么什么是微服务呢?

微服务(Microservices)是一种软体架构格式,它是以专注于单一责任与功能的小型功能区块(Small Building Blocks)为基础,利用模组化的方式组合出复杂的大型应用程式,各功能区块使用与语言无关(Language-Independent/Language agnostic)的API集相互通讯。 ——wiki

微服务是一种以业务功能为主的服务设计概念,每一个服务都具有自主运行的业务功能,对外开放不受语言限制的API(最常用的是HTTP),应用程式则是由一个或多个微服务组成。微服务运用了以业务功能的设计概念,应用程式在设计时就能先以业务功能或流程设计先行分割,将各个业务功能都独立实作成一个能自主执行的个体服务,然后再利用相同的协定将所有应用程式需要的服务都组合起来,形成一个应用程式。若需要针对特定业务功能进行扩充时,只要对该业务功能的服务进行拓展就好,不需要整个应用程式都拓展,同时,由于微服务是以业务功能导向的实作,因此不会受到应用程式的干扰,微服务的管理员可以视运算资源的需要来配置微服务到不同的运算资源内,或是布建新的运算资源并将它配置进去。

1.1 微服务架构带来了哪些好处?

假设服务边界已经被正确地定义为可独立运行的业务领域,并确保在微服务设计中遵循诸多最佳实践。那么至少会在以下几个方面获得显而易见的好处:

  • 复杂性:服务可以更好地分离,每一个服务都足够小,能够完成完整的定义清晰的职责;
  • 扩展性:每一个服务可以独立横向扩展以满足业务伸缩性,并减少资源的不必要消耗;
  • 灵活性:每一个服务可以独立失败,允许每个团队自主选择最适合他们的技术和基础架构;
  • 敏捷性:每一个服务都可以独立开发,测试和部署,并允许团队独立扩展和维护各自的部署服务。

每个微服务是孤立、独立的「模块」,它们共同为更高的逻辑目的服务。微服务之间通过契约彼此沟通,每个服务都负责特定的功能。这使得每个服务都能够保持简单、简洁和可测试性。
在这一基础上微服务架构允许企业更自发地采取更深远的业务决策,因为每个微服务都是独立运作的,而且每一个管理团队可以很好地控制该服务的变更。

2.背景

在传统的软件开发当中,大多数软件都是单体式应用架构。在瞬息万变的商业时代背景下,企业必须学会适应这个时代的不确定性。快速试验、快速失败、更快地推出新产品以及有效改进当前产品,从而为客户提供有意义的数字体验。
而单体应用这种软件架构对于企业来说有一个致命缺点——会致使企业对于市场的响应速度变慢。企业决策者在一年内需要做的决策数量非常有限,由于存在依赖关系,其响应周期往往会变得非常漫长。每当开发或升级产品,都需要在一系列体量庞大的相关服务中同时增加新功能,这就需要所有利益相关方共同努力,以同步方式进行变更。

在前端,往往由一个前端团队创建并维护一个 Web 应用程序,使用 REST API 从后端服务获取数据。这样的做法能够提供优秀的用户体验,但会导致单页面应用(SPA)不能很好地扩展和部署。在一个大公司里,单前端团队可能成为一个发展瓶颈。随着时间的推移,由一个独立团队所开发的前端层往往会越来越难以维护。特别是一个特性丰富、功能强大的前端 Web 应用程序,却位于后端微服务架构之上时。随着业务的发展,前端会变得越来越臃肿,一个项目可能会有 90% 的前端代码,却只有非常薄的后端,这种情况在 Serverless 架构的背景下还会愈演愈烈。

解决遗留系统,才是人们采用微前端方案最重要的原因。在即不重写原有系统的基础之下,又可以抽出人力来开发新的业务。其不仅仅对于业务人员来说, 是一个相当吸引力的特性;对于技术人员来说,不重写旧的业务,同时还能做一些技术上的挑战,也是一件相当有挑战的事情。

在初期,后台微服务的一个很大的卖点在于,可以使用不同的技术栈来开发后台应用。但是,事实上,采用微服务架构的组织和机构,一般都是中大型规模的。相较于中小型,对于框架和语言的选型要求比较严格,如在内部限定了框架,限制了语言。因此,在充分使用不同的技术栈来发挥微服务的优势这一点上,几乎是很少出现的。在这些大型组织机构里,采用微服务的原因主要还是在于,使用微服务架构来解耦服务间依赖。

而在前端微服务化上,则是恰恰与之相反的,人们更想要的结果是聚合,尤其是那些 To B(to Bussiness)的应用。

在这两三年里,移动应用出现了一种趋势,用户不想装那么多应用了。而往往一家大的商业公司,会提供一系列的应用。这些应用也从某种程度上,反应了这家公司的组织架构。然而,在用户的眼里他们就是一家公司,他们就只应该有一个产品。相似的,这种趋势也在桌面 Web 出现。聚合成为了一个技术趋势,体现在前端的聚合就是微服务化架构。

2.1 微前端的定义 - 将微服务理念扩展到前端开发

微前端这个术语其实就是微服务的衍生物。将微服务理念扩展到前端开发,同时构建多个完全自治、松耦合的 App 模块(服务),其中每个 App 模块只负责特定的 UI 元素和功能。
p-1.png

微服务与微前端,都是希望将某个单一的单体应用,转化为多个可以独立运行、独立开发、独立部署、独立维护的服务或者应用的聚合,从而满足业务快速变化及分布式多团队并行开发的需求。

2.2 拆分微前端所带来的好处

拆分微前端能使各个前端团队按照自己的步调进行迭代,并随时准备就绪进入可发布状态,并隔离相互依赖所产生的风险,与此同时也更容易尝试新技术。

  • Web 应用程序被分解成独立的特征,并且每个特征都由不同的团队拥有,前端到后端。这确保了每个功能都是独立于其他功能开发、测试和部署的。
  • 将网站或 Web 应用程序视为由独立团队拥有的功能组合。每个团队都有一个独特的业务或关注点确定的任务。
  • 每一个团队是跨职能的,从数据库到用户界面端到端地开发其功能/特性。
  • 所有前端功能(身份验证,库存,购物车等)都是 Web 应用程序的一部分,并与后端(大部分时间通过 HTTP)进行通信,并将其分解为微服务。
  • 可以同时拥有后端、前端、数据访问层和数据库,即一个服务子域所需的所有内容。
  • 查找线上 bug、测试、框架迭代,甚至语言、代码隔离与责任和其他事情变得更容易处理。

我们不得不付出的代价是部署,但是容器(Docker 和 Rocket)以及不可变服务器使得这种情况也得到了极大的改善。

3.核心

3.1 微前端的核心思想

  • Be Technology Agnostic:每个团队都应该能够选择并升级他们的技术栈,而不必与其他团队协调。自定义元素(后面会具体提到)是隐藏实现细节的好方法,同时为其他人提供公共接口。
  • Isolate Team Code:即使所有团队使用相同的框架,也不要共享运行时。构建独立的应用程序。不要依赖共享状态或全局变量。
  • Establish Team Prefixes:相互约定命名隔离。为 CSS、浏览器事件、Local Storage 和 Cookies 制定命名空间,以避免冲突,明确其所有权。
  • Favor Native Browser Features over Custom APIs:使用浏览器事件进行通信,而不是构建全局的 PubSub 系统。如果确实需要构建跨团队 API,请尽量保持简单。(与框架无关,可使用 CustomEvent)
  • Build a Resilient Site:即使 JavaScript 失败或尚未执行,Web 应用程序的功能仍应有效。可以使用通用渲染和渐进增强来提高用户的感知性能。

p-2.png

4.微前端的可选实践方案(6 种+)

4.1 创建更小的 Apps(而不是 Components)

首先让我们来创建一个典型 Web 应用程序的基本组件(HeaderProductListShoppingCart),以 Header 组件为例:

1
2
3
4
5
6
7
8
9
10
11
// src/App.js
export default () =>
<header>
<h1>Logo</h1>
<nav>
<ul>
<li>About</li>
<li>Contact</li>
</ul>
</nav>
</header>;

然后需要注意的是我们会用到 Express 对刚刚创建的 React 组件进行服务器端渲染,使之成为一个 App 模块:

1
2
3
4
5
6
7
8
// server.js

fs.readFile(htmlPath, 'utf8', (err, html) => {
const rootElem = '<div id="root">';
const renderedApp = renderToString(React.createElement(App, null));

res.send(html.replace(rootElem, rootElem + renderedApp));
});

其他组件同理,然后得出如下几个模块页面:

如何组合微前端的 App 模块?
在每个独立团队创建好各自的 App 模块后,我们就可以将网站或 Web 应用程序视为由各种模块的功能组合。接下来将介绍多种技术实践方案来重新组合这些模块(有时作为页面,有时作为组件),而前端(不管是不是 SPA)将只需要负责路由器(Router)如何选择和决定要导入哪些模块,从而为最终用户提供一致性的用户体验。

4.1 方法一:使用后端模板引擎插入 HTML

1
2
3
4
5
6
7
8
// server.js

Promise.all([
getContents('https://microfrontends-header.herokuapp.com/'),
getContents('https://microfrontends-products-list.herokuapp.com/'),
getContents('https://microfrontends-cart.herokuapp.com/')
]).then(responses => res.render('index', { header: responses[0], productsList: responses[1], cart: responses[2] }))
.catch(error => res.send(error.message));

模板页:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// views/index.ejs

<head>

<meta charset="utf-8">

<title>Microfrontends Homepage</title>

</head>

<body>

<%- header %>

<%- productsList %>

<%- cart %>

</body>

但是,这种方案也存在弊端,即某些 App 模块可能会需要相对较长的加载时间,而在前端整个页面的渲染却要取决于最慢的那个模块。
比如说,可能 Header 模块的加载速度要比其他部分快得多,而 ProductList 则因为需要获取更多 API 数据而需要更多时间。通常情况下我们希望尽快将网页显示给用户,而在这种情况下后台加载时间就会变得更长。

4.1.1 改进:后端渐进式加载

我们可以通过修改一些后端代码来渐进式地(Progressive)往前端发送 HTML,但与此同时却徒增了后端复杂度,并且又将前端的渲染控制权交回了后端服务器。而且我们的优化也取决于每个模块加载的速度,若是进行优化就必须按一定顺序进行加载。

4.2 方式二:使用 IFrame 隔离运行时

1
2
3
4
5
6
7
8
9
<body>

<iframe width="100%" height="200" src="https://microfrontends-header.herokuapp.com/"></iframe>

<iframe width="100%" height="200" src="https://microfrontends-products-list.herokuapp.com/"></iframe>

<iframe width="100%" height="200" src="https://microfrontends-cart.herokuapp.com/"></iframe>

</body>

我们也可以将每个子应用程序嵌入到各自的 <iframe>中,这使得每个模块能够使用任何他们需要的框架,而无需与其他团队协调工具和依赖关系,依然可以借助于一些库或者 window.postMessageAPI 来进行交互。

优点

  • 最强大的是隔离了组件和应用程序部分的运行时环境,因此每个模块都可以独立开发,并且可以与其他部分的技术无关
  • 可以各自使用完全不同的前端框架,可以在 React 中开发一部分,在 Angular 中开发一部分,然后使用原生 JavaScript 开发其他部分或任何其他技术。
  • 只要每个 iframe 来自同一个来源,消息传递也就相当直接和强大。参考文档 window.postMessageAPI

缺点

  • Bundle 的大小非常明显,因为可能最终会多次发送相同的库,并且由于应用程序是分开的,所以在构建时也不能提取公共依赖关系。
  • 至于浏览器的支持,基本上不可能嵌套两层以上的 iframe(parent - > iframe - > iframe)。
  • 如果任何嵌套的框架需要能够滚动或具有 Form 表单域,那样的情况处理起来就会变得特别痛苦。

4.3 方式三:客户端 JavaScript 异步加载

如下脚本实现异步加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function loadPage (element) {

[].forEach.call(element.querySelectorAll('script'), function (nonExecutableScript) {

var script = document.createElement("script");

script.setAttribute("src", nonExecutableScript.src);

script.setAttribute("type", "text/javascript");

element.appendChild(script);

});

}

document.querySelectorAll('.load-app').forEach(loadPage);

1
2
3
4
5
<div class="load-app" data-url="header"></div>

<div class="load-app" data-url="products-list"></div>

<div class="load-app" data-url="cart"></div>

简单来说,这种方式就是在客户端浏览器通过 Ajax 加载应用程序,然后将不同模块的内容插入到对应的 div 中,而且还必须手动克隆每个 script 的标记才能使其工作。

需要注意的是,为了避免 Javascript 和 CSS 加载顺序的问题,建议将其修改成类似于 Facebook bigpipe 的解决方案,返回一个 JSON 对象 { html: …, css: […], js: […] } 再进行加载顺序的控制。

4.4 WebComponents 整合所有功能模块

Web Components 是一个 Web 标准,所以像 Angular、React/Preact、Vue 或 Hyperapp 这样的主流 JavaScript 框架都支持它们。你可以将 Web Components 视为使用开放 Web 技术创建的可重用的用户界面小部件,也许会是 Web 组件化的未来。

Web Components 由以下四种技术组成(尽管每种技术都可以独立使用):

  • 自定义元素(Custom Elements)对外提供组件的标签,实现自定义标签:可以创建自己的自定义 HTML 标签和元素。每个元素可以有自己的脚本和 CSS 样式。还包括生命周期回调,它们允许我们定义正在加载的组件特定行为。
  • HTML 模板(HTML 定义组件的 HTML 模板能力:一种用于保存客户端内容的机制,该内容在页面加载时不被渲染,但可以在运行时使用 JavaScript 进行实例化。可以将一个模板视为正在被存储以供随后在文档中使用的一个内容片段。
  • 影子 DOM(Shadow DOM)封装组件的内部结构,并且保持其独立性:允许我们在 Web 组件中封装 JavaScript,CSS 和 HTML。在组件内部时,这些东西与主文档的 DOM 分离。
  • HTML 导入(HTML Imports)解决组件组合和依赖加载:在微前端的上下文中,可以是包含我们要使用的组件在服务器上的远程位置。
1
2
3
4
5
6
7
8
9
10
11
12
13
// src/index.js

class Header extends HTMLElement {

attachedCallback() {

ReactDOM.render(<App />, this.createShadowRoot());

}

}

document.registerElement('microfrontends-header', Header);
1
2
3
4
5
6
7
8
9
10

<body>

<microfrontends-header></microfrontends-header>

<microfrontends-products-list></microfrontends-products-list>

<microfrontends-cart></microfrontends-cart>

</body>

在微前端的实践当中:

  • 每个团队使用各自的技术栈创建他们的组件,并把它包装到自定义元素(Custom Element)中(如 )。
  • Web 组件就是应用程序中包含的组件的本地实现,如菜单,表单,日期选择器等。每个组件都是独立开发的,主应用程序项目利用它们组装成最终的应用程序。
  • 特定元素(标签名称,属性和事件)的 DOM 规范还可以充当跨团队之间的契约或公共 API。
  • 创建可被导入到 Web 应用程序中的可重用组件,它们就像可以导入任何网页的用户界面小部件。
    1
    2
    3
    4
    5
    <link rel="import" href="/components/microfrontends/header.html">

    <link rel="import" href="/components/microfrontends/products-list.html">

    <link rel="import" href="/components/microfrontends/cart.html">

p-8.png

可以看到这边方式与我们上面使用 iframe 的方式很相似,组件拥有自己独立的 Scripts 和 Styles,以及对应的用于单独部署组件的域名。

优点

  • 代码的可读性变得非常清晰,组件资源内部高内聚,组件资源由自身加载控制,作用域独立。
  • 功能团队可以使用组件及其功能,而不必知道实现,他们只需要能够与 HTML DOM 进行交互。
  • 使用 PubSub 机制,组件可以发布消息,其他组件可以订阅特定的主题。幸运的是浏览器内置了这个功能。比如购物车可以在 window 订阅此事件并在应该刷新其数据时得到通知。

缺点

  • 可惜的是,Web 组件规范跟服务器渲染无关。没有 JavaScript,就没有所谓的自定义元素。
  • 浏览器和框架的支持不够,需要更多的 polyfills 从而影响到用户页面的加载体验。
  • 我们需要在整个 Web 应用程序上做出改变,把它们全部转换成 Web Components。
  • 社区不够活跃,Web Components 还没有真正流行起来,也许永远也不会。

不同 App 模块之间如何交互?

1
2
3
4
// angularComponent.ts

const event = new CustomEvent('addToCart', { detail: item });
window.dispatchEvent(event);
1
2
3
4
5
6
7
// reactComponent.js

componentDidMount() {
window.addEventListener('addToCart', (event) => {
this.setState({ products: [...this.state.products, event.detail] });
}, false);
}

得益于浏览器的原生 API——CustomEvent 可以与其他任何技术和框架一起工作。比如,我们可以将消息从 Angular 组件发送到 React 组件。其实这也是现在 API 之间普遍使用 JSON 进行通信的原因,即使没有人使用 NodeJS 作为服务器端。
但是,新的问题又出现了。我们该如何测试这种跨模块之间的交互?需要编写类似于后端微服务之间的 Contract Testing 或 Integration Testing 吗?并没有答案。

p-3.png

4.5 路由分发式微前端

路由分发式微前端,即通过路由将不同的业务分发到不同的、独立前端应用上。其通常可以通过 HTTP 服务器的反向代理来实现,又或者是应用框架自带的路由来解决。

就当前而言,通过路由分发式的微前端架构应该是采用最多、最易采用的 “微前端” 方案。但是这种方式看上去更像是多个前端应用的聚合,即我们只是将这些不同的前端应用拼凑到一起,使他们看起来像是一个完整的整体。但是它们并不是,每次用户从 A 应用到 B 应用的时候,往往需要刷新一下页面。

在几年前的一个项目里,我们当时正在进行遗留系统重写。我们制定了一个迁移计划:

  • 1.首先,使用静态网站生成动态生成首页
  • 2.其次,使用 React 计划栈重构详情页
  • 3.最后,替换搜索结果页

整个系统并不是一次性迁移过去,而是一步步往下进行。因此在完成不同的步骤时,我们就需要上线这个功能,于是就需要使用 Nginx 来进行路由分发。

如下是一个基于路由分发的 Nginx 配置示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
http {
server {
listen 80;
server_name www.phodal.com;
location /api/ {
proxy_pass http://http://172.31.25.15:8000/api;
}
location /web/admin {
proxy_pass http://172.31.25.29/web/admin;
}
location /web/notifications {
proxy_pass http://172.31.25.27/web/notifications;
}
location / {
proxy_pass /;
}
}
}

在这个示例里,不同的页面的请求被分发到不同的服务器上。

随后,我们在别的项目上也使用了类似的方式,其主要原因是:跨团队的协作。当团队达到一定规模的时候,我们不得不面对这个问题。除此,还有 Angluar 跳崖式升级的问题。于是,在这种情况下,用户前台使用 Angular 重写,后台继续使用 Angular.js 等保持再有的技术栈。在不同的场景下,都有一些相似的技术决策。

因此在这种情况下,它适用于以下场景:

  • 不同技术栈之间差异比较大,难以兼容、迁移、改造
  • 项目不想花费大量的时间在这个系统的改造上
  • 现有的系统在未来将会被取代
  • 系统功能已经很完善,基本不会有新需求

4.6 组合式集成:将应用微件化

组合式集成,即通过软件工程的方式在构建前、构建时、构建后等步骤中,对应用进行一步的拆分,并重新组合。

从这种定义上来看,它可能算不上并不是一种微前端——它可以满足了微前端的三个要素,即:独立运行、独立开发、独立部署。但是,配合上前端框架的组件 Lazyload 功能——即在需要的时候,才加载对应的业务组件或应用,它看上去就是一个微前端应用。

与此同时,由于所有的依赖、Pollyfill 已经尽可能地在首次加载了,CSS 样式也不需要重复加载。

常见的方式有:

  • 独立构建组件和应用,生成 chunk 文件,构建后再归类生成的 chunk 文件。(这种方式更类似于微服务,但是成本更高)
  • 开发时独立开发组件或应用,集成时合并组件和应用,最后生成单体的应用。
  • 在运行时,加载应用的 Runtime,随后加载对应的应用代码和模板。
  • 应用间的关系如下图所示(忽略图中的 “前端微服务化”):
    p-7.png

这种方式看上去相当的理想,即能满足多个团队并行开发,又能构建出适合的交付物。

但是,首先它有一个严重的限制:必须使用同一个框架。对于多数团队来说,这并不是问题。采用微服务的团队里,也不会因为微服务这一个前端,来使用不同的语言和技术来开发。当然了,如果要使用别的框架,也不是问题,我们只需要结合上一步中的自制框架兼容应用就可以满足我们的需求。

其次,采用这种方式还有一个限制,那就是:规范!规范!规范!。在采用这种方案时,我们需要:

  • 统一依赖。统一这些依赖的版本,引入新的依赖时都需要一一加入。
  • 规范应用的组件及路由。避免不同的应用之间,因为这些组件名称发生冲突。
  • 构建复杂。在有些方案里,我们需要修改构建系统,有些方案里则需要复杂的架构脚本。
  • 共享通用代码。这显然是一个要经常面对的问题。
  • 制定代码规范。
    因此,这种方式看起来更像是一个软件工程问题。

考虑到现有及常用的技术的局限性问题,让我们再次将目光放得长远一些。

p-10.png

5 微前端页面优化与实例

5.1 多模块页面加载问题与优化建议

5.1.1 使用 skeleton screen 响应式布局

p-4.png

如上图 LinkedIn 所做的那样,首先展现给用户一个页面的空白版本,然后在这个页面中逐渐加载和填充相应的信息。否则中间的信息流部分的内容最初是空白的,然后在 JavaScript 被加载和执行过后,信息流就会因为需要占用更多的空间而推动整个页面的布局。虽然我们可以控制页面来固定中间部分的高度,但在响应式网站上,确定一个确切的高度往往很难,而且不同的屏幕尺寸可能会有所不同。但更重要的问题是,这种高度尺寸的约定会让不同团队之间产生紧密的联系,从而违背了微前端的初衷。

5.1.2 使用浏览器异步加载加快初始渲染

对于加载成本高且难以缓存的碎片,将其从初始渲染中排除是一个好主意。比如说 LinkedIn 首页的信息流就是一个很好的例子。

5.1.3 共享 UI 组件库保证视觉体验一致

在前端设计中,必须向用户呈现外观和感觉一致的用户界面。建议可以建立一个共享组件库(包含 CSS、字体和 JavaScript)。将这些资源托管在 CDN,每个微前端就可以在其 HTML 输出中引用它们的位置。每个组件库的版本都正确地对资源进行版本控制,而每个微前端都指定要使用的组件库的版本和显式更新依赖关系。

5.1.4 使用集中式服务(Router)来管理 URL

可以理解为前端的 Gateway,不同的 URL 对应不同应用程序所包含的内容。建议通过一个集中式的 URLs Router 来为应用程序提供一个 API 来注册他们自己的 URL,Router 将会位于 Web 应用程序的前面,根据不同的用户请求指向不同的 App 模块组合。

p-5.png

5.1.5 提取共同依赖作为 externals 加载

虽然说不同 App 模块之间不能直接共享相同的第三方模块,当我们依然可以将常用的依赖比如 lodash、moment.js等公共库,或者跨多个团队共同使用的 react 和 react-dom。通过 Webpack 等构建工具就可以把打包的时候将这些共同模块排除掉,而只需要在 HTML

中的