前言
本来是想直接去学习下qiankun的源码,但是qiankun是基于single-spa做的二次封装,通过解决了single-spa的一些弊端和不足来帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。
所以我们应该先对single-spa有一个全面的认识和了解,了解它的不足和缺陷,到时候让我们带着问题去学习qiankun的底层,会有更大的帮助。
single-spa中文文档
代码库地址 https://github.com/sunlianglife/single-spa-study,可以打开代码再对照着阅读,更容易理解
关于微前端
可以分别从single-spa的文档介绍和qiankun的文档介绍初步了解
目录结构及相关文件总览
我是先在github的clone的qiankun代码,看了下package.json里面的single-spa的版本是5.9.2的,所以我就clone了对应版本的single-spa的代码
多写注释,做笔记,编写示例代码+console调试
1、src/single-spa.js
single-spa的入口文件,其中就是暴露出single-spa的一些属性和方法
2、src/start.js
应用注册完之后,调用start()的逻辑
- started——应用是否启动的标志
- start——开启应用的方法
- isStarted——判断应用是否启动的方法
3、src/jquery-support.js
确保jquery的支持
4、utils
utils里面的工具函数在下一节开始会介绍到
5、src/parcels/mount-parcel.js
沙箱 Parcels
single-spa的一个高级特性,与框架无关,api与注册应用一致,不同的是:parcel组件需要手动挂载,而不是通过 activity 方法被动激活。
single-spa中的微前端有两种类型
- single-spa applications: application 模式下,子应用的切换(挂载、卸载)都是由修改路由触发的,整个切换过程由 single-spa 框架控制,子应用仅需提供正确的生命周期方法即可。
- single-spa parcels: 不受路由控制,渲染组件的微前端。在 parcel 模式下,我们需要使用 single-spa 提供的 mountRootParcel 方法来手动挂载/更新/卸载组件
mountParcel
或 mountRootParcel
将立即挂载parcel并返回这个parcel对象。 需要卸载需要手动调用 parcel的 unmount
.
mountRootParcel
和 mountParcel
的用法完全一样,只不过 mountParcel 方法不能直接从 single-spa 中获取,需要从子应用/组件的 mount 生命周期方法执行时传入的 props 中获取,
6、src/navigation/navigation-events.js
处理导航事件的文件,包括事件监听,自定义事件创建、事件收集、不同应用之间的跳转等
- capturedEventListeners——导航事件的收集
- routingEventsListeningTo——监听到浏览器导航变化的两种事件
- navigateToUrl——导航到对应url,实现在不同注册应用之前的切换
- patchedUpdateState——当触发replaceState和pushState方法时,对其进行一个增强
- createPopStateEvent——创建自定义事件
- window.addEventListener——对“hashchange”和“popstate”监听
- parseUri——创建一个a连接的导航
7、src/navigation/reroute.js
reroute()在整个single-spa中就是负责改变app.status和执行在子应用中注册的生命周期函数。
8、src/applications/app-errors.js
异常处理的方法文件
9、src/applications/app.helpers.js
- 定义应用各个状态的常量
- isActive——应用是否加载完毕
- shouldBeActive——当前路由关联的子应用是否激活
- toName——返回应用的名称
- isParcel——是否为Parcel模式
- objectType——区分single-spa的两种模式 parcel || application
10、src/applications/apps.js
注册子应用的方法就这里面,其他大多数是对参数的一些校验处理
- registerApplication——注册子应用
- getAppChanges——将子应用按照状态拆分
- getMountedApps——获取已经挂载的应用名称
- getAppNames——获取应用的名称
- getAppStatus——根据名称获取应用的状态
- checkActivityFunctions——将会调用每个应用的 activeWhen 并且返回一个根据当前路径判断那些应用应该被挂载的列表
- unregisterApplication——应用卸载
- unloadApplication——移除已注册的应用的目的是将其设置回 NOT_LOADED 状态,
- immediatelyUnloadApp——立即卸载应用,调用卸载的生命周期函数
- validateRegisterWithArguments——参数异常处理
- validateRegisterWithConfig——验证应用的配置信息是否合法,抛出异常
- validCustomProps——验证注册子应用的propps
- sanitizeArguments——格式化注册子应用的属性参数
- sanitizeLoadApp——验证注册子应用是的第二个参数一定是一个返回promise的函数
- sanitizeCustomProps——保证props存在
- sanitizeActiveWhen——得到一个函数,用来判断当前地址和用户的给定的baseUrl的比配关系,函数返回boolean
- pathToActiveWhen——函数返回boolean值,判断当前路由是否匹配用户给定的路径
- toDynamicPathValidatorRegex——根据用户提供的baseURL,生成正则表达式
11、src/applications/timeouts.js
超时的一些处理
12、src/devtools/devtools.js
暴露的属性和方法,在入口文件中导出
// 暴露的方法集合
// window.__SINGLE_SPA_DEVTOOLS__ single-spa在window中挂载的变量
if (isInBrowser && window.__SINGLE_SPA_DEVTOOLS__) {
window.__SINGLE_SPA_DEVTOOLS__.exposedMethods = devtools;
}
13、src/lifecycles
这个文件夹下面的文件,从名字就能看出是子应用各个生命周期的执行方法,改变状态,和src/applications/app.helpers.js中定义的状态是对应的
源码分析(摘取部分核心的方法,全部代码可以去代码仓库上去看)
拿到一个陌生的项目,首先需要看的是package.json
、README.md
、config
文件,从目录能看出来single-spa是用rollup来打包的,打开之后在导出的配置信息里面找到入口文件src/single-spa.js
input: “./src/single-spa.js”
-
先介绍一下utils的工具函数,好多地方会用到
-
应用的状态常量
// App statuses
export const NOT_LOADED = "NOT_LOADED"; // single-spa应用注册了,还未加载。
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE"; // 应用代码正在被拉取。
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED"; // 应用已经加载,还未初始化。
export const BOOTSTRAPPING = "BOOTSTRAPPING"; // 生命周期函数已经执行,还未结束。
export const NOT_MOUNTED = "NOT_MOUNTED"; // 应用已经加载和初始化,还未挂载
export const MOUNTING = "MOUNTING"; // 应用正在被挂载,还未结束。
export const MOUNTED = "MOUNTED"; // 应用目前处于激活状态,已经挂载到DOM元素上。
export const UPDATING = "UPDATING"; // 更新中
export const UNMOUNTING = "UNMOUNTING"; // 应用正在被卸载,还未结束
export const UNLOADING = "UNLOADING"; // 应用正在被移除,还未结束
export const LOAD_ERROR = "LOAD_ERROR"; // 应用的加载功能返回了一个rejected的Promise。这通常是由于下载应用程序的javascript包时出现网络错误造成的。Single-spa将在用户从当前路由导航并返回后重试加载应用。
export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN"; // 应用在加载、初始化、挂载或卸载过程中抛出错误,由于行为不当而被跳过,因此被隔离。其他应用将正常运行。
01|src/single-spa.js 入口文件
我们先来看single-spa给我们暴露了哪些属性和方法
export { start } from "./start.js"; // 启动的方法
export { ensureJQuerySupport } from "./jquery-support.js"; // 确保jquery支持,可以外部传入
export {
setBootstrapMaxTime, // 全局配置初始化超时时间。
setMountMaxTime, // 全局配置挂载超时时间。
setUnmountMaxTime, // 全局配置卸载超时时间
setUnloadMaxTime, // 全局配置移除超时时间。
} from "./applications/timeouts.js";
export {
registerApplication, // 注册子应用的方法
unregisterApplication, // 卸载子应用
getMountedApps, // 返回当前已经挂载的子应用的名称
getAppStatus, // 参数:注册应用的名字,返回:应用的状态
unloadApplication, // 移除已注册的应用
checkActivityFunctions, // 将会调用每个应用的 mockWindowLocation 并且返回一个根据当前路判断那些应用应该被挂载的列表。
getAppNames, // 获取应用的名称(任何状态)
pathToActiveWhen, // 判断应用的前缀url,返回:boolean
} from "./applications/apps.js";
export { navigateToUrl } from "./navigation/navigation-events.js"; // 实现在不同注册应用之前的切换
export { triggerAppChange } from "./navigation/reroute.js"; // 返回一个Promise对象,当所有应用挂载/卸载时它执行 resolve/reject 方法,它一般被用来测试single-spa,在生产环境可能不需要。
export {
addErrorHandler, // 添加异常处理,抛出错误
removeErrorHandler, // 删除给定的错误处理程序函数
} from "./applications/app-errors.js";
export { mountRootParcel } from "./parcels/mount-parcel.js"; // 将会创建并挂载一个 single-spa parcel.
// 应用的状态,已备注到app.helpers.js中
export {
NOT_LOADED,
LOADING_SOURCE_CODE,
NOT_BOOTSTRAPPED,
BOOTSTRAPPING,
NOT_MOUNTED,
MOUNTING,
UPDATING,
LOAD_ERROR,
MOUNTED,
UNMOUNTING,
SKIP_BECAUSE_BROKEN,
} from "./applications/app.helpers.js";
import devtools from "./devtools/devtools"; // 暴露的方法集合
import { isInBrowser } from "./utils/runtime-environment.js"; // 判断浏览器环境
// 暴露的方法集合
// window.__SINGLE_SPA_DEVTOOLS__ single-spa在window中挂载的变量
if (isInBrowser && window.__SINGLE_SPA_DEVTOOLS__) {
window.__SINGLE_SPA_DEVTOOLS__.exposedMethods = devtools;
}
上面导出的和挂载到window上的都是我们可以在开发阶段获取到的
single-spa官网api解析
02|注册子应用 registerApplication ——src/applications/apps.js
/**
*
* @param {*} appNameOrConfig 子应用的名称
* @param {*} appOrLoadApp 应用的加载方法,返回一个应用或者promise
* @param {*} activeWhen 纯函数,返回应用是否激活的boolean
* @param {*} customProps 传递给子应用的props
* 每注册一个子应用 registerApplication方 法就需要调用一次
*/
export function registerApplication(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
) {
// 格式化注册子应用的参数
const registration = sanitizeArguments(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
);
// 子应用注册的防重复校验
if (getAppNames().indexOf(registration.name) !== -1)
throw Error(
formatErrorMessage(
21,
__DEV__ &&
`There is already an app registered with name ${registration.name}`,
registration.name
)
);
// 将各个应用的配置信息存储到apps数组中
apps.push(
assign(
{
loadErrorTime: null,
status: NOT_LOADED,
parcels: {},
devtools: {
overlays: {
options: {},
selectors: [],
},
},
},
registration
)
);
// 浏览器环境运行
if (isInBrowser) {
ensureJQuerySupport();
reroute();
}
}
这里注意最后调用的方法reroute()后面会说到
能看出来注册方法做的事情不多,就是对接受的参数做一个格式化校验,然后将各个应用的配置信息存储到apps数组中,最后执行reroute()方法。
文件里面的其他方法及属性在第一节总览里面有介绍,在具体的可以去代码仓库看详细的,源码分析这一块只摘了大流程相关的
03|启动应用start()——src/start.js
在start被调用之前,应用先被下载,但不会初始化/挂载/卸载。
/**
* reroute // reroute在整个single-spa就是负责改变app.status和执行在子应用中注册的生命周期函数。
* formatErrorMessage 格式化异常信息
* setUrlRerouteOnly // 路由的变化,应用是否从定向
* isInBrowser 是否是浏览器环境
*/
import { reroute } from "./navigation/reroute.js";
import { formatErrorMessage } from "./applications/app-errors.js";
import { setUrlRerouteOnly } from "./navigation/navigation-events.js";
import { isInBrowser } from "./utils/runtime-environment.js";
// 应用启动的标志
let started = false;
// 开启的方法
/**
* 必须在你single spa的配置中调用!在调用 start 之前, 应用会被加载, 但不会初始化,挂载或卸载。
* start 的原因是让你更好的控制你单页应用的性能。
* 举个栗子,你想立即声明已经注册过的应用(开始下载那些激活应用的代码),
* 但是实际上直到初始化AJAX(或许去获取用户的登录信息)请求完成之前不会挂载它们 。
* 在这个例子里,立马调用 registerApplication 方法,完成AJAX后再去调用 start方法会获得最佳性能。
*
* @param {*} opts 属性对象,可选 示例: {urlRerouteOnly: true}
* urlRerouteOnly:默认为false的布尔值。如果设置为true,
* 对history.pushState()和history.replaceState()的调用将不会触发单个spa重新定向路由,
* 除非客户端路由已更改。在某些情况下,将此设置为true可以提高性能。有关更多信息,请阅读https://github.com/single-spa/single-spa/issues/484。
*/
export function start(opts) {
started = true;
if (opts && opts.urlRerouteOnly) {
setUrlRerouteOnly(opts.urlRerouteOnly);
}
if (isInBrowser) {
reroute();
}
}
// 返回应用是否启动的boolean值
export function isStarted() {
return started;
}
// 在浏览器环境中
if (isInBrowser) {
setTimeout(() => {
// 如果应用注册了,没有调用start方法,抛出异常,“single-spa应用加载5000后尚未调用start方法。。。。”
if (!started) {
console.warn(
formatErrorMessage(
1,
__DEV__ && // 是否是开发环境
`singleSpa.start() has not been called, 5000ms after single-spa was loaded. Before start() is called, apps can be declared and loaded, but not bootstrapped or mounted.`
)
);
}
}, 5000);
}
这里重点只看start()方法:更改应用启动的标志之后,也调用了reroute()
方法
04|以reroute()为切入点——src/navigation/reroute.js
reroute在整个single-spa就是负责改变app.status和执行在子应用中注册的生命周期函数。
export function reroute(pendingPromises = [], eventArguments) {
// ....省略展示
if (isStarted()) {
appChangeUnderway = true;
appsThatChanged = appsToUnload.concat(
appsToLoad,
appsToUnmount,
appsToMount
);
return performAppChanges();
} else {
appsThatChanged = appsToLoad;
return loadApps();
}
// .... 省略展示
}
在调用start方法之前会执行loadApps()
方法, 调用start方法之后执行performAppChanges()
方法
1、调用start之前的逻辑
- 调用start之前也就是注册应用时触发的rerote方法
import { toLoadPromise } from "../lifecycles/load.js";
export function reroute(pendingPromises = [], eventArguments) {
// ....省略展示
const {
appsToUnload, // 需要移除
appsToUnmount, // 需要卸载
appsToLoad, // 需要加载
appsToMount, // 需要挂载
} = getAppChanges(); // 得到各个状态的应用
return 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;
})
);
});
}
}
- getAppChanges()方法
// 将应用按照状态拆分
export function getAppChanges() {
const appsToUnload = [], // 需要移除的
appsToUnmount = [], // 需要卸载的
appsToLoad = [], // 需要加载的
appsToMount = []; // 需要挂载的
// We re-attempt to download applications in LOAD_ERROR after a timeout of 200 milliseconds
// 超时200毫秒后,重新尝试在LOAD_ERROR中下载应用程序
const currentTime = new Date().getTime();
apps.forEach((app) => {
// 确保应用没有被隔离 && 应用已激活
const appShouldBeActive =
app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
switch (app.status) {
case LOAD_ERROR: // 加载错误,可能由于网络原因
if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) {
appsToLoad.push(app);
}
break;
case NOT_LOADED:
case LOADING_SOURCE_CODE:
if (appShouldBeActive) {
appsToLoad.push(app);
}
break;
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED: // 挂载结束
if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
appsToUnload.push(app);
} else if (appShouldBeActive) {
appsToMount.push(app);
}
break;
case MOUNTED:
if (!appShouldBeActive) {
appsToUnmount.push(app);
}
break;
// all other statuses are ignored
}
});
return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}
- toLoadPromise
// 应用代码正在被拉取的生命周期
/**
* 通过微任务加载子应用,最终是return了一个promise出行,在注册了加载子应用的微任务.
* 更改app.status为LOAD_SOURCE_CODE => NOT_BOOTSTRAP,当然还有可能是LOAD_ERROR
* 执行加载函数,并将props传递给加载函数,给用户处理props的一个机会,因为这个props是一个完备的props
* 验证加载函数的执行结果,必须为promise,且加载函数内部必须return一个对象
* 这个对象是子应用的,对象中必须包括各个必须的生命周期函数
* 然后将生命周期方法通过一个函数包裹并挂载到app对象上
* app加载完成,删除app.loadPromise
* @param {*} app
*/
export function toLoadPromise(app) {
return Promise.resolve().then(() => {
if (app.loadPromise) {
// app已经在被加载
return app.loadPromise;
}
// 状态为NOT_LOADED和LOAD_ERROR的app才可以被加载
if (app.status !== NOT_LOADED && app.status !== LOAD_ERROR) {
return app;
}
app.status = LOADING_SOURCE_CODE;
let appOpts, isUserErr;
return (app.loadPromise = Promise.resolve()
.then(() => {
// 执行app的加载函数,并给子应用传递props => 用户自定义的customProps和内置的比如应用的名称、singleSpa实例
const loadPromise = app.loadApp(getProps(app));
if (!smellsLikeAPromise(loadPromise)) {
// The name of the app will be prepended to this error message inside of the handleAppError function
isUserErr = true;
throw Error(
formatErrorMessage(
33,
__DEV__ &&
`single-spa loading function did not return a promise. Check the second argument to registerApplication('${toName(
app
)}', loadingFunction, activityFunction)`,
toName(app)
)
);
}
return loadPromise.then((val) => {
app.loadErrorTime = null;
// window.singleSpa
appOpts = val;
let validationErrMessage, validationErrCode;
if (typeof appOpts !== "object") {
validationErrCode = 34;
if (__DEV__) {
validationErrMessage = `does not export anything`;
}
}
// 必须导出bootstrap生命周期函数
if (
// ES Modules don't have the Object prototype
Object.prototype.hasOwnProperty.call(appOpts, "bootstrap") &&
!validLifecycleFn(appOpts.bootstrap)
) {
validationErrCode = 35;
if (__DEV__) {
validationErrMessage = `does not export a valid bootstrap function or array of functions`;
}
}
// 必须导出mount生命周期函数
if (!validLifecycleFn(appOpts.mount)) {
validationErrCode = 36;
if (__DEV__) {
validationErrMessage = `does not export a mount function or array of functions`;
}
}
// 必须导出unmount生命周期函数
if (!validLifecycleFn(appOpts.unmount)) {
validationErrCode = 37;
if (__DEV__) {
validationErrMessage = `does not export a unmount function or array of functions`;
}
}
const type = objectType(appOpts);
if (validationErrCode) {
let appOptsStr;
try {
appOptsStr = JSON.stringify(appOpts);
} catch {}
console.error(
formatErrorMessage(
validationErrCode,
__DEV__ &&
`The loading function for single-spa ${type} '${toName(
app
)}' resolved with the following, which does not have bootstrap, mount, and unmount functions`,
type,
toName(app),
appOptsStr
),
appOpts
);
handleAppError(validationErrMessage, app, SKIP_BECAUSE_BROKEN);
return app;
}
if (appOpts.devtools && appOpts.devtools.overlays) {
app.devtools.overlays = assign(
{},
app.devtools.overlays,
appOpts.devtools.overlays
);
}
app.status = NOT_BOOTSTRAPPED;
// 在app对象上挂载生命周期方法,每个方法都接收一个props作为参数,方法内部执行子应用导出的生命周期函数,并确保生命周期函数返回一个promise
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;
}));
});
}
2、调用start之后
- reroute
import { toLoadPromise } from "../lifecycles/load.js";
export function reroute(pendingPromises = [], eventArguments) {
// ....省略展示
const {
appsToUnload, // 需要移除
appsToUnmount, // 需要卸载
appsToLoad, // 需要加载
appsToMount, // 需要挂载
} = getAppChanges(); // 得到各个状态的应用
appChangeUnderway = true;
appsThatChanged = appsToUnload.concat(
appsToLoad,
appsToUnmount,
appsToMount
);
return performAppChanges();
}
- performAppChanges
function performAppChanges() {
return Promise.resolve().then(() => {
// https://github.com/single-spa/single-spa/issues/545
// 自定义事件,在应用状态发生改变之前可触发,给用户提供做事情的机会
window.dispatchEvent(
new CustomEvent(
appsThatChanged.length === 0
? "single-spa:before-no-app-change"
: "single-spa:before-app-change",
getCustomEventDetail(true)
)
);
window.dispatchEvent(
new CustomEvent(
"single-spa:before-routing-event",
getCustomEventDetail(true, { cancelNavigation })
)
);
if (navigationIsCanceled) {
window.dispatchEvent(
new CustomEvent(
"single-spa:before-mount-routing-event",
getCustomEventDetail(true)
)
);
finishUpAndReturn();
navigateToUrl(oldUrl);
return;
}
// 移除应用 => 更改应用状态,执行unload生命周期函数,执行一些清理动作
// 其实一般情况下这里没有真的移除应用
const unloadPromises = appsToUnload.map(toUnloadPromise);
// 卸载应用,更改状态,执行unmount生命周期函数
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
* 这个原因其实是因为这些操作都是通过注册不同的微任务实现的,而JS是单线程执行,
* 所以自然后续的只能等待前面的执行完了才能执行
* 这里一般情况下其实不会执行,只有手动执行了unloadApplication方法才会二次加载
*/
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);
});
});
}
05|监听路由变化
其实说了这么多,到底是在哪里监听的路由变化呢,看这个文件src/navigation/navigation-events.js
if (isInBrowser) {
// We will trigger an app change for any routing events.
// 在浏览器环境对 hashchange 和 popstate的触发做一个监听
window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
// Monkeypatch addEventListener so that we can ensure correct timing
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
// 监听事件触发的时候,对触发的事件做一个收集
window.addEventListener = function (eventName, fn) {
if (typeof fn === "function") {
if (
routingEventsListeningTo.indexOf(eventName) >= 0 &&
!find(capturedEventListeners[eventName], (listener) => listener === fn)
) {
capturedEventListeners[eventName].push(fn);
return;
}
}
return originalAddEventListener.apply(this, arguments);
};
// 移除事件响应的对收集的事件做删除
window.removeEventListener = function (eventName, listenerFn) {
if (typeof listenerFn === "function") {
if (routingEventsListeningTo.indexOf(eventName) >= 0) {
capturedEventListeners[eventName] = capturedEventListeners[
eventName
].filter((fn) => fn !== listenerFn);
return;
}
}
return originalRemoveEventListener.apply(this, arguments);
};
window.history.pushState = patchedUpdateState(
window.history.pushState,
"pushState"
);
window.history.replaceState = patchedUpdateState(
window.history.replaceState,
"replaceState"
);
if (window.singleSpaNavigate) {
console.warn(
formatErrorMessage(
41,
__DEV__ &&
"single-spa has been loaded twice on the page. This can result in unexpected behavior."
)
);
} else {
/* For convenience in `onclick` attributes, we expose a global function for navigating to
* whatever an <a> tag's href is.
*/
window.singleSpaNavigate = navigateToUrl;
}
}
这段代码不是放在方法里面导出调用的,而是直接这样写,是什么意思呢
文件通过引入建立依赖关系,在最后打包输出为bundle文件时,这段代码是存在全局作用域的,所用当引入single-spa的时候这些会自动执行
在使用 window.history 时,如果执行 pushState(repalceState) 方法,是不会触发 popstate 事件的,而 single-spa 通过一种巧妙的方式,实现了执行 pushState(replaceState) 方法可触发 popstate 事件
/**
* 因为上面只对hashChange和popState事件做了监听,所以当触发replaceState和pushState方法时,对其进行一个增强,保证其内部逻辑不变的同时,执行自定义事件
* @param {*} updateState | 浏览器的replaceState和pushState方法触发
* @param {*} methodName | 字符串 ‘replaceState‘ || 'pushState'
* @returns
*/
function patchedUpdateState(updateState, methodName) {
return function () {
// 跳转之前的url
const urlBefore = window.location.href;
// 劫持使用传入的updateState方法,保证原来的功能不失效
const result = updateState.apply(this, arguments);
const urlAfter = window.location.href;
if (!urlRerouteOnly || urlBefore !== urlAfter) {
if (isStarted()) {
// fire an artificial popstate event once single-spa is started,
// so that single-spa applications know about routing that
// occurs in a different application
// 如过开启了start方法,则不会调用reroute方法
// window.dispatchEvent 触发自定义事件,
window.dispatchEvent(
// 创建自定义事件
createPopStateEvent(window.history.state, methodName)
);
} else {
// do not fire an artificial popstate event before single-spa is started,
// since no single-spa applications need to know about routing events
// outside of their own router.
reroute([]);
}
}
return result;
};
}
/**
* 创建自定义事件
* @param {*} state window.history.state
* @param {*} originalMethodName 方法名 replaceState || pushState
* @returns
*/
function createPopStateEvent(state, originalMethodName) {
// https://github.com/single-spa/single-spa/issues/224 and https://github.com/single-spa/single-spa-angular/issues/49
// We need a popstate event even though the browser doesn't do one by default when you call replaceState, so that
// all the applications can reroute. We explicitly identify this extraneous event by setting singleSpa=true and
// singleSpaTrigger=<pushState|replaceState> on the event instance.
let evt;
try {
// 创建 popstate 自定义事件,当触发 replaceState || pushState 时, 监听popstate就能触发
evt = new PopStateEvent("popstate", { state });
} catch (err) {
// IE 11 compatibility https://github.com/single-spa/single-spa/issues/299
// https://docs.microsoft.com/en-us/openspecs/ie_standards/ms-html5e/bd560f47-b349-4d2c-baa8-f1560fb489dd
evt = document.createEvent("PopStateEvent");
evt.initPopStateEvent("popstate", false, false, state);
}
evt.singleSpa = true;
evt.singleSpaTrigger = originalMethodName;
return evt;
}
之所以能在执行 pushState、replaceState
方法时,触发 popstate 事件,是因为 single-spa在这里 重写了 window.history 的 pushState 和 replaceState 方法。在执行 pushState、replaceState 方法时,会通过原生方法 – PopStateEvent 构建一个事件对象,然后调用 window.dispatchEvent 方法,手动触发 popState 事件。
06|流程梳理
当我们启动应用时,会调用registerApplication注册子应用和start开启应用,这两个方法内部都调用了reroute函数
- 其中registerApplication注册子应用,对应用的信息进行配置包裹到apps中
- start方法执行时通过urlRerouteOnly判断是否要监听url路由变化,然后调用reroute方法
- 与此同时全局对浏览器的hashchange 和 popstate的触发做一个监听,并通过createPopStateEvent自定义popstate事件的方式对replaceState和pushState进行重写。所以我们通过history.replaceState或者history.pushState本质上还是触发了我们监听的popstate事件,从而触发reroute。
- reroute方法内部调用getAppChanges,该方法会遍历apps应用数组,根据shouldBeActive方法判断window.location匹配的app激活规则判断子应用是已激活,返回不同状态的应用
- 然后reroute方法根据started变量的状态走了两个分支,如果started是未开启状态会调用loadApps函数执行app.loadApp来实际加载子应用。再调用callAllEventListeners遍历执行路由收集的函数
- 如果started是开启状态则调用performAppChanges方法先卸载需要卸载的应用,再执行appsToLoad、appsToMount加载启动挂载应用,期间子应用的生命周期函数会挂载到app配置对象的属性上,在指定的情况下执行
关注的点 Q&A
1、single-spa 是如何工作的
single-spa 有两种使用模式:application 和 parcel
-
application
application 模式下,先通过 registerApplication 注册子应用,然后在基座应用挂载完成以后执行 start 方法, 这样基座应用就可以根据 url 的变化来进行子应用切换,激活对应的子应用。
-
parcel
取到组件的生命周期方法,然后通过 mountRootParcel 方法直接挂载。mountRootParcel 方法会返回一个 parcel 实例对象,内部包含 update、unmount 方法。当我们需要更新组件时,直接调用 parcel 对象的 update 方法,就可以触发组件的 update 生命周期方法;当我们需要卸载组件时,直接调用 parcel 对象的 unmount 方法。
在执行 mountRootParcel 方法时,传入的第二个参数,会作为组件 mount 生命周期方法的入参;在执行 parcel.update 方法时,传入的参数,会作为组件 update 生命周期方法的入参。
2、如何通信
父组件 —— parcel
父组件通过props透传
具体的前面也有简单提到:在执行 mountRootParcel 方法时,传入的第二个参数,会作为组件 mount 生命周期方法的入参;在执行 parcel.update 方法时,传入的参数,会作为组件 update 生命周期方法的入参。
就像平时开发组件:子组件回调伏组件某个方法这种方式,我们在父组件定一个方法传给parcel组件,parcel组件就可以在需要的时候执行这个方法通知父组件更新
parcel组件之间的通信
这种其实也是 parcel 组件和父组件之间的通信。 parcel 组件可以通过父组件传递的方法,触发父组件的更新,父组件更新以后,在触发另一个parcel 组件的更新。
基座应用和子应用的通信
在基座应用注册子应用的时候,可以给每个子应用定义一个customProps,这个会作为mount方法的入参数,里面也可以包裹回调的方法,当子应用需要通知基座应用更新时,可以执行这个方法
子应用的通信
也是基于和基座应用通信的这种方式
3、为什么子应用导出的生命周期函数都是一个promise
子应用使用
export function mount(props) {
return Promise.resolve().then(() => {
// 子应用/组件具体的挂载逻辑
...
})
}
single-spa——src/lifecycles/mount.js中执行逻辑
// 应用挂载完的生命周期
export function toMountPromise(appOrParcel, hardFail) {
return Promise.resolve().then(() => {
if (appOrParcel.status !== NOT_MOUNTED) {
return appOrParcel;
}
// single-spa其实在不同的阶段提供了相应的自定义事件,让用户可以做一些事情
if (!beforeFirstMountFired) {
window.dispatchEvent(new CustomEvent("single-spa:before-first-mount"));
beforeFirstMountFired = true;
}
// 执行子应用的生命周期方法
return reasonableTime(appOrParcel, "mount")
.then(() => {
appOrParcel.status = MOUNTED;
if (!firstMountFired) {
window.dispatchEvent(new CustomEvent("single-spa:first-mount"));
firstMountFired = true;
}
return appOrParcel;
})
.catch((err) => {
// If we fail to mount the appOrParcel, we should attempt to unmount it before putting in SKIP_BECAUSE_BROKEN
// We temporarily put the appOrParcel into MOUNTED status so that toUnmountPromise actually attempts to unmount it
// instead of just doing a no-op.
appOrParcel.status = MOUNTED;
return toUnmountPromise(appOrParcel, true).then(
setSkipBecauseBroken,
setSkipBecauseBroken
);
function setSkipBecauseBroken() {
if (!hardFail) {
handleAppError(err, appOrParcel, SKIP_BECAUSE_BROKEN);
return appOrParcel;
} else {
throw transformErr(err, appOrParcel, SKIP_BECAUSE_BROKEN);
}
}
});
});
}
4、single-spa 生命周期 hooks
single-spa 定义了一些生命周期 hooks,可以帮助我们在子应用/组件生命周期中执行自定义操作,这些 hooks 包括:
- single-spa:before-first-mount:第一次挂载子应用/组件之前触发,之后就不会再触发
- single-spa:first-mount:第一次挂载子应用/组件之后触发,之后就不会再触发
- single-spa:before-no-app-change:application 模式下,修改 url 会触发子应用的切换。如果路由注册表中没有匹配当前 url 的子应用,那么 single-spa:before-no-app-change 事件会触发
- single-spa:before-app-change:修改 url 导致子应用切换时,如果路由注册表中有匹配当前 url 的子应用, single-spa:before-app-change 事件会触发。
- single-spa:before-routing-event:application 模式下, hashchange、popstate 触发以后,single-spa:before-routing-event 事件就会触发。
- single-spa:before-mount-routing-event:application 模式下, 旧的子应用卸载完成之后,新的子应用挂载之前触发。
- single-spa:no-app-change:application 模式下,执行performAppChanges方法里面,在single-spa:before-app-change触发以后触发
- single-spa:app-change:application 模式下,执行performAppChanges方法里面,在single-spa:before-app-change触发以后触发
- single-spa:routing-event:application 模式下, single-spa:app-change / single-spa:no-app-change 触发以后, single-spa:routing-event 触发。
例如:single-spa——src/lifecycles/mount.js
// single-spa其实在不同的阶段提供了相应的自定义事件,让用户可以做一些事情
if (!beforeFirstMountFired) {
window.dispatchEvent(new CustomEvent("single-spa:before-first-mount"));
beforeFirstMountFired = true;
}
这样我们可以自定义使用
window.addEventListener('single-spa:before-first-mount', event => {...})
不足
- single-spa 采用 JS Entry 的方式接入微应用,对微应用的入侵太强
○ 微应用路由改造,添加一个特定的前缀
○ 微应用入口改造,挂载点变更和生命周期函数导出
○ 打包工具配置更改 - 通信问题
通过注册微应用时给微应用注入一些状态信息,剩下的只能用户自己去实现,实现方式上面也有提到几种通信方式 - 资源预加载
single-spa会将微应用打包成一个js文件 - js隔离
js全局对象污染的问题 - 样式隔离问题
只能通过约定命名的方式去做规范实现
结尾
single-spa是一个很好的微前端基础框架,阿里的qiankun就是基于single-spa实现的,在它的基础上做了一层封装和解决了一些缺陷。接下来会去学习下qiankun的源码。