react-routerV6依赖的history库源码分析

3,140 阅读16分钟

前言

最近开始阅读react-routerV6的源码,一开始接触到一个名为history的第三方库(下称history库),后面慢慢发现react-router无论是V5版本还是V6版本的设计中很大程度依赖于这个库,因此我先把注意力转移到history库上进行学习。下面就写一下我对这个库的学习总结。

截止到文章收笔的今天,react-routerV6的版本已经更新到6.0.0-beta.7,但从react-router``v6alphav6beta一直都依赖history库V5版本,而history库V5目前只更新到5.0.1,可见目前history库5.0.1已经基本可以说是稳定版本了。因此,这篇文章分析的history库版本为5.0.1

注意:不同版本的react-router所依赖的history库版本也不同。react-router的v5和v4版本依赖history库的v4版本,react-routerV6正式版虽然还没出,但history库官方已宣称react-routerV6将依赖history库V5

作用

关于history库的作用,可以引述官方的介绍来说明:

history库可以令你更轻松地管理在JavaScript环境下运行的会话历史(session history)。一个history实例抽象了各种环境中的差异,且提供了最简洁的API去管理会话中的历史栈(history stack)、导航(navigate)以及持久化的状态(persist state)。

通常我们在管理页面路由方面有两种模式:hash模式history模式。如果要我们手动去实现的话,hash模式需要通过用window.onhashchange监听location.hash的变化实现。history模式需要通过window.historywindow.onpopstate实现。而history库提供了针对两种模式下的简洁的管理方式, 我们只需要根据项目的需求获取hash模式history模式下的管理实例。调用这个管理实例下统一的API(如pushreplace等)就可以做到对url的动态改变。

而且,history库也弥补了原生的window.history一些不尽人意的效果,例如popstate事件的触发条件:

调用history.pushState()或者history.replaceState()不会触发popstate事件. popstate事件只会在浏览器某些行为下触发, 比如点击后退、前进按钮(或者调用history.back()history.forward()history.go()方法)

由于很多人都没单独使用过history库,因此接下来,我会采用一边介绍API一边分析源码的方式来揭秘history库的原理。

用法以及源码分析

实例化

用法

首先,我们需要获取一个会话历史管理实例history instance)来执行后续的操作。history库提供了三个对应不同模式的方法来创建会话历史管理实例

  • createBrowserHistory:用于创建history模式下的会话历史管理实例
  • createHashHistory:用于创建hash模式下的会话历史管理实例
  • createMemoryHistory:用于无浏览器环境下,例如React Native和测试用例

接下来我们先以history模式进行分析,获取会话历史管理实例的代码如下:

// 有两种获取实例的方式:
// 1. 通过函数创建实例
import { createBrowserHistory } from 'history';
const history = createBrowserHistory();

// 如果要创建管理iframe历史的实例,可以把iframe.window作为形参传入该函数上
const iframeHistory = createBrowserHistory({
  window: iframe.contentWindow
});

// 2. 通过导入获取对应的单例
import history from 'history/browser';

源码分析

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; 
}

其实createBrowserHistory内部就是生成pushreplacelisten等方法,然后挂到一个刚生成的普通对象的属性上,最后把这个对象返回出去,因此会话历史管理实例其实就是一个包含多个属性的纯对象而已。

对于第二种获取实例的方式,我们可以看一下history/browser的代码:

import { createBrowserHistory } from 'history';

export default createBrowserHistory();

其实就是用createBrowserHistory创建了实例,然后把该实例导出,这样子就成为了单例模式的导出。

获取location

用法

// 可通过history.location获取当前路由地址信息
let location = history.location;

location是一个纯对象,代表当前路由地址,包含以下属性:

  • pathname:等同于window.location.pathname
  • search:等同于window.location.search
  • hash:等同于window.location.hash
  • state:当前路由地址的状态,类似但不等于window.history.state
  • key:代表当前路由地址的唯一值

源码分析

我们看一下hisoory.location的相关源码

const readOnly: 
  // ts类型定义
  <T extends unknown>(obj: T) => T 
  /**
   *  __DEV__在开发环境下为true
   *  在开发环境下使用Object.freeze让obj只读,
   *  不可修改其中的属性和新增属性
   */
  = __DEV__? obj => Object.freeze(obj): obj => obj;
export function createBrowserHistory(): BrowserHistory {
  // 获取index和location的方法
  // index代表该历史在历史栈中的第几个
  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'
      })
    ];
  }

  let [index, location] = getIndexAndLocation();

  let history: BrowserHistory = {
    get location() {
      return location;
    },
    // ...
  }

  return history;
}

getIndexAndLocation中可以看出location中的pathnamesearchhash取自window.locationstatekey分别取自window.history.state中的keyusr。至于window.history.state中的keyusr是怎么生成的,我们留在后面介绍push方法时介绍。

listen

用法

history.listen用于监听当前路由(history.location)变化。作为形参传入的回调函数会在当前路由变化时执行。示例代码如下:

// history.listen执行后返回一个解除监听的函数,该函数执行后解除对路由变化的监听
let unlisten = history.listen(({ location, action }) => {
  console.log(action, location.pathname, location.state);
});

unlisten();

history.listen中传入的回调函数中,形参是一个对象,解构该对象可以获取两个参数:

  • location:等同于history.location
  • action:描述触发路由变化的行为,字符串类型,有三个值:
    1. "POP": 代表路由的变化是通过history.gohistory.backhistory.forward以及浏览器导航栏上的前进和后退键触发。
    2. "PUSH":代表路由的变化是通过history.push触发的。
    3. "REPLACE":代表路由的变化是通过history.replace触发的。 此值可以通过history.action获取

源码分析

// 基于观察者模式创建一个事件中心
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));
    }
  };
}

let listeners = createEvents<Listener>();

let history: BrowserHistory = {
  // ...
  listen(listener) {
    return listeners.push(listener);
  },
}

至于为啥回调函数的形参可以解构出actionlocation。可以看下面介绍push的源码分析。

push

用法

用过Vue-RouterReact-Router都知道,push就是把新的历史条目添加到历史栈上。而history.push也是同样的效果,实例代码如下:

// 改变当前路由
history.push('/home');
// 第二参数可以指定history.location.state
history.push('/home', { some: 'state' });
// 第一参数可以是字符串,也可以是普通对象,在对象中可以指定pathname、search、hash属性
history.push({
  pathname: '/home',
  search: '?the=query'
}, {
  some: state
});

源码分析

// 上面说到history.push的第一参数除了字符串还可以是含部分特定属性的对象
// 此函数的作用在于把此对象转化为url字符串
export function createPath({
  pathname = '/',
  search = '',
  hash = ''
}: PartialPath) {
  return pathname + search + hash;
}

// 根据传入的to的数据类型创建链接字符串
function createHref(to: To) {
  return typeof to === 'string' ? to : createPath(to);
}

// 此函数用于根据传入的location获取新的history.state和url
function getHistoryStateAndUrl(
  nextLocation: Location,
  index: number
): [HistoryState, string] {
  return [
    {
      usr: nextLocation.state,
      key: nextLocation.key,
      idx: index
    },
    createHref(nextLocation)
  ];
}

function push(to: To, state?: State) {
  // Action是一个TypeScript中枚举类型的数据,有三个值:Push、Pop、Replace,
  // 分别对应字符串'PUSH'、'POP'、'PUSH'
  // Action用于描述触发路由改变行为,与history.action一样
  let nextAction = Action.Push;
  // 根据传入的to生成下一个location
  let nextLocation = getNextLocation(to, state);
  // 用于重试
  function retry() {
    push(to, state);
  }
  // allowTx主要与history.block相关,该API在后面的章节会介绍,
  // 我们这里先默认它为true然后往下阅读
  if (allowTx(nextAction, nextLocation, retry)) {
    // 根据新的location生成新的historyState和url
    // 注意此处index + 1,会赋值于historyState.idx,
    // 在globalHistory.pushState更新state后,再次调用getIndexAndLocation时,
    // 由于getIndexAndLocation是根据globalHistory.state生成index和location的,
    // 因此得出的index会比之前的多1
    let [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);
    // 之所以用try~catch包裹着是因为iOS中限制pushState的调用次数为100次
    try {
      globalHistory.pushState(historyState, '', url);
    } catch (error) {
      // 刷新页面,该写法等同于window.location.href = url
      // 官方源码注释:此处操作会失去state,但没有明确的方法警告他们因为页面会被刷新
      window.location.assign(url);
    }
    // 触发listeners执行注册其中的回调函数
    applyTx(nextAction);
  }
}

function applyTx(nextAction: Action) {
  // 更新history.action
  action = nextAction;
  // 更新index和location,在history.pushState过后得出的index比之前的多1
  [index, location] = getIndexAndLocation();
  // 触发listeners中注册的回调函数的执行
  // 此处传入的参数{ action, location }会作为形参传入到回调函数中
  listeners.call({ action, location });
}

replace

用法

Vue-RouterReact-Router类似,replace用于把当前历史栈中的历史条目替换成新的历史条目。传入的参数和history.push一样。这里就不展示代码实例了。

源码分析

function replace(to: To, state?: State) {
  // 定义行为为"REPLACE"
  let nextAction = Action.Replace;
  let nextLocation = getNextLocation(to, state);
  function retry() {
    replace(to, state);
  }

  if (allowTx(nextAction, nextLocation, retry)) {
    // 注意此处getHistoryStateAndUrl的第二参数为index,而push的是index+1
    // 是因为replace只是生成新的历史条目替代现在的历史条目
    let [historyState, url] = getHistoryStateAndUrl(nextLocation, index);

    globalHistory.replaceState(historyState, '', url);

    applyTx(nextAction);
  }
}

history.replacehistory.push的源码基本一致,只是在index的更新和调用window.history的原生API方面有所不同,我这里就重复了。

block

用法

history.block是相比于原生的window.history中新增的一个很好的特性。它能够在当前路由即将变化之前,执行回调函数从而阻塞甚至拒绝更改路由。看一下示例代码:

let unblock = history.block((tx:{nextAction, nextLocation, retry}) => {
  if (window.confirm(`你确定要离开当前页面吗?`)) {
    // 移除阻塞函数,这里必须要移除,不然该阻塞函数会一直执行
    unblock();
    // 继续执行路由更改操作
    tx.retry();
  }
});

页面效果如下:

block.gif

源码分析

// 此方法在beforeunload事件触发时执行,beforeunload事件是在浏览器窗口关闭或刷新的时候才触发的
// 该方法中做了两件事:
//    1. 调用event.preventDefault()
//    2. 设置event.returnValue为字符串值,或者返回一个字符串值
// 此时达成的效果是,页面在刷新或关闭时会被弹出的提示框阻塞,直至用户与提示框进行交互
function promptBeforeUnload(event: BeforeUnloadEvent) {
  // 阻止默认事件
  event.preventDefault();
  // 官方注释:Chrome和部分IE需要设置返回值
  event.returnValue = '';
}
// 这里可以看出,blockers和listeners一样是一个观察者模式的事件中心
let blockers = createEvents<Blocker>();

const BeforeUnloadEventType = 'beforeunload';

let history = {
  // ...
  block(blocker) {
    // 往blockers中注册回调函数blocker
    let unblock = blockers.push(blocker);
    // blockers中被注册回调函数时,则监听'beforeunload'以阻止默认操作
    if (blockers.length === 1) {
      window.addEventListener(BeforeUnloadEventType, promptBeforeUnload);
    }

    return function() {
      unblock();
      // 当blockers中注册的回调函数数量为0时,移除事件监听
      // 官方注释:移除beforeunload事件监听以让页面文档document在pagehide事件中是可回收的
      if (!blockers.length) {
        window.removeEventListener(BeforeUnloadEventType, promptBeforeUnload);
      }
    };
  }
}

history.block的源码中,只编写了关于beforeunload事件监听的注册和注销。而beforeunload事件是在浏览器窗口关闭或刷新的时候才触发的,beforeunload事件触发后执行传入的回调函数promptBeforeUnload,此时会达到下面的效果:

beforeunload-block.gif

要注意一点,上面的效果图中的弹窗内容是不可自定义的,根据history.block源码中的内容可以推断出,history.block中的形参blocker是不会参与手动刷新页面或者跳转页面时的阻塞交互的。history.block做到的是,在blockers阻塞事件中心的回调函数不为空时,进行上述两个操作(手动刷新页面或者跳转页面)时,就会按照上面的效果图进行阻塞,而阻塞事件中心的回调函数为空时,则不会阻塞。

这么一说,history.block中传入的回调函数blocker只会影响到history.pushhistory.replacehistory.go这些history库中定义的用于改变当前路由的API。这里我们先研究history.block怎么影响到history.pushhistory.replace的。至于如何影响history.go的就放在下面介绍history.go时说明。

首先再次看一下history.push中的源码:

function push(to: To, state?: State) {
    let nextAction = Action.Push;
    let nextLocation = getNextLocation(to, state);
    function retry() {
        push(to, state);
    }
    // 上一次说到allowTx是用于处理history.block中注册的回调函数的。
    // 这里可以看出,allowTx返回true时才会往历史栈添加新的历史条目(globalHistory.pushState)
    if (allowTx(nextAction, nextLocation, retry)) {
        let [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);
        try {
            globalHistory.pushState(historyState, '', url);
        } catch (error) {
            window.location.assign(url);
        }
        applyTx(nextAction);
    }
}

function allowTx(action: Action, location: Location, retry: () => void) {
    return (
        !blockers.length || 
        // 此处(xxx,false)的格式用于保证无论xxx返回什么,最后该条件的值还是false
        (blockers.call({ action, location, retry }), false) 
    );
}

allowTx可知,只有blockers中注册的的回调函数数量为0(blockers.length)时,allowTx才会返回true。当在开头用法中的示例代码是这么写的:

let unblock = history.block(tx => {
  if (window.confirm(`你确定要离开当前页面吗?`)) {
    unblock();
    tx.retry();
  }
});

window.confirm形成的弹出框被用户点击确定后,会进入if语句块,从而执行unlockretryunlock用于注销该注册函数,retry用于再次执行路由更改的操作。在retry执行后,会再次调用push函数,然后再次进入allowTx函数,然后执行被history.block注册的阻塞函数。

在存在多个被注册的阻塞函数,且history.push被调用时,会有以下流程:

  1. 进入history.push函数
  2. 进入allowTx函数,有两种情况:
    • blockers中含被阻塞回调函数时:执行其中一个注册的阻塞回调函数,然后到下面第3步👇
    • blockers中不含被阻塞回调函数时,allowTx函数返回true,然后到下面第4步👇
  3. 阻塞回调函数中调用unlock把自己从blockers中移除,然后调用retry回到第1步,一直重复1~3直到blockers中的阻塞函数被清空。
  4. 调用globalHistory.pushState更改路由

注意:实现以上流程需要我们在每个被注册的阻塞回调函数中必须写调用unlockretry的逻辑。

go

用法

类似的,我们可以推理出history.go的作用是基本当前历史条目在历史栈中的位置去前进或者后退到附近的历史条目上。如:

// 下面两条语句实现的效果都一样,都是后退到前面的历史条目上
history.go(-1);
history.back();

源码分析

function go(delta: number) {
  globalHistory.go(delta);
}

let history: BrowserHistory = {
  // ...
  go,
  back() {
    go(-1);
  },
  forward() {
    go(1);
  },
}

从源码看出,gobackforward其实就是间接性地调用globalHistory.go那么现在问题来了,使用go可以触发在history.listenhistory.block中注册的回调函数吗?

答案是可以的,我们看一下源码中下面这部分的代码:

const PopStateEventType = 'popstate';
// 前面也说过,'popstate'是在调用window.history.go或者点击浏览器的回退前进按钮才触发的
window.addEventListener(PopStateEventType, handlePop);
// handlePop主要用于在popstate触发后,触发阻塞事件中心blockers和监听事件中心listeners
function handlePop() {
  if (blockedPopTx) {
    blockers.call(blockedPopTx);
    blockedPopTx = null;
  } else {
    let nextAction = Action.Pop;
    let [nextIndex, nextLocation] = getIndexAndLocation();

    if (blockers.length) {
      if (nextIndex != null) {
        let delta = index - nextIndex;
        if (delta) {
          // Revert the POP
          blockedPopTx = {
            action: nextAction,
            location: nextLocation,
            retry() {
              go(delta * -1);
            }
          };

          go(delta);
        }
      } else {
        // Trying to POP to a location with no index. We did not create
        // this location, so we can't effectively block the navigation.
        warning(
          // ...打印内容就不展示了
        );
      }
    } else {
      applyTx(nextAction);
    }
  }
}

我们假设popstate刚被触发的情况下去逐步分析handlePop的执行。首先,blockedPopTx为空,所以会执行下面的操作:

// 定义行为为"POP"
let nextAction = Action.Pop;
// 从现在的historyState生成当前历史条目的index和location
let [nextIndex, nextLocation] = getIndexAndLocation();

// 如果有注册阻塞函数,则进入该语句执行
if (blockers.length) {
  // 极少数情况存在某些历史条目的state中没记录idx即index,但这种情况我们不做讨论
  if (nextIndex != null) {
    // 根据delta可以知道当前历史条目的位置nextIndex和上一次的历史条目的位置index
    // nextIndex:当前历史条目的位置
    // index:上一次的历史条目的位置,调用history.go或者点击导航栏前后键时,
    // history中的index不会立即被改变,只有执行了applyTx才会改变index和location
    // 因此,delta即是表示上述两个索引的差值
    let delta = index - nextIndex;
    if (delta) {
      // blockedPopTx用于在blockers.call时作为参数传入
      blockedPopTx = {
        action: nextAction,
        location: nextLocation,
        // 注意这里的retry里的逻辑,retry的调用可是做放行,此时调用go是为了让页面回到原位,
        // 而retry的内部是调用go然后跳转到相对目前的第(delta * -1)的历史条目,
        // 结合下面的go(delta)可知,在赋值了blockedPopTx后,通过go(delta)跳回到上一个历史条目上,
        // 由此再次触发popstate事件,导致handlePop再次被执行
        retry() {
          go(delta * -1);
        }
      };

      go(delta);
    }
  } else {
    // ...这部分内容不作探讨
  }
} 
// 如果没有注册阻塞函数,则执行applyTx,执行监听回调函数的同时更新index和location
else { 
  applyTx(nextAction);
}

根据上面的注释,当blockedPopTx被赋值后,通过go(delta)再次触发popstate事件。继而导致handlePop再次被执行。此时会执行handlePop下面的逻辑:

if (blockedPopTx) {
  // 就是触发阻塞事件中心blockers去执行注册的阻塞函数
  blockers.call(blockedPopTx);
  blockedPopTx = null;
}

当阻塞函数被执行时,陆续执行内部的unlockretry后。又会达到类似上一章block.源码分析章节中说到的循环的流程,如下所示:

  1. 初次popstate事件被触发,执行handlePop,因blockedPopTx为空,在此会有两种情况:
    • 存在被注册的阻塞回调函数:此时,在赋值赋值blockedPopTx后通过调用history.go再次触发popstate事件,继而到下面👇第2步
    • 不存在被注册的阻塞回调函数:调用applyTx以执行监听回调函数的同时更新index和location
  2. popstate事件再次被触发,此时因blockedPopTx已被赋值,因此触发blockers轮询执行注册其中的阻塞函数,继而到下面👇第3步
  3. 阻塞函数在用户交互中陆续调用unlockretry,但轮询不会因为retry的调用而中断,因为history.go是异步的(来源:MDN History.go),因此不会立即执行。且值得注意的是:**在一个宏任务或微任务中,无论history.go(-1)被执行多少次,最终达到的效果只是路由只会回到前一次的路由,而不是多次。**因此retry在轮询过程中被阻塞注册函数调用多次不会影响最终只回到相对现在距离delta的页面的效果。轮询结束后,blockedPopTx被置为null值。然后到下面👇第4步。
  4. 轮询结束后,因为retry的调用,页面再次跳转。在依次经历了history.go(delta*-1)hsitory.go(delta)后,页面回到初次触发popstate时所在的历史条目。而popstate事件再次被触发,此时会回到上面👆第1步的不存在被注册的阻塞回调函数的情况。

流程比较巧妙,看不懂可以多在脑子里想象一下流程。


基本history库history模式下的所有API用法和分析都写完了。另外的两种模式其实大同小异,读者可以自行阅读。

后记

之后会更新一篇分析react-routerV6源码的文章,由于react-routerV6目前还不存在稳定版本,因此,我会一直观察到可以执笔写下文章的时候。