说到微前端,现在最火的方案就是qiankun。qiankun的特点是易用性和完备性很高。说白了就是能很方便、快速的接入,同时bug少,功能强大。
介绍
微前端已经火了一段时间了,就不介绍了,直接贴图得了。
话不多少,本次主要做两件事情:
-
拆解和解析qiankun源码
-
尝试qiankun造轮子
分析qiankun原理
截止现在,有很多大神已经将qiankun剖析得一清二楚。从各个层面去讲的都有,参考资料如下:
作者介绍qiankun
介绍Html Entry
介绍JavaScript沙盒
介绍CSS沙盒
清除副作用
应用间通信方案
参考这么多的文档资料,以及自己阅读源码之后,我对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