探究对 Vue Router 的 Hash 模式进行外部监听

Vue Router Hash 路由 hashchange 未实现

Vue Router 的 Hash 模式,常见印象是「改 URL 里的 hash → 触发 hashchange → 切换页面组件」。但在真实场景里,给 window 挂上 hashchange 监听往往毫无反应。本文记录从现象到原因的排查过程,以及尚未落地的外部监听思路。

背景

需要在 iframe 父页面中感知子应用(Vue Router Hash 模式)的路由变化。直觉上监听 hashchange 即可,但实测回调从未触发。下文先复现问题,再分析根因。

复现

serve 起本地静态服务,暴露一个 HTML 文件即可快速复现:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Hash路由问题</title>
    <script src="http://localhost:3000/libs/vue.global.js"></script>
    <script src="http://localhost:3000/libs/vue-router.global.js"></script>
  </head>
  <body>
    <div id="app">
      <h1>Vue Router</h1>
      <p>
        <router-link to="/">主页</router-link>
        <router-link to="/apple">Go to Apple</router-link>
        <router-link to="/banana">Go to Banana</router-link>
        <router-link to="/orange">Go to Orange</router-link>
      </p>
      <p>下方为路由页面</p>
      <router-view></router-view>
    </div>
    <script>
      const Home = { template: "<div>水果</div>" };
      const Apple = { template: "<div>Apple</div>" };
      const Banana = { template: "<div>Banana</div>" };
      const Orange = { template: "<div>Orange</div>" };

      const router = VueRouter.createRouter({
        history: VueRouter.createWebHashHistory(),
        routes: [
          { path: "/", component: Home },
          { path: "/apple", component: Apple },
          { path: "/banana", component: Banana },
          { path: "/orange", component: Orange },
        ],
      });

      const app = Vue.createApp({});
      app.use(router);
      app.mount("#app");
    </script>
  </body>
</html>

window 加上 hashchange 监听:

window.addEventListener("hashchange", () => {
  console.log("页面 Hash 值发生变化!");
});

无论怎么切换路由,控制台没有任何输出

点击 router-link 切换路由时,hashchange 回调未触发

但在控制台手动输入 location.hash,又能看到 hash 确实在变。

location.hash 已变化,但 hashchange 未触发

原因分析

SegmentFault 上有一篇讨论与此现象一致:为什么 vue-router 的 hash 模式不触发 hashchange?

核心结论是:Vue Router 的 Hash 模式表面上在改 hash,实际上走的是 history.pushState / history.replaceState。这两个 API 可以在不触发 hashchange 的前提下更新地址栏里的 hash 片段。

因此,外部代码若只依赖原生 hashchange,就会和 Vue Router 的内部实现「对不上号」。

能否改听 pushState / replaceState?

既然路由跳转用的是 pushStatereplaceState,直接监听这两个 API 行不行?

很遗憾,浏览器没有为它们提供对应的原生事件。原生只暴露了 popstate,且它只在用户点击后退/前进、或调用 history.back() 等场景下触发;pushStatereplaceState 本身不会触发 popstate

Vue Router 的做法是:在源码里重写 pushState / replaceState,挂上自己的监听器,从而在内部完成路由分发。下面这段 useHistoryListeners 可以看到它如何维护 listeners 数组,并在 popstate 时统一回调:

function useHistoryListeners(base, historyState, currentLocation, replace) {
  let listeners = [];
  let teardowns = [];
  // TODO: should it be a stack? a Dict. Check if the popstate listener
  // can trigger twice
  let pauseState = null;
  const popStateHandler = ({ state }) => {
    const to = createCurrentLocation(base, location);
    const from = currentLocation.value;
    const fromState = historyState.value;
    let delta = 0;
    if (state) {
      currentLocation.value = to;
      historyState.value = state;
      // ignore the popstate and reset the pauseState
      if (pauseState && pauseState === from) {
        pauseState = null;
        return;
      }
      delta = fromState ? state.position - fromState.position : 0;
    } else {
      replace(to);
    }
    // Here we could also revert the navigation by calling history.go(-delta)
    // this listener will have to be adapted to not trigger again and to wait for the url
    // to be updated before triggering the listeners. Some kind of validation function would also
    // need to be passed to the listeners so the navigation can be accepted
    // call all listeners
    listeners.forEach((listener) => {
      listener(currentLocation.value, from, {
        delta,
        type: NavigationType.pop,
        direction: delta
          ? delta > 0
            ? NavigationDirection.forward
            : NavigationDirection.back
          : NavigationDirection.unknown,
      });
    });
  };
  function pauseListeners() {
    pauseState = currentLocation.value;
  }
  function listen(callback) {
    // set up the listener and prepare teardown callbacks
    listeners.push(callback);
    const teardown = () => {
      const index = listeners.indexOf(callback);
      if (index > -1) listeners.splice(index, 1);
    };
    teardowns.push(teardown);
    return teardown;
  }
  function beforeUnloadListener() {
    const { history } = window;
    if (!history.state) return;
    history.replaceState(assign({}, history.state, { scroll: computeScrollPosition() }), "");
  }
  function destroy() {
    for (const teardown of teardowns) teardown();
    teardowns = [];
    window.removeEventListener("popstate", popStateHandler);
    window.removeEventListener("beforeunload", beforeUnloadListener);
  }
  // set up the listeners and prepare teardown callbacks
  window.addEventListener("popstate", popStateHandler);
  // TODO: could we use 'pagehide' or 'visibilitychange' instead?
  // https://developer.chrome.com/blog/page-lifecycle-api/
  window.addEventListener("beforeunload", beforeUnloadListener, {
    passive: true,
  });
  return {
    pauseListeners,
    listen,
    destroy,
  };
}

替代思路

若要在应用外部感知路由变化,更稳妥的方向包括:

  • 在 Vue 应用内使用 router.afterEach 等官方钩子,再通过 postMessage 等方式通知父页面(参见 Iframe 父子页面消息传递实战
  • 在无法改子应用源码时,考虑轮询 location.hash、或同样 monkey-patch pushState / replaceState(需自行处理兼容与卸载)

小结

别被「Hash 模式」这个名字误导——它并不等价于浏览器的 hashchange 语义。目前我还没有找到一个既干净又通用的「纯外部监听」方案。如果你有更好的思路,欢迎交流。