之前研究过相应的沙箱实现,现在走一遍整个微前端方案@ice/stark的启动流程。
版本:2.0.2
需求效果确认
再看实际代码之前,我们先简单确认下需求:主应用的路由改变时,内容节点能渲染对应的微前端。
根据该需求,首先需要每个微前端的配置上有对应的path去匹配。
但微前端不可能只有一个路由页面,但启动后的微前端,路由的改变是由主应用调用的,特别是主应用使用window.history.pushState等去修改路由的话,子应用是捕捉不到的,此时子应用就不会切换路由。
要解决上面的问题,就要统一主动触发子应用改变,所以就要拦截捕捉子应用的监听路由的方法,在匹配路由后去触发捕捉的方法。最后拦截了还是要重新包装注册监听,同样应该是先保存事件,待路由匹配完成后再触发。
以上这些都应该是启动完成前需要完成的,这些逻辑都能在入口里看到。
入口 start.ts
function start(options?: StartConfiguration) {
if (started) {
console.log('icestark has been already started');
return;
}
started = true;
recordAssets();
// update globalConfiguration
Object.keys(options || {}).forEach((configKey) => {
globalConfiguration[configKey] = options[configKey];
});
hijackHistory();
hijackEventListener();
// trigger init router
globalConfiguration.reroute(location.href, 'init');
}
首先就看到一个上面没有分析出来的recordAssets()
,这是为了记录主应用固有的资源('style', 'link', 'script'),通过加个attribute去区分子应用动态append进来的相关资源,方便子应用unmount的时候需要这些资源。
接下来是配置的合并处理
然后终于到上面提到的逻辑了。
hijackHistory()
包装主应用主动改变路由的方法,缓存事件类型,待子应用挂载后触发下面拦截到的方法
hijackEventListener()
拦截捕捉子应用的监听路由的方法
globalConfiguration.reroute(location.href, 'init')
这个就是统一触发子应用改变的方法了
包装拦截那里不深入了,看默认的reroute
,这是可以通过options传入覆盖的。
let lastUrl = null;
function reroute (url: string, type: RouteType | 'init' | 'popstate'| 'hashchange' ) {
const { pathname, query, hash } = urlParse(url, true);
// trigger onRouteChange when url is changed
if (lastUrl !== url) {
globalConfiguration.onRouteChange(url, pathname, query, hash, type);
const unmountApps = [];
const activeApps = [];
getMicroApps().forEach((microApp: AppConfig) => {
const shouldBeActive = microApp.checkActive(url);
if (shouldBeActive) {
activeApps.push(microApp);
} else {
unmountApps.push(microApp);
}
});
// trigger onActiveApps when url is changed
globalConfiguration.onActiveApps(activeApps);
// call captured event after app mounted
Promise.all(
// call unmount apps
unmountApps.map(async (unmountApp) => {
if (unmountApp.status === MOUNTED || unmountApp.status === LOADING_ASSETS) {
globalConfiguration.onAppLeave(unmountApp);
}
await unmountMicroApp(unmountApp.name);
}).concat(activeApps.map(async (activeApp) => {
if (activeApp.status !== MOUNTED) {
globalConfiguration.onAppEnter(activeApp);
}
await createMicroApp(activeApp);
}))
).then(() => {
callCapturedEventListeners();
});
}
lastUrl = url;
};
初步看就是通过对比外部变量lastUrl
去判断路由是否改变了。然后遍历所有的子应用配置,区分需要激活和需要被卸载的应用。然后并行异步去执行挂载和卸载,完成后触发子应用的路由监听方法callCapturedEventListeners()
(关于官网的例子,实际是传入了自定义的reroute,大概是只显示一个子应用的话可以稍微优化下)
卸载时,包括要移除子应用的资源,以及清理沙箱。主要还是看挂载吧createMicroApp(activeApp)
function createMicroApp(app: string | AppConfig, appLifecyle?: AppLifecylceOptions) {
const appConfig = getAppConfigForLoad(app, appLifecyle);
const appName = appConfig && appConfig.name;
// compatible with use inIcestark
const container = (app as AppConfig).container || appConfig?.container;
if (container && !getCache('root')) {
setCache('root', container);
}
if (appConfig && appName) {
// check status of app
if (appConfig.status === NOT_LOADED || appConfig.status === LOAD_ERROR ) {
if (appConfig.title) document.title = appConfig.title;
updateAppConfig(appName, { status: LOADING_ASSETS });
let lifeCycle: ModuleLifeCycle = {};
try {
lifeCycle = await loadAppModule(appConfig);
// in case of app status modified by unload event
if (getAppStatus(appName) === LOADING_ASSETS) {
updateAppConfig(appName, { ...lifeCycle, status: NOT_MOUNTED });
}
} catch (err){
globalConfiguration.onError(err);
updateAppConfig(appName, { status: LOAD_ERROR });
}
if (lifeCycle.mount) {
await mountMicroApp(appConfig.name);
}
} else if (appConfig.status === UNMOUNTED) {
if (!appConfig.cached && appConfig.umd) {
await loadAndAppendCssAssets(appConfig.appAssets || { cssList: [], jsList: []});
}
await mountMicroApp(appConfig.name);
} else if (appConfig.status === NOT_MOUNTED) {
await mountMicroApp(appConfig.name);
} else {
console.info(`[icestark] current status of app ${appName} is ${appConfig.status}`);
}
return getAppConfig(appName);
} else {
console.error(`[icestark] fail to get app config of ${appName}`);
}
return null;
}
- 在已注册的app列表中查找对应的子应用;
- 在全局变量中保存子应用挂载的节点,从而子应用启动时可根据
getMountNode
获得根节点; - 接着根据状态去判断,先搞清楚这些状态:
- NOT_LOADED 初次注册的默认状态,未加载
- LOADING_ASSETS 加载资源中(js,css)
- LOAD_ERROR 加载失败
- NOT_MOUNTED 资源已加载,但还没挂载(这个状态应该是在加载资源后同一子应用内路由切换)
- MOUNTED 已挂载
- UNMOUNTED 已卸载,由于资源已保存到配置对象中,所以不会回到未加载状态。
若是NOT_LOADED 或 LOAD_ERROR ,则需要先加载资源,然后挂载
若是NOT_MOUNTED 则直接进行挂载就好
若是UNMOUNTED,则需要重新挂载css,js资源,但实际最多只会加载css,这里是有点问题。通过看官网例子得知,官网是在unmount生命周期后继续调用unloadMicroApp
去清理资源缓存及修改状态到NOT_LOADED,所以实际UNMOUNTED只是短暂存在,幸好对于js是有缓存逻辑,所以不会看到js反复加载。
接下来看资源加载
function loadAppModule(appConfig: AppConfig) {
let lifecycle: ModuleLifeCycle = {};
globalConfiguration.onLoadingApp(appConfig);
const appSandbox = createSandbox(appConfig.sandbox);
const { url, container, entry, entryContent, name } = appConfig;
const appAssets = url ? getUrlAssets(url) : await getEntryAssets({
root: container,
entry,
href: location.href,
entryContent,
assetsCacheKey: name,
}); // 获取js,css,html。
updateAppConfig(appConfig.name, { appAssets, appSandbox });
if (appConfig.umd) {
await loadAndAppendCssAssets(appAssets);
lifecycle = await loadUmdModule(appAssets.jsList, appSandbox);
} else {
await appendAssets(appAssets, appSandbox);
lifecycle = {
mount: getCache(AppLifeCycleEnum.AppEnter),
unmount: getCache(AppLifeCycleEnum.AppLeave),
};
setCache(AppLifeCycleEnum.AppEnter, null);
setCache(AppLifeCycleEnum.AppLeave, null);
}
globalConfiguration.onFinishLoading(appConfig);
return combineLifecyle(lifecycle, appConfig);
}
沙箱已经研究过了,然后就到获取资源了,可以看出资源的提供可以有基本以下种类:
- 仅有js和css资源,可同过url属性提供;
- 通过html,这里又分两种,url形式的entry和直接提供模板的entryContent,一般是跨域的情况下使用entryContent。另外这种方式也兼容了有多个根节点的应用
主要看第二种,如何从html里获取资源
function getEntryAssets({
root,
entry,
entryContent,
assetsCacheKey,
href,
fetch = winFetch,
}: {
root: HTMLElement | ShadowRoot;
entry?: string;
entryContent?: string;
assetsCacheKey: string;
href?: string;
fetch?: Fetch;
assertsCached?: boolean;
}) {
let cachedContent = cachedProcessedContent[assetsCacheKey];
if (!cachedContent) {
let htmlContent = entryContent;
if (!htmlContent && entry) {
if (!fetch) {
warn('Current environment does not support window.fetch, please use custom fetch');
throw new Error(
`fetch ${entry} error: Current environment does not support window.fetch, please use custom fetch`,
);
}
const res = await fetch(entry);
htmlContent = await res.text();
}
cachedContent = processHtml(htmlContent, entry || href);
cachedProcessedContent[assetsCacheKey] = cachedContent;
}
root.innerHTML = cachedContent.html;
return cachedContent.assets;
}
function processHtml(html: string, entry?: string): ProcessedContent {
if (!html) return { html: '', assets: { cssList:[], jsList: []} };
const processedJSAssets = [];
const processedCSSAssets = [];
const processedHtml = html
.replace(COMMENT_REGEX, '')
.replace(SCRIPT_REGEX, (...args) => {
const [matchStr, matchContent] = args;
if (!matchStr.match(SCRIPT_SRC_REGEX)) {
processedJSAssets.push({
type: AssetTypeEnum.INLINE,
content: matchContent,
});
return getComment('script', 'inline', AssetCommentEnum.REPLACED);
} else {
return matchStr.replace(SCRIPT_SRC_REGEX, (_, argSrc2) => {
const url = argSrc2.indexOf('//') >= 0 ? argSrc2 : getUrl(entry, argSrc2);
processedJSAssets.push({
type: AssetTypeEnum.EXTERNAL,
content: url,
});
return getComment('script', argSrc2, AssetCommentEnum.REPLACED);
});
}
})
.replace(CSS_REGEX, (...args) => {
const [matchStr, matchStyle, matchLink] = args;
// not stylesheet, return as it is
if (matchStr.match(STYLE_SHEET_REGEX)) {
const url = matchLink.indexOf('//') >= 0 ? matchLink : getUrl(entry, matchLink);
processedCSSAssets.push({
type: AssetTypeEnum.EXTERNAL,
content: url,
});
return `${getComment('link', matchLink, AssetCommentEnum.PROCESSED)}`;
} else if (matchStyle){
processedCSSAssets.push({
type: AssetTypeEnum.INLINE,
content: matchStyle,
});
return getComment('style', 'inline', AssetCommentEnum.REPLACED);
}
return matchStr;
});
return {
html: processedHtml,
assets: {
jsList: processedJSAssets,
cssList: processedCSSAssets,
},
};
}
提供entry的话用fetch获取html,这里也有缓存机制,解析就是通过processHtml
,最后还会把渲染节点的innerHTML改为解析产生的html。
解析时,主要是使用了正则以及在replace的第二个参数传入function,这里就要知道function里能拿到什么结果了,第一个变量是符合正则的片段,接来下的可以是正则中分组(就是用小括号包住的)的片段,所以可以有多个。
- 备注清空
- js资源,没有src属性的,获取分组片段script包住的内容,有src的,结合entry去获得完整的url。
- css资源的也是相似的逻辑,同样最后会返回替换后的占位备注
最后就能获得干净的html,js和css列表
然后是加载资源,这里又分了两种,官方是推荐子应用使用umd打包,然后导出生命周期。
先看非umd的,就是直接把css资源插入html中,js话分直接插入和再沙箱中运行,然后把通过全局变量缓存的生命周期取出来,然后清空缓存。
而对于umd,css资源同样直接插入,而js的话,这里就要先知道如何获得新添加的全局变量,对于直接挂在window下而非proto上的变量,也就是Object.keys(),新增加的会在eys遍历的最后一个,源码上也补充,safari浏览器有时会把新增加的变量放在第一或第二个上。代码是要求生命周期由js列表的最后一个产生,所以结合以上的,js应该顺序运行,但有些可能要通过网络去获取,所以需要先获取所以的js代码,然后一个个运行,在最后一个运行前,记录第一,第二,和最后的全局key,运行后,对比此时的第一,第二,和最后的全局key,不一样的就是最后一个js代码导出的生命周期。最后记得delete掉对应全局变量的生命周期key,不然后面再次load的时候的获取不到了。
最后loadAppModule
就只剩下合并生命周期到配置上了。
接下来就是等待callCapturedEventListeners的调用了,调用后,子应用就会在节点里渲染出来了