CSS 隔离
CSS 脚本劫持
通过劫持浏览器的 <head/>
和 </body>
的相关原生方法(Node.appendChild、Node.insertBefore
)修改 style
节点的挂载位置:
此做法非常优雅地做到了隔离,在切换应用时会比较方便,一旦应用被移除相当于对应的 DOM 被移除。
注意上图,可以看到通过 href 属性的 CSS 样式全部被改写成了用 fetch 获取,相当于转成了内联的 stylesheet。如果应用二次加载,这些数据也可以从缓存(js 内存缓存,非浏览器缓存)中取。 那么它又是如何做到微应用之间的样式隔离的呢?其实现方案在上图的 process 方法。
- 例如处理 body、root、 html 这种根级别的节点(Root Level), 在微应用中将变成外层的 wrapper:
- @support、@keyframe、@font-fase、@import 这种不处理。
使用 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>
输出如下:
var 变量逃逸问题
一个完美沙箱的实现是任重而道远的,上面的 demo 只是阐述了其核心思路。实际上有很多的坑要趟平,例如上面提到了 window.window
、window.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 规范,我们得到了答案:
如果一个 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 天,点击查看活动详情