单页应用history路由监听

1,051 阅读3分钟

前言

本文仅从history路由的情况进行分析,并未分析hash路由。

之前由于工作原因,接触到单页面应用中history路由监听的实现。了解了Vue,React等前端框架对应的路由库的原理,发现在web场景下,路由工具中,很多是基于history去进行封装的。

vue-router中的跳转,其中的routerHistory就是我们在不同场景注入的对象。 image.png

react-router中的跳转 image.png

那我们下面就来探索history到底提供的api,又做了啥,以及我们如何去实现单页面应用的history路由监听。

history 中的 API

push

history库中的push实质上,底层调用的还是window.history.pushState,同时,自己封装了一些工具函数,可以更方便的得到下一次的路由状态,以及做一些调用路由监听的回调的操作。

export function createBrowserHistory(
  options: BrowserHistoryOptions = {}
): BrowserHistory {

  // ... code
  
  function push(to: To, state?: any) {
    let nextAction = Action.Push;
    let nextLocation = getNextLocation(to, state);
    function retry() {
      push(to, state);
    }

    if (allowTx(nextAction, nextLocation, retry)) {
      let [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);

      // TODO: Support forced reloading
      // try...catch because iOS limits us to 100 pushState calls :/
      try {
        // 实质上的globalHistory就是window上的history对象, 实质调用了pushState的方法。
        globalHistory.pushState(historyState, "", url);
      } catch (error) {
        // They are going to lose state here, but there is no real
        // way to warn them about it since the page will refresh...
        window.location.assign(url);
      }

      applyTx(nextAction);
    }
  }
  return {
    // other api,
    push,
  }
}

replace

replace的操作和push操作也基本一样,只不过调用的是window.history.replace

export function createBrowserHistory(
  options: BrowserHistoryOptions = {}
): BrowserHistory {

  // ... code
  
  function replace(to: To, state?: any) {
    let nextAction = Action.Replace;
    let nextLocation = getNextLocation(to, state);
    function retry() {
      replace(to, state);
    }

    if (allowTx(nextAction, nextLocation, retry)) {
      let [historyState, url] = getHistoryStateAndUrl(nextLocation, index);

      // TODO: Support forced reloading
      globalHistory.replaceState(historyState, "", url);

      applyTx(nextAction);
    }
  }
  return {
    // other api,
    replace,
  }
}

listen

listen是实现监听的方法,我们着重看一下listen的实现吧。

函数签名

let history = {
  listen(listener: Listener) {
    return listeners.push(listener);
  }
}

listeners对象

let listeners = createEvents<Listener>();

function createEvents<F extends Function>(): Events<F> {
  let handlers: F[] = [];

  return {
    get length() {
      return handlers.length;
    },
    push(fn: F) {
      handlers.push(fn);
      return function () {
        handlers = handlers.filter((handler) => handler !== fn);
      };
    },
    call(arg) {
      handlers.forEach((fn) => fn && fn(arg));
    },
  };
}

上方代码我们可以得到以下两个信息

  1. push的时候会新存入一个处理函数fn
  2. call的时候会调用所有以存入的函数(代码中使用handlers进行存储)。

何时调用

这里,其实我们只需要看listeners何时调用call即可。发现在applyTx函数中调用。

function applyTx(nextAction: Action) {
  action = nextAction;
  [index, location] = getIndexAndLocation();
  listeners.call({ action, location });
}

我们具体看看何时进行了,发现其实在pushreplace的时候使用了该函数,并且是在globalHistory调用pushStaterepalceState之后调用的。

function push(to: To, state?: any) {
    // code

    if (allowTx(nextAction, nextLocation, retry)) {
      let [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);

      // TODO: Support forced reloading
      // try...catch because iOS limits us to 100 pushState calls :/
      try {
        globalHistory.pushState(historyState, "", url);
      } catch (error) {
        // They are going to lose state here, but there is no real
        // way to warn them about it since the page will refresh...
        window.location.assign(url);
      }

      applyTx(nextAction);
    }
  }

history模式下的路由监听

上面我们都分析了vue-routerreact-routerhistory模式下,实际底层都是调用了window.historyreplaceState/pushState

所以,我们只要监听replaceState/pushState的事件,并执行我们自定义回调,就能够实现我们要的效果了。 参考了社区的方案,大多都是如下的思路。(较为hack)

  1. 重写window.historyreplaceState/pushState方法。
  2. 在重写的方法中执行回调。
  3. 如果要监听游览器的前进/后退事件,还需要监听popstate事件。

重写replaceState/pushState方法

enum HistoryEventType {
  pushState = 'pushState',
  replaceState = 'replaceState' 
}

export const rewriteHistoryPushState = (type: HistoryEventType, callback: () => void) => {
  const prevHistoryFn = window.history[type];
  window.history[type] = (...args: any[]) => {
    const ret = prevHistoryFn.apply(window.history, args as any);
    callback();
    return ret;
  }
}

重写的方法中执行回调

rewriteHistoryChangeStateFn(HistoryEventType.pushState, () => {
  console.log('custom handler');
});

监听popstate事件

window.addEventListener('popstate', () => {
    console.log('handler');
});

上述基本就可以实现一个简单的单页面history路由监听。

链接:codesandbox.io/s/condescen…

效果图: image.png