说到微前端,现在最火的方案就是qiankun。qiankun的特点是易用性和完备性很高。说白了就是能很方便、快速的接入,同时bug少,功能强大。
介绍
微前端已经火了一段时间了,就不介绍了,直接贴图得了。

话不多少,本次主要做两件事情:
拆解和解析qiankun源码
尝试qiankun造轮子
分析qiankun原理
截止现在,有很多大神已经将qiankun剖析得一清二楚。从各个层面去讲的都有,参考资料如下:
参考这么多的文档资料,以及自己阅读源码之后,我对qiankun主要功能的理解大概有这些:
基于single-spa
qiankun基于single-spa,在它的基础上增加了很多功能。single-spa
是基于Js Entry
的方案的:需要将整个微应用打包成一个JS文件,包括图片、css等资源(缺点:体积庞大、无法并行加载资源、接入成本高)。而Html Entry
则由于保留了html,通过html作为入口处理js、css等,天然的规避了这些问题。
qiankun使用single-spa
做了两件事情:
注册和管理微应用(single-spa内部存储了apps信息)
qiankun的app注册接口,会调用
single-spa
的registerApplication
接口,保留它的路由规则和部分字段。single-spa
的registerApplication
接口,app参数需要传入一个包含生命周期函数的对象或工厂函数。registerApplication({ name: 'app1', activeWhen: '/app1', app: () => import("src/app1/main.js") // 即Js Entry。其中main.js需要导出四个生命周期函数 })
而qiankun由于不使用
Js Entry
,所以在app()
异步函数里,qiankun不是来导入js文件,而是从Html Entry
开始:fetch html文件 => 创建和设置子应用dom => 创建沙盒 => 执行子应用js文件(此时可获取到子应用导出的lifecycles) => 包装lifecycles并返回 。根据路由规则切换微应用(触发微应用状态变更,需要自己实现各个状态回调)
single-spa
已经实现了hash路由和history路由的监听,然后根据子应用的激活路由规则,来调用对应子应用的生命周期函数。qiankun就拿来直接用,反正最后都会进入前面我们提到的生命周期函数。
从Html Entry入手
前面提到,qiankun在app()函数里不是导入js而是处理的Html Entry
。qiankun作者将这一部分封装成了一个独立的库import-html-entry。这里面包括的功能有:
- fetch html文件
- 处理html内容转为template字符串
- 获取scripts,包括内部和外部scripts
- 对script进行包装,控制js在指定的window中运行(js沙箱的基础)
- 获取内部和外部样式表
可以看出这个库主要就是从Html Entry
入手处理template、script和style,都只是处理,并没有执行,执行在qiankun源码中。不再细说,细节可以看它的源码或者其他大牛的解读。
Js沙箱
Js沙箱
的目的是为了让子应用拥有自己私有的全局环境。防止子应用修改的全局变量影响到主应用或其他子应用。qiankun实现了三种Js沙箱
:
快照沙箱 - snapshotSandbox
snapshotSandbox是在不支持Proxy的浏览器环境下才会使用的。原理是在子应用挂载时,备份当前window存入snapshot(同时恢复上一次卸载时最后的状态);卸载时恢复window为snapshot的状态(同时记录应用变更了哪些状态)。
由于它会遍历window对象,所以性能最差。同时它修改了window对象,所以它无法做到主应用和子应用的隔离,只做到了单个子应用相互之间的隔离,多个子应用同时加载时也不行。
单例代理沙箱 - legacySandBox
legacySandBox是一个使用Proxy的沙箱。它的原理和快照沙箱一样是记录变更和恢复:创建一个window的proxy(子应用操作的是这个proxy),监听set()
并收集更新(set仍然会同步window,get直接从window取)。在子应用挂载时,恢复window为上一次卸载时状态;卸载时恢复为挂载前状态。只不过由于使用了代理,它能准确知道哪些变量发生了变化,而不用像快照沙箱那样遍历整个window对象才能知道变化。
所以它的性能会好于快照沙箱,但是它仍然操作的是window全局对象,所以也无法做到主应用和子应用的隔离,多个子应用同时加载也一样有问题。
多例代理沙箱 - proxySandBox
proxySandBox也是一个使用Proxy技术的沙箱。只不过它是完全代理了window并替代之。每个子应用都使用的是自己的proxy而不是全局的window。它的原理是创建一个window的proxy。把window的属性都拷贝上去,完全就是一个副本。每个子应用唯一。子应用操作的一直都是这个proxy,所以挂载和卸载都不需要做什么处理。
由于window全局属性太多,处理的异常也非常多,有的函数和变量还必须从原始window获取。所以这部分代码还是很复杂的,作者估计也是做了非常多的bugfix。
好处也是非常明显,主应用window不会被污染,子应用之间也完全隔离,多个子应用同时加载也互不影响。
其中代理沙箱提供了window的代理proxy。qiankun会让子应用的js代码运行在这个proxy对象下(比如window、self、top、document、location等等)。这一部分是
import-html-entry
插件在处理javascript脚本时实现的。说白了就是eval + function + with
让全局变量都从proxy中查询,并越过了严格模式的安全性错误。
Css隔离
Css隔离
的目的是为了让子应用的Css与主应用或其他子应用的隔离开,防止相互影响。qiankun有三类隔离方式:
默认方式
qiankun的默认处理是将动态导入的样式表,比如使用webpack的style-loader、Vite中导入.css
文件、使用css-in-js
框架等,劫持appendChild等方法,将它们添加到子应用的dom容器下,卸载时则随着dom一起被移除。如果不做这个劫持的处理。那子应用的样式表将会出现在根节点<head>
标签下,假如开启了Shadom DOM
模式,子应用就找不到这些样式了。
对于子应用的内部样式表<style>
、外部样式表<link ref="stylesheet">
,不需要做拦截处理。因为它们会随着html被挂载到子应用的dom容器下。
函数调用关系:
[src/loader.ts].loadApp
=>[src/sandbox/index].createSandboxContainer
=>[src/sandbox/patchers/index].patchAtBootstrapping
(源码位置)。
对于快照沙箱
和单例代理沙箱
,使用patchLooseSandbox。它是通过劫持Head和Body元素的appendChild
和insertBefore
方法(源码),并把要添加的<style>和<link>元素插入到子应用的dom容器下(源码)。当然它只会在子应用激活时才会生效。
对于多例代理沙箱
,使用patchStrictSandbox,也是和上面一样劫持了appendChild
和insertBefore
方法。由于多例代理沙箱
下每个子应用使用自己的window代理,在这个方法中,对document也做了代理,此时仅使用document代理创建的样式表才能被插入到子应用容器下。
默认方式不需要手动开启。它能保证单例场景下子应用之间的样式隔离,但是无法保证主应用和子应用,及多实例时子应用之间的样式隔离。
Shadom DOM
qiankun支持使用Shadom DOM
实现严格样式隔离
,它会在子应用dom根节点创建Shadow DOM
,就能确保子应用和主应用,子应用之间的样式污染问题。但是它并不是完美方案。缺点就是一些弹窗需要挂载到document.body
下,这是就跳出了Shadow DOM
边界,弹窗样式也就无法起作用了。
通过start({ sandbox: { strictStyleIsolation: true } })
启用。
生成shadowDom的源码位置:loader.createElement
样式改写
qiankun会对子应用添加的样式改写,在子应用的根部dom节点增加一个data-qiankun
属性,并对子应用的所有样式规则,添加一个div[data-qiankun="xxx"]
的属性选择器,这样来保证只有子应用的dom树下,这些样式才会生效。(额外的,改写会替换body、html、:root选择器)能保证主应用和子应用,子应用之间的样式隔离。
通过start({ sandbox: { experimentalStyleIsolation: true } })
启用。
html entry中的内联和外联样式会在loader.createElement中被处理,最终会调用ScopedCSS.process方法:它会对<style>标签内的样式进行重写,并使用
MutationObserver
监听该标签内的内容变更,假如<style>中后续增删CSS规则,同样进行重写。
此外,对于动态添加的<style>标签,也会在劫持方法内对样式内容进行重写。
其他
非qiankun框架提供。在项目中我们可以有其他确保样式隔离的方案:唯一的css命名前缀、css modules、CSS-in-js。
样式重建
比如webpack和vite中,应用首次加载时,可能会有很多样式是动态导入的。正如上一节所说,qiankun默认会把这些样式插入到子应用的dom容器下。随着应用被卸载,它们也一并被移除。但是子应用再次加载的时候呢?如果qiankun不去对样式进行重建,会出现什么问题?我们分析一下。
子应用首次加载。应用从html entry开始fetch,全部代码都会被执行。正如子应用在新tab里独立运行时一样。此过程中,对于动态导入的样式会被插入到dom中。等所有代码执行完毕后qiankun会调用子应用的
mount
钩子。让框架如React
、Vue
去接管dom。子应用卸载时,子应用的dom容器会被清空。此时,所有的<style>标签也会被移除。
子应用再次加载时,qiankun不会让子应用和首次加载一样,从头fecth并执行,这样实在是太低效了。取而代之的是,qiankun会直接调用子应用的
mount
钩子。
这就带来问题了。只调用mount
钩子,那所有的<style>标签已经被移除了,不恢复不就乱套了吗?
当然是这样,所以qiankun需要对样式进行重建。qiankun定义了三个类型:Patcher、Freer、Rebuilder,Patcher函数的返回值是Freer函数,Freer函数的返回值是Rebuilder函数。分别代表给环境打补丁、还原打补丁前的环境、重建操作。
可以看到patchLooseSandbox
和patchStrictSandbox
都是Patcher函数。我们重点关注它们的Rebuilder函数,它们俩代码都一样:在Rebuilder函数内,重建CSS规则,即恢复<style>标签。
清除副作用
子应用使用window.addEventListener
或者setInterval
等全局api时,如果子应用卸载时不移除掉,则会对其他应用带来副作用。
代码是在patchers
.patchAtMounting方法中。调用patchInterval
和patchWindowListener
来清除副作用的。patch
方法内部,拦截了原生的方法,每次调用时记录下来。patch
方法返回free()
函数,用于在子应用卸载时,清除副作用。
通信方案
- 官方Actions方案
官方是事件监听的形式,监听全局状态的变更。主应用初始化状态,通过mount(props)
生命周期下发到子应用。子应用可以监听和set。
实现原理是主应用负责初始化和存储全局states,提供接口到子应用,子应用添加listener,主应用管理listeners。在任意应用调用setState接口时,都触发listener回调。
- SharedState方案
更常见的情况是,项目中已经集成了状态管理库Redux
、Zustand
或其他。这时候就可以使用官方Actions方案当作桥梁,打通主应用、子应用的状态数据同步。并且子应用在独立运行的时候仍然使用自身的状态管理库获取数据,在嵌入主应用时,使用全局状态数据。主应用或子应用使用全局状态时,只需要使用一个hooks:
// 获取和设置全局状态,响应式
const [app, setApp] = useSharedState('AppInfo');
实现原理就是主应用和子应用都可以创建自己的SharedState
,即中间层,在这里去实现针对不同状态管理库的状态获取、设置和监听。子应用如果是在qiankun环境,则从主应用获取SharedState
重载掉自己本地的。然后再提供一个Hooks Api。
/**
* 操作某一个共享状态的Hooks API。
* 使用方法类似于React.useState,返回[state, setter]。
*/
export function useSharedState<K extends keyof SharedState>(stateName: K): readonly [StateTypeOf<SharedState[K]>, (d: StateTypeOf<SharedState[K]>) => void] {
const so: SharedState[K] = state[stateName];
// 使用useState,让其转为响应式的状态
const [d, setD] = useState(so.get());
// 监听主应用状态变化,并setState
useEffect(() => {
return so.subscribe(setD); // 组件销毁时,停止监听
}, [so]);
// 有共享状态时
return [d, so.set] as const;
}
这里写得比较简单。有时间我再单独出一期方案详解。
自己造一个qiankun
qiankun原理介绍就这些,下面我们开始正题!自己模仿造一个qiankun轮子出来,抛开健壮性不谈,至少能用!
项目结构
保持qiankun的功能模块设计,特拆分几个文件如下:
- qiankun.ts # qiankun的主功能
- single-spa.ts # 类似于single-spa的功能
- import-html-entry.ts # 类似于import-html-entry的功能
- sandbox # 存放沙箱
- index.ts # 沙箱容器,控制沙箱创建、加载、卸载
- LegacySandbox.ts # 单例代理沙箱
- ProxySandbox.ts # 多例代理沙箱
- SnapshotSandbox.ts # 快照沙箱
- patchers # 存放副作用补丁
- intervals.ts # intervals副作用补丁
- listeners.ts # listeners副作用补丁
- globalState.ts # 全局state状态
single-spa.ts
功能:管理注册应用、根据路由切换应用。写完后代码如下:
export function registerApplication(appConfig: AppConfig) {
_apps.push({
status: AppStatus.NOT_LOADED,
...appConfig,
});
}
let isStarted = false;
export function start() {
if (isStarted) return;
isStarted = true;
reroute();
}
let _prevRoute: string, _nextRoute: string;
function reroute() {
const tryFetchActiveApp = async (pathname: string) => {
_prevRoute = _nextRoute;
_nextRoute = pathname;
const prevApp = _apps.find((app) => _prevRoute?.startsWith(app.activeWhen));
// find the active app
const activeApp = _apps.find((app) => pathname.startsWith(app.activeWhen));
// if the previous app is the same as the active app, do nothing
if (prevApp && activeApp && prevApp.name === activeApp.name) return;
// unmount the previous app
if (prevApp) {
if (prevApp.status === AppStatus.MOUNTED)
prevApp.status = AppStatus.NOT_LOADED;
callOrArrayCall(prevApp.unmount, {});
}
// fetch the active app
if (activeApp) {
if (activeApp?.status === AppStatus.NOT_LOADED) {
activeApp.status = AppStatus.MOUNTING;
activeApp.app().then((lifeCycles) => {
Object.assign(activeApp, lifeCycles);
activeApp.status = AppStatus.MOUNTED;
callOrArrayCall(activeApp.bootstrap, {});
callOrArrayCall(activeApp.mount, {});
});
} else {
callOrArrayCall(activeApp.bootstrap, {});
callOrArrayCall(activeApp.mount, {});
}
}
};
function callOrArrayCall<ExtraProps extends any>(
func: LifeCycleFn<ExtraProps> | Array<LifeCycleFn<ExtraProps>> | undefined,
props: any
) {
if (Array.isArray(func)) {
func.forEach((fn) => fn(props));
} else {
func?.(props);
}
}
// history.forward() or history.back() or history.go()
window.addEventListener("popstate", () => {
tryFetchActiveApp(window.location.pathname);
});
window.history.pushState = new Proxy(window.history.pushState, {
apply(target, thisArg, args) {
const pathname = args[2];
tryFetchActiveApp(pathname);
// @ts-ignore
return target.apply(thisArg, args);
},
});
window.history.replaceState = new Proxy(window.history.replaceState, {
apply(target, thisArg, args) {
const pathname = args[2];
tryFetchActiveApp(pathname);
// @ts-ignore
return target.apply(thisArg, args);
},
});
// 首次加载
setTimeout(() => {
tryFetchActiveApp(window.location.pathname);
}, 400);
}
import-html-entry.ts
主要功能:导入html,加载css和javascript。写完后代码如下:
export async function importEntry(entry: string) {
return importHtml(entry);
}
async function importHtml(entry: string) {
// fetch html entry of app
const response = await fetch(entry);
const text = await response.text();
const html = document.createElement("div");
html.innerHTML = text;
// script will not be executed when html is appended to the DOM
// so we need to create script elements and append them to the DOM
const scripts = html.querySelectorAll("script");
const scriptContents = await fetchScript(entry, ...scripts);
return {
template: html,
execScripts: (proxy: Window) => {
scriptContents.forEach((scriptContent) => {
const code = getExecutableScript(
scriptContent.src,
scriptContent.code,
{ proxy }
);
evalCode(scriptContent.src, code);
});
},
};
}
async function fetchScript(prefix: string, ...scripts: HTMLScriptElement[]) {
let scriptContents: { code: string; src: string }[] = [];
for (let i = 0; i < scripts.length; i++) {
const script = scripts[i];
if (script.textContent) {
scriptContents.push({ code: script.textContent, src: "" });
continue;
}
let src = script.attributes.getNamedItem("src")?.value;
if (!src) continue;
if (
!src.startsWith("http") &&
!src.startsWith("//") &&
!src.startsWith("https")
) {
src = prefix + "/" + src;
}
const response = await fetch(src);
let scriptContent = await response.text();
scriptContents.push({ code: scriptContent, src });
}
return scriptContents;
}
function getExecutableScript(
scriptSrc: String,
scriptText: string,
opts: { proxy: Window }
) {
const { proxy } = opts;
const sourceUrl = `//# sourceURL=${scriptSrc}\n`;
const globalWindow = (0, eval)("window");
globalWindow.proxy = proxy;
return `;(function(window, self, globalThis){with(window){;${scriptText}\n}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;
}
const evalCache: Record<string, any> = {};
export function evalCode(scriptSrc: string, code: string) {
const key = scriptSrc;
if (!evalCache[key]) {
const functionWrappedCode = `(function(){${code}})`;
evalCache[key] = (0, eval)(functionWrappedCode);
}
const evalFunc = evalCache[key];
evalFunc.call(window);
}
sandbox/index.ts
主要功能:创建沙箱容器,初始化、加载和卸载沙箱,清除副作用。写完后代码如下:
import { Freer, patchSideEffects } from "../patchers";
import LegacySandbox from "./js/LegacySandbox";
import ProxySandbox from "./js/ProxySandbox";
import SnapshotSandbox from "./js/SnapshotSandbox";
export type SandBox = {
/** 沙箱的名字 */
name: string;
/** 沙箱导出的代理实体 */
proxy: WindowProxy;
/** 沙箱是否在运行中 */
sandboxRunning: boolean;
/** latest set property */
latestSetProp?: PropertyKey | null;
/** 启动沙箱 */
active: () => void;
/** 关闭沙箱 */
inactive: () => void;
};
export function createSandboxContainer(
appName: string,
globalContext: Window,
proxy: boolean = true,
multi: boolean = true
) {
let sandbox: SandBox;
if (proxy) {
sandbox = multi
? new ProxySandbox(appName, globalContext)
: new LegacySandbox(appName, globalContext);
} else {
sandbox = new SnapshotSandbox(appName);
}
let mountingFreers: Freer[] = [];
return {
instance: sandbox,
mount() {
// 1. 启动/恢复沙箱
sandbox.active();
// 2. 开启全局变量补丁
mountingFreers = patchSideEffects(sandbox.proxy);
},
unmount() {
// 1. 释放全局变量补丁
mountingFreers.forEach((free) => free());
// 2. 关闭沙箱
sandbox.inactive();
},
};
}
sandbox/SnapshotSandbox.ts
主要功能:快照沙箱。写完后代码如下:
import { SandBox } from "..";
function iter(
obj: typeof window | Record<any, any>,
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);
}
}
}
export default class SnapshotSandbox implements SandBox {
name: string;
proxy: WindowProxy;
sandboxRunning: boolean;
private windowSnapshot!: Window;
private modifyPropsMap: Record<any, any> = {};
private deletePropsSet: Set<any> = new Set();
constructor(name: string) {
this.name = name;
this.proxy = window;
this.sandboxRunning = false;
}
active() {
this.windowSnapshot = {} as Window;
// 保存当前window到快照
iter(window, (prop) => {
this.windowSnapshot[prop] = window[prop];
});
// 恢复上次的变更
Object.keys(this.modifyPropsMap).forEach((p: any) => {
window[p] = this.modifyPropsMap[p];
});
// 删除上次删除的属性
this.deletePropsSet.forEach((p: any) => {
delete window[p];
});
// active
this.sandboxRunning = true;
}
inactive() {
this.modifyPropsMap = {};
this.deletePropsSet.clear();
iter(window, (prop) => {
// 记录变更的属性到modifyPropsMap
if (window[prop] !== this.windowSnapshot[prop]) {
this.modifyPropsMap[prop] = window[prop];
// 恢复回去
window[prop] = this.windowSnapshot[prop];
}
delete this.windowSnapshot[prop];
});
iter(this.windowSnapshot, (prop) => {
// 记录删除的属性到deletePropsSet
if (!window.hasOwnProperty(prop)) {
this.deletePropsSet.add(prop);
window[prop] = this.windowSnapshot[prop];
}
});
// inactive
this.sandboxRunning = false;
}
}
sandbox/LegacySandbox.ts
主要功能:单例代理沙箱。写完后代码如下:
import { SandBox } from "..";
const callableFnCacheMap = new WeakMap();
const boundedMap = new WeakMap<CallableFunction, boolean>();
const fnRegexCheckCacheMap = new WeakMap<any | FunctionConstructor, boolean>();
function isCallable(fn: any) {
if (callableFnCacheMap.has(fn)) {
return true;
}
const naughtySafari =
typeof document.all === "function" && typeof document.all === "undefined";
const callable = naughtySafari
? typeof fn === "function" && typeof fn !== "undefined"
: typeof fn === "function";
if (callable) {
callableFnCacheMap.set(fn, callable);
}
return callable;
}
function isBoundedFunction(fn: CallableFunction) {
if (boundedMap.has(fn)) {
return boundedMap.get(fn);
}
/*
indexOf is faster than startsWith
see https://jsperf.com/string-startswith/72
*/
const bounded =
fn.name.indexOf("bound ") === 0 && !fn.hasOwnProperty("prototype");
boundedMap.set(fn, bounded);
return bounded;
}
export function isConstructable(fn: () => any | FunctionConstructor) {
// prototype methods might be changed while code running, so we need check it every time
const hasPrototypeMethods =
fn.prototype &&
fn.prototype.constructor === fn &&
Object.getOwnPropertyNames(fn.prototype).length > 1;
if (hasPrototypeMethods) return true;
if (fnRegexCheckCacheMap.has(fn)) {
return fnRegexCheckCacheMap.get(fn);
}
/*
1. 有 prototype 并且 prototype 上有定义一系列非 constructor 属性
2. 函数名大写开头
3. class 函数
满足其一则可认定为构造函数
*/
let constructable = hasPrototypeMethods;
if (!constructable) {
// fn.toString has a significant performance overhead, if hasPrototypeMethods check not passed, we will check the function string with regex
const fnString = fn.toString();
const constructableFunctionRegex = /^function\b\s[A-Z].*/;
const classRegex = /^class\b/;
constructable =
constructableFunctionRegex.test(fnString) || classRegex.test(fnString);
}
fnRegexCheckCacheMap.set(fn, constructable);
return constructable;
}
export function getTargetValue(target: any, value: any) {
/*
仅绑定 isCallable && !isBoundedFunction && !isConstructable 的函数对象,如 window.console、window.atob 这类。目前没有完美的检测方式,这里通过 prototype 中是否还有可枚举的拓展方法的方式来判断
@warning 这里不要随意替换成别的判断方式,因为可能触发一些 edge case(比如在 lodash.isFunction 在 iframe 上下文中可能由于调用了 top window 对象触发的安全异常)
*/
if (
isCallable(value) &&
!isBoundedFunction(value) &&
!isConstructable(value)
) {
const boundValue = Function.prototype.bind.call(value, target);
for (const key in value) {
boundValue[key] = value[key];
}
if (
value.hasOwnProperty("prototype") &&
!boundValue.hasOwnProperty("prototype")
) {
Object.defineProperty(boundValue, "prototype", {
value: value.prototype,
enumerable: false,
writable: true,
});
}
return boundValue;
}
return value;
}
export default class LegacySandbox implements SandBox {
name: string;
proxy: WindowProxy;
sandboxRunning = true;
globalContext: Window;
// 新增的属性
addedPropsMapInSandbox = new Map();
// 修改的属性
modifiedPropsOriginalValueMap = new Map();
// 始终记录的最新值
currentUpdatedPropsValueMap = new Map();
constructor(name: string, global: WindowProxy) {
this.name = name;
this.globalContext = global;
const rawWindow = global as any;
const fakeWindow = Object.create(null);
const {
addedPropsMapInSandbox,
modifiedPropsOriginalValueMap,
currentUpdatedPropsValueMap,
} = this;
const proxy = new Proxy(fakeWindow, {
set: (_, p, value) => {
if (this.sandboxRunning) {
if (!rawWindow.hasOwnProperty(p)) {
// 这是新增的属性,记录下来,用于inactive时删除
addedPropsMapInSandbox.set(p, value);
} else if (!modifiedPropsOriginalValueMap.has(p)) {
// 这个属性被修改了,记录原始值,方便inactive时恢复window
const originalVal = rawWindow[p];
modifiedPropsOriginalValueMap.set(p, originalVal);
}
// 总是记录最新的值,方便active时同步到真实window
currentUpdatedPropsValueMap.set(p, value);
rawWindow[p] = value;
}
return true;
},
get(_: Window, p: PropertyKey): any {
// avoid who using window.window or window.self to escape the sandbox environment to touch the really window
// or use window.top to check if an iframe context
// see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
if (p === "top" || p === "parent" || p === "window" || p === "self") {
return proxy;
}
const value = (rawWindow as any)[p];
return getTargetValue(rawWindow, value);
},
has(_, p) {
//返回boolean
return p in rawWindow;
},
getOwnPropertyDescriptor(_, p) {
const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
// 如果属性不作为目标对象的自身属性存在,则不能将其设置为不可配置
if (descriptor && !descriptor.configurable) {
descriptor.configurable = true;
}
return descriptor;
},
});
this.proxy = proxy;
}
active() {
if (!this.sandboxRunning) {
this.currentUpdatedPropsValueMap.forEach((value, key) =>
this.setWindowProp(key, value)
);
}
// active
this.sandboxRunning = true;
}
inactive() {
this.modifiedPropsOriginalValueMap.forEach((value, key) =>
this.setWindowProp(key, value)
);
this.addedPropsMapInSandbox.forEach((_, key) =>
this.setWindowProp(key, undefined, true)
);
// inactive
this.sandboxRunning = false;
}
private setWindowProp(prop: PropertyKey, value: any, toDelete?: boolean) {
if (value === undefined && toDelete) {
delete (this.globalContext as any)[prop];
} else {
(this.globalContext as any)[prop] = value;
}
}
}
sandbox/ProxySandbox.ts
主要功能:代理沙箱。写完后代码如下:
import { without } from "lodash";
import { SandBox } from "..";
import { getTargetValue } from "./LegacySandbox";
import { array2TruthyObject, nativeGlobal } from "../../common";
import { globalsInBrowser, globalsInES2015 } from "../../globals";
const frozenPropertyCacheMap = new WeakMap<any, Record<PropertyKey, boolean>>();
export function isPropertyFrozen(target: any, p?: PropertyKey): boolean {
if (!target || !p) {
return false;
}
const targetPropertiesFromCache = frozenPropertyCacheMap.get(target) || {};
if (targetPropertiesFromCache[p]) {
return targetPropertiesFromCache[p];
}
const propertyDescriptor = Object.getOwnPropertyDescriptor(target, p);
const frozen = Boolean(
propertyDescriptor &&
propertyDescriptor.configurable === false &&
(propertyDescriptor.writable === false ||
(propertyDescriptor.get && !propertyDescriptor.set))
);
targetPropertiesFromCache[p] = frozen;
frozenPropertyCacheMap.set(target, targetPropertiesFromCache);
return frozen;
}
// these globals should be recorded while accessing every time
const accessingSpiedGlobals = ["document", "top", "parent", "eval"];
const overwrittenGlobals = ["window", "self", "globalThis", "hasOwnProperty"];
export const cachedGlobals = Array.from(
new Set(
without(
globalsInES2015.concat(overwrittenGlobals).concat("requestAnimationFrame"),
...accessingSpiedGlobals
)
)
);
const unscopables = array2TruthyObject(
without(cachedGlobals, ...accessingSpiedGlobals.concat(overwrittenGlobals))
);
const useNativeWindowForBindingsProps = new Map<PropertyKey, boolean>([
['fetch', true],
['mockDomAPIInBlackList', process.env.NODE_ENV === 'test'],
]);
const cachedGlobalsInBrowser = array2TruthyObject(
globalsInBrowser.concat(process.env.NODE_ENV === 'test' ? ['mockNativeWindowFunction'] : []),
);
function isNativeGlobalProp(prop: string): boolean {
return prop in cachedGlobalsInBrowser;
}
const globalVariableWhiteList: string[] = [
// FIXME System.js used a indirect call with eval, which would make it scope escape to global
// To make System.js works well, we write it back to global window temporary
// see https://github.com/systemjs/systemjs/blob/457f5b7e8af6bd120a279540477552a07d5de086/src/evaluate.js#L106
"System",
// see https://github.com/systemjs/systemjs/blob/457f5b7e8af6bd120a279540477552a07d5de086/src/instantiate.js#L357
"__cjsWrapper",
];
// 创建一个假的window对象,将window的属性拷贝到这个对象上。
// 后续对window的操作,都通过这个假的window对象进行。不影响真实的window对象。
function createFakeWindow(globalContext: Window, speedy: boolean) {
// map always has the fastest performance in has checked scenario
// see https://jsperf.com/array-indexof-vs-set-has/23
const propertiesWithGetter = new Map<PropertyKey, boolean>();
const fakeWindow = {} as any;
/*
copy the non-configurable property of global to fakeWindow
see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor
> A property cannot be reported as non-configurable, if it does not exist as an own property of the target object or if it exists as a configurable own property of the target object.
*/
Object.getOwnPropertyNames(globalContext)
.filter((p) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
return !descriptor?.configurable;
})
.forEach((p) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
if (descriptor) {
const hasGetter = Object.prototype.hasOwnProperty.call(
descriptor,
"get"
);
/*
make top/self/window property configurable and writable, otherwise it will cause TypeError while get trap return.
see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/get
> The value reported for a property must be the same as the value of the corresponding target object property if the target object property is a non-writable, non-configurable data property.
*/
if (
p === "top" ||
p === "parent" ||
p === "self" ||
p === "window" ||
// window.document is overwriting in speedy mode
(p === "document" && speedy)
) {
descriptor.configurable = true;
/*
The descriptor of window.window/window.top/window.self in Safari/FF are accessor descriptors, we need to avoid adding a data descriptor while it was
Example:
Safari/FF: Object.getOwnPropertyDescriptor(window, 'top') -> {get: function, set: undefined, enumerable: true, configurable: false}
Chrome: Object.getOwnPropertyDescriptor(window, 'top') -> {value: Window, writable: false, enumerable: true, configurable: false}
*/
if (!hasGetter) {
descriptor.writable = true;
}
}
if (hasGetter) propertiesWithGetter.set(p, true);
// freeze the descriptor to avoid being modified by zone.js
// see https://github.com/angular/zone.js/blob/a5fe09b0fac27ac5df1fa746042f96f05ccb6a00/lib/browser/define-property.ts#L71
Object.defineProperty(fakeWindow, p, Object.freeze(descriptor));
}
});
return {
fakeWindow,
propertiesWithGetter,
};
}
export default class ProxySandbox implements SandBox {
name: string;
proxy: WindowProxy;
private document = document;
globalContext: Window;
sandboxRunning = true;
globalWhitelistPrevDescriptor: {
[p in (typeof globalVariableWhiteList)[number]]:
| PropertyDescriptor
| undefined;
} = {};
constructor(name: string, global: WindowProxy) {
this.name = name;
const globalContext = global;
this.globalContext = globalContext;
const { fakeWindow, propertiesWithGetter } = createFakeWindow(global, true);
const proxy = new Proxy(fakeWindow, {
set: (target, p, value) => {
if (this.sandboxRunning) {
if (
typeof p === "string" &&
globalVariableWhiteList.indexOf(p) !== -1
) {
this.globalWhitelistPrevDescriptor[p] =
Object.getOwnPropertyDescriptor(globalContext, p);
// @ts-ignore
globalContext[p] = value;
} else {
// We must keep its description while the property existed in globalContext before
if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(
globalContext,
p
);
const { writable, configurable, enumerable, set } = descriptor!;
// only writable property can be overwritten
// here we ignored accessor descriptor of globalContext as it makes no sense to trigger its logic(which might make sandbox escaping instead)
// we force to set value by data descriptor
if (writable || set) {
Object.defineProperty(target, p, {
configurable,
enumerable,
writable: true,
value,
});
}
} else {
target[p] = value;
}
}
}
return true;
},
get: (target, p) => {
if (p === Symbol.unscopables) return unscopables;
// avoid who using window.window or window.self to escape the sandbox environment to touch the real window
// see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
if (p === "window" || p === "self") {
return proxy;
}
// hijack globalWindow accessing with globalThis keyword
if (p === "globalThis") {
return proxy;
}
if (p === "top" || p === "parent") {
// if your master app in an iframe context, allow these props escape the sandbox
if (globalContext === globalContext.parent) {
return proxy;
}
return (globalContext as any)[p];
}
// proxy.hasOwnProperty would invoke getter firstly, then its value represented as globalContext.hasOwnProperty
if (p === "hasOwnProperty") {
return hasOwnProperty;
}
if (p === "document") {
return this.document;
}
if (p === "eval") {
return eval;
}
if (p === "string" && globalVariableWhiteList.indexOf(p) !== -1) {
// @ts-ignore
return globalContext[p];
}
const actualTarget = propertiesWithGetter.has(p)
? globalContext
: p in target
? target
: globalContext;
const value = actualTarget[p];
// frozen value should return directly, see https://github.com/umijs/qiankun/issues/2015
if (isPropertyFrozen(actualTarget, p)) {
return value;
}
// non-native property return directly to avoid rebind
if (
!isNativeGlobalProp(p as string) &&
!useNativeWindowForBindingsProps.has(p)
) {
return value;
}
/* Some dom api must be bound to native window, otherwise it would cause exception like 'TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation'
See this code:
const proxy = new Proxy(window, {});
// in nest sandbox fetch will be bind to proxy rather than window in master
const proxyFetch = fetch.bind(proxy);
proxyFetch('https://qiankun.com');
*/
const boundTarget = useNativeWindowForBindingsProps.get(p)
? nativeGlobal
: globalContext;
return getTargetValue(boundTarget, value);
},
has(target: any, p: string | number | symbol): boolean {
// property in cachedGlobalObjects must return true to avoid escape from get trap
return p in target || p in globalContext;
},
deleteProperty: (target: any, p: string | number | symbol): boolean => {
if (target.hasOwnProperty(p)) {
delete target[p];
return true;
}
return true;
},
});
this.proxy = proxy;
function hasOwnProperty(this: any, key: PropertyKey): boolean {
// calling from hasOwnProperty.call(obj, key)
if (this !== proxy && this !== null && typeof this === "object") {
return Object.prototype.hasOwnProperty.call(this, key);
}
return (
fakeWindow.hasOwnProperty(key) || globalContext.hasOwnProperty(key)
);
}
}
active() {
// active
this.sandboxRunning = true;
}
inactive() {
// inactive
this.sandboxRunning = false;
}
}
patchers/index.ts
主要功能:执行patcher清除副作用。写完后代码如下:
import patchIntervals from "./intervals";
import patchListeners from "./listeners";
export type Freer = () => void;
export type Patcher = (global: WindowProxy) => Freer;
export function patchSideEffects(global: WindowProxy) {
const patchers: Patcher[] = [patchIntervals, patchListeners];
return patchers.map((patch) => patch(global));
}
patchers/intervals.ts
主要功能:清除setInterval副作用。写完后代码如下:
import { Patcher } from ".";
const rawSetInterval = global.setInterval;
const rawClearInterval = global.clearInterval;
const patch: Patcher = (global) => {
let intervals: number[] = [];
global.setInterval = (handler: Function, timeout: number, ...args: any[]) => {
const intervalId = rawSetInterval(handler, timeout, ...args);
intervals.push(intervalId);
return intervalId;
};
global.clearInterval = (intervalId: number) => {
rawClearInterval(intervalId);
intervals = intervals.filter((id) => id !== intervalId);
};
return function free() {
intervals.forEach((id) => rawClearInterval(id));
intervals = [];
global.setInterval = rawSetInterval;
global.clearInterval = rawClearInterval;
};
};
export default patch;
patchers/listeners.ts
主要功能:清除addListener副作用。写完后代码如下:
import { Patcher } from ".";
const rawAddListener = global.addEventListener;
const rawRemoveListener = global.removeEventListener;
const patch: Patcher = (global) => {
let listeners: Map<string, EventListenerOrEventListenerObject[]> = new Map();
global.addEventListener = (
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
) => {
rawAddListener(type, listener, options);
if (!listeners.has(type)) {
listeners.set(type, []);
}
listeners.get(type)!.push(listener);
};
global.removeEventListener = (type: string, listener: EventListenerOrEventListenerObject) => {
rawRemoveListener(type, listener);
if (listeners.has(type)) {
listeners.set(
type,
listeners.get(type)!.filter((l) => l !== listener)
);
}
}
return function free() {
listeners.forEach((ls, type) => {
ls.forEach((listener) => {
rawRemoveListener(type, listener);
});
});
listeners.clear();
global.addEventListener = rawAddListener;
global.removeEventListener = rawRemoveListener;
};
};
export default patch;
globalState.ts
主要功能:全局状态通信。写完后代码如下:
import { cloneDeep } from "lodash";
declare type State = Record<string, any>;
declare type Listener = (state: State, prev: State) => void;
let globalState: State = {};
let listenerMap: Record<string, Listener> = {};
function emit(state: State) {
for (let key in listenerMap) {
listenerMap[key](state, cloneDeep(globalState));
}
}
export function initGlobalState(state: State = {}) {
globalState = cloneDeep(state);
return getAppStateActions(`main-app`);
}
export function getAppStateActions(id: string) {
return {
onGlobalStateChange: (callback: Listener, fireImmediately?: boolean) => {
listenerMap[id] = callback;
if (fireImmediately) {
callback(cloneDeep(globalState), {});
}
},
offGlobalStateChange: () => {
delete listenerMap[id];
},
setGlobalState: (state: State) => {
state = cloneDeep(state);
console.log(`[fake qiankun]global state set: ${JSON.stringify(state)}`);
let changedKeys: string[] = [];
for (let key in state) {
if (globalState[key] !== state[key]) {
changedKeys.push(key);
}
}
if (changedKeys.length > 0) {
emit(state);
}
},
};
}
qiankun.ts
最后就是qiankun.ts
,它是一个全局协调者的角色,组装各个模块的功能,暴露接口供用户调用。
主要功能:提供外部接口,内部使用single-spa.ts
和import-html-entry
,创建沙盘,管理全局状态,加载app。写完后代码如下:
import { registerApplication, start as startSingleSpa } from "./single-spa";
import { importEntry } from "./import-html-entry";
import { createSandboxContainer } from "./sandbox";
import { getAppStateActions } from "./globalState";
export { initGlobalState } from "./globalState"
export type AppConfig = {
name: string;
entry: string;
container: string;
activeRule: string;
};
export function registerMicroApps(apps: AppConfig[]) {
apps.forEach((app) => {
registerApplication({
name: app.name,
activeWhen: app.activeRule,
app: async () => {
return loadApp(app);
},
});
});
}
declare type FrameworkConfiguration = {
proxy?: boolean;
multi?: boolean;
style?: boolean | { strict?: boolean; experiment?: boolean };
};
let frameworkConfiguration: FrameworkConfiguration = {};
export function start(opts: FrameworkConfiguration) {
frameworkConfiguration = opts;
startSingleSpa();
}
async function loadApp(app: AppConfig) {
const parent = document.querySelector(app.container);
if (!parent) throw new Error("container not found");
parent.innerHTML = "";
const { template, execScripts } = await importEntry(app.entry);
const isStrictStyle =
typeof frameworkConfiguration.style === "object" &&
!!frameworkConfiguration.style.strict;
const isScopedStyle =
typeof frameworkConfiguration.style === "object" &&
!isStrictStyle &&
!!frameworkConfiguration.style.experiment;
console.log(
`[fake qiankun] ${
isScopedStyle ? "scoped" : isStrictStyle ? "strict" : "none"
} style isolation applied`
);
const appWrapperElement = createElement(
template,
isStrictStyle,
isScopedStyle
);
parent.appendChild(appWrapperElement);
let global: Window = window;
// sandbox
const sandboxContainer = createSandboxContainer(
app.name,
global,
frameworkConfiguration.proxy,
frameworkConfiguration.multi
);
global = sandboxContainer.instance.proxy;
beforeLoad(global, app.entry);
execScripts(global);
// @ts-ignore
const scriptExports = global[app.name];
const { bootstrap, mount, unmount } = scriptExports;
const appContainer = appWrapperElement.shadowRoot || appWrapperElement;
const stateActions = getAppStateActions(app.name);
return {
bootstrap,
mount: [
sandboxContainer.mount,
async () => mount({ name: app.name, container: appContainer, ...stateActions }),
() => console.log(`[fake qiankun] app mounted: 【${app.name}】`),
],
unmount: [
() => beforeUnmountAddOns(global),
async () => unmount({ name: app.name, container: appContainer }),
sandboxContainer.unmount,
() => console.log(`[fake qiankun] app unmounted: 【${app.name}】`),
],
};
}
function beforeLoad(global: any, publicPath = "/") {
global.__POWERED_BY_QIANKUN__ = true;
global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = publicPath;
}
function beforeUnmountAddOns(global: any) {
delete global.__POWERED_BY_QIANKUN__;
delete global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
/** 创建app wrapper元素 */
function createElement(
appElement: HTMLElement,
strictStyle: boolean,
scopedStyle: boolean
) {
if (strictStyle) {
const { innerHTML } = appElement;
appElement.innerHTML = "";
const shadow = appElement.attachShadow({ mode: "open" });
shadow.innerHTML = innerHTML;
}
if (scopedStyle) {
const attr = appElement.getAttribute("data-qiankun");
if (!attr) {
appElement.setAttribute("data-qiankun", "appid");
}
const styleNodes = appElement.querySelectorAll("style") || [];
styleNodes.forEach((stylesheetElement: HTMLStyleElement) => {
// css.process(appElement!, stylesheetElement, "appid");
});
}
return appElement;
}
测试用例和贴图就先不加了,有时间补上。