概览
qiankun 是一个基于single-spa的微前端实现库。接下来我们将通过源码透析其实现方式及技巧。相信通过前面两篇文章,你也能更容易明白源码实现。
qiannkun 针对于主应用暴露了方法 registerMicroApps 和 start 以及 loadMicroApp 方法。这里的主应用就是上两篇里的容器应用,虽然个人更愿意使用容器术语,但是为了和文档统一,下面都将使用主应用指代容器应用。
对于微应用,需要其暴露约定的勾子方法:bootstrap、mount、unmount 以及 update 。前面三个接口方法是 single-spa 提供的,并且要求是必须实现的,在下面的源码中微应用将由变量 app 指代。
在主应用和微应用的交互模式上采用了发布/订阅的方式,微应用实现了统一的接口(mount、unmount…),并订阅了主应用。所以我们将主要从主应用提供的接口来追溯其实现。
源码准备
因为 qiankun 是基于 single-spa 实现的,所以你需要同时从qiankun 仓库和single-spa 仓库克隆两份 master 分支的源码。
注册微应用
在官方文档中,我们可以看到第一个步骤便是注册微应用,它调用方法 registerMicroApps :
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
{
name: 'reactApp',
entry: '//localhost:3000',
container: '#container',
activeRule: '/app-react',
},
{
name: 'vueApp',
entry: '//localhost:8080',
container: '#container',
activeRule: '/app-vue',
},
{
name: 'angularApp',
entry: '//localhost:4200',
container: '#container',
activeRule: '/app-angular',
},
]);
// 启动 qiankun
start();
注册列表项需要提供一下信息:
- 微应用名称;
- 微应用入口文件地址;
- 微应用在主应用的挂载点;
- 初始化的路由规则。
这其实就是我们在第二篇中 MicroApplication 做的事情,这里通过一个数组来管理。
在实际场景下,这个数组可以作为一个动态配置列表,在 CI 环境 产生并更新,这样在发布一个新的微应用,我们只需要关注该配置是否正确即可,而主应用也不需要重新发布。
在调用 start 之前,各个微应用会被下载,但不会被初始化、挂载或卸载。
registerMicroApps
主应用的第一步便是注册微应用,它通过 registerMicroApps 方法来实现,代码如下:
export function registerMicroApps<T extends object = {}>(
apps: Array<RegistrableApp<T>>,
lifeCycles?: FrameworkLifeCycles<T>,
) {
// 防止重复注册
const unregisteredApps = apps.filter(app => !microApps.some(registeredApp => registeredApp.name === app.name));
microApps = [...microApps, ...unregisteredApps];
unregisteredApps.forEach(app => {
const { name, activeRule, loader = noop, props, ...appConfig } = app;
// 调用【single-spa】的 registerApplication 方法
registerApplication({
name,
app: async () => {
loader(true);
await frameworkStartedDefer.promise;
const { mount, ...otherMicroAppConfigs } = await loadApp(
{ name, props, ...appConfig },
frameworkConfiguration,
lifeCycles,
);
return {
mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
...otherMicroAppConfigs,
};
},
activeWhen: activeRule,
customProps: props,
});
});
}
上面的方法做了一次剔除重复注册的app后,就直接调用了 single-spa 的 registerApplication 方法。
这里的防重复注册其实在 registerApplication 里也有处理。只是源码中以直接抛出异常方式处理。
registerApplication
registerApplication 方法定义在src/applications/apps.js文件中,我们来看看它做了哪些事:
export function registerApplication(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
) {
// 统一规整参数项
const registration = sanitizeArguments(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
);
// 这里做了防止微应用重复注册处理
// 添加到 apps 的列表里。
apps.push(
assign(
{
loadErrorTime: null,
status: NOT_LOADED, // 这里标记为 未加载 状态
parcels: {},
devtools: {
overlays: {
options: {},
selectors: [],
},
},
},
registration
)
);
if (isInBrowser) {
// 使用 jQuery 提供的事件总线
ensureJQuerySupport();
// 加载并路由页面
reroute();
}
}
上面的代码做了几个主要工作:
- 把
微应用配置,标记为未加载 (NOT_LOADED)状态,然后添加到apps列表里去; - 在浏览器环境下,如果存在
jQuery,那么 jQuery 的事件总线也会捕获路由事件消息。在下面的代码可以看到,源码使用window.dispatchEvent来派发消息。; - 在浏览器环境下,如果主应用已经启动,那么只需路由改变操作。否则,加载
目前为止,我们还没看到没有任何执行微应用加载的操作,所以接下来 reroute 方法里做的事将会是我们关注的焦点:
export function reroute(pendingPromises = [], eventArguments) {
if (appChangeUnderway) {
return new Promise((resolve, reject) => {
peopleWaitingOnAppChange.push({
resolve,
reject,
eventArguments,
});
});
}
// 获取各自未激活下各个状态的微应用列表
const {
appsToUnload,
appsToUnmount,
appsToLoad,
appsToMount,
} = getAppChanges();
let appsThatChanged,
navigationIsCanceled = false,
oldUrl = currentUrl,
newUrl = (currentUrl = window.location.href);
if (isStarted()) {
appChangeUnderway = true;
appsThatChanged = appsToUnload.concat(
appsToLoad,
appsToUnmount,
appsToMount
);
return performAppChanges();
} else {
appsThatChanged = appsToLoad;
return loadApps();
}
// 其它内部方法...
}
我们先关注主应用第一次未启动下,执行的 loadApps 方法。
未启动时
loadApps
function loadApps() {
return Promise.resolve().then(() => {
const loadPromises = appsToLoad.map(toLoadPromise);
return (
Promise.all(loadPromises)
.then(callAllEventListeners)
// there are no mounted apps, before start() is called, so we always return []
.then(() => [])
.catch((err) => {
callAllEventListeners();
throw err;
})
);
});
}
可以从源码注释中了解到,在调用 start 方法之前,不会有已挂载的 apps 。
如果你查阅 getAppChanges 方法,会发现 appsToLoad 保存着我们即将加载的 app。
在上面的的代码中,核心的函数是 toLoadPromise,直观上来看,是把 app 转为 Promise,来执行异步操作:
toLoadPromise
该函数存在 src/lifecycles/load.js 中,并内容仅此一个函数,除去不相关代码我们可以看到:
export function toLoadPromise(app) {
return Promise.resolve().then(() => {
// 其它代码...
app.status = LOADING_SOURCE_CODE;
let appOpts, isUserErr;
return (app.loadPromise = Promise.resolve()
.then(() => {
const loadPromise = app.loadApp(getProps(app));
// 其它代码...
return loadPromise.then((val) => {
app.loadErrorTime = null;
appOpts = val;
/**
* 此处省去的代码处理以下内容:
* 1. 校验 bootstrap、mount、unmount 函数的有效性,否者抛出异常
* 2. devtools overlays 处理
*/
app.status = NOT_BOOTSTRAPPED;
app.bootstrap = flattenFnArray(appOpts, "bootstrap");
app.mount = flattenFnArray(appOpts, "mount");
app.unmount = flattenFnArray(appOpts, "unmount");
app.unload = flattenFnArray(appOpts, "unload");
app.timeouts = ensureValidAppTimeouts(appOpts.timeouts);
delete app.loadPromise;
return app;
});
})
.catch((err) => {
delete app.loadPromise;
let newStatus;
if (isUserErr) {
newStatus = SKIP_BECAUSE_BROKEN;
} else {
newStatus = LOAD_ERROR;
app.loadErrorTime = new Date().getTime();
}
handleAppError(err, app, newStatus);
return app;
}));
});
}
在经过上面的代码处理后,我们的 app 的状态将将变成未启动 (NOT_BOOTSTRAPPED) ,并且 app 的勾子函数都已准备就绪。
至此,我们可以知道在主应用未启动的情况下,只做了加载 app前的准备工作,并未真正开始执行初始化操作。
接下来,我们将跟踪注册微应用这小节中的 start 方法,看它是如何启动。
启动 (bootstrap & mount) 工作
start
它的代码如下:
export function start(opts: FrameworkConfiguration = {}) {
frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
const { prefetch, sandbox, singular, urlRerouteOnly, ...importEntryOpts } = frameworkConfiguration;
if (prefetch) {
doPrefetchStrategy(microApps, prefetch, importEntryOpts);
}
if (sandbox) {
if (!window.Proxy) {
console.warn('[qiankun] Miss window.Proxy, proxySandbox will degenerate into snapshotSandbox');
// 快照沙箱不支持非 singular 模式
if (!singular) {
console.error('[qiankun] singular is forced to be true when sandbox enable but proxySandbox unavailable');
frameworkConfiguration.singular = true;
}
}
}
startSingleSpa({ urlRerouteOnly });
frameworkStartedDefer.resolve();
}
我们这边先不关注预加载和沙箱,而把重心放在它是如何启动的。所以重心落在了 single-spa 提供的 start 方法上,引入的时候被重名为 startSingleSpa 。
import { start as startSingleSpa } from 'single-spa';
single-spa 的 start 代码如下:
export function start(opts) {
started = true;
if (opts && opts.urlRerouteOnly) {
setUrlRerouteOnly(opts.urlRerouteOnly);
}
if (isInBrowser) {
reroute();
}
}
该方法很简单,可以发现它在主应用标记已启动状态后,就执行了 reroute 方法。
上面我们已经分析了在未启动状态下,reroute 对 app 进行了准备工作。而接下来 我们来看看已启动后的,reroute 里执行的另一个执行方法 performAppChanges 。
performAppChanges
它的代码如下:
function performAppChanges() {
return Promise.resolve().then(() => {
// https://github.com/single-spa/single-spa/issues/545
// 其它代码...
// 上面的代码为处理派发事件消息
// 完成卸载
const unloadPromises = appsToUnload.map(toUnloadPromise);
const unmountUnloadPromises = appsToUnmount
.map(toUnmountPromise)
.map((unmountPromise) => unmountPromise.then(toUnloadPromise));
const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);
const unmountAllPromise = Promise.all(allUnmountPromises);
unmountAllPromise.then(() => {
window.dispatchEvent(
new CustomEvent(
"single-spa:before-mount-routing-event",
getCustomEventDetail(true)
)
);
});
/* We load and bootstrap apps while other apps are unmounting, but we
* wait to mount the app until all apps are finishing unmounting
*/
const loadThenMountPromises = appsToLoad.map((app) => {
return toLoadPromise(app).then((app) =>
tryToBootstrapAndMount(app, unmountAllPromise)
);
});
/* These are the apps that are already bootstrapped and just need
* to be mounted. They each wait for all unmounting apps to finish up
* before they mount.
*/
const mountPromises = appsToMount
.filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
.map((appToMount) => {
return tryToBootstrapAndMount(appToMount, unmountAllPromise);
});
return unmountAllPromise
.catch((err) => {
callAllEventListeners();
throw err;
})
.then(() => {
/* Now that the apps that needed to be unmounted are unmounted, their DOM navigation
* events (like hashchange or popstate) should have been cleaned up. So it's safe
* to let the remaining captured event listeners to handle about the DOM event.
*/
callAllEventListeners();
return Promise.all(loadThenMountPromises.concat(mountPromises))
.catch((err) => {
pendingPromises.forEach((promise) => promise.reject(err));
throw err;
})
.then(finishUpAndReturn);
});
});
}
在执行 app 状态发生改变时,会通过 window.dispatchEvent 来派 app 状态改变事件 消息。
我们从官网文档对于其触发事件时序有明确描述:
| 事件排序 | 事件名称 | 消费条件 |
|---|---|---|
| 1 | single-spa:before-app-change 或 single-spa:before-no-app-change |
任意 app 将发生状态改变 |
| 2 | single-spa:before-routing-event |
- |
| 3 | single-spa:before-mount-routing-event |
- |
| 4 | single-spa:before-first-mount |
第一次任意 app 正在挂载中 |
| 5 | single-spa:first-mount |
第一次任意 app 已挂载 |
| 6 | single-spa:app-change 或 single-spa:no-app-change |
任意 app 发生状态改变 |
| 7 | single-spa:routing-event |
- |
在上面的代码中,首先是把 appsToUnmount 和 appsToUnload 里的 app 都标记为卸载中 (UNMOUNTING) 状态,完成后发送 single-spa:before-mount-routing-event 事件。
也就是说,在启动 app (bootstrap) 和 mount 操作之前,必须先把要卸载的 app 处理完成。
最后,当卸载操作完成后,才真正开始 bootstrap 和 mount,它由 tryToBootstrapAndMount 方法完成。
tryToBootstrapAndMount
然我们来看看它的代码:
function tryToBootstrapAndMount(app, unmountAllPromise) {
if (shouldBeActive(app)) {
return toBootstrapPromise(app).then((app) =>
unmountAllPromise.then(() =>
shouldBeActive(app) ? toMountPromise(app) : app
)
);
} else {
return unmountAllPromise.then(() => app);
}
}
可以看到上面执行 bootstrap 的方法为 toBootstrapPromise,它也是 lifecycles 中方法之一。
toBootstrapPromise
它的代码如下:
export function toBootstrapPromise(appOrParcel, hardFail) {
return Promise.resolve().then(() => {
if (appOrParcel.status !== NOT_BOOTSTRAPPED) {
return appOrParcel;
}
appOrParcel.status = BOOTSTRAPPING;
if (!appOrParcel.bootstrap) {
// Default implementation of bootstrap
return Promise.resolve().then(successfulBootstrap);
}
return reasonableTime(appOrParcel, "bootstrap")
.then(successfulBootstrap)
.catch((err) => {
if (hardFail) {
throw transformErr(err, appOrParcel, SKIP_BECAUSE_BROKEN);
} else {
handleAppError(err, appOrParcel, SKIP_BECAUSE_BROKEN);
return appOrParcel;
}
});
});
function successfulBootstrap() {
appOrParcel.status = NOT_MOUNTED;
return appOrParcel;
}
}
toBootstrapPromise 方法里,把 app 状态标记为启动中 (BOOTSTRAPPING) ,而后调用了 reasonableTime 方法。
reasonableTime
export function reasonableTime(appOrParcel, lifecycle) {
const timeoutConfig = appOrParcel.timeouts[lifecycle];
const warningPeriod = timeoutConfig.warningMillis;
const type = objectType(appOrParcel);
return new Promise((resolve, reject) => {
let finished = false;
let errored = false;
appOrParcel [lifecycle](getProps(appOrParcel) )
.then((val) => {
finished = true;
resolve(val);
})
.catch((val) => {
finished = true;
reject(val);
});
setTimeout(() => maybeTimingOut(1), warningPeriod);
setTimeout(() => maybeTimingOut(true), timeoutConfig.millis);
// 其它代码...
function maybeTimingOut(shouldError) {
// 这里代码处理超时
}
});
}
reasonableTime 方法里做了生命周期勾子执行过慢的提醒,直到执行时长大于设定的超时时间,那么判定超时并抛出异常。而在这里就表示 bootstrap 超时。
你可以在这里看到该方法的实现,并了解到其中定义了常量 globalTimeoutConfig 来表示各个生命周期中的默认超时设定。
同时,我们可以下面这里为真正执行生命周期勾子的地方:
appOrParcel [lifecycle](getProps(appOrParcel) )
参考文档
> https://juejin.cn/post/6885212507837825038#heading-6
> https://mp.weixin.qq.com/s?__biz=MzA3NTk4NjQ1OQ==&mid=2247484245&idx=1&sn=9ee91018578e6189f3b11a4d688228c5&chksm=9f696021a81ee937847c962e3135017fff9ba8fd0b61f782d7245df98582a1410aa000dc5fdc&token=165646905&lang=zh_CN#rd