解密微前端:从qiankun看沙箱隔离

11,178 阅读7分钟

在我之前的文章提到过,微前端的本质是分治的处理前端应用以及应用间的关系,那么更进一步,落地一个微前端框架,就会涉及到三点核心要素:

  • 子应用的加载;
  • 应用间运行时隔离
  • 应用间通信
  • 路由劫持;

对于 qiankun 来说,路由劫持是在 single-spa 上去做的,而 qiankun 给我们提供的能力,主要便是子应用的加载和沙箱隔离。

承接上文,这是系列的第二个 topic,这篇文章主要基于 qiankun 源码向大家讲一下沙箱隔离如何实现。

qiankun 做沙箱隔离主要分为三种:

  • 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);
  },
});

接下来看下子应用沙箱的激活 / 卸载:

  // 子应用沙箱激活
  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。

推荐阅读