微前端:让你的项目永葆青春

1,999 阅读14分钟

前言

随着前端的发展,页面承载的内容越来越多,页面组织方式经历了从 MPA(Multi-Page Application,多页应用)SPA(Single-Page Application,单页应用)的阶段,MPA的优势是各页面天然隔离,劣势是页面切换会重新加载页面,有明显的加载延迟。SPA虽然解决了加载延迟的问题,但是却导致了应用资源过大,各页面互相影响等等问题。有没有一种新的方式,可以同时结合MPASPA的优势呢?没错,就是微前端。

阅读完本文你将学习到:

  1. 什么是微前端
  2. 微前端的核心价值(解决了什么问题)
  3. 微前端有哪些方式
  4. 微前端架构
  5. 实现一套微前端方案需要做的工作
  6. 社区主流的微前端框架

什么是微前端?

微前端就是类似后台微服务架构的方式在浏览器进行了实现,简单来说就是同一个应用(页面)的不同模块可以使用不同的技术栈进行开发,并且独立维护

系统架构图.png

微前端的核心价值

微前端架构具备以下几个核心价值:

  • 各子应用独立仓库,独立开发,独立部署,部署完成后主框架自动完成同步更新
  • 技术栈无关,同一个应用的不同模块,可以选择任意框架,或者同一框架不同版本,完全由子应用自主选择
  • 独立运行时,每个子应用之间状态隔离,互不影响

因为前端迭代特别快,老旧项目很容易累积技术债(特别是管理后台项目,业务模块多,迭代时间长),如果不采用微前端的方式,最后只有重构这一条道路,既耗费人力,又容易引发新的线上问题。而如果使用了微前端架构,即使出新的前端框架,或者现有框架出了新版本,都可以直接在新模块使用,并且不影响旧模块。达到了业务侧聚合,开发侧解耦的效果。

一句话总结:让天下没有短命的前端项目

实现微前端的方式

目前主流的微前端有如下六种实现方式:

  1. 路由分发式。通过HTTP服务器的反向代理功能,将请求路由到对应的应用上。

路由.png 2. 前端微服务化。在不同的框架之上设计通信和加载机制,以在一个页面内加载对应的应用,这是目前社区主流微前端框架采用最多的方式。

微服务化.png 3. 微应用,通过软件工程的方式,在部署构建环境中,把多个独立的应用组合成一个单体应用,即开发时独立,构建时集成。

微服务化2.png 4. 微件化。开发一个新的构建系统,将部分业务功能构建成一个独立的 Chunk 代码,使用时只需要远程加载即可。
5. 前端容器化。将iframe作为容器来容纳其他前端应用,这是最快速最简单的方式,天然隔离各子应用执行环境,但是最大的缺点就是加载应用时有明显的加载延迟,用户体验很差。
6. 应用组件化。借助于Web Components技术,来构建跨框架的前端应用,该方案最大的问题是浏览器对 Web Component 支持程度不高。

webcomponent.png

微前端架构

目前主流的微前端框架都采用 Master-Slaves 的架构,即主从架构。
主应用提供子应用加载器,通过不同的路由加载不同的子应用。
在业务功能上,主应用可以提供一些基础的能力,避免各子应用重复开发,比如:

  1. 用户的登录、注册管理。
  2. 系统的统一鉴权管理。
  3. 导航菜单管理。
  4. 路由管理。
  5. 数据管理。
  6. 通信代理。 主从架构.png

实现一套微前端方案

由于目前比较主流的微前端方式是前端微服务化,所以这部分内容讲的是实现前端微服务化方案需要做的工作

JavaScript 隔离

隔离核心需要解决的问题一句话描述:不同子应用对全局资源的访问需要控制。
对应在浏览器的语境下,意味着两点:

  1. 需要隔离对全局上下文产生的副作用。
  2. 特别地,要隔离 DOM / BOM 对象。 在 JavaScript 中,讲到隔离,很容易就能联想到闭包,没错,在微前端中,子应用环境隔离最常用的方式就是使用闭包,在创建沙箱时把全局变量的副本通过参数传到闭包即可。
(function(exports, require, module, __filename, __dirname) {
  // 实际的 Module 代码
});

但是在实际创建子应用沙箱的时候,创建全局对象的成本过高,所以通常不直接创建主应用的副本传递给子应用,而是创建一个 iframe ,因为 iframe 天然就是一个独立的环境,完全和主应用隔离,并且能在主应用中引用。

// 子应用被包上 wrapper
__GLOBAL_HOOK(id, function(exports, require, module, {
  window
}) {
  // 实际的子应用代码
});

// wrapper 的实现
const frame = document.createElement('iframe'); // 创建一个新的子应用沙箱
const _window = frame.contentWindow; // 新建子应用的 window 环境

function __GLOBAL_HOOK(id, entry) {
  entry(exports, require, module, {
    window: _window
  });
}

拿到隔离后的 DOM / BOM 对象之后,我们可以使用 proxy 去插入一些额外的逻辑,比如把子应用某些方法直接委托给主应用,或者禁用子应用的某些方法。

class History {
  constructor(_history) {
    return new Proxy(_history, {
      get(target, name) {
        switch(name) {
          case 'pushState':
            // 在这里魔改掉            
            break;
        }
      }
    });
  }
}

路由

1.主应用不处理路由,完全由子应用来接管。

主应用就只作为容器,触发加载和渲染子应用。

主应用对子应用的加载定义,可以写死在一个配置 map 中,并且通过手动绑定外部事件(如点击 tab)触发。 而子应用自己的路由逻辑该干嘛还是干嘛,URL 的变化直接反馈到当前地址栏中。
主子的关系:单向传递参数(参数写死),后续子应用的状态变化按照原逻辑运行。

此方案存在以下2个问题:

  1. 不同的子应用如果存在相同的路由,就会冲突。
  2. 由于主应用没处理路由,带着路由进页面的时候不会产生效果(而是进入初始状态)。

2.子应用和主应用共享路由

主应用根据路由调度子应用,子应用内部的状态变化根据特定规则反映到主应用的路由。

第一个方案存在路由冲突的问题,解决方案很简单,只需使用命名空间去区分子应用即可,如下
方案1: 由于子应用1与子应用2首页路由一样,就会存在冲突

<Route path={`/home`} component={Home} /> // 子应用1首页路由
<Route path={`/home`} component={Home} /> // 子应用2首页路由

使用命名空间优化:

const APP1_NAME = 'app1';
const APP2_NAME = 'app2';
<Route path={`${APP1_NAME`}/home`} component={Home} /> // 子应用1首页路由
<Route path={`${APP2_NAME}/home`} component={Home} /> // 子应用2首页路由

这样就解决了路由冲突,但是这就相当于需要约束子应用的路由规则,对子应用有入侵。
此方案的主子关系:单向传递参数,参数从 URL 中来,主应用控制大粒度的路由,后续子应用的状态变化稍加改造运行。

3.子应用维护隔离的路由

主应用根据路由调度子应用,子应用内部的状态变化反映到各自的沙箱中,互不干扰,但不会反映到主应用的路由中。

在上一节已经讲过,在子应用中使用的是独立的沙箱环境,可以想到,在使用了沙箱后,子应用所有的引用以及操作,都是基于创建的 iframe 环境的,比如 location / history / document 等实际都是使用 iframe 的对象。
点击子应用中的链接,被框架拦截默认的链接跳转行为,改为 pushState,由于子应用使用的是沙箱的 history,所以最终实际上是改变了 iframe 的 URL。
那此时点击会不会触发主应用侧的 popstate 呢?答案是不会,因为子应用 DOM 中的点击事件最终会冒泡到沙箱的 document,和主应用并无关联。
这样就解决了主应用路由冲突的问题,同时又不侵入子应用路由制定,子应用路由可以随心所欲的制定了。
此方案好像已经完美了?其实并不是,还存在一个问题:由于子应用路由是在沙箱中进行的,主应用完全感受不到,所以就体现不出子应用的路由状态(浏览器地址栏不会随子应用路由变化而改变)。

4.子应用路由同步回主应用

主应用根据路由调度子应用,子应用内部的状态变化反映到各自的沙箱中,互不干扰,并且最终会反映到主应用的路由中。

主应用要感知到子应用路由变化并且反应到地址栏,需要3步:

  1. 主应用监听子应用消息
  2. 子应用路由变化之后,发送新路由相关信息给主应用
  3. 主应用拿到消息,做出相应变化,如更新到地址栏
    思考一下,子应用每次路由变化都要主动发消息给主应用,这样不是很繁琐吗?是的,不过还记得在上一节创建子应用环境时我们对子应用的某些对象创建了proxy 吗?这里的 proxy 就是可以来做这些 hook 逻辑的。
switch(name) {
  case 'pushState':
    return (...args) => {
      const returnValue = _pushState.call(_history, ...args);
      // 插入消息通信逻辑      
      frame.postMessage({
        type: 'statechange',
        data: {
          location: frame.location,
          state: _history.state
        }
      }, '*');
      return returnValue;
    };
}

主应用侧监听子应用消息,然后拼出路由后 replaceState,从而更新浏览器地址栏。

frame.contentWindow.addEventListener('message', (e) => {
  const payload = e.data;
  switch(payload.type) {
    case 'statechange':
      const { state, title, location } = payload.data;
      const url = location.href;
      window.history.replaceState(state, title, url);
      break;
  }
});

完美!

子应用加载

子应用加载有两种方式:

  1. JS Entry,子应用将资源打成一个 entry script,比如 single-spa 的 example 中的方式。但这个方案的限制也颇多,如要求子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。除了打出来的包可能体积庞大之外的问题之外,资源的并行加载等特性也无法利用上。

  2. HTML Entry,更加灵活,直接将子应用打出来 HTML 作为入口,主框架可以通过 fetch html 的方式获取子应用的静态资源,同时将 HTML document 作为子节点塞到主框架的容器中。这样不仅可以极大的减少主应用的接入成本,子应用的开发方式及打包方式基本上也不需要调整,而且可以天然的解决子应用之间样式隔离的问题。 HTML Entry子应用注册代码一般如下:

framework.registerApp('subApp1', { entry: '//www.a.com/index.html'})

这里 HTML 本质上还是充当了静态资源表的角色,所以在追求性能极致的情况下,可以改为静态的 JSON 配置,从而去掉请求 HTML 以及解析 HTML 里静态资源列表的时间。

方案优点缺点
HTML Entry解耦更彻底,子应用不依赖主应用 DOM,子应用独立开发,独立部署多了一次 HTML 请求,解析有性能损耗,无法做构建时优化
JS Entry便于作构建时优化依赖主应用提供挂载节点,打包产物体积膨胀,资源无法并行加载

样式隔离

在微前端架构中,由于存在同时运行多个子应用的场景,样式隔离就是必不可少的工作。

1.Shadow DOM

如果不考虑浏览器兼容性,Shadow DOM肯定是优先想到的方案,因为这是浏览器原生支持的 CSS 隔离

const shadow = document.querySelector('#hostElement').attachShadow({mode: 'open'});
shadow.innerHTML = '<sub-app>sub app1</sub-app><link rel="stylesheet" href="//www.a.com/index.css">';

此方案存在一个致命问题,子应用的样式作用域仅在shadow 元素下,那么一旦子应用中出现运行时越界跑到外面构建 DOM 的场景,必定会导致构建出来的 DOM 无法应用子应用的样式的情况
比如一些通用弹窗,一般都是挂在 document.body 下面的,不在 Shadow DOM 里面,导致无法应用到Shadow DOM里的样式。

2.CSS Module or CSS Namespace

通过约定 css 前缀的方式来避免样式冲突,即各个子应用使用特定的前缀来命名 class,或者直接基于 css module 方案写样式。这种方案对于新系统是可以的,但是微前端很多场景都是需要去接入已经存在的旧系统,显然这种方式需要对旧系统进行大量改造。
其实解决方案很简单,子应用的所有元素都插入到id="root"标签中,id是唯一的,所以通过添加属性选择器前缀#root可以让css样式在指定的<div id="root"></div>内生效。
思考一下,如何实现动态 css,保证运行某个子应用时只有该应用的 css生效,而加载此应用之前的其他子应用样式自动卸载,避免互相影响?

其实如果使用 HTML Entry的方式,是天然支持 css自动卸载的

<html>
  <body>
    <main id="subApp1">
      // 子应用完整的 html 结构
      <link rel="stylesheet" href="//www.a.com/subapp1.css">
      <div id="root">....</div>
    </main>
  </body>
</html>

当卸载子应用 app1 时,app1 的 container 节点会被卸载,从而删除了里面的 <link rel="stylesheet" href="www.a.com/subapp1.css">节点,也就自动卸载了该应用的样式,从而避免影响到后续加载的其他子应用。

父子应用通信

1.发布订阅

在全局挂一个事件总线,应用之间不直接交互,而是统一去事件总线上注册事件,监听事件,通过发布订阅模型来做应用间的通信。
这是最方便的方式,不用引入任何第三方库,直接使用 window 的 CustomEvent来监听一个自定义事件,然后在任意地方派发一个自定义事件,就可以天然的通过自定义事件来做到应用间互相通信。

2.基于props

这个方式就跟ReactVue父组件传递数据给子组件一样,把 state,onStateChange, setState都传递给子应用,这样就实现了子应用与主应用通信。
特别的,为了保证数据流清晰,子应用间一般不直接通信,而是都基于props来变相通信,保证自顶向下的单一数据流。

微前端框架

1.Webpack 5 和 Module Federation

Module Federation 是 Zack Jackson 发明的 JavaScript 架构,主要是用来解决多个应用之间代码共享的问题,可以让我们的更加优雅的实现跨应用的代码共享。多个单独的构建最后形成一个应用程序。这些单独的构建不应相互依赖,因此可以单独开发和部署。
该方案有一个致命缺点,需要所有的项目都基于 Webpack,并且已经升级到了 Webpack 5。

2.Single SPA

Single Spa 将自己定义为一种“前端微服务 Javascript 框架”。简言之,它将生命周期应用于每个应用程序。每个应用程序都可以响应 url 路由事件,并且知道如何从 DOM 引导,加载和卸载自身。传统 SPA 和 Single SPA 应用程序之间的主要区别在于它们能够与其他应用程序共存,并且它们各自没有自己的 HTML 页面。

3.QianKun

QianKun是一个微前端运行时框架,基于Single Spa,并且生产环境高可用,目前QianKun已在蚂蚁内部服务了超过 200+ 线上应用,在易用性及完备性上,绝对是值得信赖的。其主打的是技术栈无关,旧系统接入成本低。

总结

微前端的核心是解构巨石应用的同时,子系统独立开发迭代,子应用技术栈无关
「解构巨石应用,子系统独立开发迭代」能够极大程度避免迭代时间长,功能模块多的项目因为熵增定律,最终变成 「屎山」。
「子应用技术栈无关」意味着一方面可以兼容老旧的系统,另一方面,在以后有新的技术栈出现或者同一种技术栈发布了上下不兼容的大版本,都可以直接接入到现有架构,拓展性极强
但,微前端并不是银弹,在使用微前端之后,一定会导致架构变得复杂,从而增加维护成本,所以是否采用微前端需要结合具体场景去抉择,“只有适合的架构,没有最好的架构”

参考资料

  1. 《前端架构,从入门微前端》
  2. 微前端的核心价值
  3. Why not iFrame