微前端的總結分享(演講稿版)

開篇

時值當下,作為一名合格的前端開發(fā)人員,我相信你一定會有一個很明顯的感覺:Web 業(yè)務日益復雜化和多元化,前端的職責越來越重要,戰(zhàn)場越來越多樣,應用也越來越復雜,前端開發(fā)已經由WebPage 模式為主轉變?yōu)橐?WebApp 模式為主了——我們已經迎來了一個“大前端”的時代。

隨之也會帶來許多工程化的問題。例如:在一個相對長的時間跨度下,隨著時間的推移,越來越復雜的前端項目,會變的越來越龐大。如何保證項目的可維護性,開發(fā)質量,開發(fā)體驗......

介紹微前端

微前端是在2016年底的 ThoughtWorks Technology Radar 被提出 ,將微服務這個被廣泛應用于服務端的技術范式擴展到前端領域。

Micro Frontends 網站對微前端的定義

簡單通俗的來講就是:微前端可以使多個團隊之間使用不同技術棧,然后在客戶端(瀏覽器)運行時動態(tài)組成一個完整的 SPA 應用。(當然也有在構建時組合的方案,但并不符合微前端的核心思想,也不是主流微前端實現方式。)

總之,微前端是將微服務概念做了一個很好的延伸和實現。都是希望將某個單一的應用,轉化為多個可以獨立運行、獨立開發(fā)、獨立部署、獨立維護的服務或者應用,從而滿足業(yè)務快速變化以及多團隊可并行開發(fā)的需求。

微前端的應用場景

  1. 大型單頁應用。這類應用的特點是系統(tǒng)體量較大,而且隨著業(yè)務上的功能升級,項目體積還會不斷增大。

阿里云就是這個場景下微前端很好的實踐成果,采用微前端架構方式可無限擴展,其復雜度不會很明顯的增長。(微前端最先提出來的主要原因,就是如何將巨石應用解構)

  1. 系統(tǒng)重復模塊的復用。在多個獨立系統(tǒng)內部可能會開發(fā)一些重復度很高的功能,比如用戶管理,權限管理這些重復的功能。

微前端可以減少這些重復的開發(fā)成本。(理想情況下,可以將產品原子化,然后根據業(yè)務場景的需求,實現不同應用之間頁面級別的自由組合,且每個功能模塊都能單獨迭代)

  1. 遺留系統(tǒng)的兼容和擴展。例:一個已經存在了3,5年的項目,依賴版本落后,里面還存在著一些祖?zhèn)鞔a,因為種種原因項目不能及時升級。

微前端提供了一種增量升級的能力,在不重寫原有系統(tǒng)的基礎之上,實施漸進式重構。對于新功能的開發(fā)可以使用新的技術,避免了繼續(xù)使用過時的技術。極大的降低長期項目迭代維護的難度。

微前端的核心思想

  1. 技術無關:微應用之間可以選擇不同的技術棧。

  2. 環(huán)境獨立:為了達到高度解耦的目的,每個微應用不應當共享運行時環(huán)境,即使所有微應用都使用了相同的框架,它們之間也應該盡量避免依賴共享狀態(tài)或全局變量。(也就是應用之間的 css 和 js 隔離)

  3. 原生優(yōu)先:優(yōu)先使用瀏覽器原生事件進行通信。(如果確實必須跨應用進行通信,盡量讓通信內容和方式變得簡單,這樣能有效地減少微應用之間的公共依賴)

  4. 獨立開發(fā)、部署:微應用可獨立開發(fā),部署完成后主框架自動完成同步更新

為什么不用 iframe

說到微前端的方案,iframe 可以說是最簡單的微前端基石方案了,提供了瀏覽器原生的硬隔離方案,不論是 css 還是 js 隔離,然后正好也滿足了獨立運行,開發(fā),部署,維護?!珵槭裁此械默F代化微前端方案都不用iframe呢?

因為iframe最大的特性同時也是它最大的問題所在,它的隔離性無法被突破,導致應用間上下文無法被共享,隨之帶來的開發(fā)體驗、產品體驗的問題。

  1. url 不同步。因為不是單頁面應用,瀏覽器刷新會導致 iframe url 狀態(tài)丟失、后退前進按鈕無法使用。

  2. UI 不同步,DOM 結構不共享。iframe 內的彈窗無法應用到整個大應用中,只能在對應的窗口內展示。(iframe還不會自動調節(jié)寬高)

  3. 全局上下文完全隔離,內存變量不共享。就需要設計 iframe 應用之間的通信、數據同步等需求。(iframe 可以通過 postMessage通信)

  4. 。每次子應用進入都是一次瀏覽器上下文重建、資源重新加載的過程,占用大量資源的同時也在極大地消耗資源。

第1個問題可以解決,第4個問題不是不能忽略。第2和3很難解決。
(騰訊公開了一個基于 iframe 的微前端方案,但是要解決的問題很多,目前還沒開源)

現代微前端方案的選擇

微前端是一個技術應用架構體系,微前端架構解決方案大概分為兩類場景(技術實現角度):

  • 單實例:即同一時刻,只有一個子應用被展示,子應用具備一個完整的應用生命周期。通?;?url 的變化來做子應用的切換。——基座模式的 qiankun(qiankun2.0 時支持了多應用并行)

  • 多實例:同一時刻可展示多個子應用,子應用更像是一個業(yè)務組件而不是應用,可以說是微應用粒度的前端組件化?!ブ行哪J降?emp

emp

介紹:emp是YY業(yè)務中臺Web團隊的微前端解決方案。emp 基于 Webpack 5 的 Module Federation(模塊聯(lián)邦)實現,提供了在當前應用中遠程加載其他服務器上應用的能力(每個應用之間都可以彼此分享資源),將多個獨立構建應用組成一個應用程序。

優(yōu)點:第三方依賴可共享,減少重復的代碼加載。

缺點:無法涵蓋所有的框架,而且極度依賴于Webpack5。

因為emp主要解決的是業(yè)務的拆分,不是跨框架調用。算是對跨技術棧沒有高要求的微前端方案吧。

qiankun

介紹:qiankun 是基于 single-spa 封裝實現的(single-spa 做了完善的應用加載邏輯,qiankun 的路由系統(tǒng)就是基于此實現的),與框架無關的微前端內核。qiankun 算得上真正意義上的微前端,是螞蟻沉淀了自己的微前端方案并開源的成果。

優(yōu)點:真正做到了與技術棧無關。

缺點:Css隔離方案并不完美。

我們最后會選擇 qiankun 作為我們的微前端解決方案。

我們的項目都基于umi,而 umi 提供了配套的 qiankun 插件 @umijs/plugin-qiankun,方便 umi 應用通過修改配置的方式切換成微前端架構系統(tǒng),幾乎零成本的接入(沒有使用umi的應用,直接使用qiankun代碼其實也不需要改造太多,qiankun 工程侵入性很?。⑶也辉傩枰リP注各種過程中的技術細節(jié)。

至此,已經介紹完了微前端的一些基本內容。

但是想要進階更高級的工程師,必然不能只停留于調用API,更要了解其底層設計思想和實現原理。
下面就簡單深入下 qiankun。

qiankun在技術細節(jié)上的決策和基本實現原理

微前端方案涉及到的技術點
路由系統(tǒng)

基于Single-SPA實現。把所有應用都注冊在基座上,通過基座應用來監(jiān)聽路由,按照路由規(guī)則來加載不同的應用,來實現應用間的解耦。

應用加載

子應用提供什么形式的資源作為渲染入口?

HTML Entry VS JS Entry

Js Entry的問題在于:

  1. 需將子應用的所有資源(包括 css、圖片等資源)打成一個Entry Script,Code Splitting 也無法應用,資源加載速度變慢。

  2. 而且每一次子應用發(fā)布,主應用都需要重新配置打包,因為js/css地址的hash會變。

  3. 主應用為子應用預留的容器 id 還需與子應用容器保持一致。

相比之下HTML Entry,子應用地址只需配一次,子應用的信息可以得到完整的保留。
缺點:將子應用資源解析的消耗留到了運行時。

qiankun 采用 HTML Entry 方案替代 single-spa 的 JS Entry 加載子應用的方案進行優(yōu)化。(以達到像接入一個 iframe 一樣簡單的目的)
通過 html 作為應用入口,然后通過解析html從中提取 js 和 css 依賴下載,同時將 HTML Document 作為子節(jié)點塞到主應用的容器中。(本質上 HTML 充當的是應用靜態(tài)資源表的角色)。

@umijs/plugin-qiankun 插件的應用加載配置
應用隔離(微前端方案中最關鍵的問題)
Css隔離

CSS Module 簡單高效,也更加智能化。問題在于:雖然可以保證自己的子應用做到隔離,但是無法保證依賴的第三方庫的全局樣式可以做到應用之間的隔離。

下面介紹下qiankun的方案選擇:

  • Dynamic Stylesheet(動態(tài)樣式表):
    動態(tài)的加載和卸載樣式表。在應用切出/卸載后,同時卸載掉其樣式表。

原理:瀏覽器會對所有的樣式表的插入、移除做整個 CSSOM 的重構,從而保證了在一個時間點里,只有一個應用的樣式表是生效的。(上面提到的 HTML Entry 方案則天生具備樣式隔離的特性,因為應用卸載后會直接移除去 HTML 結構,從而自動移除了其樣式表)

問題:可以確保子應用之間的樣式沖突,但子應用和主應用之間的沖突是無法避免,只有通過手動的方式確保,比如給主應用所有樣式添加一個前綴。(但在螞蟻在實踐中,大多數主應用可能只提供一個頭部,側邊欄的組件)

  • Shadow DOM(qiankun 2.0 版本支持,在開啟 strictStylesolution時,將采用 shadow DOM的方式進行樣式隔離):
    將微應用插入到 qiankun 創(chuàng)建好的 shadow Tree 中,微應用的樣式(包括動態(tài)插入的樣式)都會被掛載到這個 shadow Host 節(jié)點下,最終整個應用的所有 DOM 都會被繪制成一顆shadow tree。

原理:Shadow DOM內部所有節(jié)點的樣式對樹外面的節(jié)點是無效的,因此微應用的樣式只會作用在 Shadow Tree 內部,自然就實現了樣式隔離。

問題:一旦子應用中出現運行時越界跑到外面構建 DOM 的場景,必定會導致構建出來的 DOM 無法應用子應用樣式的情況。(例:像 antd modal 組件是動態(tài)掛載到 document.body)

所以,qiankun 的 css 隔離方案不是特別完美。(還有一個實驗性的樣式隔離特性 experimentalStyleIsolation ,會改寫子應用所添加的樣式為所有樣式規(guī)則增加一個特殊的選擇器規(guī)則來限定其影響范圍。但目前 @keyframes, @font-face, @import, @page 等規(guī)則不會支持)

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 去逃離沙箱環(huán)境獲取到真正 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:就是對于屬性值的獲取做了一些限制,防止逃離沙箱環(huán)境獲取到真正的 window。

這樣在執(zhí)行代碼時,所有全局變量就會被掛載到了 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]) {
        // 記錄變更,恢復環(huán)境
        this.modifyPropsMap[prop] = window[prop];
        window[prop] = this.windowSnapshot[prop];
      }
    });
    this.sandboxRunning = false;
  }
}

大致思路:在加載應用前,把 window 上所有的屬性保存起來(拍攝快照)。應用被卸載時,再恢復 window 上的所有屬性,所以也可以防止全局污染。
但是當頁面同時存在多個頁面實例時,就無法把它們隔離開來了。所以快照策略并不支持多實例模式。

內部通信
  • 基于 props 以單向數據流的方式傳遞給子應用。(主要解決父子應用的強耦合時的通信)
  • initGlobalState(state) 定義全局狀態(tài),并返回通信方法

基座會創(chuàng)建一個內部包含通信的變量和兩個用來修改和監(jiān)聽變量值的方法。
下面看下簡化后的源碼,去除了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> = {};
// 觸發(fā)全局監(jiān)聽
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);
      //是否立即觸發(fā)
      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;
      }
      //發(fā)布
      emitGlobal(globalState, prevGlobalState);
      return true;
    },
    // 注銷該應用下的依賴
    offGlobalStateChange() {
      delete deps[id];
      return true;
    },
  };
}

簡單來講就是標準的訂閱-發(fā)布模式,就是通過訂閱全局變量的修改狀態(tài)來實現通信。(具體源碼可以看這里)

資源加載

在微前端方案中存在一個典型的問題:
如果子應用比較多,就會存在之間重復依賴的場景。解決方案是在主應用中主動的依賴基礎框架,然后子應用保守的將基礎的依賴處理掉,但是,這個機制里存在一個問題,如果子應用中既有react15又有react16,這時主應用該如何做?

螞蟻的方案是在主應用中維護一個語義化版本的映射表,在運行時分析當前的子應用,最后可以決定真實運行時真正的消費到哪一個基礎框架的版本,可以實現真正運行時的依賴系統(tǒng),也能解決子應用多版本共存時依賴去從的問題,能確保最大程度的依賴復用。

結尾

講了這么多,最后簡單提一下之后可能會遇到的微前端問題。

當一個單體應用被拆成若干個,其維護成本也相應增加。
如何管理多個版本,如何復用公共組件等,導致管理版本變得復雜,依賴關系也極其復雜。

還有,如果應用拆分的粒度過小,開發(fā)體驗也會不太友好。
應用如果是不同的人開發(fā)的話,如果需求跨多個業(yè)務,此時需要與多個開發(fā)應用者合作,溝通成本大大增加。

最后

趕在春節(jié)放假前,進行微前端的技術分享,這篇可以看作是演講稿。內容少點的可以看 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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容