首頁 > 軟體

微前端框架qiankun原始碼剖析之下篇

2023-02-10 06:00:42

引言

承接上文  微前端框架qiankun原始碼剖析之上篇

注意: 受篇幅限制,本文中所貼上的程式碼都是經過作者刪減梳理後的,只為講述qiankun框架原理而展示,並非完整原始碼。如果需要閱讀相關原始碼可以自行開啟文中連結。

四、沙箱隔離

在基於single-spa開發的微前端應用中,子應用開發者需要特別注意的是:

要謹慎修改和使用全域性變數上的屬性(如window、document等),以免造成依賴該屬性的自身應用或其它子應用執行時出現錯誤;

要謹慎控制CSS規則的生效範圍,避免覆蓋汙染其它子應用的樣式;

但這樣的低階人為保證機制是無法在大規模的團隊開發過程中對應用的獨立性起到完善保護的,而qiankun框架給我們提供的最便利和有用的功能就是其基於設定的自動化沙箱隔離機制了。有了框架層面的子應用隔離支援,使用者無論是在編寫JS程式碼還是修改CSS樣式時都不必再擔心程式碼對於全域性環境的汙染問題了。沙箱機制一方面提升了微應用框架執行的穩定性和獨立性,另一方面也降低了微前端開發者的心智負擔,讓其只需專注於自己的子應用程式碼開發之中。

4.1 JS隔離

在JS隔離方面,qiankun為開發者提供了三種不同模式的沙箱機制,分別適用於不同的場景之中。

1. Snapshot沙箱

該沙箱主要用於不支援Proxy物件的低版本瀏覽器之中,不能由使用者手動指定該模式,qiankun會自動檢測瀏覽器的支援情況並降級到Snapshot沙箱實現。由於這種實現方式在子應用執行過程中實際上修改了全域性變數,因此不能用於多例模式之中(同時存在多個已掛載的子應用)。

該沙箱實現方式非常簡潔,下面我們給出其簡化後的實現(原始碼地址github.com/umijs/qiank…

// 基於 diff 方式實現的沙箱,用於不支援 Proxy 的低版本瀏覽器
export default class SnapshotSandbox implements SandBox {
  private windowSnapshot!: Window;
  private modifyPropsMap: Record<any, any> = {};
  constructor() {}
  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];
    });
  }
  inactive() {
    this.modifyPropsMap = {};
    iter(window, (prop) => {
      if (window[prop] !== this.windowSnapshot[prop]) {
        // 記錄變更,恢復環境
        this.modifyPropsMap[prop] = window[prop];
        window[prop] = this.windowSnapshot[prop];
      }
    });
  }
}

沙箱內部存在兩個物件變數windowSnapshotmodifyPropsMap ,分別用來儲存子應用掛載前原始window物件上的全部屬性以及子應解除安裝時被其修改過的window物件上的相關屬性。

Snapshot沙箱會在子應用mount前將modifyPropsMap中儲存的屬性重新賦值給window以恢復該子應用之前執行時的全域性變數上下文,並在子應用unmount後將windowSnapshot中儲存的屬性重新賦值給window以恢復該子應用執行前的全域性變數上下文,從而使得兩個不同子應用的window相互獨立,達到JS隔離的目的。

2. Legacy沙箱

當用戶手動設定sandbox.loose: true時該沙箱被啟用。Legacy沙箱同樣會對window造成汙染,但是其效能比要比snapshot沙箱好,因為該沙箱不用遍歷window物件。同樣legacy沙箱也只適用於單例模式之中。

下面一起來看一下其簡化後的大致實現方式(原始碼地址github.com/umijs/qiank…

/**
 * 基於 Proxy 實現的沙箱
 * TODO: 為了相容性 singular 模式下依舊使用該沙箱,等新沙箱穩定之後再切換
 */
export default class LegacySandbox implements SandBox {
  /** 沙箱代理的全域性變數 */
  proxy: WindowProxy;
  /** 沙箱期間新增的全域性變數 */
  private addedPropsMapInSandbox = new Map<PropertyKey, any>();
  /** 沙箱期間更新的全域性變數 */
  private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();
  /** 持續記錄更新的(新增和修改的)全域性變數的 map,用於在任意時刻做 snapshot */
  private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();
  constructor() {
    const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this;
    const rawWindow = window;
    const fakeWindow = Object.create(null) as Window;
    const setTrap = (p: PropertyKey, value: any, originalValue: any) => {
      if (!rawWindow.hasOwnProperty(p)) {
        // 當前 window 物件不存在該屬性,將其記錄在新增變數之中
        addedPropsMapInSandbox.set(p, value);
      } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
        // 如果當前 window 物件存在該屬性,且 record map 中未記錄過,則記錄該屬性初始值
        modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
      }
      // 無論何種修改都記錄在currentUpdatedPropsValueMap中
      currentUpdatedPropsValueMap.set(p, value);
      // 必須重新設定 window 物件保證下次 get 時能拿到已更新的資料
      (rawWindow as any)[p] = value;
    };
    const proxy = new Proxy(fakeWindow, {
      set: (_: Window, p: PropertyKey, value: any): boolean => {
        const originalValue = (rawWindow as any)[p];
        return setTrap(p, value, originalValue, true);
      },
      get(_: Window, p: PropertyKey): any {
        // avoid who using window.window or window.self to escape the sandbox environment to touch the really window or use window.top to check if an iframe context
        if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
          return proxy;
        }
        const value = (rawWindow as any)[p];
        return value;
      },
    });
    this.proxy = proxy
  }
  active() {
    // 啟用時將子應用之前的所有改變重新賦予window,恢復其執行時上下文
    this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
  }
  inactive() {
    // 解除安裝時將window上修改的值復原,新新增的值刪除
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
    this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, undefined, true));
  }
  private setWindowProp(prop: PropertyKey, value: any, toDelete?: boolean) {
    if (value === undefined && toDelete) {
      delete (this.globalContext as any)[prop];
    } else {
      (this.globalContext as any)[prop] = value;
    }
  }
}

Legacy沙箱為一個空物件fakewindow使用proxy代理攔截了其全部的set/get等操作,並在loader中用其替換了window。當用戶試圖修改window屬性時,fakewindow上代理的set操作生效捕獲了相關修改,其分別將新增的屬性和修改前的值存入addedPropsMapInSandboxmodifiedPropsOriginalValueMapInSandbox這兩個Map之中,此外還將所有修改記錄在了currentUpdatedPropsValueMap之中,並改變了window物件。

這樣當子應用掛載前,legacy沙箱會將currentUpdatedPropsValueMap之中記錄的子應用相關修改重新賦予window,恢復其執行時上下文。當子應用解除安裝後,legacy沙箱會遍歷addedPropsMapInSandboxmodifiedPropsOriginalValueMapInSandbox這兩個Map並將window上的相關值恢復到子應用執行之前的狀態。最終達到了子應用間JS隔離的目的。

3. Proxy沙箱

Proxy沙箱是qiankun框架中預設使用的沙箱模式(也可以通過設定sandbox.loose: false來開啟),只有該模式真正做到了對window的無汙染隔離(子應用完全不能修改全域性變數),因此可以被應用在單/多例模式之中。

Proxy沙箱的原理也非常簡單,它將window上的所有屬性遍歷拷貝生成一個新的fakeWindow物件,緊接著使用proxy代理這個fakeWindow,使用者對window操作全部被攔截下來,只作用於在這個fakeWindow之上(原始碼地址github.com/umijs/qiank…

// 便利window拷貝建立初始代理物件
function createFakeWindow(globalContext: Window) {
  const fakeWindow = {} as FakeWindow;
  Object.getOwnPropertyNames(globalContext)
    .forEach((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
      rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
    });
  return { fakeWindow };
}
/**
 * 基於 Proxy 實現的沙箱
 */
export default class ProxySandbox implements SandBox {
  // 標誌該沙箱是否被啟用
  sandboxRunning = true;
  constructor() {
    const { fakeWindow } = createFakeWindow(window);
    const proxy = new Proxy(fakeWindow, {
      set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
        if(this.sandboxRunning){
          // 修改代理物件的值
          target[p] = value;
          return true; 
        }
      }
      get: (target: FakeWindow, p: PropertyKey): any => {
        // avoid who using window.window or window.self to escape the sandbox environment to touch the really window
        if (p === 'window' || p === 'self' || p === 'globalThis') {
          return proxy;
        }
        // 獲取代理物件的值
      	const value = target[p];
        return value;
      },
    })
  }
  active() {
    if (!this.sandboxRunning) activeSandboxCount++;
    this.sandboxRunning = true;
  }
  inactive() {
    this.sandboxRunning = false;
  }
}

4.2 CSS隔離

對於CSS隔離的方式,在預設情況下由於切換子應用時,其相關的CSS內外連屬性會被解除安裝掉,所以可以確保單範例場景子應用之間的樣式隔離,但是這種方式無法確保主應用跟子應用、或者多範例場景的子應用樣式隔離。不過,qiankun也提供了兩種可設定生效的內建方式供使用者選擇。

1. ShadowDOM

當用戶設定sandbox.strictStyleIsolation: true時,ShadowDOM樣式沙箱會被開啟。在這種模式下 qiankun 會為每個微應用的容器包裹上一個 shadow dom 節點,從而確保微應用的樣式不會對全域性造成影響。(原始碼地址github.com/umijs/qiank…

// 在子應用的DOM樹最外層進行一次包裹
function createElement(
  appContent: string,
  strictStyleIsolation: boolean,
  scopedCSS: boolean,
  appInstanceId: string,
): HTMLElement {
  // 包裹節點
  const containerElement = document.createElement('div');
  containerElement.innerHTML = appContent;
  // 子應用最外層節點
  const appElement = containerElement.firstChild as HTMLElement;
  // 當開啟了ShadowDOM沙箱時
  if (strictStyleIsolation) {
    const { innerHTML } = appElement;
    appElement.innerHTML = '';
    let shadow: ShadowRoot;
		// 判斷瀏覽器相容的建立ShadowDOM的方式,並使用該方式建立ShadowDOM根節點
    if (appElement.attachShadow) {
      shadow = appElement.attachShadow({ mode: 'open' });
    } else {
      // createShadowRoot was proposed in initial spec, which has then been deprecated
      shadow = (appElement as any).createShadowRoot();
    }
    // 將子應用內容掛在ShadowDOM根節點下
    shadow.innerHTML = innerHTML;
  }
	// 。。。。。。
  return appElement;
}

這種方式雖然看起來清晰簡單,還巧妙利用了瀏覽器對於ShadowDOM的CSS隔離特性,但是由於ShadowDOM的隔離比較嚴格,所以這並不是一種無腦使用的方案。例如:如果子應用記憶體在一個彈出時會掛在document根元素的彈窗,那麼該彈窗的樣式是否會受到ShadowDOM的影響而失效?所以開啟該沙箱的使用者需要明白自己在做什麼,且可能需要對子應用內部程式碼做出一定的調整。

2. Scoped CSS

因為ShadowDOM存在著上述的一些問題,qiankun貼心的為使用者提供了另一種更加無腦簡便的樣式隔離方式,那就是Scoped CSS。通過設定sandbox.experimentalStyleIsolation: true,Scoped樣式沙箱會被開啟。

在這種模式下,qiankun會遍歷子應用中所有的CSS選擇器,通過對選擇器字首新增一個固定的帶有該子應用標識的屬性選擇器的方式來限制其生效範圍,從而避免子應用間、主應用與子應用的樣式相互汙染。(原始碼地址github.com/umijs/qiank…

export const QiankunCSSRewriteAttr = 'data-qiankun';
// 在子應用的DOM樹最外層進行一次包裹
function createElement(
  appContent: string,
  strictStyleIsolation: boolean,
  scopedCSS: boolean,
  appInstanceId: string,
): HTMLElement {
  // 包裹節點
  const containerElement = document.createElement('div');
  containerElement.innerHTML = appContent;
  // 子應用最外層節點
  const appElement = containerElement.firstChild as HTMLElement;
  // 。。。。。。
  // 當開啟了Scoped CSS沙箱時
  if (scopedCSS) {
    // 為外層節點新增qiankun自定義屬性,其值設定為子應用id標識
    const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
    if (!attr) {
      appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId);
    }
		// 獲取子應用中全部樣式並進行處理
    const styleNodes = appElement.querySelectorAll('style') || [];
    forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
      css.process(appElement!, stylesheetElement, appInstanceId);
    });
  }
  return appElement;
}

qiankun首先對子應用最外層的包裹節點(一般為div節點)新增一個屬性名為data-qiankun,值為appInstanceId的屬性。接著遍歷處理子應用中的所有樣式(原始碼地址github.com/umijs/qiank…

export const process = (
  appWrapper: HTMLElement,
  stylesheetElement: HTMLStyleElement | HTMLLinkElement,
  appName: string,
): void => {
  // lazy singleton pattern
  if (!processor) {
    processor = new ScopedCSS();
  }
	// !!!注意,對於link標籤引入的外聯樣式不支援。qiankun在初期解析使用的import-html-entry在解析html模版時會將所有外聯樣式拉取並轉換為style標籤包裹的內聯樣式,所以這裡不再處理link的外聯樣式。
  if (stylesheetElement.tagName === 'LINK') {
    console.warn('Feature: sandbox.experimentalStyleIsolation is not support for link element yet.');
  }
  const mountDOM = appWrapper;
  if (!mountDOM) {
    return;
  }
	// 獲取包裹元素標籤
  const tag = (mountDOM.tagName || '').toLowerCase();
  if (tag && stylesheetElement.tagName === 'STYLE') {
    // 生成屬性選擇器字首,準備將其新增在選擇器前(如div[data-qiankun=app1])
    const prefix = `${tag}[${QiankunCSSRewriteAttr}="${appName}"]`;
    processor.process(stylesheetElement, prefix);
  }
};
// 。。。。。。
process(styleNode: HTMLStyleElement, prefix: string = '') {
  if (styleNode.textContent !== '') {
    // 獲取相關css規則rules
    const textNode = document.createTextNode(styleNode.textContent || '');
    this.swapNode.appendChild(textNode);
    const sheet = this.swapNode.sheet as any; // type is missing
    const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);
    // 重寫這些CSS規則,將字首新增進去
    const css = this.rewrite(rules, prefix);
    // 用重寫後的CSS規則覆蓋之前的規則
    styleNode.textContent = css;
    // 標誌符,代表該節點已經處理過
    (styleNode as any)[ScopedCSS.ModifiedTag] = true;
    return;
  }
	// 監聽節點變化
  const mutator = new MutationObserver((mutations) => {
    for (let i = 0; i < mutations.length; i += 1) {
      const mutation = mutations[i];
      // 忽略已經處理過的節點
      if (ScopedCSS.ModifiedTag in styleNode) {
        return;
      }
      // 如果新增了未處理過的子節點(代表了使用者新注入了一些屬性),那麼會再次重寫所有的CSS規則以確保新增的CSS不會汙染子應用外部
      if (mutation.type === 'childList') {
        const sheet = styleNode.sheet as any;
        const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);
        const css = this.rewrite(rules, prefix);
        styleNode.textContent = css;
        (styleNode as any)[ScopedCSS.ModifiedTag] = true;
      }
    }
	});
  // 註冊監聽
  mutator.observe(styleNode, { childList: true });
}
// 具體CSS規則重寫方式
private rewrite(rules: CSSRule[], prefix: string = '') {
  // 。。。。。。
  // 這裡省略其實現方式,整體實現思路簡單但步驟很繁瑣,主要就是對字串的正則判斷和替換修改。
  // 1. 對於根選擇器(html/body/:root等),直接將其替換為prefix
  // 2. 對於其它選擇器,將prefix放在最前面( selector1 selector2, selector3 =》 prefix selector1 selector2,prefix selector3)
}

可以看到,qiankun通過為子應用的外層包裹元素注入屬性並將子應用全部樣式的作用範圍都限制在該包裹元素下(通過新增指定的屬性選擇器作為字首)實現了scoped樣式沙箱隔離。需要注意的是,如果使用者在執行時對內聯樣式進行修改,qiankun是可以偵測到並幫助使用者限制其作用範圍,但如果使用者在執行時引入了新的外聯樣式或者自行建立了新的內聯標籤,那麼qiankun並不會做出反應,相關的CSS規則還是可能會汙染全域性樣式。

五、通訊方式

對於微前端來說,除了應用間的隔離外,應用間的通訊也是非常重要的部分。這裡,single-spa提供了從主應用向子應用傳遞customProps的方式實現了最基礎的引數傳遞。但是真實的開發場景需要的資訊傳遞是非常複雜的,靜態的預設引數傳遞只能起到很小的作用,我們還需要一種更加強大的通訊機制來幫助我們開發應用。

這裡,qiankun在框架內部預先設計實現了完善的釋出訂閱模式,降低了開發者的上手門檻。我們首先來看一下qiankun中的通訊是如何進行的。

// ------------------主應用------------------
import { initGlobalState, MicroAppStateActions } from 'qiankun';
// 初始化 state
const actions: MicroAppStateActions = initGlobalState(state);
// 在當前應用監聽全域性狀態,有變更觸發 callback
actions.onGlobalStateChange((state, prev) => {
  // state: 變更後的狀態; prev 變更前的狀態
  console.log(state, prev);
});
// 按一級屬性設定全域性狀態,微應用中只能修改已存在的一級屬性
actions.setGlobalState(state);
// 移除當前應用的狀態監聽,微應用 umount 時會預設呼叫
actions.offGlobalStateChange();
// ------------------子應用------------------
// 從生命週期 mount 中獲取通訊方法,使用方式和 master 一致
export function mount(props) {
  props.onGlobalStateChange((state, prev) => {
    // state: 變更後的狀態; prev 變更前的狀態
    console.log(state, prev);
  });
  props.setGlobalState(state);
}

接下來,讓我們一起來看一下它是如何實現的。(原始碼地址github.com/umijs/qiank…

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) {
    console.warn('[qiankun] state has not changed!');
  } 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 全域性依賴監聽
     *
     * 收集 setState 時所需要觸發的依賴
     *
     * 限制條件:每個子應用只有一個啟用狀態的全域性監聽,新監聽覆蓋舊監聽,若只是監聽部分屬性,請使用 onGlobalStateChange
     *
     * 這麼設計是為了減少全域性監聽濫用導致的記憶體爆炸
     *
     * 依賴資料結構為:
     * {
     *   {id}: callback
     * }
     *
     * @param callback
     * @param fireImmediately 是否立即執行callback
     */
    onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) {
      if (!(callback instanceof Function)) {
        console.error('[qiankun] callback must be function!');
        return;
      }
      if (deps[id]) {
        console.warn(`[qiankun] '${id}' global listener already exists before this, new listener will overwrite it.`);
      }
      / 註冊訂閱
      deps[id] = callback;
      if (fireImmediately) {
        const cloneState = cloneDeep(globalState);
        callback(cloneState, cloneState);
      }
    },
    /**
     * setGlobalState 更新 store 資料
     *
     * 1. 對輸入 state 的第一層屬性做校驗,只有初始化時宣告過的第一層(bucket)屬性才會被更改
     * 2. 修改 store 並觸發全域性監聽
     *
     * @param state
     */
    setGlobalState(state: Record<string, any> = {}) {
      if (state === globalState) {
        console.warn('[qiankun] state has not changed!');
        return false;
      }
      const changeKeys: string[] = [];
      const prevGlobalState = cloneDeep(globalState);
      globalState = cloneDeep(
        Object.keys(state).reduce((_globalState, changeKey) => {
          if (isMaster || _globalState.hasOwnProperty(changeKey)) {
            changeKeys.push(changeKey);
            return Object.assign(_globalState, { [changeKey]: state[changeKey] });
          }
          console.warn(`[qiankun] '${changeKey}' not declared when init state!`);
          return _globalState;
        }, globalState),
      );
      if (changeKeys.length === 0) {
        console.warn('[qiankun] state has not changed!');
        return false;
      }
      // 觸發全域性監聽
      emitGlobal(globalState, prevGlobalState);
      return true;
    },
    // 登出該應用下的依賴
    offGlobalStateChange() {
      delete deps[id];
      return true;
    },
  };
}

可以看到在initGlobalState函數的執行中完成了一個釋出訂閱模式的建立工作,並返回了相關的訂閱/釋出/登出方法。接著qiankun將這些返回的方法通過生命週期函數mount傳遞給子應用,這樣子應用就能夠拿到並使用全域性狀態了,從而應用間的通訊就得以實現了。此外offGlobalStateChange會在子應用unmount時自動呼叫以解除該子應用的訂閱,避免記憶體洩露。(第三節子應用載入中的程式碼已經提到,原始碼參見github.com/umijs/qiank…

六、結語

qiankun在single-spa的基礎上進行了二次封裝,分別從子應用載入方式、應用間沙箱隔離、應用間通訊這三個方面著手,通過自己的方式降低了使用者的使用門檻,簡便了微前端專案的開發改造成本,從而成為目前為止最為流行的微前端框架。

優化點single-spaqiankun
子應用載入方式使用者自行編碼設定子應用載入方式使用者只需設定子應用入口URL
應用間沙箱隔離無隔離機制內建了三種JS沙箱和兩種CSS沙箱
應用間通訊主應用通過customProps向子應用傳遞靜態引數內建了一整套基於釋出訂閱的通訊模式

本文通過對於qiankun原始碼的粗略解讀,希望讀者可以獲取到自己所需的知識,得到些許的進步。編碼的路程漫長且艱辛,諸位共同努力!

更多關於微前端框架qiankun剖析的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com