解密微前端:從qiankun看沙箱隔離

在我以前的文章提到過,微前端的本質是分治的處理前端應用以及應用間的關係,那麼更進一步,落地一個微前端框架,就會涉及到三點核心要素:javascript

  • 子應用的加載;
  • 應用間運行時隔離
  • 路由劫持;

對於 qiankun 來講,路由劫持是在 single-spa 上去作的,而 qiankun 給咱們提供的能力,主要即是子應用的加載和沙箱隔離。css

承接上文,這是系列的第二個 topic,這篇文章主要基於 qiankun 源碼向你們講一下沙箱隔離如何實現。前端

qiankun 作沙箱隔離主要分爲三種:java

  • legacySandBox
  • proxySandBox
  • snapshotSandBox。

其中 legacySandBox、proxySandBox 是基於 Proxy API 來實現的,在不支持 Proxy API 的低版本瀏覽器中,會降級爲 snapshotSandBox。在現版本中,legacySandBox 僅用於 singular 單實例模式,而多實例模式會使用 proxySandBox。編程

legacySandBox

legacySandBox 的核心思想是什麼呢?legacySandBox 的本質上仍是操做 window 對象,可是他會存在三個狀態池,分別用於子應用卸載時還原主應用的狀態和子應用加載時還原子應用的狀態瀏覽器

  • addedPropsMapInSandbox: 存儲在子應用運行時期間新增的全局變量,用於卸載子應用時還原主應用全局變量;
  • modifiedPropsOriginalValueMapInSandbox:存儲在子應用運行期間更新的全局變量,用於卸載子應用時還原主應用全局變量;
  • currentUpdatedPropsValueMap:存儲子應用全局變量的更新,用於運行時切換後還原子應用的狀態;

咱們首先看下 Proxy 的 getter / setter:前端框架

const rawWindow = window;
const fakeWindow = Object.create(null) as Window;
// 建立對fakeWindow的劫持,fakeWindow就是咱們傳遞給自執行函數的window對象
const proxy = new Proxy(fakeWindow, {
  set(_: Window, p: PropertyKey, value: any): boolean {
    // 運行時的判斷
    if (sandboxRunning) {
      // 若是window對象上沒有這個屬性,那麼就在狀態池中記錄狀態的新增;
      if (!rawWindow.hasOwnProperty(p)) {
        addedPropsMapInSandbox.set(p, value);

        // 若是當前 window 對象存在該屬性,而且狀態池中沒有該對象,那麼證實改屬性是運行時期間更新的值,記錄在狀態池中用於最後window對象的還原
      } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
        const originalValue = (rawWindow as any)[p];
        modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
      }

      // 記錄全局對象修改值,用於後面子應用激活時還原子應用
      currentUpdatedPropsValueMap.set(p, value);
      (rawWindow as any)[p] = value;

      return true;
    }

    return true;
  },

  get(_: Window, p: PropertyKey): any {
    // iframe的window上下文
    if (p === "top" || p === "window" || p === "self") {
      return proxy;
    }

    const value = (rawWindow as any)[p];
    return getTargetValue(rawWindow, value);
  },
});

接下來看下子應用沙箱的激活 / 卸載:antd

// 子應用沙箱激活
  active() {
    // 經過狀態池,還原子應用上一次寫在前的狀態
    if (!this.sandboxRunning) {
      this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
    }

    this.sandboxRunning = true;
  }

  // 子應用沙箱卸載
  inactive() {
    // 還原運行時期間修改的全局變量
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
    // 刪除運行時期間新增的全局變量
    this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));

    this.sandboxRunning = false;
  }

因此,總結起來,legacySandBox 仍是會操做 window 對象,可是他經過激活沙箱時還原子應用的狀態,卸載時還原主應用的狀態來實現沙箱隔離的框架

proxySandBox

在 qiankun 中,proxySandBox 用於多實例場景。什麼是多實例場景,這裏我簡單提下,通常咱們的中後臺系統同一時間只會加載一個子應用的運行時。可是也存在這樣的場景,某一個子應用聚合了多個業務域,這樣的子應用每每會經歷多個團隊的多個同窗共同維護本身的業務模塊,這時候即可以採用多實例的模式聚合子模塊(這種模式也能夠叫微前端模塊)。
函數

回到正題,和 legacySandBox 最直接的不一樣點就是,爲了支持多實例的場景,proxySandBox 不會直接操做 window 對象。而且爲了不子應用操做或者修改主應用上諸如 window、document、location 這些重要的屬性,會遍歷這些屬性到子應用 window 副本(fakeWindow)上,咱們首先看下建立子應用 window 的副本:

function createFakeWindow(global: Window) {
  // 這裏qiankun給咱們了一個知識點:在has和check的場景下,map有着更好的性能 :)
  const propertiesWithGetter = new Map<PropertyKey, boolean>();
  const fakeWindow = {} as FakeWindow;

  // 從window對象拷貝不可配置的屬性
  // 舉個例子:window、document、location這些都是掛在Window上的屬性,他們都是不可配置的
  // 拷貝出來到fakeWindow上,就間接避免了子應用直接操做全局對象上的這些屬性方法
  Object.getOwnPropertyNames(global)
    .filter((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(global, p);
      // 若是屬性不存在或者屬性描述符的configurable的話
      return !descriptor?.configurable;
    })
    .forEach((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(global, p);
      if (descriptor) {
        // 判斷當前的屬性是否有getter
        const hasGetter = Object.prototype.hasOwnProperty.call(
          descriptor,
          "get"
        );

        // 爲有getter的屬性設置查詢索引
        if (hasGetter) propertiesWithGetter.set(p, true);

        // freeze the descriptor to avoid being modified by zone.js
        // zone.js will overwrite Object.defineProperty
        // const rawObjectDefineProperty = Object.defineProperty;
        // 拷貝屬性到fakeWindow對象上
        rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
      }
    });

  return {
    fakeWindow,
    propertiesWithGetter,
  };
}

接下來看下 proxySandBox 的 getter/setter:

const rawWindow = window;
// window副本和上面說的有getter的屬性的索引
const { fakeWindow, propertiesWithGetter } = createFakeWindow(rawWindow);

const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>();
const hasOwnProperty = (key: PropertyKey) =>
  fakeWindow.hasOwnProperty(key) || rawWindow.hasOwnProperty(key);

const proxy = new Proxy(fakeWindow, {
  set(target: FakeWindow, p: PropertyKey, value: any): boolean {
    if (sandboxRunning) {
      // 在fakeWindow上設置屬性值
      target[p] = value;
      // 記錄屬性值的變動
      updatedValueSet.add(p);

      // SystemJS屬性攔截器
      interceptSystemJsProps(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;

    // 避免window.window 或 window.self 或window.top 穿透sandbox
    if (p === "top" || p === "window" || p === "self") {
      return proxy;
    }

    if (p === "hasOwnProperty") {
      return hasOwnProperty;
    }

    // 批處理場景下會有場景使用,這裏就很少贅述了
    const proxyPropertyGetter = getProxyPropertyGetter(proxy, p);
    if (proxyPropertyGetter) {
      return getProxyPropertyValue(proxyPropertyGetter);
    }

    // 取值
    const value = propertiesWithGetter.has(p)
      ? (rawWindow as any)[p]
      : (target as any)[p] || (rawWindow as any)[p];
    return getTargetValue(rawWindow, value);
  },

  // 還有一些對屬性作操做的代碼我就不一一列舉了,能夠自行查閱源碼
});

接下來看下 proxySandBox 的 激活 / 卸載:

active() {
    this.sandboxRunning = true;
    // 當前激活的子應用沙箱實例數量
    activeSandboxCount++;
  }

  inactive() {
    clearSystemJsProps(this.proxy, --activeSandboxCount === 0);

    this.sandboxRunning = false;
  }

可見,由於 proxySandBox 不直接操做 window,因此在激活和卸載的時候也不須要操做狀態池更新 / 還原主子應用的狀態了。相比較看來,proxySandBox 是現階段 qiankun 中最完備的沙箱模式,徹底隔離了主子應用的狀態,不會像 legacySandBox 模式下在運行時期間仍然會污染 window。

snapshotSandBox

最後一種沙箱就是 snapshotSandBox,在不支持 Proxy 的場景下會降級爲 snapshotSandBox,如同他的名字同樣,snapshotSandBox 的原理就是在子應用激活 / 卸載時分別去經過快照的形式記錄/還原狀態來實現沙箱的。

源碼很簡單,直接看源碼:

active() {
    if (this.sandboxRunning) {
      return;
    }


    this.windowSnapshot = {} as Window;
    // iter方法就是遍歷目標對象的屬性而後分別執行回調函數
    // 記錄當前快照
    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 和記錄的快照作 diff 來實現沙箱。

css 隔離

這實際上是個沉重的話題,從我作微前端到如今對於 css 的處理也沒有太好的辦法,這裏我直接總結了兩種目前項目中使用的方案你們能夠參考。

約定式編程

這裏咱們能夠採用必定的編程約束:

  • 儘可能不要使用可能衝突全局的 class 或者直接爲標籤訂義樣式;
  • 定義惟一的 class 前綴,如今的項目都是用諸如 antd 這樣的組件庫,這類組件庫都支持自定義組件 class 前綴;
  • 主應用必定要有自定義的 class 前綴;

css in js

這種方式其實有待商榷,由於徹底的 css in js 雖然必定會實現 css 隔離,可是其實這樣的編程寫法不利於咱們後期的項目維護而且也比較難去抽離一些公共 css。

推薦閱讀

相關文章
相關標籤/搜索