微前端系列之:
一、记一次微前端技术选型
二、清晰简单易懂的qiankun主流程分析
三、记一次落地qiankun
本文是系列之二。
综述
qiankun
是在 single-spa
基础上进行二次开发的。本文核心分析应用加载
、应用切换
、应用隔离
这三个核心功能原理。
一开始计划是直接从源码角度上介绍流程和原理的,但会导致篇幅过长,且重点抓不住。
所以打算把一些主流程直接用流程图和文字说明,一些核心实现,可以再结合原来来解读。
完整流程图:https://github.com/Rockergmai...
主流程
路由劫持
路由劫持功能,是在 single-spa
实现的。
对 hashchange
/ popstate
全局事件做监听,触发 reroute
方法。
对 window.addEventListener
window.removeEventListener
window.history.pushState
window.history.replaceState
做劫持。
核心功能是 reroute
,下文会重点介绍,这个方法被好几个方法都调用了。
注册微应用
用户调用 qiankun
提供的 registerMicroApps
注册子应用。它其实是调用了 single-spa
的 registerApplication
来进行注册的,其中的 appOrLoadApp
这个选项用来传入加载微应用那个的逻辑。当路由匹配 activeWhen
时,会用来加载微应用。
qiankun
帮我们实现了加载微应用的逻辑,是通过 loadApp
方法来加载的,其核心是使用到了 import-html-entry
这个库来实现的。
最后还是会调用 reroute
方法。
启动qiankun
用户调用 qiankun
的 start
方法,启动 qiankun
。
启用预加载微应用策略,根据不同的策略,加载微应用。核心实现就是用 requestIdelCallback
在 cpu
空闲时预加载微应用的入口文件、以及远程 scripts
和 styles
。
最后也会调用 reroute
方法。
reroute
调用 getAppChanges
获取 toUnload
toUnmount
toLoad
toMount
这四种状态的微应用(根据当前url和微应用注册的路径是否匹配,以及微应用当前状态来做过滤的)。
如果 qiankun
已启动,则对 toUnload
toUnmount
toLoad
toMount
中的微应用对象做一些相应改动(如 state
的改变、生命周期钩子的增删等等),以及触发对应的生命周期钩子。其中 toLoad
的微应用会先进行加载微应用,再进行微应用对象的改动。
如果 qiankun
未启动,对 toLoad
的微应用会进行加载微应用操作。
加载微应用
加载微应用是 single-spa
调用 registerApplication
的 appOrLoadApp
这个传入的方法实现的。传入的方法是 qiankun
的 loadApp
方法,是通过 import-html-entry
这个库来是现实的。
通过 fetch
方法来加载入口html。得到html文本后通过正则分析出inline scripts、远程scripts、inline style和远程styles。加载远程styles之后,把inline styles和远程下载下来的styles文本覆盖到html的style和link标签。然后返回template(html文本)、assetPublicPath、getExternalScripts、getExternalStyleSheets、execScripts(script执行器)。
用 div#__qiankun_microapp_wrapper_for_微应用名字__ 来包裹入口html内容,然后挂载到微应用容器中。在挂载前,如果设置了样式隔离会进行样式隔离。
创建沙箱。
调用script执行器(execScripts),传入沙箱的代理对象作为微应用的全局对象。
取出微应用导出的bootstrap、mount、unmount、update生命周期函数。取出用于跟当前微应用通讯的方法:onGlobalStateChange, setGlobalState, offGlobalStateChange。
返回微应用对象(app),其中有bootstrap、mount和unmount这三个生命周期钩子属性。
bootstrap就是微应用提供的bootstrap方法。
mount封装了以下方法:挂载前用户提供的loader(true)、确保每次应用加载前容器 dom 结构已经设置完毕,对样式做隔离、挂载沙箱、触发beforeMount钩子、微应用提供的mount方法、再次确保每次应用加载前容器 dom 结构已经设置完毕、触发afterMount钩子、挂载后用户提供的loader(false)
unmount封装了以下方法:触发beforeUnmount钩子、调用微应用提供的unmount方法、触发afterUnmount钩子、删除div#__qiankun_microapp_wrapper_for_微应用名字__
检查微应用是否有导出bootstrap、mount、unmount生命周期函数,没有则报错
微应用对象app,置status为NOT_BOOTSTRAPPED,以及对几个参数做格式化
(图片是从下到上网上看)
reroute
现在再来看reroute,如果 qiankun
已启动,则对 toUnload
toUnmount
toLoad
toMount
中的微应用对象做一些相应改动(如 state
的改变、生命周期钩子的增删等等),以及触发对应的生命周期钩子。这里的生命周期钩子,就是刚刚说的注册的bootstrap、mount、unmount生命钩子。
样式隔离
样式隔离,qiankun
提供了两种方案:shadow dom方案、scoped方案。
在挂载沙箱之前,进行样式隔离操作,可以在 qiankun
的 src/loader.ts
查看源码
function createElement(
appContent: string,
strictStyleIsolation: boolean,
scopedCSS: boolean,
appInstanceId: string,
): HTMLElement {
const containerElement = document.createElement('div');
containerElement.innerHTML = appContent;
// appContent always wrapped with a singular div
const appElement = containerElement.firstChild as HTMLElement;
if (strictStyleIsolation) {
if (!supportShadowDOM) {
console.warn(
'[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
);
} else {
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow: ShadowRoot;
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// createShadowRoot was proposed in initial spec, which has then been deprecated
shadow = (appElement as any).createShadowRoot();
}
shadow.innerHTML = innerHTML;
}
}
if (scopedCSS) {
const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
if (!attr) {
appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId);
}
const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appInstanceId);
});
}
return appElement;
}
如果是 shadow dom 模式,为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响。
如果是 scoped 模式,可以到 qiankun
的 src/sandbox/patchers/css.ts
查看源码。主要做了,监听style节点的监听,然后有变动,则做 rewrite
操作。rewrite
主要做了以下工作:针对每个样式规则前都加 div#微应用名 来实现 scoped 的效果。如本来是.test{width: 100%;}
,scoped之后变成 div[data-qiankun=微应用名] .test{width: 100%:}
沙箱
可以到 qiankun
的 src/sandbox/index.ts
查看源码。
根据环境是否支持proxy、以及是否多例模式,选择不同的沙箱实现方案(共三种)。很多文章已经做了源码分析,我就不再重复劳动了。无非是针对全局变量的增、删、改操作做代理,在卸载的时候,把全局变量切回去。
script执行器(execScripts)
可以到 import-html-entry
的 src/index.js
查看源码。
export function execScripts(entry, scripts, proxy = window, opts = {}) {
const {
fetch = defaultFetch, strictGlobal = false, success, error = () => {
}, beforeExec = () => {
}, afterExec = () => {
},
} = opts;
return getExternalScripts(scripts, fetch, error)
.then(scriptsText => {
const geval = (scriptSrc, inlineScript) => {
const rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript;
const code = getExecutableScript(scriptSrc, rawCode, proxy, strictGlobal);
evalCode(scriptSrc, code);
afterExec(inlineScript, scriptSrc);
};
function exec(scriptSrc, inlineScript, resolve) {
const markName = `Evaluating script ${scriptSrc}`;
const measureName = `Evaluating Time Consuming: ${scriptSrc}`;
if (process.env.NODE_ENV === 'development' && supportsUserTiming) {
performance.mark(markName);
}
if (scriptSrc === entry) {
noteGlobalProps(strictGlobal ? proxy : window);
try {
// bind window.proxy to change `this` reference in script
geval(scriptSrc, inlineScript);
const exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {};
resolve(exports);
} catch (e) {
// entry error must be thrown to make the promise settled
console.error(`[import-html-entry]: error occurs while executing entry script ${scriptSrc}`);
throw e;
}
} else {
if (typeof inlineScript === 'string') {
try {
// bind window.proxy to change `this` reference in script
geval(scriptSrc, inlineScript);
} catch (e) {
// consistent with browser behavior, any independent script evaluation error should not block the others
throwNonBlockingError(e, `[import-html-entry]: error occurs while executing normal script ${scriptSrc}`);
}
} else {
// external script marked with async
inlineScript.async && inlineScript?.content
.then(downloadedScriptText => geval(inlineScript.src, downloadedScriptText))
.catch(e => {
throwNonBlockingError(e, `[import-html-entry]: error occurs while executing async script ${inlineScript.src}`);
});
}
}
if (process.env.NODE_ENV === 'development' && supportsUserTiming) {
performance.measure(measureName, markName);
performance.clearMarks(markName);
performance.clearMeasures(measureName);
}
}
function schedule(i, resolvePromise) {
if (i < scripts.length) {
const scriptSrc = scripts[i];
const inlineScript = scriptsText[i];
exec(scriptSrc, inlineScript, resolvePromise);
// resolve the promise while the last script executed and entry not provided
if (!entry && i === scripts.length - 1) {
resolvePromise();
} else {
schedule(i + 1, resolvePromise);
}
}
}
return new Promise(resolve => schedule(0, success || resolve));
});
}
execScripts的entry,是取入口html的最后一个script作为入口js。对每个scripts串行逐个执行exec,exec又调用到geval。直到执行到最后一个scripts,就resolve掉promise。
exec,如果script不是入口js,则执行geval。如果script是入口js,执行geval之后,resolve掉promise,传入代理全局window的proxy对象。
geval,将js代码,用iife封装,且传入全局window的proxy,即微应用的沙箱全局对象。
function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) {
const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`;
// 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上
// 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy
const globalWindow = (0, eval)('window');
globalWindow.proxy = proxy;
// TODO 通过 strictGlobal 方式切换 with 闭包,待 with 方式坑趟平后再合并
return strictGlobal
? `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
: `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;
}
这里的 (0, eval)('window')
是获取全局window对象的方法,为什么不 eval('window')
,是因为这样执行,是在当前作用域上执行,获取到的window对象不能保证是全局window对象,有可能是在某层微应用中的作用域,指向了该微应用的全局对象。关于eval的黑魔法,可以看这两篇文章:1 2
然后执行evalCode方法。本质就是执行这段代码。
export function evalCode(scriptSrc, code) {
const key = scriptSrc;
if (!evalCache[key]) {
const functionWrappedCode = `window.__TEMP_EVAL_FUNC__ = function(){${code}}`;
(0, eval)(functionWrappedCode);
evalCache[key] = window.__TEMP_EVAL_FUNC__;
delete window.__TEMP_EVAL_FUNC__;
}
const evalFunc = evalCache[key];
evalFunc.call(window);
}
可以看到,是通过iife封装微应用js,然后传入全局window的proxy对象作为微应用的全局对象。以达到执行微应用、隔离微应用的效果。
assetPublicPath
assetPublicPath是加载微应用资源的publicPath,它默认的获取方式,是基于defaultGetPublicPath方法获取的
export function defaultGetPublicPath(entry) {
if (typeof entry === 'object') {
return '/';
}
try {
const { origin, pathname } = new URL(entry, location.href);
const paths = pathname.split('/');
// 移除最后一个元素
paths.pop();
return `${origin}${paths.join('/')}/`;
} catch (e) {
console.warn(e);
return '';
}
}
可以看到,它会把html入口地址的path,去掉最后一个元素,再返回,当做 assetPublicPath
。
这里的 assetPublicPath
就是 qiankun
注入到全局的 __INJECTED_PUBLIC_PATH_BY_QIANKUN__
变量。
后记
很多细节没有在这篇写出来,如果感兴趣,可以去啃源码+打断点去探索下。如以下这些:
- 如果是singular模式,会在加载微应用前,先卸载已有的微应用等等
- 统计性能
- reroute调度,有一些事件被延后执行的
- qiankun源码注释中,说3.x会废弃props通讯方案、shadow dom 样式隔离方案
- 应用通讯,通过props通讯