微前端-大型巨石应用的破局者

244 阅读12分钟

本文将从微前端的概念出发,讲解其出现的背景、常见的处理方案以及存在并需要解决的问题,并对其中核心的思想进行细致的分析,对于文中出现的其他基础概念,欢迎留言或上网搜索相关资料~

微前端是什么?

首先说明微前端(Micro-Frontends)它并不是指某一种具体的技术,它更像是一种思想、一种架构的理念。类似于微服务架构,可以理解为是将微服务的理念应用于web端。它是一种由独立交付的多个前端应用组成整体的架构风格,从而将前端应用分解成一些更小、更简单的能够独立开发、测试、部署的应用,而在用户看来仍然是内聚的单个产品(用户无感知)。个人认为微前端核心的理念中更加强调组合、分而治之的思想。

微前端出现的背景

要回答这个问题,让我们先看看当今前端开发中存在哪些痛点

1、重构

相信做开发的同学都一定知道重构这两个字背后所隐藏的痛苦(hhh)。没错,在日常的开发中,由于历史项目中所用的技术栈年代久远,但是由于公司业务日常的需要,不能随意抛弃,这些"老而旧"的页面需要整合到新框架中来使用,重构需要耗费巨大的人力,对此我们没有理由浪费时间和精力重写旧的逻辑,但可以使用微前端进行拆分。可以将老项目整合到新的框架项目中去,可以基本不修改老项目的逻辑,同时兼容新项目,甚至可以在,需求不多的时候,渐进式地替换老项目中的逻辑。

2、发版

当今web应用的复杂度以及参与开发、维护的人员快速增长,随着时间的推移以及应用内部功能的丰富,单页应用(SPA)变得不再单一而是越来越庞大也越来越难以维护,往往是改一处而动全身,由此带来的发版成本也越来越高。而且由于包体积的巨大,每次打包时长也会很长,抛开发版的成本不说,由于线上问题造成的版本回退也会使得大量不相干的代码被回退,各个业务线耦合在一起也是我们所不能接受的。微前端理念的出现可以使得多个子应用独立构建、发布,有效地减小了这个问题带来的影响。

3、技术栈无关

为什么要强调与技术栈无关,因为当今web的发展十分快速,各种技术栈更是层出不穷。尽管公司内部都尽量要求技术栈统一,但是任然存在用不同的技术栈来开发相似的需求。但是从事前端开发的小伙伴他们最终的目的都是开发出页面,只是实现方式不同罢了。而微前端强调组合,将不同的技术栈组合,形成单个产品。事实上如果所有的 web 技术栈能做到统一,我们确实就不需要微前端了。

微前端适用于哪些场景

是不是所有的项目都适合用微前端去改造呢,个人认为B端的项目更加适用于微前端。C端项目迭代较为频繁,而B端项目例如一些后台管理系统,它的生命周期更为长久,对于 ToB 应用而言,保持3~5 年的活力还是比较常见的。这就会出现一个现象,B端的项目中会出现比较多的"遗产项目"。如何给遗产项目续命,才是我们对微前端最原始的诉求,确保我们的"遗产代码"能够平滑且合理地升级。

当前有哪些较为成熟的解决方案

基于上述对微前端整体概念和理论的阐述,目前业界已经有不少框架来帮助开发者轻松的集成微前端架构,例如下面这些

  • Single-Spa:最早的微前端框架,兼容多种前端技术栈。

  • Qiankun:基于Single-Spa,阿里系开源微前端框架。

  • micro-app:借鉴了WebComponent的思想,京东微前端框架。

  • wujie:基于 Web Components + iframe 的微前端框架,腾讯微前端框架。

感兴趣的朋友可以自行查阅哦~

微前端方案中有哪些问题值得我们关注

看图说话

v2-6814eb5c0c35911aabeabaf2016b0088_720w.webp

本文对如下3个方面做了细致分析

1、JS沙箱

沙箱隔离(Sandbox), 为什么需要沙箱。其实在过去的 Web 应用中是很少提及到沙箱这一概念的,因为组件的开发一般都会由研发通过研发规范来尽可能的去避免组件对当前应用环境造成副作用,诸如:组件渲染后添加了定时器、全局变量、滚动事件、全局样式并且在组件销毁后会及时的清除子应用对当前环境产生的副作用。和浏览器沙箱一样,js沙箱能将不同子应用间的状态相互隔离。为了保证应用能够稳定的运行且互不影响,需要提供安全的运行环境,能够有效地隔离、收集、清除应用在运行期间所产生的副作用。这里以qiankun中相关源码为例,分享2种不同的沙箱设计理念。

(1)快照沙箱

顾名思义就是给window拍照。在子应用运行前保存当前快照实例,子应用销毁时又重新恢复到之前的状态,以达到应用间副作用的隔离的作用。话不多说,看如下代码:

class SnapShotSandbox {
  constructor() {
    this.modifyPropsMap = {} // 存储全局哪些属性被修改了
  }
  active() {
    this.windowSnapShot = {}
    // 给window拍照
    Object.keys(window).forEach(prop => {
      this.windowSnapShot[prop] = window[prop]
    })
    Object.keys(this.modifyPropsMap).forEach(prop => {
      window[prop] = this.modifyPropsMap[prop]
    })
  }
  inactive() {
    this.modifyPropsMap = {}
    Object.keys(window).forEach(prop => {
      if (window[prop] !== this.windowSnapShot[prop]) {
        this.modifyPropsMap[prop] = window[prop]
        window[prop] = this.windowSnapShot[prop]
      }
    })
  }
}
const sandbox = new SnapShotSandbox()
sandbox.active()
window.a = 100
window.b = 200
sandbox.inactive()
console.log('window.a=', window.a)
console.log('window.b=', window.b)

qiankun中相关源码如下 截屏2023-05-21 17.28.30.png 通过快照沙箱的设计思想我们可以发现,整个全局状态的存储、销毁过程都是线性的,即存储全局变量 - 加载子应用 - 子应用执行内部副作用 - 子应用卸载 - 恢复全局状态。但实际情况却是,一个主应用中往往会存在多个子应用实例并行的情况,在同时运行多个快照沙箱实例时,在代码执行顺序非线性的场景下,并不能有效的收集和处理应用的副作用,也基于此快照沙箱无法使用在非线性、多实例的场景中,因此我们看下一种沙箱的实现。

(2) VM(virtual machine) 沙箱

自己实现的伪代码

class ProxySanbox {
  constructor() {
    this.running = false
    const fakeWindow = Object.create(null)
    this.proxy = new Proxy(fakeWindow, {
      get(target, key) {
        if (!this.running) {
          return window[key]
        }
        return key in target ? target[key] : window[key]
      },
      set(target, key, value) {
        target[key] = value
        return true
      }
      // 修改不再操作window属性
    })
  }
  active() {
    if (!this.running) this.running = true
  }
  inactive() {
    this.running = false
  }
}
const sanbox1 = new ProxySanbox()
const sanbox2 = new ProxySanbox()
sanbox1.active()
sanbox2.active()
sanbox1.proxy.a = 100
sanbox2.proxy.a = 100
console.log('sanbox1.proxy.a=', sanbox1.proxy.a)
console.log('sanbox2.proxy.a=', sanbox2.proxy.a)
sanbox1.inactive()
sanbox2.inactive()
sanbox1.proxy.a = 200
sanbox2.proxy.a = 200
console.log('sanbox1.proxy.a=', sanbox1.proxy.a)
console.log('sanbox2.proxy.a=', sanbox2.proxy.a)

qiankun中相关源码如下

截屏2023-05-21 17.40.44.png

当然qiankun源码中还做了一些降级方案,例如在浏览器不支持proxy的情况该如何处理,由于ie浏览器被逐步淘汰,相关降级方案也许会被调整。VM沙箱最大的好处就是适用于多子应用实例并行存在的情况,其本质就是创建出多个proxy实例,每个子应用被打包后的代码类似于一段IIFE自调用函数

const fakeWindow1 = new Proxy(window, {
    get() {
        ...
    }
    set() {
        ...
    }
})
const fakeWindow2 = new Proxy(window, {
    get() {
        ...
    }
    set() {
        ...
    }
})
// 子应用1
((window) => {
    console.log(window)
}(fakeWindow1))
// 子应用2
((window) => {
    console.log(window)
}(fakeWindow2))
2、CSS样式隔离

CSS样式隔离问题主要分为主应用和微应用,微应用和微应用之间,当主应用和微应用同屏渲染时,就可能会有一些样式会相互污染,如果要隔离CSS污染,也有很多常见的实现方式。我们通常的做法是约定 css 前缀的方式来避免样式冲突,即各个子应用使用特定的前缀来命名 class,或者直接基于 css module 方案写样式。对于一个全新的项目,这样当然是可行,但是通常微前端架构更多的目标是解决某些现有+遗产项目的接入问题。很显然遗产应用通常是很难有动力做大幅改造的。当然每种方式都有其特定的优缺点,下面列举几种常见的解决方案。

(1) Shadow DOM

即影子DOM, 属于web-component标准中的规范之一。它可以将一个隐藏的、独立的 DOM 附加到一个元素上,这项技术能很好的做到样式隔离。

shadowdom.svg 但是目前这套技术也存在不少问题

  • 浏览器兼容性不好,由于微前端多应用于B端PC,可以适当限制下用户浏览器
  • 某些UI库中的组件会被动态地挂载到document上,例如antd中的Modal、ToolTip等等,这就导致Shadow DOM中对外部样式的修改并不会生效。
(2)BEM规范
  • B:Block 一个独立的模块,一个本身就有意义的独立实体 比如:header、menu、container
  • E:Element 元素,块的一部分但是自身没有独立的含义 比如:header title、container input
  • M:Modifier 修饰符,块或者元素的一些状态或者属性标志 比如:small、checked。 这种方式说到底还是需要在开发人员之间形成一套命名规范,而通常情况由于代码量的增加,通过人为的规范来约束也是不可靠的,这里就不再过多阐述。
(3) CSS Module

CSS Module和BEM规范的原理基本相同,本质上也是用来生成一个单一的类名,不同之处在于这个类名是由唯一的hash值组成,以此来避免类名的重复。但是如果团队中老旧的项目没有采用CSS Module的话,改造的成本还是不小的。

(4) css in js

原理就是用js的方式来书写css代码,自然也就不存在类名重复等问题了,但是和css module的情况一样,老旧的项目用css in js的话不太友好,改造成本巨大。

(5) postcss

postcss的原理比较简单,使用postcss能为整体css添加一个外层的命名空间,在打包时添加特定的前缀,以此来避免类名的冲突。

3、公共依赖加载

当我们项目中的子项目多了之后,公共依赖如果没有处理得当,不仅会造成我们开发工时的浪费,还会带来各种重复性极高的工作,同时BUG的风险也会随着复制的代码过多而成倍地增长。所以如何处理公共依赖也是值得我们深思的,在这里我列举了几种常见的做法。

(1)发布NPM包

可以在企业内部搭建自己的NPM仓库,这样比较方便,各个包之间独立发布,但问题也很明显。后期随着某些产品线的业务趋于稳定,有的需求只有一些小小的改动,在各个项目中都需要将这些公共依赖的包进行升级,最后重新打包、上线,严重影响了项目的迭代速度。

(2)webpack的external属性

由webpack中external引入的外部资源,将不会被打包进最后的bundle中,一般引入的外部资源多为CDN服务器中存储的资源。但是这里的公共依赖需要处理成UMD格式,原有的项目代码也可能要做相应的兼容处理。

(3)webpack5的新特性-Module Federation

模块联邦是webpack5中的新特性,Module Federation 的基本逻辑是一端导出模块,另一端导入、使用模块,实现上两端都依赖于 Webpack 5 内置的 ModuleFederationPlugin 插件。

  1. 对于模块生成方,需要使用 ModuleFederationPlugin 插件的 expose 参数声明需要导出的模块列表;
  2. 对于模块使用方,需要使用 ModuleFederationPlugin 插件的 remotes 参数声明需要从哪些地方导入远程模块。
  3. 我们还可以通过插件的 shared 配置项实现在应用间共享基础依赖库,具体细节可以查阅官方文档。

dbb78e89b39941818ab0c323d4873c1f~tplv-k3u1fbpfcp-zoom-in-crop-mark_3024_0_0_0.webp

至于各个子应用间相互通信的问题,这个倒不会有太多的问题。因为子应用之间通信的场景相对较少,从软件设计的角度出发,应该尽量解耦,如果两个子应用之间需要频繁通信,不如考虑把两个项目做到一起。因为频繁的与其他项目通信,会对项目维护造成很大的困扰。

写在最后

从浏览器原生的方案这个层面而言,iframe 不从体验角度上来看可以说是最可靠的微前端方案了,主应用通过iframe 来加载子应用,iframe 自带的样式、环境隔离机制使得它具备天然的沙盒机制,但也正因为它的隔离性导致它并不适合作为加载子应用的加载器,iframe 的特性不仅会导致用户体验的下降,也会在研发在日常工作中造成较多困扰。