reroute 根据路由更新应用状态
在上一节中,我们已经了解到 s-spa 会监听到各种 url 变化的事件(注: history.pushState
和 history.replaceState
是通过代理(劫持)来实现监听的),并且调用 reroute 以根据路由状态的变化来更新应用的状态。从整体上说,reroute 模块是路由管理和应用生命周期管理的桥梁。
在 s-spa 的概念中,applications 指的是 为一组特定路由而渲染的微前端组件
。也就是说 applications 的生命周期是受到路由控制的。当前还有一种 不受路由控制,独立渲染的微前端组件
的组件成为 parcel。这属于是 s-spa 中一个很独特和进阶的概念,将在下一章节中探讨。
在本节的内容中,我们将探讨 reroute 的原理,了解 reroute 是如何更新应用的生命状态。
# 目录
# 学习目标
- 学习 reroute 根据路由变化更新应用的生命状态的原理。
- 了解应用的生命周期是如何与路由监听和路由变化联动的。
# reroute
在 single-spa 中写的最精彩的代码就是应用生命周期的代码和路由管理的代码,尤其是 reroute 的代码。建议精读 reroute 的代码,体会其中使用的编程技巧。在这段代码里,使用了非阻塞式编程(出神入化的 Promise 的使用)、非阻塞式错误处理、函数互斥锁与任务队列、事件消息钩子等。
// 是否正在处理 reroute,互斥锁
let appChangeUnderway = false,
// 等待 reroute 的任务队列
peopleWaitingOnAppChange = [],
// 上一次 url
currentUrl = isInBrowser && window.location.href;
// pendingPromises:需要 resolve 的 pending 的 reroute 任务,eventArguments:调用 listener 时的传参
export function reroute(pendingPromises = [], eventArguments) {
if (appChangeUnderway) {
// 如果 reroute 正在处理中,将 reroute 任务入队列
return new Promise((resolve, reject) => {
// 注意:这里的对象中有 reject, reject 是一个“伪造的” promise,不是真正的 promise。
// promise 一般是通过 then 和 catch 判断的。
peopleWaitingOnAppChange.push({
resolve,
reject,
eventArguments,
});
});
}
// 根据新的路由状态计算生命状态需要改变的应用
// 需要 unload、unmount、load、mount 的应用,reroute 只处理这几个状态
const {
appsToUnload,
appsToUnmount,
appsToLoad,
appsToMount,
} = getAppChanges();
// 需要更新的应用
let appsThatChanged,
// 导航是否被取消
navigationIsCanceled = false,
// 更新 oldUrl 和 newUrl
oldUrl = currentUrl,
newUrl = (currentUrl = window.location.href);
if (isStarted()) {
// s-spa 已经启动,开启互斥锁,阻止 reroute 重复进入
appChangeUnderway = true;
// 四种类型都需要更新
appsThatChanged = appsToUnload.concat(
appsToLoad,
appsToUnmount,
appsToMount
);
// 更新应用
return performAppChanges();
} else {
// s-spa 未启动,只更新需要 load 的应用以提前加载应用
appsThatChanged = appsToLoad;
// 加载应用,返回 mount 成功的应用,[]
return loadApps();
}
function cancelNavigation() {
navigationIsCanceled = true;
}
function loadApps() {
return Promise.resolve().then(() => {
// load appsToLoad 中的应用,loadPromises 是 Promise[]
const loadPromises = appsToLoad.map(toLoadPromise);
return (
Promise.all(loadPromises)
// 应用都 load 成功后调用 listeners
.then(callAllEventListeners)
// there are no mounted apps, before start() is called, so we always return []
// 由于未 start 时没有已经 mount 的应用,因此返回 []
.then(() => [])
.catch((err) => {
// 发生错误仍然要调用 listeners
callAllEventListeners();
throw err;
})
);
});
}
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)
)
);
// 监听此事件,并且执行 cancelNavigation 可以取消导航
// see https://single-spa.js.org/docs/api/#canceling-navigation
window.dispatchEvent(
new CustomEvent(
"single-spa:before-routing-event",
getCustomEventDetail(true, { cancelNavigation })
)
);
// 如果取消了导航,返回 oldUrl,触发事件 before-mount-routing-event
// see https://single-spa.js.org/docs/api#canceling-navigation
if (navigationIsCanceled) {
window.dispatchEvent(
new CustomEvent(
"single-spa:before-mount-routing-event",
getCustomEventDetail(true)
)
);
finishUpAndReturn();
navigateToUrl(oldUrl);
return;
}
// unload apps promises
const unloadPromises = appsToUnload.map(toUnloadPromise);
// appsToUnmount 中的应用,要先 unmount,再 unload
const unmountUnloadPromises = appsToUnmount
.map(toUnmountPromise)
.map((unmountPromise) => unmountPromise.then(toUnloadPromise));
// 需要 unload 完成的 promises,此时并没有真正 unload
const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);
// 等待 unload 完成的 promises
const unmountAllPromise = Promise.all(allUnmountPromises);
unmountAllPromise.then(() => {
// unload 完成后,触发 before-mount-routing-event 事件
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
*/
// 在上述应用 unmounting 的过程中先尝试将 appsToLoad 中的应用进行 bootstrap 和 load
// 在上述应用 unmounting 完成后再将 appsToLoad 和 appsToMount 中的应用进行 mount
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.
*/
// 过滤掉已经在 appsToLoad 处理过的的应用,在调用 tryToBootstrapAndMount
// 注意:应用mount 顺序:load => bootstrap => mount
const mountPromises = appsToMount
.filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
.map((appToMount) => {
return tryToBootstrapAndMount(appToMount, unmountAllPromise);
});
return unmountAllPromise
.catch((err) => {
// 不管 unmount 成功还是失败都要调用 listeners
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.
*/
// 需要被 unmount 的应用都已经 unmount
callAllEventListeners();
// 将 loadThenMountPromises 和 mountPromises 合并起来,分别对应 appsToLoad 和 appsToMount
return Promise.all(loadThenMountPromises.concat(mountPromises))
.catch((err) => {
// 将 pending 的 reroute 任务都 reject
pendingPromises.forEach((promise) => promise.reject(err));
throw err;
})
// 等待 finish 并返回最终 mount 的应用
.then(finishUpAndReturn);
});
});
}
function finishUpAndReturn() {
// 获取已经 mount 的应用
const returnValue = getMountedApps();
// 将 pending 的 reroute 任务都 resolve
pendingPromises.forEach((promise) => promise.resolve(returnValue));
// dispatch app-change 或者 no-app-change 事件
try {
const appChangeEventName =
appsThatChanged.length === 0
? "single-spa:no-app-change"
: "single-spa:app-change";
window.dispatchEvent(
new CustomEvent(appChangeEventName, getCustomEventDetail())
);
// dispatch routing-event 事件
window.dispatchEvent(
new CustomEvent("single-spa:routing-event", getCustomEventDetail())
);
} catch (err) {
/* We use a setTimeout because if someone else's event handler throws an error, single-spa
* needs to carry on. If a listener to the event throws an error, it's their own fault, not
* single-spa's.
*/
// 非阻塞式抛出异常
setTimeout(() => {
throw err;
});
}
/* Setting this allows for subsequent calls to reroute() to actually perform
* a reroute instead of just getting queued behind the current reroute call.
* We want to do this after the mounting/unmounting is done but before we
* resolve the promise for the `reroute` function.
*/
// 关闭互斥锁,将会在 mount/unmount 执行完毕,reroute 还没有 resolve 之前关闭
appChangeUnderway = false;
// 处理 reroute 任务队列中的任务
if (peopleWaitingOnAppChange.length > 0) {
/* While we were rerouting, someone else triggered another reroute that got queued.
* So we need reroute again.
*/
// Array<{resolve: any; reject: any; eventArguments: any[]}>
const nextPendingPromises = peopleWaitingOnAppChange;
// 清空任务队列
peopleWaitingOnAppChange = [];
// 再次执行 reroute,处理队列中的执行任务,因为每个任务都期待一个 promise 的结果
// 因此只需要执行一次,resolve 全部的结果的任务即可
reroute(nextPendingPromises);
}
return returnValue;
}
/* We need to call all event listeners that have been delayed because they were
* waiting on single-spa. This includes haschange and popstate events for both
* the current run of performAppChanges(), but also all of the queued event listeners.
* We want to call the listeners in the same order as if they had not been delayed by
* single-spa, which means queued ones first and then the most recent one.
*/
// 调用路由监听中收集的 listeners
function callAllEventListeners() {
// 虽然任务执行可以节流,调用 listener 是伴随 reroute 调用的,不可节流
pendingPromises.forEach((pendingPromise) => {
// 队列中的任务:调用捕获的 listener 并传参
callCapturedEventListeners(pendingPromise.eventArguments);
});
// 当前的任务:调用捕获的 listener 并传参
callCapturedEventListeners(eventArguments);
}
// 包装一个 customEvent 事件对象的参数信息
// CustomEvent 使用了 https://github.com/webmodules/custom-event/blob/master/index.js
function getCustomEventDetail(isBeforeChanges = false, extraProperties) {
const newAppStatuses = {};
const appsByNewStatus = {
// for apps that were mounted
[MOUNTED]: [],
// for apps that were unmounted
[NOT_MOUNTED]: [],
// apps that were forcibly unloaded
[NOT_LOADED]: [],
// apps that attempted to do something but are broken now
[SKIP_BECAUSE_BROKEN]: [],
};
// 应用声明状态还未更新,需要根据四种分类进行状态预测
if (isBeforeChanges) {
appsToLoad.concat(appsToMount).forEach((app, index) => {
addApp(app, MOUNTED);
});
appsToUnload.forEach((app) => {
addApp(app, NOT_LOADED);
});
appsToUnmount.forEach((app) => {
addApp(app, NOT_MOUNTED);
});
} else {
// 应用生命状态已经更新完毕,可以根据 appsThatChanged 真实情况计算
appsThatChanged.forEach((app) => {
addApp(app);
});
}
const result = {
detail: {
newAppStatuses,
appsByNewStatus,
totalAppChanges: appsThatChanged.length,
originalEvent: eventArguments?.[0],
oldUrl,
newUrl,
navigationIsCanceled,
},
};
// 添加额外的属性
if (extraProperties) {
assign(result.detail, extraProperties);
}
return result;
function addApp(app, status) {
const appName = toName(app);
status = status || getAppStatus(appName);
// 将 app 状态信息加到 newAppStatuses
newAppStatuses[appName] = status;
const statusArr = (appsByNewStatus[status] =
appsByNewStatus[status] || []);
// 将 app 加入到 statusArr 相应的数组
statusArr.push(appName);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# 为什么收集 hashchange 和 popstate 的 listeners?
对于 hashchange 和 popstate 的 listeners,可能其中会有一些 DOM 的操作,开发者可能以为 url 变化了应用就变化了,使用了更新后的应用的 DOM 情况。因此需要将此 listener 进行劫持,在应用变化(load、mount)之后,再调用 listeners。
# 为什么 cancelNavigation 能够取消导航?
MDN: dispatchEvent
The dispatchEvent() method of the EventTarget sends an Event to the object, (synchronously) invoking the affected EventListeners in the appropriate order.
我们可能会疑惑的是,为什么 before-routing-event
这个事件被 dispatch 之后,如果其中一个 listener 执行了 cancelNavigation
,紧随其后的 navigationIsCanceled
一定是 false。在我们的印象里,dispatchEvent 似乎是异常的,其实不然。
dispatchEvent
其实是同步的,因为从其返回值大致可知。
false if event is cancelable, and at least one of the event handlers which received event called Event.preventDefault(). Otherwise true.
dispatchEvent 本质上是利用了观察订阅模式,一般来说像是 emitter
会将 listener 的消息处理为同步的, dispatchEvent
也是如此。
参考:
- EventTarget.dispatchEvent() - Web APIs | MDN (opens new window)
- javascript - Is dispatchEvent a sync or an async function - Stack Overflow (opens new window)
# 函数互斥锁与任务队列
// 函数互斥锁
let appChangeUnderway = false,
// 函数执行任务队列
peopleWaitingOnAppChange = [];
// pendingPromises 为 pending 任务的 promise
function reroute(pendingPromises = []) {
if (appChangeUnderway) {
// 互斥锁关闭,将执行任务存入任务队列
return new Promise((resolve, reject) => {
peopleWaitingOnAppChange.push({
resolve,
reject,
eventArguments,
});
});
}
// ......
// 进入函数主体:关闭互斥锁
appChangeUnderway = true;
// ......
// 执行当前任务:等待当前任务的 promise
// ......
// 如果当前任务执行失败:将 pending 的任务 reject
pendingPromises.forEach((promise) => promise.reject(err));
// 准备函数返回值
let returnValue;
// 如果当前任务执行成功:将 pending 的任务 resolve,并 resolve 当前任务的返回值 returnValue
pendingPromises.forEach((promise) => promise.resolve(returnValue));
// 关闭互斥锁
appChangeUnderway = false;
// 处理任务队列中的任务
if (peopleWaitingOnAppChange.length > 0) {
const nextPendingPromises = peopleWaitingOnAppChange;
// 清空任务队列
peopleWaitingOnAppChange = [];
// 重新调用自身,将 pending 任务的 promise 传入
reroute(nextPendingPromises);
}
return returnValue;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
这种方式十分巧妙,可应用于处理如下场景的函数:
- 函数需要互斥执行。
- 函数中包含大量的计算任务或者异步任务。
- 函数业务本身需要节流(一系列的调用结果可以复用最后一次的调用结果)。
这种应用场景很广泛,比如说:
- 作图应用中渲染参考线系统时需要大量的计算任务。
# 事件的触发顺序及含义
部分生命周期需要参考应用生命周期章节。
Event order | Event Name | Condition for firing | 备注 |
---|---|---|---|
1 | single-spa:before-app-change or single-spa:before-no-app-change | Will any applications change status? | 在 reroute 之前是否将会有应用发生状态变化? |
2 | single-spa:before-routing-event | — | 可以通过 cancelNavigation 取消导航 |
3 | single-spa:before-mount-routing-event | — | 在 unmount(inactive 应用) 之后 mount(active 应用) 之前触发 |
4 | single-spa:before-first-mount | Is this the first time any application is mounting? | 首次有应用被 mount 之前触发 |
5 | single-spa:first-mount | Is this the first time any application was mounted? | 首次有应用被 mount 之后触发 |
6 | single-spa:app-change or single-spa:no-app-change | Did any applications change status? | 在 reroute 之后是否有应用已经发生了状态变化? |
7 | single-spa:routing-event | — | 在 reroute 成功执行后触发 |
参考:
# tryToBootstrapAndMount
tryToBootstrapAndMount 异步 bootstrap 应用,并且在 inactive 应用 unmounting 之后 mount active 应用。
/**
* Let's imagine that some kind of delay occurred during application loading.
* The user without waiting for the application to load switched to another route,
* this means that we shouldn't bootstrap and mount that application, thus we check
* twice if that application should be active before bootstrapping and mounting.
* https://github.com/single-spa/single-spa/issues/524
*/
function tryToBootstrapAndMount(app, unmountAllPromise) {
// 如果应用确实被路由匹配上
if (shouldBeActive(app)) {
// 先 bootstrap 应用,注意这时可能并非所有应用都 unmount 完毕
return toBootstrapPromise(app).then((app) =>
// 等待所有应用 unmount 完毕再次判断是应用是否匹配路由,如果匹配在 mount 应用
// 再次判断是否匹配路由是因为在等待其他需要 unmount 应用 unmount 的过程中,路由还可能会变化
unmountAllPromise.then(() =>
shouldBeActive(app) ? toMountPromise(app) : app
)
);
} else {
return unmountAllPromise.then(() => app);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# callCapturedEventListeners
callCapturedEventListeners 调用 reroute 执行成功之前收集的 hashchange 和 popstate 的 liseners。
export function callCapturedEventListeners(eventArguments) {
if (eventArguments) {
const eventType = eventArguments[0].type;
// 事件类型是否是需要监听的类型
if (routingEventsListeningTo.indexOf(eventType) >= 0) {
// 调用收集的该事件类型的所有的 listener
capturedEventListeners[eventType].forEach((listener) => {
// 调用外界的 listener 错误时不能将程序中断
try {
// The error thrown by application event listener should not break single-spa down.
// Just like https://github.com/single-spa/single-spa/blob/85f5042dff960e40936f3a5069d56fc9477fac04/src/navigation/reroute.js#L140-L146 did
listener.apply(this, eventArguments);
} catch (e) {
setTimeout(() => {
throw e;
});
}
});
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# triggerAppChange
export function triggerAppChange() {
// Call reroute with no arguments, intentionally
return reroute();
}
2
3
4
s-spa 为外界提供此方法,以便开发者在内部未能正确监听到路由变化时手动更新(或者强制更新)应用的生命状态。