微前端沙箱设计与实现:从 qiankun 源码剖析到主流微前端框架简评

132 阅读25分钟

前言:本文是掘金小册深入浅出微前端的学习成果。子弈老师从微前端的框架原理到工程实践写得非常详细,是一本不可多得的优质小册,强烈推荐大家去学习。 本文并非简单的阅读笔记,而是旨在介绍 qiankun 微前端框架的核心原理。我们将基于 qiankun,由浅入深地实现一个 JavaScript 沙箱,以揭示其工作机制,希望能为大家提供有价值的参考。微前端的技术深度或许不及 Vue 或 React 等框架,但其广度却几乎涵盖了前端的所有方面。因此,学习微前端也是一个全面巩固和提升前端基础能力的过程。

微前端是一种将大型前端应用拆分成多个独立、可单独开发、部署和上线的小型应用的技术,这些小型应用可以采用不同的技术栈。这种架构极大地提升了DX(developer experience),同时在用户看来,所有部分仍聚合为单个内聚的产品,保证了无缝的UX(user experience)

微前端的诞生,主要是为了解决三大挑战:应用的增量升级、多技术栈并存,以及大规模企业级 Web 应用的复杂性管理。

在 MPA 时代,微前端可以通过路由天然隔离,但在 SPA 时代,要在同一个页面上部署和管理多个前端应用,则需要强大的工程框架支持。这其中的核心难点,除了应用的加载与通信,便是微应用间的环境隔离,即 JavaScript 隔离CSS 隔离,统称为“沙箱”。

沙箱本质上是一个隔离环境。在许多场景下,平台提供了原生沙箱,如 Node.js 的 vm 模块、浏览器的 iframe 或浏览器插件。当我们希望执行不受信任的代码时,沙箱能够限制其作用域和对系统资源的访问。然而,在某些情况下,例如我们需要在一个系统中安全地执行用户提交的插件代码时,并没有现成的原生沙箱可用。这时,我们就必须在同一个执行作用域内手动实现隔离。理解沙箱原理的意义便在于此。

本文会从脚本和样式的隔离方案入手,重点展开 js 沙箱的实现。

微前端之 js 沙箱

在浏览器环境中,每个标签页(tab)通常对应一个主渲染进程,但为了实现站点隔离(Site Isolation),也可能包含多个渲染进程。JavaScript 代码在物理隔离的渲染进程中执行,因此不同的标签页之间存在天然的进程级隔离。在同一标签页下,跨站点的 iframe 会创建新的渲染进程,而同源的 iframe 则在同一进程中使用不同的 JavaScript 执行上下文进行隔离。

而微前端框架要做的是,在同一个 JavaScript 执行上下文中,实现不同微应用之间的隔离。

基于 iframe 的隔离

iframe是浏览器目前唯一提供的原生JS 运行时隔离,当然,严格意义来讲 Web WorkersWebAssembly 也是一种进程级隔离,因为他们都不能直接操作 DOM 所以不在我们的考虑范畴。

通过在主应用中动态创建一个空白的  src="about:blank"iframe标签,然后将微应用放到这里执行,天然就是隔离的 js 和 css 环境。但问题在于 about:blank 可能会导致微应用的路由异常,比如 Vue 的 hash 或者 history 模式,底层都会因为调用 history 而产生异常。这个可以通过服务端提供的同源 iframe 来解决。

在实践中,采用 iframe 方案还需解决主子应用通信、URL 同步、以及子应用弹窗定位等一系列工程问题。

接下来我们讨论在同一个 js 上下文中做作用域隔离,这也是 qiankun的隔离方案。

作用域隔离

Snapshot 快照隔离

在 js 中很容易制造局部作用域,也就是函数,但是问题在于局部作用域是可以访问全局作用域的,所以重点在于让微应用的在一个函数作用域执行的基础上(IIFE)上做全局作用域的隔离。

比如这段代码:

window.a = 1
window.b = 2
window.c = 3
window.d = 4
console.log(`微应用开始前:a:${a},b:${b},c:${c},d:${d}`);
//微应用代码 开始
a = 11
var b = 12
this.c = 13
window.d = 14
console.log(`微应用执行后:a:${a},b:${b},c:${c},d:${d}`);
//微应用代码 结束
console.log(`微应用结束后:a:${a},b:${b},c:${c},d:${d}`);

// 这段代码执行输出:
// 微应用开始前:a:1,b:2,c:3,d:4
// 微应用执行后:a:11,b:12,c:13,d:14
// 微应用结束后:a:11,b:12,c:13,d:14

直接将微应用和主应用在同一个作用域执行会发现全局变量会全部被污染,如果我们加上 IIFE:

window.a = 1
window.b = 2
window.c = 3
window.d = 4
console.log(`微应用开始前:a:${a},b:${b},c:${c},d:${d}`);
//微应用代码 开始
; (() => {
  a = 11
  var b = 12
  this.c = 13
  window.d = 14
  console.log(`微应用执行后:a:${a},b:${b},c:${c},d:${d}`);
})()
//微应用代码 结束
console.log(`微应用结束后:a:${a},b:${b},c:${c},d:${d}`);

// 这段代码执行输出:
// 微应用开始前:a:1,b:2,c:3,d:4
// 微应用执行后:a:11,b:12,c:13,d:14
// 微应用结束后:a:11,b:2,c:13,d:14

可以看到通过 var声明的变量因为是在函数的局部作用域所以不会和主应用的全局变量冲突,但是隐式访问的变量(a)以及通过 thiswindow 就会逃逸到主应用的作用域从而造成污染。一个直接的思路是,通过为 IIFE 绑定 this 并传入 window 参数,尝试将它们作为局部变量来限制作用域:

window.a = 1
window.b = 2
window.c = 3
window.d = 4
console.log(`微应用开始前:a:${a},b:${b},c:${c},d:${d}`);

// 模拟一个 window 沙箱
const fakeWindow = {};
//微应用代码 开始
(function (window) {
  a = 11
  var b = 12
  this.c = 13
  window.d = 14
  console.log(`微应用执行后:a:${a},b:${b},c:${c},d:${d}`);
}).bind(fakeWindow)(fakeWindow)
//微应用代码 结束
console.log(`微应用结束后:a:${a},b:${b},c:${c},d:${d}`);

// 这段代码执行输出:
// 微应用开始前:a:1,b:2,c:3,d:4
// 微应用执行后:a:11,b:12,c:3,d:4
// 微应用结束后:a:11,b:2,c:3,d:4

可以看到微应用代码通过 .bind(fakeWindow)(fakeWindow) 在沙箱环境中运行只有 隐式全局变量 会因为JavaScript 引擎在找不到变量声明时,会自动将其提升为全局变量从而逃逸沙箱。

有 2 个解决方向:一个是静态代码转换(AST 方案),一个是运行时作用域劫持(with 方案)。

代码转换是指在代码执行前,通过语法分析找出所有隐式全局变量,将其转换为显式的沙箱属性访问。缺点是实现复杂,需要完整的 JavaScript 解析器。

我们重点看一下运行时的作用域劫持,这也是 qiankun 的方案。我们给这段代码加一个 with:

(function (window) {
      with (window) {
        a = 11
        var b = 12
        this.c = 13
        window.d = 14
        console.log(`微应用执行后:a:${a},b:${b},c:${c},d:${d}`);
      }
    }).bind(fakeWindow)(fakeWindow)

运行后发现,全局作用域的 a 依然被修改。其原因是 with 语句在 fakeWindow 上找不到 a 属性,便会沿着作用域链向上查找,最终在全局作用域中找到了并修改了它。为此,我们可以在微应用代码执行前创建一份全局作用域的快照,执行完毕后,再将全局作用域恢复到快照状态,以此撤销微应用带来的变更:

window.a = 1
window.b = 2
window.c = 3
window.d = 4
console.log(`微应用开始前:a:${a},b:${b},c:${c},d:${d}`);

// 模拟一个 window 沙箱
const fakeWindow = {};

// 记录微应用执行前的 window 快照
const windowSnapshot = {};
for (const key in window) {
  if (window.hasOwnProperty(key)) {
	windowSnapshot[key] = window[key];
  }
}

//微应用代码 开始
(function (window) {
  with (window) {
	a = 11
	var b = 12
	this.c = 13
	window.d = 14
	console.log(`微应用执行后:a:${a},b:${b},c:${c},d:${d}`);
  }
}).bind(fakeWindow)(fakeWindow)
//微应用代码 结束 先执行恢复
for (const key in windowSnapshot) {
  if (windowSnapshot[key] != window[key]) {
	window[key] = windowSnapshot[key];
  }
}
console.log(`微应用结束后:a:${a},b:${b},c:${c},d:${d}`);

// 这段代码执行输出:
// 微应用开始前:a:1,b:2,c:3,d:4
// 微应用执行后:a:11,b:12,c:3,d:4
// 微应用结束后:a:1,b:2,c:3,d:4

可以看到微应用中的所有全局作用域都是在沙箱内并不会逃逸。微应用中很常见的一个场景是激活微应用 -> 失活 ->再次激活,这种二次激活的时候我们希望微应用能够从上一次失活的状态继续而不是重新开始,我们就需要做状态的保持和恢复:

window.a = 1
window.b = 2
window.c = 3
window.d = 4
// 模拟一个 window 沙箱
const fakeWindow = {};

// 记录微应用执行前的 window 快照
const windowSnapshot = {};
// 记录微应用执行期间 window 对象的属性变更
const modifyPropsMap = {};

function active () {
  for (const key in window) {
	if (window.hasOwnProperty(key)) {
	  windowSnapshot[key] = window[key];
	}
  }
  for (const key in modifyPropsMap) {
	window[key] = modifyPropsMap[key];
  }
}

function inactive () {
  // 微应用代码 结束 先执行恢复
  // 记录微应用执行期间 window 对象的属性变更
  for (const key in windowSnapshot) {
	if (windowSnapshot[key] != window[key]) {
	  modifyPropsMap[key] = window[key];
	  window[key] = windowSnapshot[key];
	}
  }
}

function executeMicroApp () {
  //微应用代码 开始
  (function (window) {
	with (window) {
	  if (a === 11) {
		console.log('a is recovered');
		a = 111
	  } else {
		a = 11
	  }
	  var b = 12
	  this.c = 13
	  window.d = 14
	  console.log(`微应用执行中:a:${a},b:${b},c:${c},d:${d}`);
	}
  }).bind(fakeWindow)(fakeWindow)
}
console.log(`微应用开始前:a:${a},b:${b},c:${c},d:${d}`);
active()
executeMicroApp()
inactive()
console.log(`微应用结束后:a:${a},b:${b},c:${c},d:${d}`);

// 再次执行微应用
active()
executeMicroApp()
inactive()
console.log(`微应用第二次结束后:a:${a},b:${b},c:${c},d:${d}`);

// 这段代码执行输出:
// 微应用开始前:a:1,b:2,c:3,d:4
// 微应用执行中:a:11,b:12,c:13,d:14
// 微应用结束后:a:1,b:2,c:3,d:4
// a is recovered
// 微应用执行中:a:111,b:12,c:13,d:14
// 微应用第二次结束后:a:1,b:2,c:3,d:4

这便是 qiankun 快照沙箱 snapshotSandbox 的基本原理。当然,此处的示例代码简化了许多边界情况的处理,例如需要重写 appendChild 等方法来拦截动态创建的 script 标签。

总结Snapshot快照隔离是一种基于“快照-恢复”机制的沙箱实现方式,主要用于不支持 Proxy 的低版本浏览器。在激活(active)沙箱时,会遍历并记录当前 window 对象的所有自有属性及其值,形成一份快照。当沙箱失活(inactive)时,再次遍历 window,将当前属性与快照进行对比,找出在沙箱运行期间被修改过的属性,记录这些差异,并将 window 恢复到快照时的状态。下次再次激活沙箱时,会根据上一次记录的差异,恢复微前端应用上次运行时对 window 的修改,从而实现环境的隔离与还原。

然而,这种快照机制的缺点也十分明显:

  1. 性能开销大:每次激活和失活都需要遍历 window 的所有自有属性,属性数量较多时,性能损耗明显。
  2. 无法隔离新增/删除的属性:如果有属性在快照后被新增或删除,恢复时可能出现遗漏或冗余。
  3. 不支持异步变更:如果 window 属性的变更发生在异步任务中,可能无法被及时捕获和还原,导致隔离不彻底。
  4. 兼容性有限:对于某些特殊属性(如 DOM 相关对象、不可枚举属性等),快照和还原可能不完全准确。

正因为快照沙箱存在一些不足,qiankun 在现代浏览器环境下引入了基于 Proxy 的沙箱隔离方案,Proxy 沙箱通过代理 window 对象,可以实时拦截和隔离全局变量的访问和修改,不仅性能更优,还能支持更复杂的隔离场景。

Legacy Proxy 隔离

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

window.a = 1
window.b = 2
window.c = 3
window.d = 4
// 模拟一个 window 沙箱
const fakeWindow = {};

// 记录微应用执行期间的所有新增的属性 用于恢复沙箱状态
const addedPropsMapInSandbox = new Map();
// 记录微应用执行期间更新的 window 属性的原始值,用于失活沙箱时恢复 window 状态
const modifiedPropsOriginalValueMapInSandbox = new Map();
// 记录微应用执行期间的所有新增和修改的属性
const currentUpdatedPropsValueMap = new Map();

const proxy = new Proxy(fakeWindow, {
  // 记录 set 新增和修改的值
  set (target, p, value) {
	console.log('proxy set', p, value);
	// 如果 window 没有这个属性,说明是新增的
	if (!window.hasOwnProperty(p)) {
	  addedPropsMapInSandbox.set(p, value);
	} else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
	  // 如果 window 有这个属性,则记录到 modifiedPropsOriginalValueMapInSandbox 中
	  modifiedPropsOriginalValueMapInSandbox.set(p, window[p]);
	}
	currentUpdatedPropsValueMap.set(p, value);

	return true;
  },
  get (target, p) {

	console.log('proxy get', p);
	return target[p];
  },
  has (target, p) {
	console.log('proxy has', p);
	return p in window;
  }
})

// 激活沙箱的时候要恢复沙箱之前的状态
function active () {
  currentUpdatedPropsValueMap.forEach((k, p) => {
	window[k] = p;
  })
}
// 失活沙箱的时候恢复 window 之前的状态
function inactive () {
  modifiedPropsOriginalValueMapInSandbox.forEach((k, p) => {
	window[k] = p;
  })
  addedPropsMapInSandbox.forEach((k, _) => {
	delete window[k];
  })
}

function executeMicroApp () {
  //微应用代码 开始
  (function (window) {
	with (window) {
	  if (a === 11) {
		console.log('a is recovered');
		a = 111
	  } else {
		a = 11
	  }
	  var b = 12
	  this.c = 13
	  window.d = 14
	  console.log(`微应用执行中:a:${a},b:${b},c:${c},d:${d}`);
	}
  }).bind(proxy)(proxy)
}
console.log(`微应用开始前:a:${a},b:${b},c:${c},d:${d}`);
active()
executeMicroApp()
inactive()
console.log(`微应用结束后:a:${a},b:${b},c:${c},d:${d}`);

// 再次执行微应用
active()
executeMicroApp()
inactive()
console.log(`微应用第二次结束后:a:${a},b:${b},c:${c},d:${d}`);

这段代码实现了一个基于 Proxy 的沙箱机制,用于隔离微前端架构中微应用对全局 window 对象的修改。整体思路是通过代理一个空对象 fakeWindow,将微应用运行时的操作重定向到真实 window 上,并记录属性变更行为,以便在微应用激活/失活时进行状态恢复。

相比于快照方案,采用 Proxy 可以精确地拦截属性的读写操作,从而更高效地追踪变更,性能也更好。以上代码是一个简单的思路演示,在实际情况下还有很多边界条件,比如:

function executeMicroApp () {
  //微应用代码 开始
  (function (window) {
	with (window) {
	  console.log('=== 开始演示沙箱问题 ===');

	  // 问题1:逃逸问题演示
	  console.log('--- 问题1: 逃逸问题 ---');
	  console.log('window === window.window:', window === window.window);
	  console.log('window === window.self:', window === window.self);
	  console.log('window === window.top:', window === window.top);
	  console.log('window === window.parent:', window === window.parent);

	  // 通过逃逸设置属性,绕过沙箱追踪
	  window.window.escapedProp = '逃逸属性';
	  window.self.escapedProp2 = '逃逸属性2';

	  // 问题2:defineProperty 和 getOwnPropertyDescriptor 未被拦截
	  console.log('--- 问题2: defineProperty 未被拦截 ---');
	  Object.defineProperty(window, 'nonConfigurable', {
		value: '不可配置属性',
		configurable: false,
		writable: false
	  });

	  Object.defineProperty(window, 'configurable', {
		value: '可配置属性',
		configurable: true,
		writable: true
	  });

	  console.log('nonConfigurable 描述符:', Object.getOwnPropertyDescriptor(window, 'nonConfigurable'));

	  // 问题3:严格的原生API绑定问题
	  console.log('--- 问题3: 原生API绑定问题 ---');
	  try {
		btoa("good!")
	  } catch (e) {
		console.log('原生API绑定错误:', e.message);
	  }

	  // 问题5:原型链操作
	  console.log('--- 问题5: 原型链操作 ---');
	  window.constructor.prototype.protoProp = '原型属性';

	  console.log('=== 问题演示结束 ===');
	}
  }).bind(proxy)(proxy)
}
console.log(`微应用开始前:a:${a},b:${b},c:${c},d:${d}`);
active()
executeMicroApp()

// 检查问题
console.log('\n=== 检查沙箱追踪情况 ===');
console.log('新增属性记录:', [...addedPropsMapInSandbox.keys()]);
console.log('修改属性记录:', [...modifiedPropsOriginalValueMapInSandbox.keys()]);
console.log('当前更新属性:', [...currentUpdatedPropsValueMap.keys()]);

console.log('\n=== 检查逃逸属性是否被追踪 ===');
console.log('escapedProp 被追踪了吗?', addedPropsMapInSandbox.has('escapedProp'));
console.log('escapedProp2 被追踪了吗?', addedPropsMapInSandbox.has('escapedProp2'));
console.log('真实window上的逃逸属性:', window.escapedProp, window.escapedProp2);

console.log('\n=== 检查defineProperty属性是否被追踪 ===');
console.log('nonConfigurable 被追踪了吗?', addedPropsMapInSandbox.has('nonConfigurable'));
console.log('configurable 被追踪了吗?', addedPropsMapInSandbox.has('configurable'));

inactive()

console.log('\n=== 检查失活后的清理情况 ===');
console.log('不可配置属性还存在吗?', window.nonConfigurable);
console.log('逃逸属性还存在吗?', window.escapedProp, window.escapedProp2);
console.log('原型属性还存在吗?', window.constructor.prototype.protoProp);

运行起来就能在浏览器控制台中看到所有的问题:

  1. 逃逸属性没有被沙箱追踪,失活后仍然存在
  2. defineProperty属性没有被沙箱追踪,失活后无法清理
  3. 不可配置属性无法被删除,会在沙箱间泄漏
  4. 原生API绑定在某些情况下会报错
  5. 原型链操作没有被隔离

我们来逐一分析并解决这些问题:

  1. 微应用可以通过 window.windowwindow.self 等方式访问到真实的 window 对象,从而绕过沙箱隔离,我们这里有在 get拦截的时候做个判断返回我们的代理对象即可:

    get (target, key) {
        if (["window", "self", "top", "parent"].includes(key)) {
          return proxy;
        }
        return window[key];
      },
    
  2. Object.defineProperty(window, ...) 直接作用在真实 window 上,需要进行沙箱隔离:

    defineProperty (target, key, descriptor) {
        const rawValue = window[key];
        const result = Reflect.defineProperty(window, key, descriptor);
        const value = window[key];
        // 追踪值:
        if (!window.hasOwnProperty(key)) {
          addedPropsMapInSandbox.set(key, value);
        } else if (!modifiedPropsOriginalValueMapInSandbox.has(key)) {
          modifiedPropsOriginalValueMapInSandbox.set(key, rawValue);
        }
        currentUpdatedPropsValueMap.set(key, value);
    
        return result
      }
    

如果这个时候运行代码会发现报错:Uncaught TypeError: 'defineProperty' on proxy: trap returned truish for defining non-configurable property 'nonConfigurable' which is either non-existent or configurable in the proxy target 这是因为Proxy 的不变性约束(Proxy Invariants):如果代理的对象本身没有某个属性,但是代理属性描述符时返回了 不可配置 那么会报错。参考:sec-invariants-of-the-essential-internal-methods,这个在 qiankun 的 LegacyProxy 沙箱 也一样存在问题,它并没有解决,我觉得要么可以拒绝沙箱的不可配置属性,要么可以强制转换为可配置属性(当然都不完美),也可以做一层拦截,在实际定义值的时候记录为可配置,在后续读取这个属性或者更改属性的时候都返回和不可配置一样的行为,在这里我们先跳过这个问题。

  1. getOwnPropertyDescriptor没有拦截:

    getOwnPropertyDescriptor (target, key) {
        const descriptor = Object.getOwnPropertyDescriptor(window, key);
        if (descriptor && !descriptor.configurable) {
          descriptor.configurable = true;
        }
        return descriptor;
      }
    
  2. 原生 API 的绑定问题,其根源在于许多原生函数(如 btoa)的执行依赖于正确的 this 上下文。当通过代理调用时,this 可能指向代理对象而非 window,从而导致 Illegal invocation 错误。我们需要将 proxy 中的原生函数调用重新绑定到原始的 window 对象。此外,还需识别那些已经被绑定过的函数或构造函数,避免重复绑定:

    function isCallable (fn) {
      return typeof fn === 'function' && fn instanceof Function;
    }
    function isBoundedFunction (fn) {
      return fn.name.includes('bound') && !fn.hasOwnProperty('prototype');
    }
    function isConstructable (fn) {
      return fn.prototype && fn.prototype.constructor === fn && Object.getOwnPropertyNames(fn.prototype).length > 1;
    }
    
    function rebindTarget2Fn (key) {
      const value = window[key];
      if (isCallable(value) && !isBoundedFunction(value) && !isConstructable(value)) {
        // return value.bind(window);
        return Function.prototype.bind.call(value, window)
      }
      return value;
    }
    // 省略其他proxy代码...
    get (target, key) {
        // console.log('proxy get', p);
        if (["window", "self", "top", "parent"].includes(key)) {
          return proxy;
        }
        return rebindTarget2Fn(key);
      },
    

    注意这里也只是简单的示例,实际上函数的判断需要不少边界条件,qiankun 还做了缓存。

  3. 属性删除操作没有被拦截,直接作用在了 fakeWindow 上,但由于 fakeWindow 是空对象,所以删除操作实际上什么都没做。,我们同样可以通过 proxy 加上拦截作用到真正的 window 上:

    deleteProperty (target, key) {
        console.log('拦截到 delete 操作:', key);
        // 如果是沙箱新增的属性,从记录中删除
        if (addedPropsMapInSandbox.has(key)) {
          addedPropsMapInSandbox.delete(key);
        }
        if (currentUpdatedPropsValueMap.has(key)) {
          currentUpdatedPropsValueMap.delete(key);
        }
        // 从真实 window 中删除
        if (window.hasOwnProperty(key)) {
          delete window[key];
        }
        return true;
      }
    

    实际上 qiankun 的 LegacyProxy 沙箱并没有处理这个问题。

  4. 直接对原型链的操作是没有经过沙箱隔离的,因为实现复杂度很高而且性能不好,实际需求下大多数微应用不会直接修改 Window.prototype ,所以目前 qiankun 选择了在实用性和复杂度之间的平衡,所有沙箱都没有处理这种边缘情况。在实际使用中,建议微应用避免直接修改全局对象的原型链。

总结:在 LegacySandbox 隔离中,拦截行为的重点是记录变更数据,最终访问和设置的仍然是全局执行上下文 window 对象,在激活和失活的瞬间根据变更数据来恢复 window 对象,从而防止对其他微应用产生影响,一旦两个微应用同时并存,仍然无法解决全局执行上下文同名属性的冲突问题。

Proxy 隔离

SnapshotSandboxLegacyProxySandbox 不同,ProxySandbox 方案能够支持多应用实例并存。其根本区别在于,它拦截的全局操作并不会直接作用于 window 对象,而是被隔离在各个微应用专属的代理对象(fakeWindow)中。这也是 qiankun 当前默认的沙箱方案。

我们按照这个思路简单做一版:

class ProxySandbox {
  constructor() {
	// 创建一个空的代理对象,作为每个沙箱的隔离对象,微应用对全局上下文的操作最后都会同步到这里
	this.fakeWindow = Object.create(null);
	const fakeWindow = this.fakeWindow;
	const proxy = new Proxy(this.fakeWindow, {
	  // get 先从代理对象取
	  get (target, key) {
		if (key === 'window') {
		  return proxy;
		}
		if (key in fakeWindow) {
		  return fakeWindow[key];
		}
		return window[key];
	  },
	  set (target, key, value) {
		fakeWindow[key] = value;
		return true;
	  },
	  has (target, key) {
		return key in fakeWindow || key in window;
	  }
	});
	this.proxy = proxy;
  }
  active () {
  }
  inactive () {
  }
}

function executeMicroApp (code, proxy) {
  window.proxy = proxy;
  eval(`;(function (window) {
		  with (window) {
			${code}
		}
	  }).bind(window.proxy)(window.proxy)
	`);
}


const proxySandbox1 = new ProxySandbox();
const proxySandbox2 = new ProxySandbox();

const code1 = `
	  // let a =setTimeout
	  console.log('微应用 1 执行前 window.a is', window.a);
	  if (!window.a) {
		window.a = 123
	  } else {
		window.a = 456
	  }
	  console.log('微应用 1 执行中 window.a is', window.a);
`

proxySandbox1.active();
executeMicroApp(code1, proxySandbox1.proxy);

console.log('执行微应用 1 后 window.a is', window.a);

const code2 = `
	  console.log('微应用 2 执行前 window.a is', window.a);
	  if (!window.a) {
		window.a = 789
	  } else {
		window.a = 100
	  }
	  console.log('微应用 2 执行中 window.a is', window.a);
`

proxySandbox2.active();
executeMicroApp(code2, proxySandbox2.proxy);

console.log('执行微应用 2 后 window.a is', window.a);

proxySandbox1.inactive();
proxySandbox1.active();
console.log('执行微应用1 失活后再次激活');

executeMicroApp(code1, proxySandbox1.proxy);
proxySandbox1.inactive();

这段代码在执行后会输出:

微应用 1 执行前 window.a is undefined
微应用 1 执行中 window.a is 123
执行微应用 1window.a is undefined
微应用 2 执行前 window.a is undefined
微应用 2 执行中 window.a is 789
执行微应用 2window.a is undefined
执行微应用1 失活后再次激活
微应用 1 执行前 window.a is 123
微应用 1 执行中 window.a is 456

可以看到各个微应用确实可以共存,并且对全局作用域的读写都不会互相影响。这是一个基本的实现思路。与 LegacyProxySandbox 类似,一个完备的 ProxySandbox 同样需要处理原生 API 的 this 绑定、window 属性逃逸、Proxy 不变性约束等问题。此处不再赘述,我们重点探讨其性能优化。

如果我们给代理对象的 get打印一下记录:

  get (target, key) {
	console.log('key is', key);
	if (key === 'window') {
	  return proxy;
	}
	if (key in fakeWindow) {
	  return fakeWindow[key];
	}
	return window[key];
  },

运行代码会发现,除了预期的属性访问被拦截外,控制台还会频繁打印 key is Symbol(Symbol.unscopables)Symbol.unscopables 是对象上的一个特殊符号属性(例如 Array.prototype[Symbol.unscopables]),用于指定在 with 环境中应被排除在作用域之外的属性名,从而防止这些属性被意外暴露。其用法如下:

// 定义一个简单的对象并设置 Symbol.unscopables
const obj = {
  a: 1,
  b: 2,
  c: 3
};

// 指定在 with 环境中哪些属性不应被暴露
obj[Symbol.unscopables] = {
  c: true,  // 属性 'c' 在 with 中不可访问
  d: true   // 即使不存在的属性也可以标记,防止意外访问
};

with (obj) {
  console.log(a); // 1,正常访问
  console.log(b); // 2,正常访问
  console.log(c); // 报错:c is not defined
}

// 说明:Symbol.unscopables 的值是一个对象,键是属性名,值为 true 表示该属性在 with 中被屏蔽。

其实有不少 Web API 是不可配置的,意味着不能重写,比如 ArrayBufferArrayInfinity等,这一些属性是不需要沙箱隔离的,可以在多个微应用中共享。而在沙箱中访问这些属性的顺序是:先访问 proxy.XXX,又会触发 get 拦截器,假设访问不到,还要继续向上遍历访问全局的 window.XXX,而如果我们可以在微应用访问 Symbol.unscopables的时候返回这些不能重写的共享变量列表,那么就可以直接进行沙箱逃逸,直接访问全局的 window.XXX 来提升性能。我们做个对比:

function testWithPerformance (iterations = 1_000_000) {
  const start = performance.now();

  for (let i = 0; i < iterations; i++) {
		// 每次访问都会触发 get 陷阱
		const temp = Infinity; // 访问全局对象
		const temp2 = Array; // 访问全局对象
		const temp3 = ArrayBuffer; // 访问全局对象
	  }
  const end = performance.now();
  return end - start;
}

const basicTime = testWithPerformance();
console.log(`基础沙箱 with 语句执行时间: ${basicTime.toFixed(2)}ms`);

这是一段微应用的代码,在我们当前的沙箱设计下平均的输出是 585ms左右(不同的机器环境会不一样),而如果加上Symbol.unscopables的优化之后:

const unscopables = {
      'Infinity': true,
      'ArrayBuffer': true,
      'Array': true,
    };
// 省略其余代码...
 get (target, key) {
	// Symbol.unscopables 优化
	if (key === Symbol.unscopables) {
	  return unscopables;
	}
	// 省略其余代码...
  },
         

大概是 433ms,有 30% 左右的性能提升(不同的机器环境会不一样)。 有没有什么办法让他变得更快呢?其实我们已经写出了静态的逃逸变量列表,我们甚至可以在微应用的作用域内将其展开,甚至不需要通过 with往外查找:

function executeMicroApp (code, proxy) {
  // 将 scopedGlobalVariables 拼接成变量声明,用于缓存全局变量,避免每次使用时都走一遍代理
  const unscopablesKeys = Object.keys(unscopables);
  const scopedGlobalVariableDefinition = unscopablesKeys.length ? `const {${unscopablesKeys.join(',')}}=this;` : '';

  window.proxy = proxy;
  eval(`;(function (window) {
		  with (window) {
			${scopedGlobalVariableDefinition}
			${code}
		}
	  }).bind(window.proxy)(window.proxy)
	`);
}

运行起来只要 1~3ms! 这带来了惊人的性能提升。这个思路实际上是在 import-html-entry 中实现的,可谓非常巧妙。

以上就是 qiankun 中 3 种沙箱原理的简单展示,仅作抛砖引玉,感兴趣的同学可以深入阅读。

代理 document

以上我们对作用域的隔离重点都在微应用逻辑代码的执行,实际上这里面还有一个难点,那就是多实例情况下,对 DOM 的操作需要进行隔离。

qiankun 会给每个微应用创建一个独一无二的挂载容器,然后通过劫持 document.createElement 和 document.querySelector 等 DOM API,结合 WeakMap 将每个创建的元素与对应的沙箱实例进行绑定,并通过一些全局的变量来追踪当前运行的沙箱,从而确保每个微应用的 DOM 操作都被路由到其专属的挂载容器中,实现多实例间的 DOM 隔离。

比如: <div id="__qiankun_microapp_wrapper_for_vue__" data-name="vue" data-version="2.10.16" data-sandbox-cfg="{&quot;loose&quot;:false}">,这里的 id便是独一无二的微应用标识,这个微应用对 DOM 的操作都会被限定在这个容器内。

具体的实现细节较为复杂,感兴趣的朋友可以自行阅读 qiankun 源码。

番外-Proxy 隔离 源码解析

这里是对 qiankun 中 Proxy 隔离的源码进行简单解析:

  1. createFakewindow: 把 window 上的不可配置属性全部设置到 fakeWindow 上,如果不可配置属性有 getter,要额外记录到 propertiesWithGetter

  2. set:如果是全局白名单 globalVariableWhiteList,那么记录设置前的 desciptor,(失活的时候需要恢复),然后直接写入全局;接着判断如果设置的值不在沙箱代理对象而是真正的全局对象上的时候,判断全局对象的这个属性描述符来判断能否写入(保持行为一致),如果可以则将这个属性同步到沙箱代理对象上,不会影响全局上下文;如果在沙箱代理对象上或者既不在沙箱代理对象也不在全局对象的,那么直接对沙箱代理对象直接写入。

  3. get:需要处理沙箱逃逸的情况, 首先是不能被重写的全局变量,这些可以在不同的微应用直接共享,这些直接通过 Symbol.unscopables 进行逃逸;

    然后针对全局对象的访问:是如果主应用在 iframe 下,那么微应用访问 window.parent 或者 window.top需要逃逸;其他情况的全局访问都应该拦截返回代理对象本身。

    hasOwnProperty的 this 指向进行调整,避免任何情况都返回全局上下文的 hasOwnProperty检查。

    在createFakewindow时我们记录了有 getter 的全局上下文的属性到 propertiesWithGetter,在 get 的时候我们还应该检查如果 get 的属性是有 getter 的,那么应该保持一致的行为,返回 getter;否则的话才要逐个判断是否在 代理对象上,没有的话再判断是否在全局上下文中。

    最后便是 this 指向的判断了,这个在之前的 LegacyProxy 沙箱 中有讲到,但是在进行 this执行判断之前,有这 2 个情况是不需要改变指向的:1 个是冻结属性,1 个是非原生属性 ,比如 atobfetch 是原生属性,需要改变指向。

微前端之样式隔离

与 JavaScript 隔离相比,样式的隔离方案通常更为直接。

shadowDOM隔离

浏览器原生支持,既可以屏蔽外部的样式干扰,也不会干扰外部样式,创建十分简单。但是这个方式无法解决微应用内动态创建和修改 <style><link>

样式作用域隔离

手动处理微应用内的全局作用域为局部作用域,比如 htmlbody a p 等选择器应该转为微应用的根容器选择器,比如 body p { color: green; } 要将 body p 处理成 div[data-name="vue"] p

具体的原理是创建一个空的 style 标签,把微应用的行内样式表(这个时候微应用的外联样式表已经通过 import-html-entry(稍后会介绍)转为行内样式表了,除非是动态创建的)的内容转移到这里来,同时禁用这个标签生效,然后利用正则表达式对不同的选择器进行替换来达到样式作用域的效果。

动态隔离

对于已有的 <style> 和 <link>,qiankun 会在微应用的加载阶段先进行隔离处理。除此之外还会在微应用的两个执行阶段之前(执行微应用导出的生命周期之前以及挂在微应用之前)重写 DOM 的添加逻辑,从而对需要动态添加的 <style> 和 <link> 进行拦截和隔离处理。

qiankun 会拦截HEADBODYappendChildHEADinsertBefore方法用于收集动态脚本并执行作用域隔离内联到微应用所在的 <qiankun-head> 和 qiankun 容器元素(或者是 shadowDOM ),最终将隔离处理后的 CSS 缓存下来,从而在微应用的下一次加载时还能恢复。当然,除了重写添加,也要重写对应的删除函数 removeChild,要正确删除内联的样式标签节点。对于JS则是要在隔离的作用域中执行 。

除了拦截,还需要对应的恢复。在load微应用的时候会启动沙箱,拦截相关DOM的操作,启动沙箱会返回一个 Freer 函数,在这里会恢复相关的DOM拦截操作,这个 Freer 函数会在微应用卸载的时候执行,并且会返回一个 rebuild函数用于恢复之前内联的样式表状态。而这个rebuild则是在微应用mount的时候执行。

single-spa

single-spa 是一个微应用的库,它提供了主应用与子应用的注册协议,包括子应用注册的生命周期以及基于路由分发子应用激活和失活的处理,这其中包括了重写子应用的路由监听事件,从而达到准确延迟分发路由变化到子应用中。

它的使用方式如下:

子应用需要暴露对应的生命周期函数:

export async function bootstrap() {
  console.log("[Vue 子应用] bootstrap excuted");
}
// 这是一个 Vue 子应用,通过主应用的传参挂载到对应节点
export async function mount(props) {
 app = createApp(App);
 app.mount(props.container);
}

export async function unmount(props) {
  app && app.unmount();
}

主应用需要导入微应用产物并注册:

import { registerApplication, start } from 'single-spa';  
  
registerApplication(  
  // 参数1:微应用名称标识
  'app',  
  // 参数2:微应用加载逻辑 / 微应用对象,必须返回 Promise,这也就是微应用的产物
  () => import('app.js'),  
  // 参数3:微应用的激活条件
  (location) => location.pathname.startsWith('/app'),  
  // 参数4:传递给微应用的 props 数据
  { container: document.querySelector("#app") }  
);  
// 注册更多子应用
registerApplication({  
	// ... 
});  
  
start();

通常,主应用还会包含用于切换子应用的菜单和用于挂载的 DOM 节点,此处不再赘述。

qiankun 在这个库的基础上进行了封装,实现了:

  • 预加载应用策略
  • css/js 沙箱隔离
  • 强化微应用实例管理(单实例,多实例)

我们不对这个库进行过多的介绍,有兴趣的同学自行阅读。

import-html-entry

上文中提到的 single-spa会在激活子应用的时候执行对应子应用的加载函数, 也就是上述示例的 () => import('app.js'), 这个方法,single-spa只关心这个函数是一个返回指定生命周期的异步函数。因为一般情况下的前端产物都是一个包含各种静态资源的 index.html文件,因此用 html文件作为入口是比较方便接入微应用的,qiankun 使用了 import-html-entry作为 html文件的加载器,解析微应用导出的生命周期函数。

import-html-entry 的原理是:读取 HTML 文件,将外联样式(<link>)转换为内联样式(<style>),并提取所有内联和外联的 JS 脚本内容,最终提供一个可在沙箱中执行这些脚本的接口。

其核心原理如下。

解析HTML

通过正则匹配出html文件的标签,区分

  1. 移除所有的 HTML 注释
  2. 收集外联css: 正则匹配HTML文件中所有的 <link> 标签,将合法的(有 href)外联样式地址收集起来,并根据外联的URL生成对应的注释占位节点。如果标签有 prefetch/proload 都会失效,因为不是正常的HTML文件发出的请求,而是在接下来手动请求资源。 如果标签上有ignore属性,这个是用于让 import-html-entry 跳过收集的,并非标准HTML属性,会直接处理成注释节点。
  3. 遍历内联样式 <style>标签,执行ignore属性的判断,如果有则替换成注释节点,否则收集样式内容。
  4. 遍历所有 <script>标签,在进行一些包括ignore属性等其他边界判断后收集内联脚本内容以及外联的地址,并设置入口脚本。 这里的边界包括:
    1. 浏览器不支持 module 但是 script 标签中有 type="module" 属性或者浏览器支持 module 但是 script 标签中有 nomodule 属性,这种情况都会被忽略。其实这也是vite降级代码的一个方案,在不同的浏览器兼容条件下执行不同的产物。
    2. type 属性的合法检测:import-html-entry只支持 "text/javascript", "module", "application/javascript", "text/ecmascript", "application/ecmascript" 这5种,最新的 type其实还有speculationrulesimportmap
    3. 如果内联脚本全是注释,那也不会收集到js依赖。
    4. 最后过滤空的脚本依赖。
  5. 这个时候就收集好了所有的内联外联的样式和脚本文件。接下来会请求所有的外联样式表,并根据占位符替换到指定的位置成为内联的样式表。
  6. 最后会返回解析好样式的HTML文件,以及收集到的脚本和样式依赖数组和入口文件。

执行脚本

收集到脚本数组后,我们需要按顺序执行所有的脚本文件。通过执行入口脚本获取微应用导出的的生命周期函数,以及最关键的,要在沙箱执行这些脚本。沙箱我们已经很熟悉了,我们刚刚的实现有一个函数是:

function executeMicroApp (code, proxy) {
  const unscopablesKeys = Object.keys(unscopables);
  const scopedGlobalVariableDefinition = unscopablesKeys.length ? `const {${unscopablesKeys.join(',')}}=this;` : '';

  window.proxy = proxy;
  eval(`;(function (window) {
		  with (window) {
			${scopedGlobalVariableDefinition}
			${code}
		}
	  }).bind(window.proxy)(window.proxy)
	`);
}

对于 import-html-entry 来讲它需要导出一个和 executeMicroApp类似的方法,其中静态代码文本是它已经解析好的,最重要的是外部给它传入沙箱的上下文,我们可以直接看他的核心源码:

function getExecutableScript(scriptSrc, scriptText, opts = {}) {
	const { proxy, strictGlobal, scopedGlobalVariables = [] } = opts;

	const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`;

	// 将 scopedGlobalVariables 拼接成变量声明,用于缓存全局变量,避免每次使用时都走一遍代理
	const scopedGlobalVariableDefinition = scopedGlobalVariables.length ? `const {${scopedGlobalVariables.join(',')}}=this;` : '';

	// 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上
	// 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy
	const globalWindow = (0, eval)('window');
	globalWindow.proxy = proxy;
	// TODO 通过 strictGlobal 方式切换 with 闭包,待 with 方式坑趟平后再合并
	return strictGlobal
		? (
			scopedGlobalVariableDefinition
				? `;(function(){with(this){${scopedGlobalVariableDefinition}${scriptText}\n${sourceUrl}}}).bind(window.proxy)();`
				: `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
		)
		: `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;
}

在 qiankun 中会在沙箱创建完成后将沙箱上下文层层传递到上述函数中执行:

// sandbox 就是沙箱示例,有沙箱上下文
 const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox, {
    scopedGlobalVariables: speedySandbox ? cachedGlobals : [],
  });

需要注意的是,import-html-entry 可能会在未来版本中被废弃。根据社区讨论,qiankun 3.0 计划引入全新的 loader 来加载微应用。参考:github.com/kuitos/impo…

微前端框架浅对比

当前微前端也是呈现百花齐放的状态,除了偏底层的single-spa ,国内大厂们都有开源方案:

这些框架的实现原理各不相同,但理解它们的核心思路,可以从考察其如何解决微前端架构的三大核心问题入手:

  1. 应用加载与路由:主应用如何加载子应用,并根据路由变化正确地展示或卸载它们?
  2. JS 沙箱:如何隔离不同子应用之间的 JavaScript 环境,防止全局变量污染和冲突?
  3. CSS 隔离:如何隔离不同子应用之间的样式,避免样式相互覆盖?

garfish

garfish 的沙箱思想与 qiankun 类似,同样提供了快照沙箱和基于 Proxy 的上下文隔离方案,本质上解决了相同的问题。但 garfish 在沙箱的工程化设计和模块化程度上表现得更为出色。CSS 隔离方面,它同样采用 Shadow DOM / Scoped CSS 方案。在应用加载机制上,两者都采用“注册应用 + 路由激活规则”来驱动加载与挂载,但 garfish 提供了更灵活的路由编排方式和更完善的工程化接口。总体而言,garfishqiankun 原理相近,但在工程化和模块化能力上更胜一筹。

wujie

wujie 采用了一种基于 iframe + Web Components 的独特方案。其核心思想是:Wujie 并不直接显示 iframe,而是将其作为一个纯粹的 JavaScript 沙箱容器。子应用中渲染的所有 DOM 节点(包括弹窗)都会被 Wujie 劫持,并“跨界”插入到主应用的 Web Components 容器内的 Shadow DOM 中。在路由方面,它采用约定式路由,即主应用路由的前缀决定加载哪个子应用。同时,Wujie 会监听子应用的路由变更并同步到主应用,反之亦然,从而实现主、子路由的双向同步。

wujie 劫持并改造 iframe 渲染流程的步骤大致如下:

  • 创建隐藏的 iframe:Wujie 在主应用中创建一个 display: noneiframe 作为沙箱。
  • 创建 Web Components 容器:Wujie 在主应用中创建一个自定义的 Web Components 元素(例如 <wujie-app>),并附加一个 Shadow DOM
  • 劫持 DOM API:在 iframe 加载子应用后,Wujie 会访问 iframe.contentWindow.document,然后重写或代理常用的 DOM API,如 createElementappendChildinsertBefore 等。
  • 跨 iframe 渲染:每当子应用调用这些被劫持的 DOM API 来创建或操作 DOM 节点时,Wujie 的拦截器就会生效。它会将这些操作的对象(即创建出的 DOM 节点)iframe 内部取出,并挂载到主应用中的 Web Components 容器的 Shadow DOM 内部。

MicroApp

MicroApp 的隔离方案和qiankun以及garfish思路一致:基于proxy的沙箱做js隔离、CSS同样是采取Shadow DOM / Scoped CSS;在路由编排上,和 wujie相似,也是一种约定式以及监听子应用路由事件+双向同步,但它的核心是利用了 Web Components,主应用中通过一个 <micro-app> 标签来加载和渲染子应用,例如 <micro-app name='my-child-app' url='http://localhost:3001/'></micro-app>。将子应用像一个组件一样在主应用中使用。

模块联邦

以上介绍的微前端方案都基于运行时的,接下来我们来介绍一种编译时+运行时的微前端方案——模块联邦。2020年 Webpack 推出这个特性后,它甚至被称为前端构建领域的 Game Changer

它的核心思想是:允许一个应用在运行时动态地加载和使用另一个独立应用暴露的 JavaScript 模块。这意味着,不同的应用可以像使用本地模块一样,无缝地共享和消费彼此的代码。

模块联邦的特点是:

  • 解决了重复加载问题: 相比于传统的微前端方案,模块联邦能自动处理依赖,避免了多个应用重复打包公共库(如 React、Vue、Antd)的问题,有效减小了应用体积。
  • 兼顾独立开发与代码共享: 它让应用能够独立开发、独立部署,同时又实现了细粒度的代码共享。共享的粒度可以小到一个组件、一个函数,而不再局限于整个 npm 包或 Monorepo。这大大提升了大型项目的开发效率和维护灵活性。

与传统的 npm 包、CDN 等共享方式相比,模块联邦的优势在于它提供了更自动化、更灵活的共享机制,让前端应用间的协作变得前所未有的简单和高效。

模块联邦的出现,为微前端提供了一种全新的编译时共享思路。它放弃了传统运行时框架追求的**“强隔离”,因为其集成粒度是模块而非应用。这种转变带来了“极致的共享”和“灵活的组合”,因此特别适合团队内部高度协作、互相信任的复杂项目。相较之下,qiankun、wujie 等运行时方案则更擅长解决需要强隔离、集成第三方或遗留系统**的场景。

结语

从快照沙箱的巧妙 diff,到 Legacy Proxy 的过渡,再到现代 Proxy 沙箱的精细拦截;从 qiankun 的“上下文切换”,到 wujie 的“iframe 跨界渲染”,再到模块联邦的“编译时共享”。回顾全文,我们不难发现,微前端的演进史,在某种程度上也是一部前端开发者与浏览器环境“斗智斗勇”的隔离技术探索史。

正如前言所述,学习微前端是对前端技术广度的绝佳锻炼,通过本文的探讨,相信大家能更深刻地理解这一点。微前端并非银弹,它并未凭空消除复杂性,而是将复杂度从业务代码转移到了基础设施层面,对架构设计提出了更高的要求,并需要配套的工具链和生态来支持新的研发模式。最后,由于小林目前的工作场景未直接应用微前端,本文的探讨更多是基于原理和源码分析,也就是纸上谈兵。非常欢迎有生产环境实践经验的朋友们进行交流与指正。