微前端 → 浅析与实践篇

1,083 阅读15分钟

前置知识

微前端是指存在于浏览器中的微服务、其主要诉求就是如何为公司遗产项目续命
其核心就是 当匹配到activeRules的时候,请求获取entry资源,然后渲染到container中

微服务就是由一组接口组成,接口地址一般是URL。当微服务收到一个接口请求时,会进行路由查找,找到相应的逻辑然后输出相应的内容;后端微服务一般会有一个网关,作为单一入口接受所有客户端的请求,根据接口的URL与服务的匹配关系,路由到对应的服务。

微前端是一种类似于微服务的结构体系,其将微服务的理念嫁接到浏览器端,即将单页面应用由单一的单体应用转变为把多个小型前端应用聚合为一体的应用;这也意味着前端应用的拆分、拆分后应用的自治单一职责技术栈隔离的相关特性,然后再通过基座模型或自由组合的模式进行聚合,从而达到微前端的目的。

  • 微前端需要解决的主要问题
    • 应用的加载和切换
      • 路由问题、应用入口、应用异步加载、生命周期管理
      • 单独实现路由系统缘由:
        • 在常规的主子应用中,当在某个子应用下刷新页面时,主框架的资源会被重新加载,同时异步加载子应用的静态资源,由于此时主应用的路由系统已被激活但子应用还没有加载完,从而导致路由注册表里发现没有匹配子应用的规则,此时就会导致路由报错或重定向到404页面;
          • 在加载子应用时,qiankun会先加载子应用的manifest.json文件,获取子应用的基本信息和依赖关系,然后再加载子应用的入口文件
        • 解决方案(路由系统后置):框架预先加载entry的资源,待entry的资源加载完毕(意味着子应用的路由系统已经成功注册进主框架),然后再执行后续的操作逻辑。具体实现可以通过劫持url change事件从而实现自己的路由系统,也可以基于社区成熟的方案进行实现
    • 应用的隔离和通信
      • JS隔离、css样式隔离、应用间的消息机制
    • 主子应用间的鉴权相关
      • 应用间的安全隔离措施
    • 主子应用间公共数据逻辑的管理
      • 公共依赖的库、业务逻辑、版本管理等
    • 渐进式升级迭代问题
      • 用微应用方案平滑重构老项目
      • 微应用打包优化问题、特定场景下的出包方案等
  • 微前端的场景化细分
    • 单实例(主流)
      • 同一时刻只有一个子应用被展示,子应用具备一个完整的生命周期
      • 通常是基于URL的变化来做子应用的切换
    • 多实例
      • 同一时刻可展示多个子应用
      • 通常使用Web Components方案来做子应用封装,子应用更像是一个业务组件而不是应用
  • 微前端的应用的自治性
    • 独立开发和技术选型自由(很鸡肋)
      • 可以由不同的团队按照自己的节奏和方式进行开发,无需过多考虑其他相关联微应用的协调逻辑
        • 📢:需要注意后期技术栈的混乱和对应架构的复杂度
    • 独立部署
      • 能够自主地进行部署,不依赖于其他微前端应用的部署状态,可以随时进行更新和发布,而不影响整个系统的其他部分
        • 📢:需要注意大量应用共同依赖同一基础设施的后期问题
    • 功能自包含
      • 具备自身完整的功能逻辑和业务处理能力,在其职责范围内独立运行,不依赖其他微前端应用来提供关键功能。
    • 故障隔离
      • 当自身出现问题或故障时,能最大程度地减少对其他微前端应用的影响,具有相对独立的故障处理和恢复能力
    • 样式隔离
      • 原理:每次切换子应用时,都会加载该子应用对应的 css 文件。同时会把原先的子应用样式文件移除掉,这样就达到了样式隔离的效果

微前端在某些方面是MPA和SPA的各自优势的合集,如MPA中的部署简单、应用间的硬性隔离、技术栈无关、独立开发部署等(存在的问题就是应用间切换会刷新页面,流程体验上会存在断点);SPA的天生的体验上的优势→应用间直接无刷新切换(缺点是各应用间是强耦合的)

微前端不是一个工具或框架,而是一套架构体系,用于实现大型Web应用。其由三部分组成:基础设施、配置中心(用于版本管理、发布策略及动态构建)和观察工具(运维职能:需具备可见性和可控性);
最简单的就是在主系统中构建一个足够轻量级的基座,然后让各子应用系统按照共同的协议去实现或重构即可;这个协议包括主应用如何加载子应用、子应用如何被主应用感知和调度、应用之间如何通信等

其主要的几个基本要素是:技术栈无关应用隔离独立开发;其核心就是

image.png 微前端基本原理.png image.png 微前端方案

主流方案

Single-SPA

最终实现效果 image.png 主应用通过路由匹配实现对子应用生命周期的管理 image.png Single-SPA实现方式

single-spa 是一个小于 5kb(gzip)的 npm 包,用于协调微前端的挂载和卸载。它知道何时挂载应用程序,并且可以框架无关的方式挂载应用程序。
single-spa的核心就是定义了一套协议。协议包含主应用的配置信息和子应用的生命周期,通过这套协议,主应用可以方便的知道在什么情况下激活哪个子应用

single-spa生命周期

image.png

  • 主应用如何导入子应用(需要用户自定义实现,推荐使用SystemJS+import maps) 主应用如何监听路由以及控制路由跳转的.png

  • single-spa的挂载、更新、卸载并未提供,而是需要用户自定实现

  • 自身存在的硬伤(一般需要基于Single-SPA进行二次封装)

    • 不支持JS沙箱隔离逻辑
      • 无界的方案:利用iframe的隔离性,将JS代码放到iframe中执行,通过Proxy
        • 无界是微前端框架的一种实现,该框架提供了一种简单的方式来构建微前端应用,将所有的应用程序模块组合在一起,形成一个完整的 Web 应用程序。它基于 Web Components 技术,支持跨框架、跨站点、跨语言的工程化协作,并提供了一些工具和组件,使得微前端应用风格更加易于管理、协调和开发。
    • 不支持css隔离逻辑
      • quankun是通过在「start」中进行配置实现的 image.png
      • 无界的方案:利用Shadow DOM的隔离性,将子应用的DOM写到ShadowRoot里实现样式的隔离
      • Shadow DOM 允许将隐藏的 DOM 树附加到常规的 DOM 树中——它以 shadow root 节点为起始根节点,在这个根节点的下方,可以是任意元素,和普通的 DOM 元素一样。 image.png

qiankun

主要利用了浏览器的路由和消息通信机制,通过Web Components实现应用之间的隔离和通信,其是以单页应用为核心,可以将多个子应用作为独立的SPA运行

在qiankun的架构中,主应用是一个单页面应用,负责管理路由和状态,而子应用也是单页面应用,负责具体的业务逻辑,每个子应用都可以独立运行,也可以通过qiankun框架集成到主应用中,实现了多个子应用在同一个页面中运行的效果;

  • 主应用需要定义路由规则状态、子应用的渲染容器、主应用的公共部分的渲染区域,与技术栈无关,只要是具备单页应用的技术栈即可
自身特点
  • 两种继承微应用的方式

    • 基于路由配置
      • 注册子应用:registerMicroApps(apps, lifeCycles?)
        • apps:微应用的一些注册信息
        • lifeCycles:选填
        • 可以通过registerMicroApps方法注册微前端应用,通过unregisterMicroApps方法来卸载微前端应用 image.png
    • 手动加载微应用

    基座中展示的子应用,关闭路由页面并未直接卸载子应用实例,仍然占据内存,并且下次打开也不是全新的。因此我们需要在应用卸载子应用

    image.png

  • 主要技术点

    • Single-spa:路由和生命周期管理
    • import-html-entry:
      • 是一个加载并处理html、js、css的第三方库
      • 加载子应用的html,从而对资源进行处理
    • 自行实现了隔离、通信等机制
  • 主要特点

    • 样式隔离
    • JS副作用隔离
    • 一个页面同时嵌入多个微前端子应用
    • 框架不限
    • 支持资源预加载
    • 支持主/子应用数据通信
  • 业界主要的沙箱(沙箱可能会影响JS的性能)

    • 需要解决的问题
      • 资源共享问题
        • 沙箱实现的是隔离的作用,但是也可能需要共享一些资源,在沙箱应用之间共享指定资源
      • 通信机制
        • 沙箱之间可能需要进行通信,如数据传递、事件触发等,因此需要建立完善的通信机制
      • 数据隔离
        • 需要保证自身的数据不会被其他应用访问和修改,除非需要指定共享的数据进行补丁抛出 image.png

    JS沙箱的核心在于修改js作用域和重写window,它的使用场景不限于微前端,也可以用于其它地方,比如在我们向外部提供组件或引入第三方组件时都可以使用沙箱来避免冲突。

    • 快照沙箱
      • 激活时将当前window属性进行快照处理(缓存+根据上一步的更改初始化window数据)
      • 失活时对应用中的内容和当前window属性对比(保存更改的值+还原window数据)
        • 如果属性发生变化保存到modifyPropsMap中,并用快照还原window属性
      • 再次激活时,再次进行快照,并用上次修改的结果还原window
      class SnapshotSandbox {
          constructor() {
              this.proxy = window; 
              this.modifyPropsMap = {}; // 修改了那些属性
              this.active();
          }
          active() {
              this.windowSnapshot = {}; // window对象的快照
              for (const prop in window) {
                  if (window.hasOwnProperty(prop)) {
                      // 将window上的属性进行拍照
                      this.windowSnapshot[prop] = window[prop];
                  }
              }
              Object.keys(this.modifyPropsMap).forEach(p => {
                  window[p] = this.modifyPropsMap[p];
              });
          }
          inactive() {
              for (const prop in window) { // diff 差异
                  if (window.hasOwnProperty(prop)) {
                      // 将上次拍照的结果和本次window属性做对比
                      if (window[prop] !== this.windowSnapshot[prop]) {
                          // 保存修改后的结果
                          this.modifyPropsMap[prop] = window[prop]; 
                          // 还原window
                          window[prop] = this.windowSnapshot[prop]; 
                      }
                  }
              }
          }
      }
      
      let sandbox = new SnapshotSandbox();
      ((window) => {
          window.a = 1;
          window.b = 2;
          window.c = 3
          console.log(a,b,c)
          // 1 2 3
          sandbox.inactive();
          console.log(a,b,c)
          // undefined undefined undefined
      })(sandbox.proxy);
      
    • Proxy沙箱(基于Proxy实现的多例模式下的沙箱)
      • Proxy 沙箱的原理是使用 JavaScript 的 Proxy 对象创建一个全局对象的代理,通过这个代理隔离子应用对全局对象的影响。当子应用试图修改全局对象时,代理会拦截这个操作,将修改应用到代理对象上,而不是全局对象上。这样,子应用就可以在沙箱中自由地修改全局对象,而不会影响到其他子应用。
      class ProxySandbox {
          constructor() {
              const rawWindow = window;
              const fakeWindow = {}
              const proxy = new Proxy(fakeWindow, {
                  set(target, p, value) {
                      target[p] = value;
                      return true
                  },
                  get(target, p) {
                      return target[p] || rawWindow[p];
                  }
              });
              this.proxy = proxy
          }
      }
      
      
      let sandbox1 = new ProxySandbox();
      let sandbox2 = new ProxySandbox();
      window.a = 1;
      ((window) => {
          window.a = 'hello';
          // hello
          console.log(window.a)
      })(sandbox1.proxy);
      ((window) => {
          window.a = 'world';
          // world
          console.log(window.a)
      })(sandbox2.proxy);
      
      //1
      console.log(window.a)
      
    • legacy沙箱
      • 是一个单例沙箱,和上面的类似都是基于Proxy实现的
      • 原理:基于 Proxy 实现的单例模式下的沙箱,直接操作原生 window 对象,并记录 window 对象的增删改查,在每次微应用切换时初始化 window 对象
      • 激活时:将window对象恢复到上次即将失活时的状态
      • 失活时:将window对象恢复为初识状态
      • 三个状态池:分别用于子应用卸载时还原主应用的状态和子应用加载时还原子应用的状态 三个状态池.png
    • snapshot快照模式
      • 浏览器不支持Proxy的场景下使用
      • 原理:基于diff方式实现的沙箱。把主应用的 window 对象做浅拷贝windowSnapshot,将windowSnapshot的变更存成一个 Hash Map。之后无论微应用对 window 做任何改动,当要在恢复环境时,把这个 Hash Map 又应用到 window 上
      • 当微应用mount时:
        • 将上一次的变更记录modifyPropsMap应用到微应用的全局window,无变更记录则跳过
        • 对主应用的window对象做浅拷贝,用于后面还原主应用的window
      • 当微应用umount时:
        • 将微应用的window与快照window做Diff,Diff的结果modifyPropsMap用于下次恢复微应用环境的依据。
        • 根据当前的快照对象windowSnapshot,还原window

主要优点与存在的挑战

  • 优点
    • 解耦:微前端架构可以将⼤型项⽬分解为多个可以独⽴开发、测试和部署的⼩型应⽤。这种解耦可以提⾼开发效率,减少团队间的协调成本
    • 技术栈无关
    • 并行开发
    • 独立部署
  • 挑战
    • 性能问题:不同的技术栈可能会导致加载和运行的性能问题
    • 一致性:在用户体验、设计和行为上的一致性可能会有较大出入
    • 状态共享:共享状态的方式可能需要特殊的工具或模式
    • 复杂性:主要指管理和协调多个独立的应用的复杂性
    • 安全性:可能引发跨域等安全问题

拓展

微前端改造过程中的问题合集

  • 各种跨域,静态资源加载失败(因为子应用是基于父应用的域名来访问的)
    • 反向代理、CORS、JSOP等
    • 路径问题、缓存问题、资源丢失等
  • 子应用内存溢出,父子应用莫名其妙加载失败。
  • 还有一些开发规范问题,某些页面是history模式,有些是hash模式,这就导致我们必须统一了。如果你的父应用是hash加载的,那你的子应用就不能有history模式的页面。
  • 本地存储的问题,首先,拆分应用后,是共享当前基座应用下的本地存储的(localStorage,sessionStorage),那么子应用最好都用应用name来单独存储自己应用的key,不然会导致相互覆盖,主应用可以存一些公共的key,如:token,userInfo等。
  • 基座应用样式影响子应用,子应用都是挂载到基座应用下面的,所以基座应用的全局样式会直接被子应用应用上。虽然官方提供了样式隔离的方案,但某些时候有缺陷。
  • 子应用路由匹配不上,导致子应用进不去页面。
微前端改造后的问题合集
  • 因为应用拆分,子应用相互隔离,与基座应用的通讯是基于变量,函数级别的通讯,导致一个问题,我们所有应用的公共组件无法复用,最简单粗暴的就是CV大法了,但是又回到了重构前的问题了;
    • Webpack 5 新增了一项功能--模块联邦(Module Federation),旨在解决前端微服务架构中的模块共享和应用集成问题。它使得不同的 Webpack 构建可以共享模块,从而实现了在不同的应用之间共享代码和资源的能力。
      • 基本原理
        • 主应用(Host)和远程应用(Remote):在模块联邦中,存在一个主应用和一个或多个远程应用。主应用是整个应用的入口,而远程应用是提供独立功能的应用

Web Components浅析

qiankun是一个基于微前端的解决方案,其主要是使用了浏览器的新特性 → Web Components来实现相关功能;
Web Components 是一组技术,包括 Custom Elements(创建自定义的标签)、Shadow DOM(隔离组件样式,防止样式污染和冲突) 和 HTML Templates(HTML模版,包括<template><slot>),它们允许开发者创建自定义的 HTML 元素,并将其封装为可重用的组件;该技术封装的组件可以突破平台限制,如可以供React、Vue、Angular等框架使用,当然这些框架也都有对应的跨平台的现有库进行实现,甚至可以通过纯JS封装一些通用组件供不同技术栈使用;

生命周期
  • connectedCallback:第一次挂载到DOM上时触发的钩子,只触发一次
    • 类似于React中的useEffect(() => {}, [])componentDidMount
  • disconnectedCallback : 当⾃定义元素与⽂档 DOM 断开连接时被调⽤
  • adoptedCallback : 当⾃定义元素被移动到新⽂档时被调⽤
  • attributeChangedCallback : 当⾃定义元素的被监听属性变化时被调⽤
状态双向绑定
<wl-input id="ipt"
  : value="data"
  @change="(e) => { data = e.detail }" >
</wl - input >

// js
(function () {
  const template = document.createElement('template')
  template.innerHTML = `
 <style>
 .wl-input {

 }
 </style>
 <input type="text" id="wlInput">
 `
  class WlInput extends HTMLElement {
    constructor() {
      super()
      const shadow = this.attachShadow({
        mode: 'closed'
      })
      const content = template.content.cloneNode(true)
      this._input = content.querySelector('#wlInput')
      this._input.value = this.getAttribute('value')
      shadow.appendChild(content)
      // 监听了这个表单的 input 事件,并且在每次触发 input 事件的时候触发⾃定义的 change 事件,并且把输⼊的参数回传
      this._input.addEventListener("input", ev => {
        const target = ev.target;
        const value = target.value;
        this.value = value;
        this.dispatchEvent(
          new CustomEvent("change", { detail: value })
        );
      });
    }
    get value() {
      return this.getAttribute("value");
    }
    set value(value) {
      this.setAttribute("value", value);
    }
  }
  window.customElements.define('wl-input', WlInput)
})()
使用方式
  • 定义组件 image.png
  • 使用组件 → 在HTML中 image.png
  • 封装成Vue组件 image.png
  • 封装成React组件 image.png
  • 组件二 image.png

推荐文献

微前端探秘:初始微前端、现有方案和未来趋势
微前端
云前端新物种-微前端体系 → 克军
微前端知识梳理
从 0 到 1 上手 Web Components 业务组件库开发
在线演示→需要科学上网