初识微前端

在过去几年中,陆陆续续就会听到“微前端”的声音。本文将从技术选型的角度,了解什么是微前端,整理目前社区使用的几种方案,以及实现微前端的一些技术要点,最后还附带一些个人对于微前端的看法。

<!--more-->

1. 什么是微前端

参考

微前端(Micro Frontends)的概念是由ThoughtWorks在2016年提出的,它借鉴了微服务的架构理念。

微服务是一种后端架构设计,将应用程序拆分成一组小型、独立的服务,每个服务都专注于特定的业务功能,同时可以独立部署、运行。

比如一个常见的商城系统中,查询商品、推荐列表、订单结算、物流管理等功能,往往由不同的服务承担,每个服务可能由不同的技术团队支撑,对应的技术选型、数据存储也可能有不少差别。

微服务可以将不同的业务功能解耦,组合成最终的商城。对于每个微服务而言,他们可以各自维护和迭代,有助于管理复杂性,提供整个系统的灵活性和可维护性。

微前端在很大程度上借鉴了微服务的设计理念,其核心在于将一个庞大的前端应用拆分成多个独立灵活的小型应用,每个应用都可以独立开发、独立运行、独立部署,再将这些小型应用融合为一个完整的应用。

2. 应用场景

对于那些多人协作的复杂系统,功能明确且频繁维护更新的区块,都是微前端架构的落地场景。

2.1. 端到端?

微服务的服务只要启动在那里就行了,他们在逻辑上是隔离的(甚至在物理上也是隔离的,部署在不同的服务器上),每个微服务可以独立存在。

而微前端指的是一个html页面上,需要同时展示多个前端应用,天然就是聚合的,需要有一个主应用来管理这些子应用,负责将他们放在对应的地方。

看了What are Micro Frontends这篇文章,我感觉提到的微前端更像是一个端到端的技术:每个技术团队独立负责各自功能的后端和前端工作,最后组成应用;而不是现在传统的后端负责数据和逻辑、前端负责UI的分工。

那么,什么场景下会出现一个HTML页面上,会展示多个前端应用呢?微前端让我们可以根据业务本身的内在边界来分割子应用,实现解耦和独立演进。下面是一些也许可以使用微前端的应用场景

  • 一个电商平台的商品详情页可以分为多个子应用:1)展示商品信息和图片 2)商品推荐区 3)用户评价区 4)店铺信息展示 5)购买区等。
  • 一个企业管理系统,可以有用户管理、权限管理、业务管理等多个子系统,它们组合在一起构成完整的管理平台。
  • 一个新闻网站,文章内容、评论插件、广告、用户登录区等都是子应用。

端到端分工,对开发团队和成员的要求会更高一些,类似于人人都是全栈,各自精通自己负责的部分。但我感觉短时间内感觉传统的前后端分工并不会改变,毕竟一个GraphQL推广起来都不是很轻松。

因此,我的疑问在于:如果还是传统的前后端分工,微前端拆分子应用好像收益不是特别大?

首先,前端应用主要负责的是UI样式展示,如果使用了微前端,每个子应用还需要有一套约定统一的UI。

其次,除了特定的业务之外,前端一般不会承担过多的业务逻辑。

那么,如果UI要统一,而应用又不负责逻辑,那么看起来拆成子应用看起来并没有后端微服务那样有很明显的收益?

对于上面这些功能,在单个页面上由单个前端团队使用统一的UI,统一的技术栈来实现这些功能,好像也没有特别大的成本。

2.2. 合并历史项目

因此目前看起来,业内微前端技术方案主要应用在:将原本运行已久、没有关联的几个应用融合为一个应用,确保它们能够在用户界面上无缝集成,提供统一的用户体验。

没错,这里我说的就是to B的管理系统,而微前端原本设计的单个页面上由不同团队负责不同业务模块的场景,反而比较少。本文也主要关注微前端的这种使用场景。

相较于单页面拆分不同的子应用,维护老项目貌似是一件更痛苦的事情。

我曾经加入过一个刚开始的项目,随着两年的快速迭代,代码规模越来越庞大,但技术栈却停留在初始化项目的那个时刻:Vue-cli3搭建的Vue2项目。

对于一个有历史包袱的项目,基础库的升级就比较麻烦,以至于新的模块也必须使用老的技术。

但前端技术在过去十年里面迭代是非常快的,这导致一些没有历史包袱的新项目,往往会采用更新的版本,甚至更换基础框架。

对于一些更大规模的公司,甚至还会有不同的前端团队负责维护不同的业务,每个前端团队使用的技术栈可能不尽相同。

当某个时候如果要在一个系统里面集成多个历史项目,这些项目使用的技术栈不相同时,往往就需要进行项目的迁移和重构。回想起更早的时候,将ruby on railsjquery项目迁移到Vue2项目中,需要前端自己去rails的模版里面梳理逻辑,记忆犹新,非常痛苦

对于这些老项目,他们的功能往往是按页面url拆分的,即使集成到一个项目中,也往往只是有个统一的菜单栏。

因此,最简单的集成就是通过iframe来实现,只需要配置一下url,就可以调到对应的项目中了,子应用也没有接入成本。

但是iframe本身也存在一些问题,参考qiankun的这个讨论Why Not Iframe

  • url不同步,iframe内的导航可能与主页面的导航不同步,主页面刷新之后iframe的url丢失
  • UI问题,iframe内的容器不太容易实现一个全屏居中的弹窗
  • 状态隔离,iframe天然样式、javaScript隔离,需要通过postMessage等方式进行窗口通信
  • iframe加载速度和渲染速度较慢,此外iframe本身的性能问题

3. 一些方案

社区有一些关于微前端的开源方案,这里大致整理一下。

3.1. single-spa

官网:single-spa

Demo:在线示例 vue-microfrontends,在官网的examples还列列举了一些其他的示例,这里不再展开

从效果上来看

看起来就是根据当前url,渲染出对应的子应用,同时清空那些未被激活的子应用。

首先需要注册应用,activeWhen就表示url映射的子应用

import { registerApplication, start } from "single-spa";

registerApplication({
  name: "@vue-mf/dogs-dashboard",
  app: () => System.import("@vue-mf/dogs-dashboard"),
  activeWhen: "/view-doggos",
});

registerApplication({
  name: "@vue-mf/rate-dogs",
  app: () => System.import("@vue-mf/rate-dogs"),
  activeWhen: "/rate-doggos",
});

start();

然后,每个子应用需要按一定的结构导出,比如@vue-mf/rate-dogs这个子应用

import "./set-public-path";
import Vue from "vue";
import singleSpaVue from "single-spa-vue";

import App from "./App.vue";
import router from "./router";

Vue.config.productionTip = false;

const vueLifecycles = singleSpaVue({
  Vue,
  appOptions: {
    render: h => h(App),
    router
  }
});

export const bootstrap = vueLifecycles.bootstrap;
export const mount = vueLifecycles.mount;
export const unmount = vueLifecycles.unmount;

在子应用模块方面,每个项目构建出来的模块,通过SystemJs引入。每个import模块的实际路径

在样式方面,为了保证多个子应用的UI,还封装了styleguide这个仓库,提供了一些公共的Vue组件,以及全局样式。

3.2. qiankun

官网:qiankun

Demo: exmaples

qiankun是对single-spa的改进,优化了子应用的加载、同时实现了沙箱,隔离js和css,此外还实现了资源预加载和缓存的功能。

但是同样地,主应用需要注册子应用,子应用需要做一些改造(路由、代码和构建层面都需要一些改造),这种接入成本实际上并不是很小。

3.3. MicroApp

官网:MicroApp

Demo:在线示例

相关文章:

micro-app的原理是,通过CustomElement结合自定义的ShadowDOM,将微前端封装成一个类WebComponent组件,从而实现微前端的组件化渲染。

3.4. wujie

官网:wujie,基于iframe打造的微前端框架

Demo: 在线体验

相关文章:wujie框架原理

原理:将子应用的js注入主应用同域的iframe中运行,iframe是一个原生的window沙箱,内部有完整的historylocation接口,子应用实例instance运行在iframe中,路由也彻底和主应用解耦,可以直接在业务组件里面启动应用。

在这个Demo中,wujie直接展示了几个早已存在的官网,包括AntD、webpack、React等,演示了不需要子应用做任何改动就可以接入到同一个页面上的特性。

这个体验跟iframe嵌入确实很像,不过解决了切换加载、url变化等iframe厂家的问题。

同样地,每个子应用也可以真:独立开发和部署。这样,即使主应用挂掉了,子应用还是可以使用原本的域名和url访问,迁移成本感觉是比较低的。

不过在查看webpack官网的时候除了一点样式上的问题:fixed的元素对应的包含块发生了变化,不再是主窗口。

猜测可能某些依赖fixed的工具库也会有类似的布局问题,暂时没有深入了解

目前看起来,wujie是最接近iframe使用的微前端方案,同时解决了iframe的一些体验问题,感觉值得深入研究一下。

3.5. garfish

官网:garfish

Demo: examples

相关文章:字节跳动是如何落地微前端的

4. 技术要点

接下来整理一下微前端中的一些技术要点。

4.1. JavaScript沙盒

每个子应用内的js代码都各自运行在隔离的环境中,这样可以避免造成全局冲突和污染。

关于JavaScript运行沙盒的实现,内容比较多,可以单开一篇文章展开,这里只介绍一下核心的原理。

所谓沙盒,实际上就是让代码在访问常规的全局变量如windowdocument等时,访问到的实际上是一个代理变量,修改这个代理变量的属性,不会影响真实的对象

4.1.1. iframe沙盒

如果单纯只是要执行一段不会影响全局变量的代码,iframe的contentWindow看起来是最适合做这个事情的,本身iframe就是全局隔离的。

function runCodeInSanbox(jsCode) {
  let iframe = document.createElement('iframe', { src: 'about:blank' });
  document.body.appendChild(iframe);
  const iframeWin = iframe.contentWindow
  const iframeDoc = iframe.contentDocument || iframeWin?.document

  iframeWin.eval(jsCode)

  iframe.parentNode.removeChild(iframe)
}

const userCode = `
window.a = "1"
console.log(window.a)
`
runCodeInSanbox(userCode)
console.log(window.a) // 全局window不受影响

缺点在于用户代码里面如果包含操作主页面DOM节点等功能,而iframe中又没有插入对应的内容,就会报错。

如果不涉及DOM操作,也可以使用web worker来使用创建独立的js运行环境。

如果是希望在主页面通过js代码创造一个沙盒,其使用应该是类似于下面这种代码

class Sandbox {
  active() { }
  inactive() { }
}

const sandbox = new Sandbox()

function runCodeInSandbox(userCode) {
  sandbox.active() // 开启沙盒
  eval(userCode)
  sandbox.inactive() // 禁用,恢复到原始的window
}

const userCode = `
window.a = "1"
console.log(window.a) // 1
`

runCodeInSandbox(userCode)
console.log(window.a) // undefined

通过active开启沙盒,开启之后对window全局对象进行的任何修改实际上都是在沙盒内进行的,对外部的全局对象不会有任何影响。

window.xxx = '1'
console.log(window.xxx) // '1'

4.1.2. 快照沙盒

快照沙盒的实现思路是:在active的时候,缓存当前window上面的属性作为快照;在inactive的时候,再从快照上重置window属性

class SnapshotSandbox {
  constructor() {
    this.state = {};
    this.snapshot = {}
  }
  active() {
    this.snapshot = {};
    for (const prop in window) {
      if (window.hasOwnProperty(prop)) {
        this.snapshot[prop] = window[prop];
      }
    }
    for (const prop in this.state) {
      if (this.state.hasOwnProperty(prop)) {
        window[prop] = this.state[prop];
      }
    }
  }
  inactive() {
    for (const prop in window) {
      if (window.hasOwnProperty(prop) && window[prop] !== this.snapshot[prop]) {
        this.state[prop] = window[prop];
        window[prop] = this.snapshot[prop];
      }
    }
  }
}

可以看出,在activeinactive的时候,都需要遍历window对象,这在某些场景下可能会有性能消耗。

4.1.3. 代理沙盒

ES6提供了Proxy创建对象的代理,实现基本操作的拦截和自定义。借助这个功能,可以在操作全局对象的时候,记录相关的操作,这样就可以实现类似于快照沙盒的功能,而不会有特别大的性能消耗

function setGlobalProp(prop, value) {
  window[prop] = value;
}
function removeGlobalProp(prop) {
  delete window[prop];
}

class ProxySandbox {
  constructor() {
    this.proxy = null;
    this.added = new Map();
    this.updated = new Map();
    this.changed = new Map();

    const { added, changed, updated } = this;
    const fakeWindow = Object.create(null);
    const proxy = new Proxy(fakeWindow, {
      set(target, prop, value) {
        if (!window.hasOwnProperty(prop)) {
          added.set(prop, value);
        } else if (!updated.has(prop)) {
          const originalValue = window[prop];
          updated.set(prop, originalValue);
        }
        changed.set(prop, value);
        setGlobalProp(prop, value);
        return true;
      },
      get(target, prop) {
        return window[prop];
      },
    });
    this.proxy = proxy;
  }

  active() {
    this.changed.forEach((v, p) => setGlobalProp(p, v));
  }
  inactive() {
    this.updated.forEach((v, p) => setGlobalProp(p, v));
    this.added.forEach((_, p) => removeGlobalProp(p));
  }
}

由于代理的是一个fakeWindow,而不是真正的window对象,而用户代码里面编写的是全局变了,因此这里还需要通过with语句

const sandbox = new ProxySandbox();

function runCodeInSandbox(userCode) {
  sandbox.active() // 开启沙盒
    ; ((window) => {
      eval(userCode)
    })(sandbox.proxy);
  sandbox.inactive() // 禁用,恢复到原始的window
}

const userCode = `
window.a = "1"
console.log(window.a) // 1
`
runCodeInSandbox(userCode)
console.log(window.a); // undefined

4.1.4. 小结

上面展示了一些通过沙盒技术避免用户代码不会影响全局变量的方案,要在生产环境中使用的话,还需要添加各种判断和功能支持才行。

如果需要同时支持多个沙盒同时运行,还需要再包装一层,这里就不继续展开了。

4.2. CSS隔离

最简单的比如子应用1定义了全局的类h1 {color: red;},子应用2定义了全局的类h1 {color: blue;}。将他们同时加载,就会出现样式覆盖和冲突的情况。

一种办法是约定命名空间、命名方式如BEM等,这对老项目来说也许不太行的通。

另外一种办法是使用Shadow DOM——影子DOM。

事实上,HTML提供的部分元素如video、audio等标签,就是内置的ShadowDOM,这些标签里面包含了一堆按钮和其他控件。

<body>
  <template id="my-element">
    <link rel="stylesheet" href="./1.css">
    <style>
      span {
        color: red;
        border: 2px dotted black;
      }
    </style>
    <span>I'm in the shadow DOM</span>
  </template>

  <div id="host"></div>

  <span>I'm not in the shadow DOM</span>

  <script>
    const host = document.querySelector("#host");
    const shadow = host.attachShadow({ mode: "open" });
    const template = document.getElementById("my-element");
    shadow.appendChild(template.content);
  </script>
</body>

上面的template还引入了一个外部css文件

/*1.css*/
span {
  font-size: 20px;
}

运行之后可以看见,所有的样式只会对shadowDOM中的span标签生效,而不会影响外部的span标签。

因此,只要讲子应用的所有样式代码都放在shadowDOM,就可以解决样式冲突的问题。

遗憾的是shadowDOM并不能解决javascript隔离的问题,在shadowDOM的script标签中,可以访问和修改外部的全局变量。因此还需要找到一种实现javaScript隔离的办法。

4.3. 路由托管

在微前端主要用于合并老应用,并且一个页面只会同时展示一个子应用的情况下,url是单页子应用的基础实现。

比如single-spa通过监听 url change 事件,在路由变化时匹配到渲染的子应用并进行渲染,在注册子应用的时候配置项activeWhen就是用来做这个事情的。

当子应用使用了不同模式的路由时,可能会存在一些问题。

首先是不同子应用,可能定义了相同的url,比如两个子应用都定义了/index这个路由。解决办法是每个子应用有独立的route base,比如/app-1/index/app-2/index

其次是不同子应用,可能使用了不同的路由模式history和hash,这个就有点麻烦了,比如对于url/app-1/index#/app-2/index,主应用如何知道应该渲染的是哪个子应用呢?

在这种情况下,就需要比较精细地配置activeWhen规则。此外,当子应用有采用history模式的时候,主应用尽量不要再选择hash模式作为路由跳转。

4.4. 通信

无论是哪种微前端方案,通信机制都是比较类似的:通过全局的EventBus,注入到主应用和各个子应用,实现他们之间的相互通信。

eventbus实现比较简单,由于篇幅有限,这里不再展开。

4.5. 资源构建和加载

如果采用的是非iframe的方案,还需要考虑子应用构建和资源加载的问题。

关于构建,有两种方案

一是将子应用所有资源都打包到js bundle中,包括css、图片等,这种方案并不是很推荐,原因是打包出来的bundle很大,无法使用多路复用,加载慢。

第二种是像独立应用一样,最后生成的是html入口文件,内部通过link和script标签依赖的资源。

采用第二种资源构建方案,微前端框架就还需要将原本一个子应用html页面上需要的所有js、css资源,加载到主应用的html上面。

目前的处理思路是:

  • 通过fetch拿到子应用的html文件
  • 通过正则提取html中的js、css、入口文件等
  • 拿到对应的文件后进行包装处理,比如css需要放在style中,js则通过eval在沙箱中运行。

4.6. 资源共享

每个子应用如果存在一些共享的的代码块,他们分别构建和打包,会造成资源的重复和浪费。

目前这个问题在大部分框架中都没有得到比较好的解决方案,毕竟他们都是尽量在不影响老项目的情况下将他们合并到一个应用里面。

也许未来通过module script 和import和可以解决这个问题,至于老项目,能跑起来大概就很不错了。

5. 回到微前端

5.1. 合并历史项目

参考

从目前的使用场景,微前端好像更适合维护遗留项目。上面例举的前端框架,也主要是实现这个功能。

一个软件的生命周期有多长?to C和to B的项目差别可能非常大。

在面对一个新的前端项目进行技术评估和选型的时候,我想大部分前端工程师都无法拍着胸脯保证说,这个技术架构在未来三五年内都非常稳定。

微前端的一个核心观点是:技术栈无关。每个应用在技术栈、依赖和实现上应该是完全隔离的。

但实际上,对于整个前端团队而言,统一技术栈应该是一个比较基本的要求,这样才能尽可能在人员流动的情况下保证项目的稳定性。

诚然,让一个写react的用户去维护jQuery的项目,这个感觉可能就像是现代人看原始人一样。同样地,让一个用react hooks的用户去维护一个充满componentDidMount的class组件项目,他可能也很难适应。

微前端看似提供了一种方案,让你可以在不重构迁移老项目的情况下,愉快地在使用各种新技术来搭建新应用,最后一个完整的系统。

但是,怎么保证新项目和老项目在同一个页面上,对用户来说有一样的体验呢?就拿UI来说,新应用是需要去兼容老应用的UI,还是老用户要迭代成新的UI。

对于前者,需要指定一套统一的样式风格,这看起来好像又回到了维护遗留项目的地步,毕竟前端负责的并不是只有js;而对于后者,好像又是要去重构老项目。

因此,使用微前端合并应用,应该是在因为某些原因在无法维护老项目的情况下,采取的一种兜底技术方案,而不是为了单纯随心所欲的采用不同的技术栈来搭建新项目。

如果是因为初期技术选型带来的项目扩展和兼容,导致项目迭代比较困难,也应该优先尝试重构,而不是直接拆分一个新应用,否则,对于后来的维护者而言就是一场灾难。

当然,回到现实中,项目往往不太会在开发中途给开发者留下足够的重构时间,以及还需要评估重构所带来的收益是否物有所值。这就对项目初期的技术选型、开发过程中的流程和规范都有一定的要求。

总之,在我看来,微前端应该是一个在无法重构和迭代老项目的情况下,又需要将老项目合并运行时的一种hack方案。

5.2. 端到端

当然,微前端的应用场景并不只是在于兼容老项目,他的端到端功能应该才符合像微服务那样的设计初衷。

就如文章开头提到的,按照目前前后端分工的模式,推广端到端的组件构成的应用应该还是有一些阻碍。

我思考了一下,下面的两种方式也许是不错的实现端到端的微服务方案

webpack 的module federation:多个独立的构建可以组成一个应用程序,这些独立的构建之间不应该存在依赖关系,因此可以单独开发和部署它们。

低代码的远程组件:一个页面由多个组件组合而成,在低代码平台管理他们的UI布局,以及统一注入的数据,每个组件的内部实现由各个团队自己负责。

这两个概念看起来又可以写一堆东西了,由于这篇文章感觉塞的内容太多了,这里就不再展开了。

6. 小结

本文首先从微前端的概念出发,发现目前微前端的落地场景是合并老应用,然后整理了社区的一些微前端框架,接着整理了实现微前端的一些技术要点,包括:隔离沙盒、路由托管、子应用通信、资源加载等。

最后,我认为在合并老应用方面,微前端更像是一个妥协的兜底方案。而微前端的设计初衷:端到端,则感觉在实际业务落地上还需要等待一段时间。