微前端的总结分享(演讲稿版)

开篇

时值当下,作为一名合格的前端开发人员,我相信你一定会有一个很明显的感觉:Web 业务日益复杂化和多元化,前端的职责越来越重要,战场越来越多样,应用也越来越复杂,前端开发已经由WebPage 模式为主转变为以 WebApp 模式为主了——我们已经迎来了一个“大前端”的时代。

随之也会带来许多工程化的问题。例如:在一个相对长的时间跨度下,随着时间的推移,越来越复杂的前端项目,会变的越来越庞大。如何保证项目的可维护性,开发质量,开发体验......

介绍微前端

微前端是在2016年底的 ThoughtWorks Technology Radar 被提出 ,将微服务这个被广泛应用于服务端的技术范式扩展到前端领域。

Micro Frontends 网站对微前端的定义

简单通俗的来讲就是:微前端可以使多个团队之间使用不同技术栈,然后在客户端(浏览器)运行时动态组成一个完整的 SPA 应用。(当然也有在构建时组合的方案,但并不符合微前端的核心思想,也不是主流微前端实现方式。)

总之,微前端是将微服务概念做了一个很好的延伸和实现。都是希望将某个单一的应用,转化为多个可以独立运行、独立开发、独立部署、独立维护的服务或者应用,从而满足业务快速变化以及多团队可并行开发的需求。

微前端的应用场景

  1. 大型单页应用。这类应用的特点是系统体量较大,而且随着业务上的功能升级,项目体积还会不断增大。

阿里云就是这个场景下微前端很好的实践成果,采用微前端架构方式可无限扩展,其复杂度不会很明显的增长。(微前端最先提出来的主要原因,就是如何将巨石应用解构)

  1. 系统重复模块的复用。在多个独立系统内部可能会开发一些重复度很高的功能,比如用户管理,权限管理这些重复的功能。

微前端可以减少这些重复的开发成本。(理想情况下,可以将产品原子化,然后根据业务场景的需求,实现不同应用之间页面级别的自由组合,且每个功能模块都能单独迭代)

  1. 遗留系统的兼容和扩展。例:一个已经存在了3,5年的项目,依赖版本落后,里面还存在着一些祖传代码,因为种种原因项目不能及时升级。

微前端提供了一种增量升级的能力,在不重写原有系统的基础之上,实施渐进式重构。对于新功能的开发可以使用新的技术,避免了继续使用过时的技术。极大的降低长期项目迭代维护的难度。

微前端的核心思想

  1. 技术无关:微应用之间可以选择不同的技术栈。

  2. 环境独立:为了达到高度解耦的目的,每个微应用不应当共享运行时环境,即使所有微应用都使用了相同的框架,它们之间也应该尽量避免依赖共享状态或全局变量。(也就是应用之间的 css 和 js 隔离)

  3. 原生优先:优先使用浏览器原生事件进行通信。(如果确实必须跨应用进行通信,尽量让通信内容和方式变得简单,这样能有效地减少微应用之间的公共依赖)

  4. 独立开发、部署:微应用可独立开发,部署完成后主框架自动完成同步更新

为什么不用 iframe

说到微前端的方案,iframe 可以说是最简单的微前端基石方案了,提供了浏览器原生的硬隔离方案,不论是 css 还是 js 隔离,然后正好也满足了独立运行,开发,部署,维护。——但为什么所有的现代化微前端方案都不用iframe呢?

因为iframe最大的特性同时也是它最大的问题所在,它的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。

  1. url 不同步。因为不是单页面应用,浏览器刷新会导致 iframe url 状态丢失、后退前进按钮无法使用。

  2. UI 不同步,DOM 结构不共享。iframe 内的弹窗无法应用到整个大应用中,只能在对应的窗口内展示。(iframe还不会自动调节宽高)

  3. 全局上下文完全隔离,内存变量不共享。就需要设计 iframe 应用之间的通信、数据同步等需求。(iframe 可以通过 postMessage通信)

  4. 。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程,占用大量资源的同时也在极大地消耗资源。

第1个问题可以解决,第4个问题不是不能忽略。第2和3很难解决。
(腾讯公开了一个基于 iframe 的微前端方案,但是要解决的问题很多,目前还没开源)

现代微前端方案的选择

微前端是一个技术应用架构体系,微前端架构解决方案大概分为两类场景(技术实现角度):

  • 单实例:即同一时刻,只有一个子应用被展示,子应用具备一个完整的应用生命周期。通常基于 url 的变化来做子应用的切换。——基座模式的 qiankun(qiankun2.0 时支持了多应用并行)

  • 多实例:同一时刻可展示多个子应用,子应用更像是一个业务组件而不是应用,可以说是微应用粒度的前端组件化。——去中心模式的 emp

emp

介绍:emp是YY业务中台Web团队的微前端解决方案。emp 基于 Webpack 5 的 Module Federation(模块联邦)实现,提供了在当前应用中远程加载其他服务器上应用的能力(每个应用之间都可以彼此分享资源),将多个独立构建应用组成一个应用程序。

优点:第三方依赖可共享,减少重复的代码加载。

缺点:无法涵盖所有的框架,而且极度依赖于Webpack5。

因为emp主要解决的是业务的拆分,不是跨框架调用。算是对跨技术栈没有高要求的微前端方案吧。

qiankun

介绍:qiankun 是基于 single-spa 封装实现的(single-spa 做了完善的应用加载逻辑,qiankun 的路由系统就是基于此实现的),与框架无关的微前端内核。qiankun 算得上真正意义上的微前端,是蚂蚁沉淀了自己的微前端方案并开源的成果。

优点:真正做到了与技术栈无关。

缺点:Css隔离方案并不完美。

我们最后会选择 qiankun 作为我们的微前端解决方案。

我们的项目都基于umi,而 umi 提供了配套的 qiankun 插件 @umijs/plugin-qiankun,方便 umi 应用通过修改配置的方式切换成微前端架构系统,几乎零成本的接入(没有使用umi的应用,直接使用qiankun代码其实也不需要改造太多,qiankun 工程侵入性很小),并且不再需要去关注各种过程中的技术细节。

至此,已经介绍完了微前端的一些基本内容。

但是想要进阶更高级的工程师,必然不能只停留于调用API,更要了解其底层设计思想和实现原理。
下面就简单深入下 qiankun。

qiankun在技术细节上的决策和基本实现原理

微前端方案涉及到的技术点
路由系统

基于Single-SPA实现。把所有应用都注册在基座上,通过基座应用来监听路由,按照路由规则来加载不同的应用,来实现应用间的解耦。

应用加载

子应用提供什么形式的资源作为渲染入口?

HTML Entry VS JS Entry

Js Entry的问题在于:

  1. 需将子应用的所有资源(包括 css、图片等资源)打成一个Entry Script,Code Splitting 也无法应用,资源加载速度变慢。

  2. 而且每一次子应用发布,主应用都需要重新配置打包,因为js/css地址的hash会变。

  3. 主应用为子应用预留的容器 id 还需与子应用容器保持一致。

相比之下HTML Entry,子应用地址只需配一次,子应用的信息可以得到完整的保留。
缺点:将子应用资源解析的消耗留到了运行时。

qiankun 采用 HTML Entry 方案替代 single-spa 的 JS Entry 加载子应用的方案进行优化。(以达到像接入一个 iframe 一样简单的目的)
通过 html 作为应用入口,然后通过解析html从中提取 js 和 css 依赖下载,同时将 HTML Document 作为子节点塞到主应用的容器中。(本质上 HTML 充当的是应用静态资源表的角色)。

@umijs/plugin-qiankun 插件的应用加载配置
应用隔离(微前端方案中最关键的问题)
Css隔离

CSS Module 简单高效,也更加智能化。问题在于:虽然可以保证自己的子应用做到隔离,但是无法保证依赖的第三方库的全局样式可以做到应用之间的隔离。

下面介绍下qiankun的方案选择:

  • Dynamic Stylesheet(动态样式表):
    动态的加载和卸载样式表。在应用切出/卸载后,同时卸载掉其样式表。

原理:浏览器会对所有的样式表的插入、移除做整个 CSSOM 的重构,从而保证了在一个时间点里,只有一个应用的样式表是生效的。(上面提到的 HTML Entry 方案则天生具备样式隔离的特性,因为应用卸载后会直接移除去 HTML 结构,从而自动移除了其样式表)

问题:可以确保子应用之间的样式冲突,但子应用和主应用之间的冲突是无法避免,只有通过手动的方式确保,比如给主应用所有样式添加一个前缀。(但在蚂蚁在实践中,大多数主应用可能只提供一个头部,侧边栏的组件)

  • Shadow DOM(qiankun 2.0 版本支持,在开启 strictStylesolution时,将采用 shadow DOM的方式进行样式隔离):
    将微应用插入到 qiankun 创建好的 shadow Tree 中,微应用的样式(包括动态插入的样式)都会被挂载到这个 shadow Host 节点下,最终整个应用的所有 DOM 都会被绘制成一颗shadow tree。

原理:Shadow DOM内部所有节点的样式对树外面的节点是无效的,因此微应用的样式只会作用在 Shadow Tree 内部,自然就实现了样式隔离。

问题:一旦子应用中出现运行时越界跑到外面构建 DOM 的场景,必定会导致构建出来的 DOM 无法应用子应用样式的情况。(例:像 antd modal 组件是动态挂载到 document.body)

所以,qiankun 的 css 隔离方案不是特别完美。(还有一个实验性的样式隔离特性 experimentalStyleIsolation ,会改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围。但目前 @keyframes, @font-face, @import, @page 等规则不会支持)

Js隔离
  • proxySandbox 沙箱:
    解析 script 标签,用 with 语句包裹起来,然后把 Proxy 包装的 fakeWindow (window 上不可修改的属性) 作为第一个参数传进去。
    (with做的是扩展语句的作用域链,也就是将 Proxy(fakeWindow) 添加到作用域链的顶部)

这里稍微再深入一下,看下简化后的代码,具体是如何实现的(源码链接):

// zone.js将覆盖Object.defineProperty
const rawObjectDefineProperty = Object.defineProperty;

function createFakeWindow(globalContext: Window) {
  const propertiesWithGetter = new Map<PropertyKey, boolean>();
  const fakeWindow = {} as FakeWindow;
  Object.getOwnPropertyNames(globalContext)
    .filter((p) => {
      //筛选不可修改的属性描述
      const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
      return !descriptor?.configurable;
    })
    .forEach((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
      if (descriptor) {
        const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');
        //对窗口对象的处理,使top/self/window属性可配置和可写,否则当get trap(捕获器)返回时会导致TypeError。
        if (p === 'top' || p === 'parent' || p === 'self' || p === 'window') {
          descriptor.configurable = true;
          if (!hasGetter) {
            descriptor.writable = true;
          }
        }
        if (hasGetter) propertiesWithGetter.set(p, true);
        // 冻结描述符以避免被zone.js修改
        rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
      }
    });
  return {
    fakeWindow,
    propertiesWithGetter,
  };
}

可以到 fakeWindow 就是 createFakeWindow 返回出来的,主要是从 window 上筛选出不可修改的属性,伪造一个 window 。

const useNativeWindowForBindingsProps = new Map<PropertyKey, boolean>([
  ['fetch', true],
  ['mockDomAPIInBlackList', process.env.NODE_ENV === 'test'],
]);
const nativeGlobal = new Function('return this')();
export default class ProxySandbox implements SandBox {
  name: string;
  type: SandBoxType;
  proxy: WindowProxy;
  globalContext: typeof window;
  sandboxRunning = true;
  //......

  /** 启动沙箱 */
  active() {
    if (!this.sandboxRunning) activeSandboxCount++;
    this.sandboxRunning = true;
  }
  /** 关闭沙箱 */
  inactive() {
    if (--activeSandboxCount === 0) {
      variableWhiteList.forEach((p) => {
        if (this.proxy.hasOwnProperty(p)) {
          delete this.globalContext[p];
        }
      });
    }
    this.sandboxRunning = false;
  }

  constructor(name: string, globalContext = window) {
    this.name = name;
    this.globalContext = globalContext;
    this.type = SandBoxType.Proxy;
    const { fakeWindow, propertiesWithGetter } = createFakeWindow(globalContext);
    const hasOwnProperty = (key: PropertyKey) =>
      fakeWindow.hasOwnProperty(key) || globalContext.hasOwnProperty(key);

    const proxy = new Proxy(fakeWindow, {
      set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
        if (this.sandboxRunning) {
          // 当属性在globalContext中存在时,必须保持它的描述一致
          if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
            const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
            const { writable, configurable, enumerable } = descriptor!;
            if (writable) {
              //将修改对象属性代理到 fakeWindow
              Object.defineProperty(target, p, {
                configurable,
                enumerable,
                writable,
                value,
              });
            }
          } else {
            target[p] = value;
          }
          return true;
        }
        // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
        return true;
      },
      get: (target: FakeWindow, p: PropertyKey): any => {
        if (p === Symbol.unscopables) return unscopables;
        if (p === 'window' || p === 'self') {
          //防止使用 window.window 或 window.self 去逃离沙箱环境获取到真正 window
          return proxy;
        }
        if (p === 'globalThis') {
          // 劫持 globalWindow 访问与 globalThis 关键字
          return proxy;
        }
        if (p === 'top' || p === 'parent') {
          // 如果你的主应用程序在 iframe 上下文中, 允许些 props 离开沙箱
          if (globalContext === globalContext.parent) {
            //如果一个窗口没有父窗口,则它的 parent 属性为自身的引用.
            return proxy;
          }
          return (globalContext as any)[p];
        }
        if (p === 'hasOwnProperty') {
          //先查找 fakeWindow,后查找 globalContext 对象自身属性中是否具有指定的属性
          return hasOwnProperty;
        }
        if (p === 'document') {
          //将返回的 子应用的 document 更正为主应用的 document
          return document;
        }
        if (p === 'eval') {
          return eval;
        }
        const value = propertiesWithGetter.has(p)
          ? (globalContext as any)[p]
          : p in target
          ? (target as any)[p]
          : (globalContext as any)[p];

        // 一些dom api必须绑定到本机窗口,否则会导致异常报错:'TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation'
        const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext;
        //getTargetValue 主要对 window.console、window.atob 这类的检测处理,不然微应用中调用时会抛出 Illegal invocation 异常
        return getTargetValue(boundTarget, value);
      },
      //......
    });
    this.proxy = proxy;
  }
}

主要看下 Proxy 拦截操作中的 get 和 set 做了什么 :

setter:这里就是把对 window 属性的修改,全局属性的操作代理到 fakeWindow 上。

getter:就是对于属性值的获取做了一些限制,防止逃离沙箱环境获取到真正的 window。

这样在执行代码时,所有全局变量就会被挂载到了 fakeWindow 上,而不是真正的全局 window 上,当应用被卸载时,对应的 Proxy 会被清除,所以不会导致全局污染。

  • SnapshotSandbox(qiankun 2.0 版本支持):在不支持 proxy 特性的浏览器(IE11)上,使用快照模式来保证兼容性。

也简单看下代码的实现:

function iter(obj: typeof window, callbackFn: (prop: any) => void) {
  for (const prop in obj) {
    if (obj.hasOwnProperty(prop) || prop === 'clearInterval') {
      callbackFn(prop);
    }
  }
}
/**
 * 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
 */
 export default class SnapshotSandbox implements SandBox {
  proxy: WindowProxy;
  name: string;
  type: SandBoxType;
  sandboxRunning = true;
  private windowSnapshot!: Window;
  private modifyPropsMap: Record<any, any> = {};

  constructor(name: string) {
    this.name = name;
    this.proxy = window;
    this.type = SandBoxType.Snapshot;
  }
  /** 启动沙箱 */
  active() {
    // 记录当前快照
    this.windowSnapshot = {} as Window;
    iter(window, (prop) => {
      this.windowSnapshot[prop] = window[prop];
    });
    // 恢复之前的变更
    Object.keys(this.modifyPropsMap).forEach((p: any) => {
      window[p] = this.modifyPropsMap[p];
    });
    this.sandboxRunning = true;
  }
  /** 关闭沙箱 */
  inactive() {
    this.modifyPropsMap = {};
    iter(window, (prop) => {
      if (window[prop] !== this.windowSnapshot[prop]) {
        // 记录变更,恢复环境
        this.modifyPropsMap[prop] = window[prop];
        window[prop] = this.windowSnapshot[prop];
      }
    });
    this.sandboxRunning = false;
  }
}

大致思路:在加载应用前,把 window 上所有的属性保存起来(拍摄快照)。应用被卸载时,再恢复 window 上的所有属性,所以也可以防止全局污染。
但是当页面同时存在多个页面实例时,就无法把它们隔离开来了。所以快照策略并不支持多实例模式。

内部通信
  • 基于 props 以单向数据流的方式传递给子应用。(主要解决父子应用的强耦合时的通信)
  • initGlobalState(state) 定义全局状态,并返回通信方法

基座会创建一个内部包含通信的变量和两个用来修改和监听变量值的方法。
下面看下简化后的源码,去除了console.warn和console.error:

//cloneDeep深度拷贝
import { cloneDeep } from 'lodash';
import type { OnGlobalStateChangeCallback, MicroAppStateActions } from './interfaces';

let globalState: Record<string, any> = {};
const deps: Record<string, OnGlobalStateChangeCallback> = {};
// 触发全局监听
function emitGlobal(state: Record<string, any>, prevState: Record<string, any>) {
  Object.keys(deps).forEach((id: string) => {
    if (deps[id] instanceof Function) {
      deps[id](cloneDeep(state), cloneDeep(prevState));
    }
  });
}
export function initGlobalState(state: Record<string, any> = {}) {
  if (state === globalState) { } else {
    const prevGlobalState = cloneDeep(globalState);
    globalState = cloneDeep(state);
    emitGlobal(globalState, prevGlobalState);
  }
  return getMicroAppStateActions(`global-${+new Date()}`, true);
}
export function getMicroAppStateActions(id: string, isMaster?: boolean): MicroAppStateActions {
  return {
    onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) {
      //订阅
      deps[id] = callback;
      const cloneState = cloneDeep(globalState);
      //是否立即触发
      if (fireImmediately) {
        callback(cloneState, cloneState);
      }
    },
    setGlobalState(state: Record<string, any> = {}) {
      if (state === globalState) {
        return false;
      }
      const changeKeys: string[] = [];
      //记录之前的 globalState
      const prevGlobalState = cloneDeep(globalState);
      //生成新的 globalState
      globalState = cloneDeep(
        Object.keys(state).reduce((_globalState, changeKey) => {
          if (isMaster || _globalState.hasOwnProperty(changeKey)) {
            changeKeys.push(changeKey);
            return Object.assign(_globalState, { [changeKey]: state[changeKey] });
          }
          return _globalState;
        }, globalState),
      );
      if (changeKeys.length === 0) {
        return false;
      }
      //发布
      emitGlobal(globalState, prevGlobalState);
      return true;
    },
    // 注销该应用下的依赖
    offGlobalStateChange() {
      delete deps[id];
      return true;
    },
  };
}

简单来讲就是标准的订阅-发布模式,就是通过订阅全局变量的修改状态来实现通信。(具体源码可以看这里)

资源加载

在微前端方案中存在一个典型的问题:
如果子应用比较多,就会存在之间重复依赖的场景。解决方案是在主应用中主动的依赖基础框架,然后子应用保守的将基础的依赖处理掉,但是,这个机制里存在一个问题,如果子应用中既有react15又有react16,这时主应用该如何做?

蚂蚁的方案是在主应用中维护一个语义化版本的映射表,在运行时分析当前的子应用,最后可以决定真实运行时真正的消费到哪一个基础框架的版本,可以实现真正运行时的依赖系统,也能解决子应用多版本共存时依赖去从的问题,能确保最大程度的依赖复用。

结尾

讲了这么多,最后简单提一下之后可能会遇到的微前端问题。

当一个单体应用被拆成若干个,其维护成本也相应增加。
如何管理多个版本,如何复用公共组件等,导致管理版本变得复杂,依赖关系也极其复杂。

还有,如果应用拆分的粒度过小,开发体验也会不太友好。
应用如果是不同的人开发的话,如果需求跨多个业务,此时需要与多个开发应用者合作,沟通成本大大增加。

最后

赶在春节放假前,进行微前端的技术分享,这篇可以看作是演讲稿。内容少点的可以看 PPT

参考学习链接:
https://martinfowler.com/articles/micro-frontends.html
https://swearer23.github.io/micro-frontends/

https://developer.aliyun.com/article/742576?spm=a2c6h.14164896.0.0.6e633edbLg3STt
https://zhuanlan.zhihu.com/p/78362028
https://zhuanlan.zhihu.com/p/131022025
https://zhuanlan.zhihu.com/p/355419817
https://www.yuque.com/kuitos/gky7yw/nwgk5a
https://www.yuque.com/zhuanjia/oeisq4/vt6kto

https://zhuanlan.zhihu.com/p/97226980
https://zhuanlan.zhihu.com/p/356225293

https://developer.mozilla.org/zh-CN/docs/Web/Web_Components/Using_shadow_DOM

本文章由javascript技术分享原创和收集

发表评论 (审核通过后显示评论):