微前端原理之隔离

  • iframe 隔离: 空白页(src="about:blank") iframe 隔离和服务端同源的 iframe 隔离方案设计。不仅可以利用不同的浏览上下文实现彻底的微应用隔离,与普通 iframe 方案而言,还可以解决白屏体验问题,是微前端框架实现隔离的重要手段;
  • iframe + Proxy 隔离: 解决空白页 iframe 隔离无法调用 history API 的问题,并可用于解决 iframe 方案中无法处理的 URL 状态同步问题;
  • 快照隔离: 浏览器无法兼容 Proxy 时,可以通过简单的快照实现 window 变量的隔离,但是这种隔离方案限制较多,例如无法实现主子应用的隔离,无法实现多个微应用并存的隔离。当然大多数场景是一个时刻运行一个微应用,因此是一种兼容性良好的隔离方案;
  • CSS 隔离: 如果主应用和微应用同处于一个 DOM 上下文,那么需要考虑 CSS 样式的隔离处理。

iframe + proxy隔离

function execMicroCode() {
    
    // 注意传入了第二个参数 contentWindow,这是没有被代理的 iframe 的 window 对象
    const microCode = `(function(window, contentWindow){
      
      // 打印信息 
      // [proxy set 执行] 拦截的 prop:  a 
    
	  this.a = 2;
      
      // 打印信息 
      // [proxy get 执行] 拦截的 prop:  parent
      // this 是否是主应用的 window:  false
      console.log('[微应用执行] this 是否是主应用的 window: ', this === window.parent);
      
      // 打印信息 
      // [微应用执行] this 是否是子应用的 window:  false
      console.log('[微应用执行] this 是否是子应用的 window: ', this === contentWindow);
      
      // 打印信息 
      // [proxy get 执行] 拦截的 prop:  proxy
      // [微应用执行] this 是否是子应用的 window.proxy:  true
      console.log('[微应用执行] this 是否是子应用的 window.proxy: ', this === window.proxy);
 
    // 将内部的 this 指向 window 的代理对象
    }).bind(window.proxy)(window.proxy, window)`;
  
    const scriptElement =
      iframe.contentWindow.document.createElement("script");
    scriptElement.textContent = microCode;
    // 添加内嵌的 script 元素时会自动触发 JS 的解析和执行
    iframe.contentWindow.document.head.appendChild(scriptElement);
}Ï

具备了上述的 iframe + Proxy 隔离设计后:

  • 可以解决 JS 运行环境的隔离问题,当然除了 history 故意不进行隔离
  • 微应用在使用 var 时需要挂载在全局对象上的能力缺失
  • 可以解决微应用之间的全局属性隔离问题,包括使用未限定标识符的变量、this
  • 使用 this 时访问是 iframe 的 window 代理对象,可以和主应用的 this 隔离
  • 可以解决 iframe 隔离中无法进行 history 操作和同步的问题

问题修复

一些构造函数不需要进行 bind 操作,因为 bind 生成的函数会失去原有函数的属性和 prototype
为了修复上述问题,可以在 iframe 的 window 被拦截时对 prop 进行判断,通过 bind 对 window.alert 、window.addEventListenerwindow.atob 等进行 this 修正:

// 1. 修正 window.alert、window.addEventListener 等 API 的 this 指向,需要识别出这些函数
// 2. 过滤掉已经做了 bind 的函数
// 3. 过滤掉构造函数,例如原生的 Object、Array 以及用户自己创建的构造函数等
 
function isFunction(value) {
   return typeof value === "function";
}
 
function isBoundedFunction(fn) {
   // 被绑定的函数本身没有 prototype
   return fn.name.indexOf("bound ") === 0 && !fn.hasOwnProperty("prototype")
}
 
// 是否是构造函数(这个实现比较复杂,这里可以简单参考 qiankun 实现)
function isConstructable() {
  // 可以过滤 Object、Array 等
  return (
    // 过滤掉箭头函数、 async 函数等。这些函数没有 prototype
    fn.prototype &&
    // 通常情况下构造函数的 prototype.constructor 指向本身
    fn.prototype.constructor === fn &&
    // 通常情况下构造函数和类都会存在 prototype.constructor,因此长度至少大于 1
    // 需要注意普通函数中也会存在 prototype.constructor,
    // 因此如果 prototype 有自定义属性或者方法,那么判定为类或者构造函数,因此这里的判断是大于 1
    // 注意不要使用 Object.keys 进行判断,Object.keys 无法获取 Object.defineProperty 定义的属性
    Object.getOwnPropertyNames(fn.prototype).length > 1
  );
  // TODO: 没有 constructor 的构造函数识别、class 识别、function Person() {} 识别等
  // 例如 function Person {};  Person.prototype = {}; 此时没有 prototype.constructor 属性
}
 
// 最后可以对 window 的属性进行修正,以下函数执行在 Proxy 的 get 函数中
function getTargetValue(target, prop) {
    const value = target[prop];
    // 过滤出 window.alert、window.addEventListener 等 API 
    if(isFunction(value) && !isBoundedFunction(value) && !isConstructable(value)) {
        // 修正 value 的 this 指向为 target 
        const boundValue = Function.prototype.bind.call(value, target);
        // 重新恢复 value 在 bound 之前的属性和原型(bind 之后会丢失)
        for (const key in value) {
          boundValue[key] = value[key];
        }
        // 如果原来的函数存在 prototype 属性,而 bound 之后丢失了,那么重新设置回来
        if(value.hasOwnProperty("prototype") && !boundValue.hasOwnProperty("prototype")) {
            boundValue.prototype = value.prototye;
        }
        return boundValue;
    }
    return value;
}

快照隔离

可以创建自执行的匿名函数来隔绝变量声明的作用域

  • 但是!要注意使用 var 在全局作用域中声明的变量将作为全局对象 window 的不可配置属性被添加,因此在全局作用域声明的变量 a 同时也是 window 对象的属性。
  • 所以如果在全局使用var 生命的变量,子作用域是可以重新赋值的
  • 所以同理,因为子作用域的this会默认指向 window,在对 this 进行属性赋值时,也会污染全局属性

eval 和 function 的区别

Window 快照会完全复用主应用的 Context,本质上没有形成隔离,仅仅是在主应用 Context 的基础上记录运行时需要的差异属性,每一个微应用内部都需要维护一个和主应用 window 对象存在差异的对象。不管是调用 Web API 还是设置 window 属性值,本质上仍然是在主应用的 window 对象上进行操作,只是会在微应用切换的瞬间恢复主应用的 window 对象,此方案无法做到真正的 Context 隔离,并且在一个时刻只能运行一个微应用,无法实现多个微应用同时运行

快照隔离是是一种相对简单的隔离方案,如果微应用在运行时仅仅需要隔离 window 对象的属性冲突,那么快照隔离是一个非常不错的隔离方案。当然,快照隔离无法解决主子应用同时运行时的 window 对象属性冲突问题,也无法解决多个微应用同时运行的问题。

CSS隔离

如果要彻底实现 CSS 的隔离,最好的方式是实现 Renderer 进程中浏览上下文的隔离,例如之前讲解的 iframe 隔离,它可以天然实现 CSS 隔离。但是如果微应用和主应用在同一个 DOM 环境中,那么仍然有几种思路可以避免 CSS 样式污染:

  • 对微应用的每一个 CSS 样式和对应的元素进行特殊处理,从而保证样式唯一性,例如 Vue 的 Scoped CSS
  • 对微应用的所有 CSS 样式添加一个特殊的选择器规则,从而限定其影响范围
  • 使用 Shadow DOM 实现 CSS 样式隔离

核心代码:

 
loadStyle({ style, id }) {
          return new Promise((resolve, reject) => {
            const $style = document.createElement("link");
            $style.href = style;
            $style.setAttribute("micro-style", id);
            $style.rel = "stylesheet";
            $style.onload = resolve;
            $style.onerror = reject;
            
            // 动态 Script 方案
            // document.body.appendChild($style);
 
            // Web Components 方案
            // 将微应用的 CSS 样式添加到可以隔离的 Shadow DOM 中   
            const $webcomponent = document.querySelector(`[micro-id=${id}]`);
            const $shadowRoot = $webcomponent?.shadowRoot;
            $shadowRoot?.insertBefore($style, $shadowRoot?.firstChild);
          });
        }

生成应用运行时沙箱 * * 沙箱分两个类型: *

  1. app 环境沙箱 * app 环境沙箱是指应用初始化过之后,应用会在什么样的上下文环境运行。每个应用的环境沙箱只会初始化一次,因为子应用只会触发一次 bootstrap 。 * 子应用在切换时,实际上切换的是 app 环境沙箱。
  2. render 沙箱 * 子应用在 app mount 开始前生成好的的沙箱。每次子应用切换过后,render 沙箱都会重现初始化。

Legacy 隔离

 Legacy Proxy 隔离是基于 Proxy 实现的一种单实例隔离方案,它的原理和快照隔离类似,都是在激活微应用时记录 window属性的变更,在失活时还原变更,由于操作的同样是主应用的 window,因此和快照隔离类似,只能实现单实例的隔离模式。

LegacySandbox 隔离的特性如下所示:

  • 能够实时捕获全局变量的增、删、改、查操作,并可以对这些操作进行更强的管控,防止沙箱逃逸
  • 可以拦截对全局对象的访问行为,比如避免访问 window.top 和 window.parent 进行沙箱逃逸
  • 可以禁用全局对象的设置和获取
  • 可以灵活控制哪些全局对象只读、可被修改,增强了隔离的管控
  • 可以实时动态记录全局对象的变更情况
  • 能够实时感知非激活状态的属性变更(例如卸载后仍然设置变量),提供警告信息提示,快照隔离只能在激活和失活时记录状态变更
  • 快照隔离在激活和失活时需要遍历所有的 window 属性消耗性能,代理则在操作 window 时会产生拦截性能消耗
  • 具备实时变更的反馈能力,更容易调试和理解 window 属性的变化过程,快照隔离只有在失活时才能记录变更,难以追踪变化过程