在上一篇文章【微前端】single-spa 究竟是个什么鬼 聊到了 single-spa 这个框架仅仅实现了子应用的生命周期的调度以及 url 变化的监听。微前端的一个特色都没有实现,严格来讲算不上微前端框架。css
今天就来聊一个真正的微前端框架:qiankun。一样地,本文不会教你们怎么实现一个 Demo,由于官方的 Github 已经有一个很好的 Demo 了,若是你以为官网的 Demo 太复杂了,也能够看我本身实现的小 Demo。html
首先,qiankun 并非单一个框架,它在 single-spa 基础上添加更多的功能。如下是 qiankun 提供的特性:前端
接下来不会一个特性一个特性地讲,由于这样会很无聊,讲完你也只能知道这是个啥,不能深刻了解是怎么来的。因此我更愿意聊一下这些特性是怎么来的,它们是怎么被想到的。react
先复习一下 single-spa 是怎么注册子应用的:git
singleSpa.registerApplication(
'appName',
() => System.import('appName'),
location => location.pathname.startsWith('appName'),
);
复制代码
能够看到 single-spa 采用 JS Entry 的方式接入微应用,也即:输出一个 JS,而后 bootstrap, mount, unmount 函数。github
可是事件并无这么简单:咱们项目通常都会将静态资源放到 CDN 上来加速。为了避免受缓存的影响,咱们还会将 JS 文件命名成 contenthash 的乱码文件名: jlkasjfdlkj.jalkjdsflk.js
。这样一来,每次子应用一发布,入口 JS 文件名确定又要改了,致使主应用引入的 JS url 又得改了。麻烦!正则表达式
打包成单个 JS 文件的另外一个问题就是打包的优化都没了:按需加载、首屏资源加载优化、css 独立打包等优化措施全 🈚️。bootstrap
不少时候,子应用通常都已是线上的应用了,好比 abcd.com。微前端融合多个子应用本质上不就是融合多个 HTML 嘛?那为何不给你子应用的 HTML,主应用就自动接入收工了呢?操做起来应该和在 <iframe/>
和插入 src 是同样的才对味。数组
这种经过提供 HTML 入口来接入子应用的方式就叫 HTML Entry。 qiankun 的一大亮点就是提供了 HTML Entry,在调用 qiankun 的注册子应用函数时能够这么写:浏览器
registerMicroApps([
{
name: 'react app', // 子应用名
entry: '//localhost:7100', // 子应用 html 或网址
container: '#yourContainer', // 挂载容器选择器
activeRule: '/yourActiveRule', // 激活路由
},
]);
start(); // Go
复制代码
用起来绝不费力,只须要在 JS 入口加上 single-spa 的生命周期钩子,再发布就能够直接接入了。
然而,HTML Entry 并非给个 HTML 的 url 就能够直接接入整个子应用这么简单了。子应用的 HTML 文件就是一堆乱七八糟的标签文本。<link>
, <style>
, <script>
得处理吧?要写正则表达式吧?头要秃了吧?
因此 qiankun 的做者本身也写了一个专门处理 HTML Entry 这种需求的 NPM 包:import-html-entry。用法以下:
import importHTML from 'import-html-entry';
importHTML('./subApp/index.html')
.then(res => {
console.log(res.template); // 拿到 HTML 模板
res.execScripts().then(exports => { // 执行 JS 脚本
const mobx = exports; // 获取 JS 的输出内容
// 下面就是拿到 JS 入口的内容,并用来作一些事
const { observable } = mobx;
observable({
name: 'kuitos'
})
})
});
复制代码
固然,qiankun 已经将 import-html-entry 与子应用加载函数完美地结合起来,你们只须要知道这个库是用来获取 HTML 模板内容,Style 样式和 JS 脚本内容就能够了。
有了上面的了解后,相信你们对于如何加载子应用就有思路了,伪代码以下:
// 解析 HTML,获取 html,js,css 文本
const {htmlText, jsText, cssText} = importHTMLEntry('https://xxxx.com')
// 建立容器
const $= document.querySelector(container)
$container.innerHTML = htmlText
// 建立 style 和 js 标签
const $style = createElement('style', cssText)
const $script = createElement('script', jsText)
$container.appendChild([$style, $script])
复制代码
在第三步,咱们不由有个疑问:当前这个应用完美地插入了 style 和 script 标签,那下一个应用 mount 时就会被前面的 style 和 script 污染了呀。
为了解决这两个问题,不得不作好应用之间的样式和 JS 的隔离。
qiankun 实现 single-spa 推荐的两种样式隔离方案:ShadowDOM 和 Scoped CSS。
先来讲说 ShadowDOM,qiankun 的源码实现也很简单,只是添加一个 Shadow DOM 节点,伪代码以下:
if (strictStyleIsolation) {
if (!supportShadowDOM) {
// 报错
// ...
} else {
// 清除原有的内容
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow: ShadowRoot;
if (appElement.attachShadow) {
// 添加 shadow DOM 节点
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// deprecated 的操做
// ...
}
// 在 shadow DOM 节点添加内容
shadow.innerHTML = innerHTML;
}
}
复制代码
经过 Shadow DOM 的自然的隔离特性来实现子应用间的样式隔离。
另外一个方案就是 Scoped CSS 了,说白了就是经过修改 CSS 选择器来实现子应用间的样式隔离。 好比,你有这样的 CSS 代码:
.container {
background: red;
}
div {
color: red;
}
复制代码
qiankun 会扫描给定的 CSS 文本,经过正则匹配在选择器前加上子应用的名字,若是遇到元素选择器,就加一个爸爸类名给它,好比:
.subApp.container {
background: red;
}
.subApp div {
color: red;
}
复制代码
第一步要隔离的是对全局对象 window 上的变量进行隔离。不能 A 子应用 window.setTimeout = undefined
以后, B 子应用用 setTimeout
的时候就凉了。
因此 JS 隔离深一层本质就是记录当前 window 对象之前的值,在 A 子应用进来时一顿乱搞以后,要将全部值都恢复过来(恢复现场)。这就是 SnapshotSandbox
的作法,伪代码以下:
class SnapshotSandbox {
...
active() {
// 记录当前快照
this.windowSnapshot = {} as Window;
getKeys(window).forEach(key => {
this.windowSnapshot[key] = window[key];
})
// 恢复以前的变动
getKeys(this.modifyPropsMap).forEach((key) => {
window[key] = this.modifyPropsMap[key];
});
this.sandboxRunning = true;
}
inactive() {
this.modifyPropsMap = {};
// 记录变动,恢复环境
getKeys(window).forEach((key) => {
if (window[key] !== this.windowSnapshot[key]) {
this.modifyPropsMap[key] = window[key];
window[key] = this.windowSnapshot[key];
}
});
this.sandboxRunning = false;
}
}
复制代码
除了 SnapShotSandbox
,qiankun 还提供了一种使用 ES 6 Proxy 实现的沙箱:
class SingularProxySandbox {
/** 沙箱期间新增的全局变量 */
private addedPropsMapInSandbox = new Map<PropertyKey, any>();
/** 沙箱期间更新的全局变量 */
private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();
/** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻作 snapshot */
private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();
active() {
if (!this.sandboxRunning) {
// 恢复子应用修改过的值
this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
}
this.sandboxRunning = true;
}
inactive() {
// 恢复加载子应用前的 window 值
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
// 删掉子应用期间新加的 window 值
this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));
this.sandboxRunning = false;
}
constructor(name: string) {
this.name = name;
this.type = SandBoxType.LegacyProxy;
const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this;
const rawWindow = window;
const fakeWindow = Object.create(null) as Window;
const proxy = new Proxy(fakeWindow, {
set: (_: Window, key: PropertyKey, value: any): boolean => {
if (this.sandboxRunning) {
if (!rawWindow[key]) {
addedPropsMapInSandbox.set(key, value); // 将沙箱期间新加的值记录下来
} else if (!modifiedPropsOriginalValueMapInSandbox.has(key)) {
modifiedPropsOriginalValueMapInSandbox.set(key, rawWindow[key]); // 记录沙箱前的值
}
currentUpdatedPropsValueMap.set(key, value); // 记录沙箱后的值
// 必须从新设置 window 对象保证下次 get 时能拿到已更新的数据
(rawWindow as any)[key] = value;
}
},
get(_: Window, key: PropertyKey): any {
return rawWindow[key]
},
}
}
}
复制代码
二者差不太多,那怎么不直接用 Proxy 高级方案呢,由于在一些低版本的浏览器下是没有 Proxy 对象的,因此 SnapshotSandbox
实际上是 SingularProxySandbox
的降级方案。
然而,问题仍是没有解决完。上面这种状况仅适用于一个页面只有一个子应用的状况,这种状况也被称为单例(singular mode)。 若是一个页面有多个子应用那一个 SingluarProxySandbox
明显不够的。为了解决这个问题,qiankun 提供了 ProxySandbox
,伪代码以下:
class ProxySandbox {
...
active() { // +1 废话
if (!this.sandboxRunning) activeSandboxCount++;
this.sandboxRunning = true;
}
inactive() { // -1 废话
if (--activeSandboxCount === 0) {
variableWhiteList.forEach((p) => {
if (this.proxy.hasOwnProperty(p)) {
delete window[p]; // 删除白名单里子应用添加的值
}
});
}
this.sandboxRunning = false;
}
constructor(name: string) {
...
const rawWindow = window; // 原 window 对象
const { fakeWindow, propertiesWithGetter } = createFakeWindow(rawWindow); // 将真 window 上的 key-value 复制到假 window 对象上
const proxy = new Proxy(fakeWindow, { // 代理复制出来的 window
set: (target: FakeWindow, key: PropertyKey, value: any): boolean => {
if (this.sandboxRunning) {
target[key] = value // 修改 fakeWindow 上的值
if (variableWhiteList.indexOf(key) !== -1) {
rawWindow[key] = value; // 白名单的话,修改真 window 上的值
}
updatedValueSet.add(p); // 记录修改的值
}
},
get(target: FakeWindow, key: PropertyKey): any {
return target[key] || rawWindow[key] // 在 fakeWindow 上找,找不到从直 window 上找
},
}
}
}
复制代码
从上面能够看到,在 active
和 inactive
里并无太多在恢复现场操做,由于只要子应用 unmount,把 fakeWindow
一扔掉就完事了。
等等,说了这么多上面还只是讨论 window 对象的隔离呀,格局是否是小了点?是小了。
如今咱们再来审视一下沙箱这个玩意,其实不管沙箱也好 JS 隔离也好,最终要实现的是给子应用一个独立的环境,这也意味着咱们有成百上千的东西要作补丁来打造终极的类 <iframe>
硬隔离。
然而,qiankun 也不是万能的,它只对某些重要的函数和监听器进行打补丁。
其中最重要的补丁就是 insertBefore
, appendChild
和 removeChild
的补丁了。
当咱们加载子应用的时候,免不了遇到动态添加/移除 CSS 和 JS 脚本的状况。这时 <head>
或 <body>
都有可能调用 insertBefore
, appendChild
, removeChild
这三个函数来插入或者删除 <style>
, <link>
或者 <script>
元素。
因此,这三个函数在被 <head>
或 <body>
调用时,就要用上补丁,主要目的是别插入到主应用的 <head>
和 <body>
上,要插在子应用里。打补丁伪代码以下:
// patch(element)
switch (element.tagName) {
case LINK_TAG_NAME: // <link> 标签
case STYLE_TAG_NAME: { // <style> 标签
if (scopedCSS) { // 使用 Scoped CSS
if (element.href;) { // 处理如 <link rel="icon" href="favicon.ico"> 的玩意
stylesheetElement = convertLinkAsStyle( // 获取 <link> 里的 CSS 文本,并使用 css.process 添加前缀
element,
(styleElement) => css.process(mountDOM, styleElement, appName), // 添加前缀回调
fetch,
);
dynamicLinkAttachedInlineStyleMap.set(element, stylesheetElement); // 缓存,下次加载沙箱时直接吐出来
} else { // 处理如 <style>.container { background: red }</style> 的玩意
css.process(mountDOM, stylesheetElement, appName);
}
}
return rawDOMAppendOrInsertBefore.call(mountDOM, stylesheetElement, referenceNode); // 插入到挂载容器上
}
case SCRIPT_TAG_NAME: {
const { src, text } = element as HTMLScriptElement;
if (element.src) { // 处理外链 JS
execScripts(null, [src], proxy, { // 获取并执行 JS
fetch,
strictGlobal,
});
return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicScriptCommentElement, referenceNode); // 插入到挂载容器上
}
// 处理内联 JS
execScripts(null, [`<script>${text}</script>`], proxy, { strictGlobal });
return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicInlineScriptCommentElement, referenceNode);
}
default:
break;
}
复制代码
当在建立沙箱时打完补丁后,在处理样式和 JS 脚本时就能够针对当前子应用来应用样式和 JS 了。上面咱们还注意到 CSS 样式文本是被保存的,因此当子应用 remount 的时候,这些样式也能够做为缓存直接一波补上,不须要再作处理了。
剩下的补丁都是给 historyListeners
, setInterval
, addEventListeners
, removeEventListeners
作的补丁,无非就是 mount 时记录 listeners 以及一些添加的值,在 unmount 的时候再一次性执行掉或者删除掉,再也不赘述。
若是当前项目迁移成子应用,在入口的 JS 就不得不配合 qiankun 来作一些改动,而这些改动有可能影响子应用的独立运行。好比,接入了微前端后,可能就不得不在本地先起一个主应用,再起一个子应用,而后才能作开发和调试,那这也太蛋疼了。
为了解决子应用也能独立运行的问题,qiankun 注入了一些变量,来告诉子应用说:喂,你如今是儿子,要用子应用的渲染方式。而当子应用获取不到这些注入的变量时,它就知道:哦,我如今要独立运行了,用回原来的渲染方式就能够了,好比:
if (window. __POWERED_BY_QIANKUN__) {
console.log('微前端场景')
renderAsSubApp()
} else {
console.log('单体场景')
previousRenderApp()
}
复制代码
怎么注入就是个问题了,不能简单的 window.__POWERED_BY_QIANKUN__ = true
就完事了,由于子应用会在编译时就要这个变量了。因此,qiankun 在 single-spa 提供的生命周期 load, mount, unmount 前作了变量的注入,伪代码以下:
// getAddOn
export default function getAddOn(global: Window): FrameworkLifeCycles<any> {
return {
async beforeLoad() {
// eslint-disable-next-line no-param-reassign
global.__POWERED_BY_QIANKUN__ = true;
},
async beforeMount() {
// eslint-disable-next-line no-param-reassign
global.__POWERED_BY_QIANKUN__ = true;
},
async beforeUnmount() {
// eslint-disable-next-line no-param-reassign
delete global.__POWERED_BY_QIANKUN__;
},
};
}
// loadApp
const addOnLifeCycles = getAddOn(window)
return {
load: [addOnLifeCycles.beforeLoad, subApp.load],
mount: [addOnLifeCycles.mount, subApp.mount],
unmount: [addOnLifeCycles.unmount, subApp.unmount]
}
复制代码
总结一下,新增的生命周期有:
好了,上面就是加载一个子应用的全部步骤了,这里先作个小总结:
从上面能够看到加载一个子应用的时候须要不少的步骤,咱们不由想到:若是在 mount 第一个子应用空闲时候,能够预先加载别的子应用,那以后切换子应用就能够更快了,也即子应用预加载。
在空闲的时候干一些事,可使用浏览器提供的 requestIdleCallback
。OK,那咱们再来定义一下“预加载”是什么,其实就是把 CSS 和 JS 下载下来就完事了,因此 qiankun 的源码也是很简单的:
requestIdleCallback(async () => {
const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
requestIdleCallback(getExternalStyleSheets);
requestIdleCallback(getExternalScripts);
});
复制代码
如今,咱们再来脑洞大开一下:难道一会儿就要全部子应用都要预加载么?不见得吧?有可能一些子应用要预加载,一些不须要。
因此 qiankun 提供了三种预加载策略:
criticalAppNames
数组里的子应用要立马预加载,在 minorAppsName
数组里的子应用在第一个子应用加载后才预加载源码实现以下:
export function doPrefetchStrategy( apps: AppMetadata[], prefetchStrategy: PrefetchStrategy, importEntryOpts?: ImportEntryOpts, ) {
const appsName2Apps = (names: string[]): AppMetadata[] => apps.filter((app) => names.includes(app.name));
if (Array.isArray(prefetchStrategy)) {
// 所有都在第一个子应用加载后才预加载
prefetchAfterFirstMounted(appsName2Apps(prefetchStrategy as string[]), importEntryOpts);
} else if (isFunction(prefetchStrategy)) {
(async () => {
// 一半一半
const { criticalAppNames = [], minorAppsName = [] } = await prefetchStrategy(apps);
prefetchImmediately(appsName2Apps(criticalAppNames), importEntryOpts);
prefetchAfterFirstMounted(appsName2Apps(minorAppsName), importEntryOpts);
})();
} else {
switch (prefetchStrategy) {
case true: // 所有都在第一个子应用加载后才预加载
prefetchAfterFirstMounted(apps, importEntryOpts);
break;
case 'all': // 所有子应用都立马预加载
prefetchImmediately(apps, importEntryOpts);
break;
default:
break;
}
}
}
复制代码
全局状态颇有可能出如今微前端的场景中,好比主应用提供能够一些初始化好的 SDK。刚开始先传个未初始好的 SDK,等主应用把 SDK 初始化好了,再经过回调通知子应用:醒醒,SDK 准备好了。
这种思路和 Redux, Event Bus 如出一辙。 状态都存在 window 的 gloablState
全局对象里,再添加一个 onGlobalStateChange
回调就完事了,实现伪代码以下:
let gloablState = {}
let deps = {}
// 触发全局监听
function emitGlobal(state: Record<string, any>, prevState: Record<string, any>) {
Object.keys(deps).forEach((id: string) => {
if (deps[id] instanceof Function) {
deps[id](cloneDeep(state), cloneDeep(prevState));
}
});
}
// 添加全局状态变化的监听器
function onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) {
deps[id] = callback;
if (fireImmediately) {
const cloneState = cloneDeep(globalState);
callback(cloneState, cloneState);
}
}
// 更新 globalState
function setGlobalState(state: Record<string, any> = {}) {
const prevState = globalState
globalState = {...cloneDeep(globalState), ...state}
emitGlobal(globalState, prevState);
}
// 注销该应用下的依赖
function offGlobalStateChange() {
delete deps[id];
}
复制代码
onGlobalStateChange
添加监听器,当调用 setGlobalState
更新值,值改了,调用 emitGlobal
,执行全部对应的监听器。调用 offGlobalStateChange
删掉监听器。Easy ~
主要监听了 error 和 unhandledrejection 两个错误事件:
export function addGlobalUncaughtErrorHandler(errorHandler: OnErrorEventHandlerNonNull): void {
window.addEventListener('error', errorHandler);
window.addEventListener('unhandledrejection', errorHandler);
}
export function removeGlobalUncaughtErrorHandler(errorHandler: (...args: any[]) => any) {
window.removeEventListener('error', errorHandler);
window.removeEventListener('unhandledrejection', errorHandler);
}
复制代码
使用的时候添加监听器,不要的时候移除监听器,不废话。
再次总结一下 qiankun 作了什么事情:
beforeXXX
的钩子里注入 qiankun 提供的变量虽然阿里说:“多是你见过最完善的微前端解决方案🧐”。可是从上面对源码的解读也能够看出来,qiankun 也有一些事情没有作的。好比没有对 localStorage 进行隔离,若是多个子应用都用到 localStorage 就有可能冲突了,除此以外,还有 cookie, indexedDB 的共享等。再好比若是单个页面下多个子应用都依赖了前端路由怎么办呢?固然这里的质疑也仅是我我的的猜测。
另外一件事想说的是:微前端的难点并非 single-spa 的生命周期、路由挟持。而是如何加载好一个子应用。从上面能够看到,有不少 hacky 的编码,好比在选择器前面加前缀,将子应用的 <link>
, <script>
加载到子应用上,监听 window 的变化,恢复现场等等,都是台上一句话,台下想秃头的操做。若是不是真见过,估计想破头都想不出来。
也正是这些 hacky 代码,在搭建微前端的时候会遇到很是多的问题,并且微前端的目的是要将多个💩山聚合起来,因此微前端的解决方案是注定没有银弹的,且行且珍惜吧。