【源碼】微前端qiankun源碼閱讀(2):加載子應用與沙箱隔離

前言

上一篇文章了解了qiankun的整體運行。下面繼續看:
1.qiankun如何根據entry字段去加載子應用的資源。
2.qiankun提供的沙箱隔離。

正文

(1) loadApp

在上一篇中說到single-spa的app配置需要開發者自己處理加載子應用的邏輯,在qiankun的registerMicroApps中,封裝了loadApp方法。

export function registerMicroApps<T extends ObjectType>(
  apps: Array<RegistrableApp<T>>,
  lifeCycles?: FrameworkLifeCycles<T>,
) {
  // Each app only needs to be registered once
  const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));

  microApps = [...microApps, ...unregisteredApps];

  unregisteredApps.forEach((app) => {
    const { name, activeRule, loader = noop, props, ...appConfig } = app;

    registerApplication({
      name,
      app: async () => {
        loader(true);
        await frameworkStartedDefer.promise;

        const { mount, ...otherMicroAppConfigs } = (
          await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
        )();

        return {
          mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
          ...otherMicroAppConfigs,
        };
      },
      activeWhen: activeRule,
      customProps: props,
    });
  });
}

然后進入到src/loader.ts中查看loadApp都做了什么:

image.png

首先使用importEntry將entry傳入,獲取到 template, execScripts, assetPublicPath。importEntry這個包的作用就是,給它一個站點鏈接,它請求到站點的整個html,然后解析出html的各個內容:dom、script、css等,然后根據需要去加載。下面是其全部返回的輸出:

image.png

這里也許會有疑問是:為什么像上一篇中直接使用動態加載script技術一樣,直接將整個html append到domcument加載出來,不是很省事情嗎?
這是因為qiankun要做沙箱隔離,所以先自己解析出資源,處理后再加載到頁面。

對于dom和style,qiankun會對其進行一些包裝,然后使用getRender下的render方法,將內容append到容器中:

      if (element) {
        rawAppendChild.call(containerElement, element);
      }

最終容器內容如下:

image.png
(2) execScripts 與沙箱隔離

上圖可以看到script標簽都被注釋掉了,下面要使用execScripts去執行JS。在importEntry中查看execScripts:

image.png

其可以傳入一個沙箱,讓JS都在這個沙箱中執行。回到qiankun的loadApp方法中:

export async function loadApp<T extends ObjectType>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
  const { entry, name: appName } = app;
  const {
    singular = false,
    sandbox = true,
    excludeAssetFilter,
    globalContext = window,
    ...importEntryOpts
  } = configuration;
  ... ...
  const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
  let global = globalContext;

  const useLooseSandbox = typeof sandbox === 'object' && !!sandbox.loose;
  let sandboxContainer;
  if (sandbox) {
    sandboxContainer = createSandboxContainer(
      appInstanceId,
      // FIXME should use a strict sandbox logic while remount, see https://github.com/umijs/qiankun/issues/518
      initialAppWrapperGetter,
      scopedCSS,
      useLooseSandbox,
      excludeAssetFilter,
      global,
    );
    // 用沙箱的代理對象作為接下來使用的全局對象
    global = sandboxContainer.instance.proxy as typeof window;
    mountSandbox = sandboxContainer.mount;
    unmountSandbox = sandboxContainer.unmount;
  }

  ... ...

  // get the lifecycle hooks from module exports
  const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox);

從上面可以看到,global一開始默認為window環境,后判斷如果sandbox為true,使用createSandboxContainersandboxContainer.instance.proxy來替換。在src/sandbox/index.ts下查看createSandboxContainer

import LegacySandbox from './legacy/sandbox';
import ProxySandbox from './proxySandbox';
import SnapshotSandbox from './snapshotSandbox';

export function createSandboxContainer(
  appName: string,
  elementGetter: () => HTMLElement | ShadowRoot,
  scopedCSS: boolean,
  useLooseSandbox?: boolean,
  excludeAssetFilter?: (url: string) => boolean,
  globalContext?: typeof window,
) {
  let sandbox: SandBox;
  if (window.Proxy) {
    sandbox = useLooseSandbox ? new LegacySandbox(appName, globalContext) : new ProxySandbox(appName, globalContext);
  } else {
    sandbox = new SnapshotSandbox(appName);
  }
  ... ...
}

可以看到qiankun有三種JS隔離機制,分別是 SnapshotSandbox、LegacySandbox和ProxySandbox。

三個沙箱的原理,都比較簡單:
SnapshotSandbox:快照沙箱。就是在加載子應用時淺拷貝一份window,名為windowSnapshot。在卸載子應用時,再使用windowSnapshot將window復原。下面是自己的簡單實現:

image.png

LegacySandbox
LegacySandbox和快照沙箱差不多,不同的是,其使用Proxy劫持set操作,記錄那些被更改的window屬性。這樣在后續的狀態還原時候就不再需要遍歷window的所有屬性來進行對比,提升了程序運行的性能。但是它最終還是去修改了window上的屬性,所以這種機制仍然污染了window的狀態。

ProxySandbox:看了LegacySandbox會有疑問,既然都用了代理了,修改代理對象就好了,為什么經過代理后還去修改window啊:

    const proxy = new Proxy(fakeWindow, {
      set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
        if (this.sandboxRunning) {
          this.registerRunningApp(name, proxy);
          // We must kept its description while the property existed in globalContext before
          if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
            const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
            const { writable, configurable, enumerable } = descriptor!;
            if (writable) {
              Object.defineProperty(target, p, {
                configurable,
                enumerable,
                writable,
                value,
              });
            }
          } else {
            // @ts-ignore
            target[p] = value;
          }
          ... ...
        }
      },
    });

上面代碼中fakeWindow初始為一個對象,通過劫持set操作,將value保存到fakeWindow即可,不用去修改window,所以也不用復原操作。

(3) execScripts原理

終于弄明白傳入execScripts的沙箱是個啥玩意了,下面回到import-html-entry的execScripts中:

image.png

主要看到getExecutableScriptevalCode,我們有了沙箱后,如何讓JS代碼在沙箱環境執行呢?

看到getExecutableScript,它將傳入的scriptText進行了一層自執行函數包裹,自執行函數接收代理對象,然后函數參數名為window。這樣子,scriptText中對window的訪問,實際都是訪問到代理對象!

image.png

得到最終的code后,調用evalCode

image.png

可以看到,evalCode就是簡單的調用eval,執行我們的JS代碼,由此實現應用的JS Bundle加載!

總結

這一篇主要講的是qiankun的loadApps,如何根據entry字段去加載子應用的資源、以及提供的沙箱來執行JS。大概流程就是這樣。


image.png

另外有個疑問是,無論是快照沙箱還是代理沙箱,只能監聽到window上第一層的key值,對于更深層的對象,如果被修改了那還是會被污染的。

參考

微前端-最容易看懂的微前端知識
微前端01 : 乾坤的Js隔離機制原理剖析(快照沙箱、兩種代理沙箱)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容