微前端核心原理分析

86 阅读4分钟

CSS 隔离

CSS 脚本劫持

通过劫持浏览器的 <head/></body> 的相关原生方法(Node.appendChild、Node.insertBefore)修改 style 节点的挂载位置:

image.png

image.png

此做法非常优雅地做到了隔离,在切换应用时会比较方便,一旦应用被移除相当于对应的 DOM 被移除。

注意上图,可以看到通过 href 属性的 CSS 样式全部被改写成了用 fetch 获取,相当于转成了内联的 stylesheet。如果应用二次加载,这些数据也可以从缓存(js 内存缓存,非浏览器缓存)中取。 那么它又是如何做到微应用之间的样式隔离的呢?其实现方案在上图的 process 方法。

image.png

image.png

image.png

  • 例如处理 body、root、 html 这种根级别的节点(Root Level), 在微应用中将变成外层的 wrapper:

image.png

image.png

image.png

image.png

image.png

  • @support、@keyframe、@font-fase、@import 这种不处理。

image.png

使用 Shadow DOM

Shadow DOM 拥有原生的样式隔离,但是对于一些挂载在根节点的组件,例如 popover 之类的非常不友好。业务上很难搞定,故这里不深入展开。

JavaScript 隔离

快照 (snapshot) 沙箱

在应用沙箱挂载和卸载的时候记录快照,在应用切换的时候依据快照恢复环境。

import type { SandBox } from '../interfaces';
import { SandBoxType } from '../interfaces';

function iter(obj: typeof window, callbackFn: (prop: any) => void) {
  // eslint-disable-next-line guard-for-in, no-restricted-syntax
  for (const prop in obj) {
    // patch for clearInterval for compatible reason, see #1490
    if (obj.hasOwnProperty(prop) || prop === 'clearInterval') {
      callbackFn(prop);
    }
  }
}

/**
 * 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
 */
export default class SnapshotSandbox implements SandBox {
  proxy: WindowProxy;

  name: string;

  type: SandBoxType;

  sandboxRunning = true;

  private windowSnapshot!: Window;

  private modifyPropsMap: Record<any, any> = {};

  constructor(name: string) {
    this.name = name;
    this.proxy = window;
    this.type = SandBoxType.Snapshot;
  }

  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];
    });

    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];
      }
    });

    if (process.env.NODE_ENV === 'development') {
      console.info(`[qiankun:sandbox] ${this.name} origin window restore...`, Object.keys(this.modifyPropsMap));
    }

    this.sandboxRunning = false;
  }
}

几个问题:

  • 只支持浅层的隔离,如果是 window 下复杂的引用对象则不能处理。
  • 无法支持多实例模式,因为大家本质上还是共享一个 window。

代理 (proxy) 沙箱

核心思路

Proxy 沙箱解决了快照沙箱无法支持多实例的问题,它通过代理 window 对象,使得微应用所有对 window 变量的修改全部代理到另外一个 map 对象,而这个 map 对象可以和子应用的生命周期绑定在一起。

一个简单的代理沙箱实现如下:

<!DOCTYPE html>
<html lang='en'>
<head>
  <meta charset='UTF-8'>
  <title>Title</title>
</head>
<body>
<script>
  const varBox = {}

  const get = (target, key) => {
    // 防止 window.window 逃逸,类似的还有 window.self 之类的 API,
    // 需要特别注意一些边界 case
    if (key === 'window') {
      return fakeWindow
    }
    return varBox[key] || window[key]
  }

  const set = (target, key, value) => {
    varBox[key] = value
    return true
  }

  const context = new Proxy(window, {
    get,
    set
  })

  const fakeWindow = new Proxy(window, {
    get,
    set
  })

  const fn = (code) => {
    return () => {
      new Function(
        'window',
        `with(window){
          ${code}
        }`
      )(fakeWindow)
    }
  }

  fn('console.log(window); window.a = 1')()
  console.log(window.a)
  console.log(fakeWindow.a)
</script>
</body>
</html>

输出如下:

image.png

var 变量逃逸问题

一个完美沙箱的实现是任重而道远的,上面的 demo 只是阐述了其核心思路。实际上有很多的坑要趟平,例如上面提到了 window.windowwindow.self 会逃逸。

下面我们会介绍 var 变量逃逸的坑,我们现在有两份代码,分别来自不同的模块,假如先执行 a.js 再执行 b.js

// x 会被挂在 window 上,因为在全局作用域中声明的会成为 window 对象的属性
var x = 1

// 在 b.js 中执行, 根据作用域会从 window 中取,所以这里 x = 1
console.log(x)

根据我们的沙箱机制,这两份代码会如此执行(高亮表示)

var obj = {};

(function() {
  with (obj) {
    var x = 1
  }
  // console.log(x) // x = 1, 说明 x 会被定义在外围函数的作用域上
})();

(function() {
  with (obj) {
    console.log(x) // ReferenceError: x is not defined
  }
})()

特别注意这里 var x = 1 做了什么,读者请尝试执行下面的代码:

var obj = { x: 3 };

(function() {
  with (obj) {
    var x = 1
  }
})()

console.log(obj)

你会发现 with 传入的作用域直接被改写了!而使用 const 却没有此情况,这是为什么?查阅 ECMAScript 规范,我们得到了答案:

image.png

如果一个 var 声明被嵌套在一个 with 语句中,并且声明的变量名是其属性(property),那么会跳过第五步,直接修改该属性的值。

综上所述,var x = 1 只有在 obj 里面有 x 属性时(undefined、null 这种也算有属性),那么我们的问题就是如何将 x = 1 在声明的时候就放到 obj 里面。

其实这个方案很简单,判断是否存在的不就是 proxy 的 has 属性嘛!

最终我们只需要将上面的 proxy 沙箱稍作修改即可:

<!DOCTYPE html>
<html lang='en'>
<head>
  <meta charset='UTF-8'>
  <title>Title</title>
</head>
<body>
<script>
  const varBox = {}

  const get = (target, key) => {
    if (key === 'window') {
      // 略去其它情况下的边界 case。例如 self
      return fakeWindow
    }
    return varBox[key] || window[key]
  }

  const set = (target, key, value) => {
    varBox[key] = value
    return true
  }

  const has = (target, p) => {
    return true
  }

  const fakeWindow = new Proxy(window, {
    get,
    set,
    has
  })

  const fn = (code) => {
    return () => {
      new Function(
        'window',
        `with(window){
          ${code}
        }`
      )(fakeWindow)
    }
  }

  fn('var x = 1;')()

  // 正常执行
  fn('console.log(x)')()
</script>
</body>
</html>

但带来了新问题, in 语法永远返回 true,不符合预期:

fn('console.log("fn" in window)')() // 哪怕不存在也会返回 true

这个问题如何解决?其实用两个 proxy 即可解决,他们共同维护一个全局沙箱变量(即虚假的 window,代码中为 varBox 变量), 第一个作为 with 的 contextWindow(has 返回值全部为 true),第二个利用作用域链的机制让代码中所有的 window 指向他,并且 has 为 false:

<!DOCTYPE html>
<html lang='en'>
<head>
  <meta charset='UTF-8'>
  <title>Title</title>
</head>
<body>
<script>
  const varBox = {}

  const get = (target, key) => {
    // 防止 window.window 逃逸,类似的还有 window.self 之类的 API,
    // 需要特别注意一些边界 case
    if (key === 'window') {
      return fakeWindow
    }
    return varBox[key] || window[key]
  }

  const set = (target, key, value) => {
    varBox[key] = value
    return true
  }

  // 使用 has 解决 var 逃逸问题
  const has = (obj, property) => {
    return true
  }

  const fakeWindow = new Proxy(window, {
    get,
    set
  })

  const withContext = new Proxy(window, {
    get,
    set,
    has
  })

  const fn = (code) => {
    return () => {
      new Function(
        'window',
        'withContext',
        `with(withContext){
          ${code}
        }`
      )(fakeWindow, withContext)
    }
  }

  fn('var x = 1;')()
  fn('console.log(x)')() // 1

  fn('console.log("foo" in window)')() // false

  console.log(window.x) // undefined
</script>
</body>
</html>

微应用的加载

这里以知名框架 qiankun 为例,谈谈它的加载方式。 qiankun 的加载是利用了一个库 import-html-entry。qiankun 加载微应用时会之前去加载其入口 HTML,这样的好处有:

  • 支持一个微应用多个入口资源,在某些性能优化场景下这个会很有用,另外某些 webpack 插件必须要多入口支持,例如 module-federation-plugin 或者 react hmr webpack plugin。
  • 无需考虑资源缓存的问题,在之前单入口环境下路径是写死的,如果需要动态处理可能需要额外传 query,增加运维成本。
  • 更友好的 DOM 处理,主应用可以获取微应用的入口 DOM 结构。 其具体方法如下:
  • 根据 HTML 入口发送请求,获取 HTML 文本。
  • 解析入口,将所有的静态资源全部注释掉。
  • 手动加载上一步的注释掉的资源,隔离/沙箱化运行相应的 css、js。

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 15 天,点击查看活动详情