搞不懂路由跳转?带你了解 history.js 实现原理

22,613 阅读10分钟

本系列作为 SPA 单页应用相关技术栈的探索与解析,路由管理在单页应用中的重要性不言而喻,而路由的跳转与拦截等操作都依赖于 history API。本系列对 react-router 方案中使用的 history.js 与相关技术进行解析。

说到当前市面上流行的 React 路由管理方案,大家一定会想到 react-router。其底层依赖于 history.js ,其所提供的能力本质上又是对 History API 的二次封装,同时增添了监听路由变化与阻塞路由跳转的能力。本文我们基于当前最新的稳定版本 v5.3.0 的 history.js 进行分析。

聊聊路由和页面跳转的区别

许多小伙伴可能会有疑问:同样要实现针对不同路径展示不同UI组件,那路由跳转和页面跳转有什么区别呢?

这就要讲到单页应用(SPA)与多页应用(MPA)的区别了:多页应用意味着项目存在多个 HTML,url 变化时,加载新的 HTML 实现页面跳转;而单页应用只存在一个主 HTML,通过 js 脚本中对 url 变化的监听,实现不同页面的组件加载,这也就是我们所称的路由管理。

我们熟知的问卷星就是典型的多页应用,可以看到切换 Tab 时会重新加载页面以及 HTML:

而腾讯文档收集表项目则是单页应用,切换 Tab 时 url 上的 hash 会变化,展示不同页面组件,页面不会重新加载,且始终只有一个 HTML 文件:

路由会话 History 管理实例

对于基于 react-router 进行路由管理的项目中,路由操作都封装 history.js 中,通过生成的 history 实例来操作并记录 url 及对应状态的变化。主要有三种方式:

Hash History

如腾讯文档收集表项目,通过管理与操作 location.hash 来加载对应页面,可以通过如下方法创建实例对象:

import { createHashHistory } from 'history';
const history = createHashHistory({ window });

其中 window 属性可选,默认为 document.defaultView。

很简单吧,history 对象也就是当前封装后的 history 实例了,我们在项目中通过调用 history.push、history.replace、history.back 等方法就可以操作 url 的 hash 来实现路由跳转了!

Browser History

类似于常规的微前端方案,根据 location.pathname/search/hash 共同管理页面,可以通过如下方法创建:

import { createBrowserHistory } from 'history';
const history = createBrowserHistory({
  // 指定 history 对象为 iframe 内的 window 的会话 History 管理实例
  window: iframe.contentWindow,
});

同样,window 属性默认为 document.defaultView。该方法和 hash 路由十分相似,唯一的区别就是路由的跳转从 hash 变为了 pathname。

Memory History

上述两种方案都需要借助浏览器的能力,需要借助 window.history 的原生能力。但对于无浏览器的环境下,例如 Native 环境中,我们就可以考虑在内存中维护当前页面地址的索引,以达到和 url 类似的效果。而 Memory 路由就是借助了这一思路实现的。

import { createMemoryHistory } from 'history';
const history = createMemoryHistory({
  initialEntries: ['/'],
  initialIndex: 0,
});

由于三种方式的核心逻辑相同,为了不给大家增加太多的心智负担,本文仅针对 browser history 进行分析。

基于 browser history 的路由管理原理

首先我们解读 createBrowserHistory 主逻辑:

export function createBrowserHistory(
  options: BrowserHistoryOptions = {}
): BrowserHistory {
  let { window = document.defaultView! } = options;
  let globalHistory = window.history;
  // 省略大量逻辑...
  let history: BrowserHistory = {
    get action() { return action },
    get location() { return location },
    createHref,
    push,
    replace,
    go,
    back() { go(-1) },
    forward() { go(1) },
    listen(listener) { // 省略部分逻辑 },
    block(blocker) { // 省略部分逻辑 }
  };
  return history; 
}

可以看到,返回的 history 实例主要包含的方法都是我们熟悉的,和 Web 的 History Api 也基本保持一致。

此外,函数中的其他主要执行逻辑如下:

export function createBrowserHistory() {
  let { window = document.defaultView! } = options;
  let globalHistory = window.history;
  
  let blockedPopTx: Transition | null = null;
  function handlePop() { /* 省略逻辑 */ }
  window.addEventListener(PopStateEventType, handlePop);
  
  // 初始行为默认为 POP
  let action = Action.Pop;
  let [index, location] = getIndexAndLocation();
  let listeners = createEvents<Listener>();
  let blockers = createEvents<Blocker>();
  
  if (index == null) {
    index = 0;
    globalHistory.replaceState({ ...globalHistory.state, idx: index }, '');
  }

  // 省略大量逻辑...
  
  return history; 
}

为了更深入地理解,我们对上面的逻辑简单拆分为三个部分:

操作与 Location 状态字段维护与初始化

在 createBrowserHistory 函数内部,维护了三个重要的字段:

  • action:上一个执行的操作类型,可能为 POP、PUSH、REPLACE

  • index:用于记录当前操作的历史条目的位置,即当前页面的 history 栈中的第几次操作

  • location:当前操作的 location 对象,用于记录 url 与 location 相关的信息。

getIndexAndLocation 方法在 browser history 中,截取了 window.location 对象,对 pathname,search 与 hash 字段进行了封装,连同 history.state.idx 字段一起返回:

function getIndexAndLocation(): [number, Location] {
  let { pathname, search, hash } = window.location;
  let state = globalHistory.state || {};
  return [
    state.idx,
    readOnly<Location>({
      pathname,
      search,
      hash,
      state: state.usr || null,
      key: state.key || 'default'
    })
  ];
}

值得注意的是,window.history.state 可能是 null,所以在执行 createBrowserHistory 创建 history 对象时,这里生成的 index 值应该为 undefined :

对于这种场景,上面代码块中 if (index == null) 操作做了规范了初始化时的 index 为 0,且为 window.history.state 属性增添了从 0 开始计数的 idx 字段属性。

image.png

事件监听

这一步也是路由 history 管理中的核心,逻辑稍微有些绕,不理解也没关系,等我们分析完了其余部分,会回来仔细讲这里的执行逻辑的。现在,我们只需要知道,在调用 createBrowserHistory 创建 history 实例时,对 popstate 事件做了监听就好。

初始化监听队列与阻塞队列

对于 listeners 与 blockers,则是创建了类数组的数据结构,用于储存注册的监听函数/阻塞函数:

function createEvents<F extends Function>(): Events<F> {
  let handlers: F[] = [];
  return {
    get length() { return handlers.length },
    // 增添新的 fn,返回值为函数,调用后将这个 fn 从 handlers 中移除
    push(fn: F) {
      handlers.push(fn);
      return function () {
        handlers = handlers.filter((handler) => handler !== fn);
      };
    },
    // 依次调用 handlers 中的方法
    call(arg) {
      handlers.forEach((fn) => fn && fn(arg));
    }
  };
}

browser history 是如何实现跳转、监听、拦截的

首先,要理解 browser history 的原理,我们需要对浏览器原生 History API 有较为全面的理解。不了解的同学可以先看看 MDN 中的文档:developer.mozilla.org/zh-CN/docs/…

在分析 broswer history 的 API 实现之前,我们先来看几个工具函数,这对我们理解后面的逻辑十分有用:

getNextLocation:

// 根据传入的新地址解析出对应的 Location 对象
function getNextLocation(to: To, state: any = null): Location {
  // 生成不可变对象
  return readOnly<Location>({
    pathname: location.pathname,
    hash: '',
    search: '',
    // parsePath 是将 string 类型 url 解析为类 Location 对象字段,省略
    ...(typeof to === 'string' ? parsePath(to) : to),
    state,
    key: createKey()  // 生成随机 key
  });
}

getHistoryStateAndUrl:

// 根据新的 Location 对象获取 url 和 State 对象
function getHistoryStateAndUrl(
  nextLocation: Location,
  index: number
): [HistoryState, string] {
  return [
    {
      usr: nextLocation.state,
      key: nextLocation.key,
      idx: index
    },
    // 解析 Location 对象,拼出 URL
    createHref(nextLocation)
  ];
}

allowTx & applyTx:

// 如果没有 blockers 返回 true,否则依次执行 blockers,并返回 false
function allowTx(action: Action, location: Location, retry: () => void) {
  return (
    !blockers.length || (blockers.call({ action, location, retry }), false)
  );
}

// 执行 Action:同步设置全局 action、index、location 字段,随后调用 listeners
function applyTx(nextAction: Action) {
  action = nextAction;
  [index, location] = getIndexAndLocation();
  listeners.call({ action, location });
}

重头戏来了,有了上面的分析基础,我们可以来看看跳转、监听、拦截是如何实现的了。

监听

注册监听函数很简单,生成的 history 实例对象调用 listen 方法,参数中传入监听函数即可。返回结果为取消监听的函数:

unlisten = history.listen(() => alert(222));
unlisten();

拦截

注册拦截函数的方法和监听函数相同:

unBlock = history.block(() => alert(333));
unBlock();

不同之处在于,在注册了 blocker 函数之后,需要监听 beforeunload 事件,进而在关闭浏览器窗口时页面取消注册的事件:

跳转

history.go

很简单,就是调用了 window.history.go 方法,支持传入 delta 参数指定浏览器走几步。

history.replace

可以看到,其本质是调用了 window.history.replaceState 方法,如果注册了 blockers,则不会进行跳转,函数内部状态也将不变。只有没有注册 blockers 时才会继续执行,且执行 listeners。

history.push

类似的,push 方法实际上是对 window.history.pushState 的封装,其余逻辑同上。不同点在于 push 对于 history 调用栈来说,相当于给它增加了一个 【state 对象与对应的 url】;而 replace 则是替换栈顶的【state 对象与对应的 url】:

值得注意的是,window.history 的 pushState 与 replaceState 方法的主要目的是为了修改或增添 history.state 对象,并通过栈的方式组织其关系。虽然在大多场景下 url 也会相应改变,但也可以实现不修改任何 url 的情况下修改 state 。这时,我们仍然可以点击浏览器左上角的【返回】与【前进】(也就是 history.go 方法),只不过 url 和页面并不一定改变。

所以,上述的 push 与 replace 方法,实际上都是对 history.state 对象进行操作,可以视作对 history 栈的修改。而 go, back, forward 以及浏览器的【返回】、【前进】按钮,则是在已有的 history 栈上游走,只有 history.go 时,才会触发浏览器的 popstate 事件。

回看 popstate 事件的玄机

在前面的【事件监听】小节,createBrowserHistory 对 popstate 事件注册了回调函数 handlePop,它主要的作用是获取 POP 操作后的 history.state 和 url 信息,并根据是否存在 blockers 决定 1)执行阻塞函数,还是 2)触发监听函数且更新全局 location 状态

let blockedPopTx: Transition | null = null;
function handlePop() {
  // 首次触发 popstate 时,blockedPopTx 为空
  // 不走 if 分支内的逻辑,进入 else 分支
  // 此处出现在 设置了阻塞函数,且回撤掉了之前的 POP,需要借助 blockedPopTx 执行阻塞器
  if (blockedPopTx) {
    blockers.call(blockedPopTx);
    blockedPopTx = null;
  } else {
    // 因为是 popstate 事件,所以指定下一个 Action 为 POP
    let nextAction = Action.Pop;
    // 从现在的 history.state 与 url 生成当前历史条目的对应的 index 和 location 对象
    // 注意,只有调用了 applyTx 才会将 action、index、location 设置到全局,这里的 nextAction, nextIndex, nextLocation 都只是暂存在局部
    let [nextIndex, nextLocation] = getIndexAndLocation();

    // 如果设置了阻塞器,说明当前 POP 操作需要被拦截且回撤掉
    if (blockers.length) {
      if (nextIndex != null) {
        // 计算当前历史条目和之前的差值,这个值代表着我们需要回撤几步
        // 例如,index = 3, nextIndex = 5 -> delta = 2
        // 说明前一个操作的 index 为 3,当前操作的 index 为 5,意味着 POP 操作让我们从 3 -> 5
        // 有 blocker 的情况下,就需要再从 5 退回 3,所以需要走下面的 go(2) 回到原来的状态
        let delta = index - nextIndex;
        if (delta) {
          blockedPopTx = {
            action: nextAction,
            location: nextLocation,
            retry() {
              go(delta * -1);
            }
          };

          // 因为走了 go 方法后又会触发 popstate,而此时的 blockedPopTx 已经附带了当前的 state 与 location 信息
          // 交给函数开头的 blockers 执行即可
          go(delta);
        }
      } else {
        // 在某些场景下的 history.state 中没有 idx 字段
        // 这可能是因为使用的 history 并不是 createBrowserHistory 创建的
        // 这种场景暂时不做讨论
      }
    } else {
      // 如果没有注册阻塞函数,走 applyTx
      // 更新全局 action,index,location 为新的 history.state 与 url 生成当前历史条目
      applyTx(nextAction);
    }
  }
}

注释部分详细讲解了整个流程,如果还是觉得不够清晰,可以参考如下流程图:

以上,我们就较为全面地分析了 history.js 中三种不同的 History 类型以及 createBrowserHistory 的实现。本质上还是对 History API 的封装,同时结合对 popstate 事件的监听,基于 history.state 与 url 的角度实现路由监听、跳转与阻塞。

了解了 Browser History 的实现原理,我们就理解了 history.js 的核心逻辑。其余两种类型 createHashHistory 与 createMemoryHistory 的主线逻辑相同,我们将在下一篇文章对三者的差异部分进行分析。

参考文章

juejin.cn/post/702179…

juejin.cn/post/684490…

developer.mozilla.org/zh-CN/docs/…

developer.mozilla.org/zh-CN/docs/…