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

lxf2023-02-16 15:48:46

本系列作为 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:

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

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

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

路由会话 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 :

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

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

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

事件监听

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

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

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

对于 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.js 实现原理

跳转

history.go

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

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

history.replace

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

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

history.push

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

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

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

值得注意的是,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.js 中三种不同的 History 类型以及 createBrowserHistory 的实现。本质上还是对 History API 的封装,同时结合对 popstate 事件的监听,基于 history.state 与 url 的角度实现路由监听、跳转与阻塞。

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

参考文章

Admin.net/post/702179…

Admin.net/post/684490…

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

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