【源码】微前端qiankun源码阅读(2):加载子应用与沙箱隔离

前言

在上一篇文章了解了qiankun的整体运行。下面继续看:
1.qiankun如何根据entry字段去加载子应用的资源。
2.qiankun提供的沙箱隔离。

正文

(1) loadApp

在上一篇中说到single-spa的app配置需要开发者自己处理加载子应用的逻辑,在qiankun的registerMicroApps中,封装了loadApp方法。

export function registerMicroApps(
  apps: Array>,
  lifeCycles?: FrameworkLifeCycles,
) {
  // 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(
  app: LoadableApp,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles,
): Promise {
  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隔离机制原理剖析(快照沙箱、两种代理沙箱)

你可能感兴趣的:(【源码】微前端qiankun源码阅读(2):加载子应用与沙箱隔离)