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)