微前端系列之:
一、记一次微前端技术选型
二、清晰简略易懂的 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 通信