微前端的那些事儿

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

同时,它们也可以在共享组件的同时进行并行开发——这些组件可以通过 NPM 或者 Git Tag、Git Submodule 来管理。

注意:这里的前端应用指的是前后端分离的单页面应用,在这基础才谈论微前端才有意义。

(纸质版):

微前端的那些事儿_第1张图片

  • 京东:《前端架构:从入门到微前端》

为什么微前端开始在流行——Web 应用的聚合

采用新技术,更多不是因为先进,而是因为它能解决痛点。

过去,我一直有一个疑惑,人们是否真的需要微服务,是否真的需要微前端。毕竟,没有银弹。当人们考虑是否采用一种新的架构,除了考虑它带来好处之外,仍然也考量着存在的大量的风险和技术挑战。

前端遗留系统迁移

自微前端框架 Mooa 及对应的《微前端的那些事儿》发布的两个多月以来,我陆陆续续地接收到一些微前端架构的一些咨询。过程中,我发现了一件很有趣的事:解决遗留系统,才是人们采用微前端方案最重要的原因

这些咨询里,开发人员所遇到的情况,与我之前遇到的情形并相似,我的场景是:设计一个新的前端架构。他们开始考虑前端微服务化,是因为遗留系统的存在。

过去那些使用 Backbone.js、Angular.js、Vue.js 1 等等框架所编写的单页面应用,已经在线上稳定地运行着,也没有新的功能。对于这样的应用来说,我们也没有理由浪费时间和精力重写旧的应用。这里的那些使用旧的、不再使用的技术栈编写的应用,可以称为遗留系统。而,这些应用又需要结合到新应用中使用。我遇到的较多的情况是:旧的应用使用的是 Angular.js 编写,而新的应用开始采用 Angular 2+。这对于业务稳定的团队来说,是极为常见的技术栈。

在即不重写原有系统的基础之下,又可以抽出人力来开发新的业务。其不仅仅对于业务人员来说, 是一个相当吸引力的特性;对于技术人员来说,不重写旧的业务,同时还能做一些技术上的挑战,也是一件相当有挑战的事情。

后端解耦,前端聚合

而前端微服务的一个卖点也在这里,去兼容不同类型的前端框架。这让我又联想到微服务的好处,及许多项目落地微服务的原因:

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

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

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

兼容遗留系统

那么,在这个时候,我们就需要使用新的技术、新的架构,来容纳、兼容这些旧的应用。而前端微服务化,正好是契合人们想要的这个卖点罢了。

实施微前端的六种方式

微前端架构是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用

由此带来的变化是,这些前端应用可以独立运行独立开发独立部署。以及,它们应该可以在共享组件的同时进行并行开发——这些组件可以通过 NPM 或者 Git Tag、Git Submodule 来管理。

注意:这里的前端应用指的是前后端分离的单应用页面,在这基础才谈论微前端才有意义。

结合我最近半年在微前端方面的实践和研究来看,微前端架构一般可以由以下几种方式进行:

  1. 使用 HTTP 服务器的路由来重定向多个应用
  2. 在不同的框架之上设计通讯、加载机制,诸如 Mooa 和 Single-SPA
  3. 通过组合多个独立应用、组件来构建一个单体应用
  4. iFrame。使用 iFrame 及自定义消息传递机制
  5. 使用纯 Web Components 构建应用
  6. 结合 Web Components 构建

基础铺垫:应用分发路由 -> 路由分发应用

在一个单体前端、单体后端应用中,有一个典型的特征,即路由是由框架来分发的,框架将路由指定到对应的组件或者内部服务中。微服务在这个过程中做的事情是,将调用由函数调用变成了远程调用,诸如远程 HTTP 调用。而微前端呢,也是类似的,它是将应用内的组件调用变成了更细粒度的应用间组件调用,即原先我们只是将路由分发到应用的组件执行,现在则需要根据路由来找到对应的应用,再由应用分发到对应的组件上。

后端:函数调用 -> 远程调用

在大多数的 CRUD 类型的 Web 应用中,也都存在一些极为相似的模式,即:首页 -> 列表 -> 详情:

  • 首页,用于面向用户展示特定的数据或页面。这些数据通常是有限个数的,并且是多种模型的。
  • 列表,即数据模型的聚合,其典型特点是某一类数据的集合,可以看到尽可能多的数据概要(如 Google 只返回 100 页),典型见 Google、淘宝、京东的搜索结果页。
  • 详情,展示一个数据的尽可能多的内容。

如下是一个 Spring 框架,用于返回首页的示例:

@RequestMapping(value="/")
public ModelAndView homePage(){
   return new ModelAndView("/WEB-INF/jsp/index.jsp");
}

对于某个详情页面来说,它可能是这样的:

@RequestMapping(value="/detail/{detailId}")
public ModelAndView detail(HttpServletRequest request, ModelMap model){
   ....
   return new ModelAndView("/WEB-INF/jsp/detail.jsp", "detail", detail);
}

那么,在微服务的情况下,它则会变成这样子:

@RequestMapping("/name")
public String name(){
    String name = restTemplate.getForObject("http://account/name", String.class);
    return Name" + name;
}

而后端在这个过程中,多了一个服务发现的服务,来管理不同微服务的关系。

前端:组件调用 -> 应用调用

在形式上来说,单体前端框架的路由和单体后端应用,并没有太大的区别:依据不同的路由,来返回不同页面的模板。

const appRoutes: Routes = [
  { path: 'index', component: IndexComponent },
  { path: 'detail/:id', component: DetailComponent },
];

而当我们将之微服务化后,则可能变成应用 A 的路由:

const appRoutes: Routes = [
  { path: 'index', component: IndexComponent },
];

外加之应用 B 的路由:

const appRoutes: Routes = [
  { path: 'detail/:id', component: DetailComponent },
];

而问题的关键就在于:怎么将路由分发到这些不同的应用中去。与此同时,还要负责管理不同的前端应用。

路由分发式微前端

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

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

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

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

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

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

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 等保持再有的技术栈。在不同的场景下,都有一些相似的技术决策。

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

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

而在满足上面场景的情况下,如果为了更好的用户体验,还可以采用 iframe 的方式来解决。

使用 iFrame 创建容器

iFrame 作为一个非常古老的,人人都觉得普通的技术,却一直很管用。

HTML 内联框架元素

Mooa 提供了两种模式,一种是基于 Single-SPA 的实验做的,在同一页面加载、渲染两个 Angular 应用;一种是基于 iFrame 来提供独立的应用容器。

解决了以下的问题:

  • 首页加载速度更快,因为只需要加载首页所需要的功能,而不是所有的依赖。
  • 多个团队并行开发,每个团队里可以独立地在自己的项目里开发。
  • 独立地进行模块化更新,现在我们只需要去单独更新我们的应用,而不需要更新整个完整的应用。

但是,它仍然包含有以下的问题:

  • 重复加载依赖项,即我们在 A 应用中使用到的模块,在 B 应用中也会重新使用到。有一部分可以通过浏览器的缓存来自动解决。
  • 第一次打开对应的应用需要时间,当然预加载可以解决一部分问题。
  • 在非 iframe 模式下运行,会遇到难以预料的第三方依赖冲突。

于是在总结了一系列的讨论之后,我们形成了一系列的对比方案:

方案对比

在这个过程中,我们做了大量的方案设计与对比,便想写一篇文章对比一下之前的结果。先看一下图:

微前端的那些事儿_第10张图片

表格对比:

x 标准 Lazyload 构建时集成 构建后集成 应用独立
开发流程 多个团队在同一个代码库里开发 多个团队在不同的代码库里开发 多个团队在不同的代码库里开发 多个团队在不同的代码库里开发
构建与发布 构建时只需要拿这一份代码去构建、部署 将不同代码库的代码整合到一起,再构建应用 将直接编译成各个项目模块,运行时通过懒加载合并 将直接编译成不同的几个应用,运行时通过主工程加载
适用场景 单一团队,依赖库少、业务单一 多团队,依赖库少、业务单一 多团队,依赖库少、业务单一 多团队,依赖库多、业务复杂
表现方式 开发、构建、运行一体 开发分离,构建时集成,运行一体 开发分离,构建分离,运行一体 开发、构建、运行分离

详细的介绍如下:

标准 LazyLoad

开发流程:多个团队在同一个代码库里开发,构建时只需要拿这一份代码去部署。

行为:开发、构建、运行一体

适用场景:单一团队,依赖库少、业务单一

LazyLoad 变体 1:构建时集成

开发流程:多个团队在不同的代码库里开发,在构建时将不同代码库的代码整合到一起,再去构建这个应用。

适用场景:多团队,依赖库少、业务单一

变体-构建时集成:开发分离,构建时集成,运行一体

LazyLoad 变体 2:构建后集成

开发流程:多个团队在不同的代码库里开发,在构建时将编译成不同的几份代码,运行时会通过懒加载合并到一起。

适用场景:多团队,依赖库少、业务单一

变体-构建后集成:开发分离,构建分离,运行一体

前端微服务化

开发流程:多个团队在不同的代码库里开发,在构建时将编译成不同的几个应用,运行时通过主工程加载。

适用场景:多团队,依赖库多、业务复杂

前端微服务化:开发、构建、运行分离

总对比

总体的对比如下表所示:

x 标准 Lazyload 构建时集成 构建后集成 应用独立
依赖管理 统一管理 统一管理 统一管理 各应用独立管理
部署方式 统一部署 统一部署 可单独部署。更新依赖时,需要全量部署 可完全独立部署
首屏加载 依赖在同一个文件,加载速度慢 依赖在同一个文件,加载速度慢 依赖在同一个文件,加载速度慢 依赖各自管理,首页加载快
首次加载应用、模块 只加载模块,速度快 只加载模块,速度快 只加载模块,速度快 单独加载,加载略慢
前期构建成本 设计构建流程 设计构建流程 设计通讯机制与加载方式
维护成本 一个代码库不好管理 多个代码库不好统一 后期需要维护组件依赖 后期维护成本低
打包优化 可进行摇树优化、AoT 编译、删除无用代码 可进行摇树优化、AoT 编译、删除无用代码 应用依赖的组件无法确定,不能删除无用代码 可进行摇树优化、AoT 编译、删除无用代码

前端微服务化:使用微前端框架 Mooa 开发微前端应用

Mooa 是一个为 Angular 服务的微前端框架,它是一个基于 single-spa,针对 IE 10 及 IFRAME 优化的微前端解决方案。

Mooa 概念

Mooa 框架与 Single-SPA 不一样的是,Mooa 采用的是 Master-Slave 架构,即主-从式设计。

对于 Web 页面来说,它可以同时存在两个到多个的 Angular 应用:其中的一个 Angular 应用作为主工程存在,剩下的则是子应用模块。

  • 主工程,负责加载其它应用,及用户权限管理等核心控制功能。
  • 子应用,负责不同模块的具体业务代码。

在这种模式下,则由主工程来控制整个系统的行为,子应用则做出一些对应的响应。

微前端主工程创建

要创建微前端框架 Mooa 的主工程,并不需要多少修改,只需要使用 angular-cli 来生成相应的应用:

ng new hello-world

然后添加 mooa 依赖

yarn add mooa

接着创建一个简单的配置文件 apps.json,放在 assets 目录下:

[{
    "name": "help",
    "selector": "app-help",
    "baseScriptUrl": "/assets/help",
    "styles": [
      "styles.bundle.css"
    ],
    "prefix": "help",
    "scripts": [
      "inline.bundle.js",
      "polyfills.bundle.js",
      "main.bundle.js"
    ]
  }
]]

接着,在我们的 app.component.ts 中编写相应的创建应用逻辑:

mooa = new Mooa({
  mode: 'iframe',
  debug: false,
  parentElement: 'app-home',
  urlPrefix: 'app',
  switchMode: 'coexist',
  preload: true,
  includeZone: true
});

constructor(private renderer: Renderer2, http: HttpClient, private router: Router) {
  http.get('/assets/apps.json')
    .subscribe(
      data => {
        this.createApps(data);
      },
      err => console.log(err)
    );
}

private createApps(data: IAppOption[]) {
  data.map((config) => {
    this.mooa.registerApplication(config.name, config, mooaRouter.hashPrefix(config.prefix));
  });

  const that = this;
  this.router.events.subscribe((event) => {
    if (event instanceof NavigationEnd) {
      that.mooa.reRouter(event);
    }
  });

  return mooa.start();
}

再为应用创建一个对应的路由即可:

{
  path: 'app/:appName/:route',
  component: HomeComponent
}

接着,我们就可以创建 Mooa 子应用。

Mooa 子应用创建

Mooa 官方提供了一个子应用的模块,直接使用该模块即可:

git clone https://github.com/phodal/mooa-boilerplate

然后执行:

npm install

在安装完依赖后,会进行项目的初始化设置,如更改包名等操作。在这里,将我们的应用取名为 help。

然后,我们就可以完成子应用的构建。

接着,执行:yarn build 就可以构建出我们的应用。

dist 目录一下的文件拷贝到主工程的 src/assets/help 目录下,再启动主工程即可。

导航到特定的子应用

在 Mooa 中,有一个路由接口 mooaPlatform.navigateTo,具体使用情况如下:

mooaPlatform.navigateTo({
  appName: 'help',
  router: 'home'
});

它将触发一个 MOOA_EVENT.ROUTING_NAVIGATE 事件。而在我们调用 mooa.start() 方法时,则会开发监听对应的事件:

window.addEventListener(MOOA_EVENT.ROUTING_NAVIGATE, function(event: CustomEvent) {
  if (event.detail) {
    navigateAppByName(event.detail)
  }
})

它将负责将应用导向新的应用。

嗯,就是这么简单。DEMO 视频如下:

Demo 地址见:http://mooa.phodal.com/

GitHub 示例:https://github.com/phodal/mooa

前端微服务化:使用特制的 iframe 微服务化 Angular 应用

Angular 基于 Component 的思想,可以让其在一个页面上同时运行多个 Angular 应用;可以在一个 DOM 节点下,存在多个 Angular 应用,即类似于下面的形式:

<app-home _nghost-c3="" ng-version="5.2.8">
  <app-help _nghost-c0="" ng-version="5.2.2" style="display:block;"><div _ngcontent-c0="">div>app-help>
  <app-app1 _nghost-c0="" ng-version="5.2.3" style="display:none;"><nav _ngcontent-c0="" class="navbar">div>app-app1>
  <app-app2 _nghost-c0="" ng-version="5.2.2" style="display:none;"><nav _ngcontent-c0="" class="navbar">div>app-app2>
app-home>

可这一样一来,难免需要做以下的一些额外的工作:

  • 创建子应用项目模板,以统一 Angular 版本
  • 构建时,删除子应用的依赖
  • 修改第三方模块

而在这其中最麻烦的就是第三方模块冲突问题。思来想去,在三月中旬,我在 Mooa 中添加了一个 iframe 模式。

iframe 微服务架构设计

在这里,总的设计思想和之前的《如何解构单体前端应用——前端应用的微服务式拆分》中介绍是一致的:

微前端的那些事儿_第11张图片

主要过程如下:

  • 主工程在运行的时候,会去服务器获取最新的应用配置。
  • 主工程在获取到配置后,将一一创建应用,并为应用绑定生命周期。
  • 当主工程监测到路由变化的时候,将寻找是否有对应的路由匹配到应用。
  • 当匹配对对应应用时,则创建或显示相应应用的 iframe,并隐藏其它子应用的 iframe。

其加载形式与之前的 Component 模式并没有太大的区别:

微前端的那些事儿_第12张图片

而为了控制不同的 iframe 需要做到这么几件事:

  1. 为不同的子应用分配 ID
  2. 在子应用中进行 hook,以通知主应用:子应用已加载
  3. 在子应用中创建对应的事件监听,来响应主应用的 URL 变化事件
  4. 在主应用中监听子程序的路由跳转等需求

因为大部分的代码可以与之前的 Mooa 复用,于是我便在 Mooa 中实现了相应的功能。

微前端框架 Mooa 的特制 iframe 模式

iframe 可以创建一个全新的独立的宿主环境,这意味着我们的 Angular 应用之间可以相互独立运行,我们唯一要做的是:建立一个通讯机制

它可以不修改子应用代码的情况下,可以直接使用。与此同时,它在一般的 iframe 模式进行了优化。使用普通的 iframe 模式,意味着:我们需要加载大量的重复组件,即使经过 Tree-Shaking 优化,它也将带来大量的重复内容。如果子应用过多,那么它在初始化应用的时候,体验可能就没有那么友好。但是与此相比,在初始化应用的时候,加载所有的依赖在主程序上,也不是一种很友好的体验。

于是,我就在想能不能创建一个更友好地 IFrame 模式,在里面对应用及依赖进行处理。如下,就是最后生成的页面的 iframe 代码:

<app-home _nghost-c2="" ng-version="5.2.8">
  <iframe frameborder="" width="100%" height="100%" src="http://localhost:4200/assets/iframe.html" id="help_206547" style="display:block;">iframe>
  <iframe frameborder="" width="100%" height="100%" src="http://localhost:4200/assets/iframe.html" id="app_235458 style="display:none;">iframe>
app-home>

对,两个 iframe 的 src 是一样的,但是它表现出来的确实是两个不同的 iframe 应用。那个 iframe.html 里面其实是没有内容的:

doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>App1title>
  <base href="/">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
head>
<body>

body>
html>

(PS:详细的代码可以见 https://github.com/phodal/mooa)

只是为了创建 iframe 的需要而存在的,对于一个 Angular 应用来说,是不是一个 iframe 的区别并不大。但是,对于我们而言,区别就大了。我们可以使用自己的方式来控制这个 IFrame,以及我们所要加载的内容。如:

  • 共同 Style Guide 中的 CSS 样式。如,在使用 iframe 集成时,移除不需要的
  • 去除不需要重复加载的 JavaScript。如,打包时不需要的 zone.min.js、polyfill.js 等等

注意:对于一些共用 UI 组件而言,仍然需要重复加载。这也就是 iframe 模式下的问题。

微前端框架 Mooa iframe 通讯机制

为了在主工程与子工程通讯,我们需要做到这么一些事件策略:

发布主应用事件

由于,我们使用 Mooa 来控制 iframe 加载。这就意味着我们可以通过 document.getElementById 来获取到 iframe,随后通过 iframeEl.contentWindow 来发布事件,如下:

let iframeEl: any = document.getElementById(iframeId)
if (iframeEl && iframeEl.contentWindow) {
  iframeEl.contentWindow.mooa.option = window.mooa.option
  iframeEl.contentWindow.dispatchEvent(
    new CustomEvent(MOOA_EVENT.ROUTING_CHANGE, { detail: eventArgs })
  )
}

这样,子应用就不需要修改代码,就可以直接接收对应的事件响应。

监听子应用事件

由于,我们也希望能直接在主工程中处理子程序的事件,并且不修改原有的代码。因此,我们也使用同样的方式来在子应用中监听主应用的事件:

iframeEl.contentWindow.addEventListener(MOOA_EVENT.ROUTING_NAVIGATE, function(event: CustomEvent) {
  if (event.detail) {
    navigateAppByName(event.detail)
  }
})

示例

同样的我们仍以 Mooa 框架作为示例,我们只需要在创建 mooa 实例时,配置使用 iframe 模式即可:

this.mooa = new Mooa({
  mode: 'iframe',
  debug: false,
  parentElement: 'app-home',
  urlPrefix: 'app',
  switchMode: 'coexist',
  preload: true,
  includeZone: true
});

...

that.mooa.registerApplicationByLink('help', '/assets/help', mooaRouter.matchRoute('help'));
that.mooa.registerApplicationByLink('app1', '/assets/app1', mooaRouter.matchRoute('app1'));
this.mooa.start();

...
this.router.events.subscribe((event: any) => {
  if (event instanceof NavigationEnd) {
    that.mooa.reRouter(event);
  }
});

子程序则直接使用:https://github.com/phodal/mooa-boilerplate 就可以了。

资源

相关资料:

  • MDN 影子DOM(Shadow DOM)
  • Web Components
  • Shadow DOM v1:独立的网络组件

你可能感兴趣的:(乱七八糟,前端,前端框架)