qiankun主、子应用 vue2、vue3 混用问题
History Api
history.pushState() 方法向浏览器的会话历史栈增加了一个条目。该方法是异步的。
语法:
pushState(state, unused)
pushState(state, unused, url)
参数:
state
state对象是一个 JavaScript 对象,其与通过pushState()创建的新历史条目相关联。每当用户导航到新的state,都会触发popstate事件,并且该事件的state属性包含历史条目state对象的副本。state对象可以是任何可以序列化的对象。因为 Firefox 将state对象保存到用户的磁盘上,以便用户重启浏览器可以恢复,我们对state对象序列化的表示施加了 16 MiB 的限制。如果你传递的state对象的序列化表示超出了pushState()可接受的大小,该方法将抛出异常。如果你需要更多的空间,建议使用sessionStorage和/或localStorage。
unused
unused由于历史原因,该参数存在且不能忽略;传递一个空字符串是安全的,以防将来对该方法进行更改。
url
url可选新历史条目的 URL。请注意,浏览器不会在调用pushState()之后尝试加载该 URL,但是它可能会在以后尝试加载该 URL,例如,在用户重启浏览器之后。新 URL 可以不是绝对路径;如果它是相对的,它将相对于当前的 URL 进行解析。新的 URL 必须与当前 URL 同源;否则,pushState()将抛出异常。如果该参数没有指定,则将其设置为当前文档的 URL。
vue3-router
以 history 路由模式为例:
`router.push` 和 `router.replace` 方法内部执行了浏览器底层的 `history.pushState` 和 `history.replaceState` 方法。
监听路由变化,渲染对应的视图,监听了浏览器底层 `Window` 的 `popstate` 事件。
`router.replace` 对应源码中的 [replace](https://github.com/vuejs/router/blob/1cb459422e210c4d4fd9b8b902849e9b82a9c1ba/packages/router/src/history/html5.ts#L245) 方法,replace方法干的事情:
```javascript
生成state
调用history.replace(state, '', url);
```
`router.push` 对应源码中的 [push](https://github.com/vuejs/router/blob/1cb459422e210c4d4fd9b8b902849e9b82a9c1ba/packages/router/src/history/html5.ts#L264) 方法,push方法干的事情:
```javascript
生成currentState
调用history.replace(currentState, '', url);
生成state
调用history.push(state, '', url);
```
问题复现过程
背景说明:
> 💡 主应用为vue2(vue@^2.6.14,vue-router@^3.5.1,qiankun@^2.10.10,运行在8080端口)。有两个路由 `/app-(.*)` 和 `/home` ,当访问 `/app-` 开头的路径时会加载 `subApp.vue` 页面,此页面会注册和加载qiankun子应用;当访问 `/home` 路径时,会加载主应用的 `home.vue` 页面。
> registerMicroApps([
> {
> name: 'vue3App',
> entry: '//localhost:8081',
> container: '#sub-app-container',
> activeRule: '/app-vue3',
> }
> ])
>
> 子应用为vue3(vue@^3.2.45,vue-router@^4.0.0-0,运行在8081端口),有两个路由 `/app-vue3/home` 和 `/app-vue3/notion` 。
> {
> path: '/',
> redirect: '/home',
> },
> {
> path: '/home',
> name: 'home',
> component: home.vue,
> },
> {
> path: '/notion',
> name: 'notion',
> component: notion.vue,
> },
>
问题复现:
> 🕷️ 1.浏览器访问 [http://localhost:8080/app-vue3](http://localhost:8080/app-vue3)
> 2.子应用 home.vue 显示出现,浏览器地址栏 URL 变为 [http://localhost:8080/app-vue3/home](http://localhost:8080/app-vue3/home)
> 3.在主应用操作路由 `this.$router.push({ query: { nodeId: 123 } })` ,给 URL 成功添加参数 ?nodeId=123
> 4.在子应用操作路由 `router.push({ name: ‘notion’ })`,发现子应用的 unmount钩子触发、子应用mount钩子触发,子应用 notion.vue 页面显示。
> (使用debug模式后,发现在子应用操作路由 `router.push({ name: ‘notion’ })` 时,URL首先变成了 [http://localhost:8080/app-vue3undefined](http://localhost:8080/app-vue3undefined) 然后再变成了[http://localhost:8080/app-vue3/notion](http://localhost:8080/app-vue3/notion))
问题分析
### 1.URL变成 [http://localhost:8080/app-vue3undefined](http://localhost:8080/app-vue3undefined) 时,qiankun为什么会销毁子应用,注册子应用时activeRule 为 `/app-vue3`,`/app-vue3undefined` 应该也符合此规则吧?
注册的子应用 activeRule 为字符串时([不为 function 类型时](https://github.com/single-spa/single-spa/blob/aff9d053a65eb03ee1106412c29733ae759312ba/src/applications/apps.js#L405)),single-spa 会根据 activeRule 值生成正则,据此正则来判断应用是否激活或卸载,如前面提到的,在 qiankun 中注册的子应用 activeRule 为 `/app-vue3` ,[single-spa 会据此生成一个正则](https://github.com/single-spa/single-spa/blob/aff9d053a65eb03ee1106412c29733ae759312ba/src/applications/apps.js#L450),`new RegExp(’^/app-vue3(/.*)?(#.*)?$’, ‘i’)` ,当 URL 发生变化时,来判断当前 URL 是否会激活此子应用,相关代码如下:
```javascript
urlReroute()
↓
reroute()
↓
getAppChanges()
↓
shouldBeActive(app)
↓
sanitizeActiveWhen(activeWhen)
↓
pathToActiveWhen(path, exactMatch) // 正则判断url是否当前子应用是否为active
```
而当 URL 为 [http://localhost:8080/app-vue3undefined](http://localhost:8080/app-vue3undefined) 时,`new RegExp('^/app-vue3(/.`_`)?(#.`_`)?$', 'i').test('/app-vue3undefined')` 的结果为 false,因此会卸载当前子应用。
**虽然注册子应用时设置的 activeRule 为** **`/app-vue3`****,但是 single-spa 将其转化为正则时,会处理成匹配以** **`/app-vue3`** **或** **`/app-vue3/`** **或** **`/app-vue3#`** **或** **`/app-vue3/#`** **开头的任意字符。**
### 2.URL 上为什么会出现 undefined?出现 undefined 后,URL 又是怎么恢复正常的?
vue2router操作路由跳转后,`window.history.state` 的值为:
```javascript
{"key":"47091.400"}
```
vue2router路由跳转相关源码:
```javascript
function genStateKey () {
return Time.now().toFixed(3)
}
history.pushState({ key: setStateKey(genStateKey()) }, '', url)
```
vue3router操作路由跳转后,`window.history.state` 的值为:
```javascript
{"back":"/","current":"/notionBill","forward":null,"replaced":false,"position":7,"scroll":null}
```
vue3router的路由跳转相关源码如下:
```javascript
function push(to, data) {
// const historyState = { value: history.state }
const currentState = assign({},
historyState.value, history.state, {
forward: to,
scroll: computeScrollPosition(),
},
);
changeLocation(currentState.current, currentState, true);
const state = assign({}, buildState(currentLocation.value, to, null), { position: currentState.position + 1 }, data);
changeLocation(to, state, false);
currentLocation.value = to;
}
function changeLocation(to, state, replace) {
const hashIndex = base.indexOf('#');
const url = hashIndex > -1
? (location.host && document.querySelector('base')
? base
: base.slice(hashIndex)) + to
: createBaseLocation() + base + to;
try {
history[replace ? 'replaceState' : 'pushState'](state, '', url);
historyState.value = state;
}
catch (err) {
// Force the navigation, this also resets the call count
location[replace ? 'replace' : 'assign'](url);
}
}
```
主应用vue2上操作路由设置参数后,history 的 state 被设置为 `{ “key”: xxxx }`,子应用 vue3router 监听到 popstate 事件,会调用 [popStateHandler](https://github.com/vuejs/router/blob/a1611b6099403cfe9539ee8d4e9308b3a6a0175c/packages/router/src/history/html5.ts#L70) 进行一系列处理,包括设置 `historyState.value = state;` 。这时再操作子应用vue3的 router.push 方法进行路由跳转,
vue3 的 router.push 方法中执行了两次 changeLocation ,第1次是调用 history.replace 设置当前路由,第2次是调用 history.push 跳转至目标路由。由于第1次执行 changeLocation 时 currentState 不存在 current 属性,因此,调用 history.replace 传入的 url 就变成了 [`/app-vue3undefined`](http://localhost:8080/app-vue3undefined) ,浏览器地址栏url就出现了undefined了,而第2次执行 changeLocation 时,传入的参数 to 就是要跳转的路由路径 /notion ,调用 history.push 传入的 url 是 `/app-vue3/notion` ,浏览器地址 url 就显示正常的 `/app-vue3/notion`
**为什么 vue2 主应用上操作路由添加参数后,vue3 子应用再进行路由跳转时 URL 上就出现 undefined,而在 vue2 主应用上操作路由激活 vue3 子应用后,在到vue3子应用中进行跳转,URL上就没有出现 undefined 呢,这不是类似的操作步骤吗(都是主应用操作了路由)?**
答:vue3 子应用激活载入时,会执行 [createWebHistory](https://github.com/vuejs/router/blob/a1611b6099403cfe9539ee8d4e9308b3a6a0175c/packages/router/src/history/html5.ts#L315C17-L315C33)() → [useHistoryStateNavigation](https://github.com/vuejs/router/blob/a1611b6099403cfe9539ee8d4e9308b3a6a0175c/packages/router/src/history/html5.ts#L318C29-L318C54)() ,会通过 [createCurrentLocation](https://github.com/vuejs/router/blob/a1611b6099403cfe9539ee8d4e9308b3a6a0175c/packages/router/src/history/html5.ts#L38) 创建一个有效的 [currentLocation](https://github.com/vuejs/router/blob/a1611b6099403cfe9539ee8d4e9308b3a6a0175c/packages/router/src/history/html5.ts#L185), 此时 [history.state](https://github.com/vuejs/router/blob/a1611b6099403cfe9539ee8d4e9308b3a6a0175c/packages/router/src/history/html5.ts#L190) 为空,执行 [changeLocation](https://github.com/vuejs/router/blob/a1611b6099403cfe9539ee8d4e9308b3a6a0175c/packages/router/src/history/html5.ts#L191C5-L191C19) 传入的参数是有效的,故不会出现上面 undefined 的情况。
```javascript
function useHistoryStateNavigation(base: string) {
const { history, location } = window
// private variables
const currentLocation: ValueContainer<HistoryLocation> = {
value: createCurrentLocation(base, location),
}
const historyState: ValueContainer<StateEntry> = { value: history.state }
// build current history entry as this is a fresh navigation
if (!historyState.value) {
changeLocation(
currentLocation.value,
{
back: null,
current: currentLocation.value,
forward: null,
// the length is off by one, we need to decrease it
position: history.length - 1,
replaced: true,
// don't add a scroll as the user may have an anchor, and we want
// scrollBehavior to be triggered without a saved position
scroll: null,
},
true
)
}
```
**代码执行日志**
第4步中,在子应用操作路由 router.push({ name: ‘notion’ }),会执行 vue-router 中的 push 方法
正常情况下,跳转 notion 路由时:
function push(to, data) {
console.log('push to=', to); // /notion
console.log('historyState.value=', JSON.stringify(historyState.value)); // {"back":null,"current":"/home","forward":null,"position":10,"replaced":true,"scroll":null,"key":"9380.800"}
console.log('history.state=', JSON.stringify(history.state)); // {"back":null,"current":"/home","forward":null,"position":10,"replaced":true,"scroll":null,"key":"9380.800"}
const currentState = assign({},
historyState.value, history.state, {
forward: to,
scroll: computeScrollPosition(),
},
);
console.log('currentState=', JSON.stringify(currentState)); // {"back":null,"current":"/home","forward":"/notion","position":10,"replaced":true,"scroll":{"left":0,"top":0},"key":"9380.800"}
changeLocation(currentState.current, currentState, true);
const state = assign({}, buildState(currentLocation.value, to, null), { position: currentState.position + 1 }, data);
console.log('state=', JSON.stringify(state)); // {"back":"/home","current":"/notion","forward":null,"replaced":false,"position":11,"scroll":null}
changeLocation(to, state, false);
currentLocation.value = to;
}
function changeLocation(to, state, replace) {
const hashIndex = base.indexOf('#');
const url = hashIndex > -1
? (location.host && document.querySelector('base')
? base
: base.slice(hashIndex)) + to
: createBaseLocation() + base + to;
console.log('base=', base);
console.log('to=', to);
try {
console.log('mjs pushState state=', JSON.stringify(state));
console.log('pushState url=', url);
history[replace ? 'replaceState' : 'pushState'](state, '', url);
historyState.value = state;
}
catch (err) {
// Force the navigation, this also resets the call count
location[replace ? 'replace' : 'assign'](url);
}
}
主应用操作给URL添加参数后,子应用再跳转 notion 路由时:
function push(to, data) {
console.log('push to=', to); // /notion
console.log('historyState.value=', JSON.stringify(historyState.value)); // {"key":"373024.700"}
console.log('history.state=', JSON.stringify(history.state)); // {"key":"373024.700"}
const currentState = assign({},
historyState.value, history.state, {
forward: to,
scroll: computeScrollPosition(),
},
);
console.log('currentState=', JSON.stringify(currentState)); // {"key":"373024.700","forward":"/notion","scroll":{"left":0,"top":0}}
// currentState.current 为undefined
changeLocation(currentState.current, currentState, true);
const state = assign({}, buildState(currentLocation.value, to, null), { position: currentState.position + 1 }, data);
console.log('state=', JSON.stringify(state)); // {"back":"/home?nodeId=456","current":"/notion","forward":null,"replaced":false,"position":null,"scroll":null}
changeLocation(to, state, false);
currentLocation.value = to;
}
**问题复现日志**:
本地运行时 qiankun 的入口文件为 node_modules/qiankun/es/apis.js
本地运行时 single-spa 的入口文件为 node_modules/single-spa/lib/esm/single-spa.min.js ,为方便debug,将 single-spa.dev.js 的代码贴入 single-spa.min.js
在上面第3步执行后,以下代码会执行:
[主应用] window.addEventListener('popstate', (e) => {
console.log('主应用popstate e.state=', JSON.stringify(e.state));
// {"key":"10512.700","forward":"/notionBill","scroll":{"left":0,"top":0}}
});
[single-spa] // window.addEventListener("popstate", urlReroute)
[single-spa] urlReroute
[single-spa] reroute
[single-spa] performAppChanges
[主应用] window.addEventListener('popstate', (e) => {
console.log('主应用popstate e.state=', JSON.stringify(e.state));
// {"back":"/home?nodeId=456","current":"/notionBill","forward":null,"replaced":false,"position":null,"scroll":null}
});
[single-spa] // window.addEventListener("popstate", urlReroute)
[single-spa] urlReroute
[single-spa] reroute->peopleWaitingOnAppChange.push
[single-spa] getCustomEventDetail -> appsToUnmount
[single-spa] getCustomEventDetail -> appsToUnmount
[single-spa] toUnmountPromise
[vue3子应用] watch route
[single-spa/single-spa.dev.js]unmountAppOrParcel
[子应用] unmount
正常子应用内路由跳转:
[主应用] window.addEventListener('popstate', (e) => {
console.log('主应用popstate e.state=', JSON.stringify(e.state));
// {"back":"/home","current":"/notionBill","forward":null,"replaced":false,"position":null,"scroll":null}
});
[single-spa] urlReroute // 由于window.addEventListener("popstate", urlReroute)触发
[single-spa] performAppChanges // appsThatChanged.length=0
[single-spa] getCustomEventDetail // isBeforeChanges true
[single-spa] getCustomEventDetail // isBeforeChanges true
[single-spa] getCustomEventDetail // isBeforeChanges true
[主应用] Watch $route
[主应用] window.addEventListener('popstate', (e) => {
console.log('主应用popstate e.state=', JSON.stringify(e.state));
// {"back":"/home","current":"/notionBill","forward":null,"replaced":false,"position":null,"scroll":null}
});
[single-spa] performAppChanges->finishUpAndReturn
[single-spa] getCustomEventDetail // isBeforeChanges false
[single-spa] getCustomEventDetail // isBeforeChanges false
[single-spa]中有对浏览器history操作的监听:
window.addEventListener("popstate", urlReroute);
function reroute() {
var _getAppChanges = getAppChanges(),
appsToUnmount = _getAppChanges.appsToUnmount, // 1
// ...
return performAppChanges();
}
function getAppChanges() {
var appsToUnmount = [],
var currentTime = new Date().getTime();
apps.forEach(function (app) {
var appShouldBeActive = app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
switch (app.status) {
case MOUNTED:
if (!appShouldBeActive) {
console.log('appShouldBeActive false');
appsToUnmount.push(app);
}
}
});
return {
appsToUnmount: appsToUnmount,
};
}
function shouldBeActive(app) {
try {
return app.activeWhen(window.location);
} catch (err) {
handleAppError(err, app, SKIP_BECAUSE_BROKEN);
return false;
}
}
function sanitizeActiveWhen(activeWhen) {
var activeWhenArray = Array.isArray(activeWhen) ? activeWhen : [activeWhen];
activeWhenArray = activeWhenArray.map(function (activeWhenOrPath) {
// console.log('typeof activeWhenOrPath =', typeof activeWhenOrPath); // string
return typeof activeWhenOrPath === "function" ? activeWhenOrPath : pathToActiveWhen(activeWhenOrPath);
});
return function (location) {
return activeWhenArray.some(function (activeWhen) {
console.log('sanitizeActiveWhen return activeWhen=', activeWhen);
console.log('location=', location);
return activeWhen(location);
});
};
}
function pathToActiveWhen(path, exactMatch) {
var regex = toDynamicPathValidatorRegex(path, exactMatch);
return function (location) {
// compatible with IE10
var origin = location.origin;
if (!origin) {
origin = "".concat(location.protocol, "//").concat(location.host);
}
var route = location.href.replace(origin, "").replace(location.search, "").split("?")[0];
return regex.test(route);
};
}
function performAppChanges() {
// 2
var unmountUnloadPromises = appsToUnmount.map(toUnmountPromise).map(function (unmountPromise) {
return unmountPromise.then(toUnloadPromise);
});
var allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);
var unmountAllPromise = Promise.all(allUnmountPromises);
}
// 3 卸载
function toUnmountPromise(appOrParcel, hardFail) {
unmountAppOrParcel()
}
function unmountAppOrParcel() {
reasonableTime(appOrParcel, "unmount")
}
function reasonableTime(appOrParcel, lifecycle) {}
问题复现时,pathToActiveWhen 中的 location 为:
{
"ancestorOrigins":{},
"href":"http://localhost:8080/app-vue3undefined",
"origin":"http://localhost:8080",
"protocol":"http:",
"host":"localhost:8080",
"hostname":"localhost",
"port":"8080",
"pathname":"/app-vue3undefined",
"search":"",
"hash":""
}
正常 pathname 应该为 /app-vue3/home
vue2router:
function pushState (url, replace) {
var history = window.history;
history.pushState({ key: setStateKey(genStateKey()) }, '', url);
}
vue3router:
function push(to, data) {
console.log('push to=', to);
const currentState = assign(
{},
historyState.value, history.state, {
forward: to,
scroll: computeScrollPosition(),
},
);
changeLocation(currentState.current, currentState, true);
const state = assign({}, buildState(currentLocation.value, to, null), { position: currentState.position + 1 }, data);
changeLocation(to, state, false);
currentLocation.value = to;
}
function changeLocation(to, state, replace) {
const hashIndex = base.indexOf('#');
const url = hashIndex > -1
? (location.host && document.querySelector('base')
? base
: base.slice(hashIndex)) + to
: createBaseLocation() + base + to;
try {
history[replace ? 'replaceState' : 'pushState'](state, '', url);
historyState.value = state;
}
catch (err) {
// Force the navigation, this also resets the call count
location[replace ? 'replace' : 'assign'](url);
}
}
state数据为:
{
"back":null,
"current":"/home",
"forward":null,
"position":17,
"replaced":true,
"scroll":{"left":0,"top":0},
"key":"8398.000"
}
url为:
http://localhost:8080/app-vue3/home
问题修复
### 方法一:
注册子应用时,将 activeRule 的字符串规则,改成自定义的函数匹配规则,函数返回 true 时表示命中规则。
```diff
registerMicroApps([
{
name: 'vue3App',
entry: '//localhost:8081',
container: '#sub-app-container',
- activeRule: '/app-vue3',
+ activeRule: () => window.location.pathname.startsWith('/app-vue3'),
}
])
```
### 方法二:(来自GitHub issue)
在 vue3 子应用中增加如下代码,确保在每次路由变化后,浏览器的history中的state数据中current属性值正确存在:
```javascript
router.afterEach((to, from, next) => {
const state = {
...history.state,
current: to.path,
}
history.replaceState(state, '', window.location.href)
});
```
增加上述代码后,当在主应用操作路由 `this.$router.push({ query: { nodeId: 123 } })` ,给 URL 成功添加参数 ?nodeId=123 后,就会触发此处的 router.afterEach ,给浏览器 history 的state设置上正确的current属性。在随后的子应用跳转中,就能从 currentState.current 得到正确的值,而不是 undefined。
【参考】:
[https://github.com/umijs/qiankun/issues/2254#issuecomment-1451436006](https://github.com/umijs/qiankun/issues/2254#issuecomment-1451436006) 解决办法 ✅
[https://github.com/lvchengli/qiankun-demo/tree/feature/fix](https://github.com/lvchengli/qiankun-demo/tree/feature/fix) 解决办法 ✅
[https://www.jianshu.com/p/c71e3fa5d39e](https://www.jianshu.com/p/c71e3fa5d39e)
[https://github.com/umijs/qiankun/issues/2254](https://github.com/umijs/qiankun/issues/2254)
[https://github.com/vuejs/router/issues/1270](https://github.com/vuejs/router/issues/1270)
[https://github.com/single-spa/single-spa/issues/911](https://github.com/single-spa/single-spa/issues/911)
[https://github.com/umijs/qiankun/issues/1423](https://github.com/umijs/qiankun/issues/1423)
评论