架构机制
qiankun 的结构机制
qiankun(乾坤)框架其实是基于 single-spa 框架搭建而成的,简单来说就是 single-spa 的优化版。
大体实现思路:
- 预加载资源:如果在应用注册配置中,有配置需要预加载的应用,则在初始化的同时去加载这些应用。
- 初始化路由:根据配置的路由规则,与当前页面路径匹配,找到当前有效的应用信息。并且通过 popstate 监听页面路由变化,根据路由变化得到当前有效的应用
- 代理部分 window 事件:由于每个应用可能各自会绑定一些 window 事件,因此劫持 window.addEventListener,将每个应用所绑定的事件记录下来,方便后续再切换路由时清除掉。
- 加载资源:目通过加载目标页面,分析各种资源然后加载执行
- 记录全局变量:在每个应用执行之前,记录当前全局变量,然后在应用被卸载的时候,清除掉所有全局变量,以免影响下个应用的执行。
传递注册信息给 single-spa
我们来看看 qiankun 的使用方式:
import { registerMicroApps, start } from "qiankun";
registerMicroApps([
{
name: "react app", // app name registered
entry: "//localhost:7100",
container: "#yourContainer",
activeRule: "/yourActiveRule",
},
{
name: "vue app",
entry: { scripts: ["//localhost:7100/main.js"] },
container: "#yourContainer2",
activeRule: "/yourActiveRule2",
},
]);
start();
实际上 qiankun 内部会把用户的应用注册信息包装后传递给 single-spa:
import { registerApplication } from "single-spa";
export function registerMicroApps(apps) {
apps.forEach((app) => {
const { name, activeRule, loader = noop, props, ...appConfig } = app;
registerApplication({
name,
app: async () => {
loader(true);
const { mount, ...otherMicroAppConfigs } = await loadApp(
{ name, props, ...appConfig },
frameworkConfiguration
);
return {
mount: [
async () => loader(true),
...toArray(mount),
async () => loader(false),
],
...otherMicroAppConfigs,
};
},
activeWhen: activeRule,
customProps: props,
});
});
}
可以看到 mount 和 unmount 函数是由 loadApp 返回的。
loadApp 的实现
export async function loadApp(app, configuration) {
const { template, execScripts } = await importEntry(entry); // 通过应用的入口链接即可获取到应用的html, js, css内容
const sandboxInstance = createSandbox(); // 创建沙箱实例
const global = sandboxInstance.proxy; // 获取一个沙箱全局上下文
const mountSandbox = sandboxInstance.mount;
const unmountSandbox = sandboxInstance.unmount;
// 在这个沙箱全局上下文执行子项目的js代码
const scriptExports = await execScripts(global);
// 获取子项目导出的 bootstrap / mount / unmount
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
scriptExports,
appName,
global
);
// 初始化事件模块
const {
onGlobalStateChange,
setGlobalState,
offGlobalStateChange,
} = getMicroAppStateActions();
// 传递给single-spa的mount, unmount方法实际是qiankun包装过的函数
return {
bootstrap,
mount: async () => {
awaitrender(template); // 把模板渲染到挂载区域
mountSandbox(); // 挂载沙箱
await mount({ setGlobalState, onGlobalStateChange }); // 调用应用的mount函数
},
ummount: async () => {
await ummount(); // 调用应用的ummount函数
unmountSandbox(); // 卸载沙箱
offGlobalStateChange(); // 解除事件监听
render(null); // 把渲染区域清空
},
};
}
看看 importEntry 的使用,这是一个独立的包 import-html-entry,通过解析一个 html 内容,返回 html, css,js 分离过的内容。例如一个子应用的入口 html 为如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>这里是标题</title>
<link rel="stylesheet" href="./css/admin.css" />
<style>
.div {
color: red;
}
</style>
</head>
<boyd>
<div id="wrap">
<div id="app"></div>
</div>
<script src="/static/js/app.12345.js"></script>
<script>
console.log("1");
</script>
</boyd>
</html>
被 qiankun 加载到页面后,最终生成的 html 结构为:
<meta charset="utf-8" />
<title>这里是标题</title>
<link rel="stylesheet" href="./css/admin.css" />
<style>
.div {
color: red;
}
</style>
<div id="wrap">
<div id="app"></div>
</div>
<!-- script /static/js/app.12345.js replaced by import-html-entry -->
<!-- inline scripts replaced by import-html-entry -->
看看 importEntry 返回的内容:
export function importEntry(entry, opts = {}) {
// ... // parse html 过程忽略
return {
// 纯dom元素的内容
template,
// 一个可以接收自定义fetch方法的获取<script>标签的方法
getExternalScripts: () => getExternalScripts(scripts, fetch),
// 一个可以接收自定义fetch方法的获取<style>标签的方法
getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
// 一个接收全局上下文的执行函数,执行这个方法则模拟了一个应用加载时浏览器执行script脚本的逻辑
execScripts: (proxy) => {},
};
}
看看 getExternalScripts 的实现,实际上是用并行 fetch 模拟浏览器加载 <style>
标签的过程(注意此时还没有执行这些脚本), getExternalStyleSheets 与这个类似。
// scripts是解析html后得到的<scripts>标签的url的数组
export getExternalScripts(scripts, fetch = defaultFetch) {
return Promise.all(scripts.map(script => {
return fetch(scriptUrl).then(response => {
return response.text();
}));
}))
}
然后看看 execScripts 的实现,可以通过给定的一个假 window 来执行所有 <script>
标签的脚本,这样就是真正模拟了浏览器执行 <script>
标签的行为。
export async execScripts(proxy) {
// 上面的getExternalScripts加载得到的<scripts>标签的内容
const scriptsTexts = await getExternalScripts(scripts)
window.proxy = proxy;
// 模拟浏览器,按顺序执行script
for (let scriptsText of scriptsTexts) {
// 调整sourceMap的地址,否则sourceMap失效
const sourceUrl = '//# sourceURL=${scriptSrc}\n';
// 通过iife把proxy替换为window, 通过eval来执行这个script
eval(`
;(function(window, self){
;${scriptText}
${sourceUrl}
}).bind(window.proxy)(window.proxy, window.proxy);
`;)
}
}
沙箱功能
沙箱主要用于解决程序的全局变量污染和内存泄漏问题。
- 全局变量污染:多个应用都使用某个同名全局变量,例如 Vue。
- 内存泄漏:内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费。
常见的内存泄漏场景:意外的全局变量、泄漏到全局的闭包、DOM 泄漏、定时器、EventListener、console.log (开发环境)。
沙箱使用
export function createSandbox() {
const sandbox = new LegacySandbox();
// load或者bootstrap阶段产生的污染和泄漏
const bootstrappingFreers = patchAtBootstrapping();
let sideEffectsRebuilders = [];
return {
proxy: sandbox.proxy,
// 沙箱被 mount, 可能是从 bootstrap 状态进入的 mount, 也可能是从 unmount 之后再次唤醒进入 mount
async mount() {
/* ------------------------------------------ 1. 启动/恢复 沙箱------------------------------------------ */
sandbox.active();
const sideEffectsRebuildersAtBootstrapping = sideEffectsRebuilders.slice(
0,
bootstrappingFreers.length
);
// 重建应用 bootstrap 阶段的副作用,比如动态插入css
sideEffectsRebuildersAtBootstrapping.forEach((rebuild) => rebuild());
/* ------------------------------------------ 2. 开启全局副作用监听 ------------------------------------------*/
// render 沙箱启动时开始劫持各类全局监听,尽量不要在应用初始化 bootstrap 阶段有 事件监听/定时器 等副作用,这些副作用无法清除
mountingFreers = patchAtMounting(
appName,
elementGetter,
sandbox,
singular,
scopedCSS,
excludeAssetFilter
);
sideEffectsRebuilders = [];
},
// 恢复 global 状态,使其能回到应用加载之前的状态
async unmount() {
// 每个Freers释放后都会返回一个重建函数,如果该Freers不需要重建,则是返回一个空函数
sideEffectsRebuilders = [...bootstrappingFreers].map((free) => free());
sandbox.inactive();
},
};
}
看看 LegacySandbox 沙箱的实现,这个沙箱的作用主要处理全局变量污染,使用一个 proxy 来替换 window 来劫持所有的 window 操作。
class SingularProxySandbox {
// 沙箱期间更新的全局变量
addedPropsMapInSandbox = new Map();
// 沙箱期间更新的全局变量
modifiedPropsOriginalValueMapInSandbox = new Map();
// 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot
currentUpdatedPropsValueMap = new Map();
sandboxRunning = true;
active() {
// 把上次该沙箱运行时的快照还原
this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
this.sandboxRunning = true;
}
inactive() {
// 沙箱销毁时把修改的值改回去
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
// 沙箱销毁时把新增的值置空
this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));
this.sandboxRunning = false;
}
constructor(name) {
const proxy = new Proxy(window, {
set(_, p, value) {
// 如果当前 window 对象不存在该属性,则记录该属性是新增的
if (!window.hasOwnProperty(p)) {
addedPropsMapInSandbox.set(p, value);
// 如果当前 window 对象存在该属性,且 map 中未记录过,则记录该属性被修改及保存修改前的值
} else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
const originalValue = window[p];
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
// 不管新增还是修改,这个值都变成最新的快照记录起来
currentUpdatedPropsValueMap.set(p, value);
window[p] = value;
}
},
get(_, p) {
return window[p]
},
})
}
}
除了全局变量污染的问题,还有其他的泄漏问题需要处理,这些泄漏问题 qiankun 使用不同的 patch 函数来劫持。
// 处理mount阶段和应用运行阶段产生的泄漏
export function patchAtMounting() {
return [
// 处理定时器泄漏
patchInterval(),
// 处理全局事件监听泄漏
patchWindowListener(),
patchHistoryListener(),
// 这个严格不算泄漏,是监听动态插入页面的dom结构(包括script和style)
patchDynamicAppend(),
];
}
// 处理load和bootstrap阶段产生的泄漏
export function patchAtBootstrapping() {
return [patchDynamicAppend()];
}
一个 patch 的例子如下:
const rawWindowInterval = window.setInterval;
const rawWindowClearInterval = window.clearInterval;
export default function patchInterval(global) {
let intervals = [];
global.clearInterval = (intervalId) => {
intervals = intervals.filter((id) => id !== intervalId);
return rawWindowClearInterval(intervalId);
};
global.setInterval = (handler, timeout, ...arg) => {
const intervalId = rawWindowInterval(handler, timeout, ...args);
intervals = [...intervals, intervalId];
return intervalId;
};
// 返回释放这些泄漏的方法
return function free() {
intervals.forEach((id) => global.clearInterval(id));
global.setInterval = rawWindowInterval;
global.clearInterval = rawWindowClearInterval;
// 这个patch有没有需要重建的场景,如果没有,则为空函数
return function rebuild() {};
};
}
这种返回取消功能的设计很精妙,在 vue 中也能找到类似设计。
// 监听返回取消监听方法,取消监听返回再重新监听的方法
const unwatch = this.$watch("xxx", () => {});
const rewatch = unwatch(); // 伪代码,实际上没有
我们来看最复杂的 patchDynamicAppend 实现,用于处理代码里动态插入 script 和 link 的场景。
const rawHeadAppendChild = HTMLHeadElement.prototype.appendChild;
export default function patchDynamicAppend(mounting, proxy) {
let dynamicStyleSheetElements = [];
// 劫持插入函数
HTMLHeadElement.prototype.appendChild = function (element) {
switch (element.tagName) {
case LINK_TAG_NAME:
// 如果是动态插入<style>标签到body上,则调整插入的位置到子应用挂载区
case STYLE_TAG_NAME: {
dynamicStyleSheetElements.push(stylesheetElement);
return rawHeadAppendChild.call(appWrapperGetter(), stylesheetElement);
}
// 如果是动态插入<script>标签,则使用execScripts来执行这个脚本,然后把脚本替换为一段注释文本表示已执行过
case SCRIPT_TAG_NAME: {
const { src, text } = element;
execScripts(null, [src ? src : `<script>${text}</script>`], proxy);
const dynamicScriptCommentElement = document.createComment(
src
? `dynamic script ${src} replaced by qiankun`
: "dynamic inline script replaced by qiankun"
);
return rawHeadAppendChild.call(
appWrapperGetter(),
dynamicScriptCommentElement
);
}
}
return rawHeadAppendChild.call(this, element);
};
// 这里free不需要释放什么东西,因为style元素会随着内容区清除而自然消失
return function free() {
// 这里需要再下次继续挂载这个应用时重建style元素
return function rebuild() {
dynamicStyleSheetElements.forEach((stylesheetElement) => {
document.head.appendChild.call(appWrapperGetter(), stylesheetElement);
});
if (mounting) dynamicStyleSheetElements = [];
};
};
}
父子应用通信
qiankun 实现了一个简单的全局数据存储,作为 single-spa 事件的补充,父子应用都可以共同读写这个存储里的数据。
let globalState = {};
export function getMicroAppStateActions(id, isMaster) {
return {
// 事件变更回调
onGlobalStateChange(callback, fireImmediately) {
deps[id] = callback;
const cloneState = cloneDeep(globalState);
if (fireImmediately) {
callback(cloneState, cloneState);
}
},
// 设置全局状态
setGlobalState(state) {
const prevGlobalState = cloneDeep(globalState);
Object.keys(deps).forEach((id) => {
deps[id](cloneDeep(globalState), cloneDeep(prevGlobalState));
});
return true;
},
// 注销该应用下的依赖
offGlobalStateChange() {
delete deps[id];
},
};
}
关于预请求
预请求充分利用了 importEntry 把获取资源和执行资源分离的点来提前加载所有子应用的资源。
function prefetch(entry, opts) {
if (!navigator.onLine || isSlowNetwork) {
// Don't prefetch if in a slow network or offline
return;
}
requestIdleCallback(async () => {
const { getExternalScripts, getExternalStyleSheets } = await importEntry(
entry,
opts
);
requestIdleCallback(getExternalStyleSheets);
requestIdleCallback(getExternalScripts);
});
}
apps.forEach(({ entry }) => prefetch(entry, opts));