你居然不知道React Router用到了history!!

6,344 阅读18分钟

前言

React Router 中很大程度上地依赖了 history 的功能,如 useNavigateuseHrefRouter 等都直接或间接地用到了 history,所以我们在分析 React Router 源码之前,有必要深入了解下 history 的用法,相信您看完本篇文章之后,能学到很多之前不知道的东西。写下本篇文章时的 history 版本是 latest 的,为 5.0.1,那废话不多说,让我们开始探索之旅吧~

history 分类

history 又分为 browserHistoryhashHistory,对应 react-router-dom 中的 BrowserRouterHashRouter, 这两者的绝大部分都相同,我们先分析下 browserHistory,后面再点出 hashHistory 的一些区别点。

createBrowserHistory

顾名思义,createBrowserHistory 自然是用于创建 browserHistory 的工厂函数,我们先看下类型

export interface BrowserHistory<S extends State = State> extends History<S> {}

export interface History<S extends State = State> {
  /**
   * @description 上一个修改当前 location的action,有 `POP`、`PUSH`、`REPLACE`,初始创建为POP
   */
  readonly action: Action;

  /**
   * @description 当前location
   */
  readonly location: Location<S>;

  /**
   * @description 返回一个新的href, to为string则返回to,否则返回 `createPath(to)` => pathname + search + hash
   */
  createHref(to: To): string;

  /**
   * @description push一个新的location到历史堆栈,stack的length会+1
   */
  push(to: To, state?: S): void;

  /**
   * @description 将历史堆栈中当前location替换为新的,被替换的将不再存在
   */
  replace(to: To, state?: S): void;

  /**
   * @description 历史堆栈前进或后退delta(可正负)
   */
  go(delta: number): void;

  /**
   * @description 同go(-1)
   */
  back(): void;

  /**
   * @description 同go(1)
   */
  forward(): void;

  /**
   * @description 设置路由切换的监听器,`listener`为函数
   *
   * @example
   *
   * const browserHistory = createBrowserHistory()
   * browserHistory.push('/user')
   * const unListen = browserHistory.listen(({action, location}) => {
   *  // 切换后新的action和location,上面push后,这里的action为PUSH, location为 { pathname: '/user', ... }
   *  console.log(action, location)
   * })
   */
  listen(listener: Listener<S>): () => void;

  /**
   * @description 改变路由时阻塞路由变化
   */
  block(blocker: Blocker<S>): () => void;
}

即是说,createBrowserHistory 最终肯定要返回一个上面形状的 browserHistory 实例,我们先看下函数总体概览(这里大致看下框架就好,后面会具体分析)。

export function createBrowserHistory(
  options: BrowserHistoryOptions = {}
): BrowserHistory {
  const { window = document.defaultView! } = options;
  const globalHistory = window.history;

  function getIndexAndLocation(): [number, Location] {}

  let blockedPopTx: Transition | null = null;

  function handlePop() {}

  window.addEventListener(PopStateEventType, handlePop);

  let action = Action.Pop;

  let [index, location] = getIndexAndLocation();

  const listeners = createEvents<Listener>();
  const blockers = createEvents<Blocker>();

  if (index == null) {
    index = 0;
    globalHistory.replaceState({ ...globalHistory.state, idx: index }, '');
  }
  function createHref(to: To) {
    return typeof to === 'string' ? to : createPath(to);
  }

  function getNextLocation(to: To, state: State = null): Location {}

  function getHistoryStateAndUrl(
    nextLocation: Location,
    index: number
  ): [HistoryState, string] {}

  function allowTx(action: Action, location: Location, retry: () => void): boolean {}

  function applyTx(nextAction: Action) {}

  function push(to: To, state?: State) {}

  function replace(to: To, state?: State) {}

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

  const history: BrowserHistory = {
    get action() {
      return action;
    },
    get location() {
      return location;
    },
    createHref,
    push,
    replace,
    go,
    back() {
      go(-1);
    },
    forward() {
      go(1);
    },
    listen(listener) {
      return listeners.push(listener);
    },
    block(blocker) {

      const unblock = blockers.push(blocker);

      if (blockers.length === 1) {
        window.addEventListener(BeforeUnloadEventType, promptBeforeUnload);
      }

      return function() {
        unblock();

        if (!blockers.length) {
          window.removeEventListener(BeforeUnloadEventType, promptBeforeUnload);
        }
      };
    }
  };

  return history;
}

可以看到函数里面又有一些内部函数和函数作用域内的顶层变量,我们先来看下这些顶层变量的作用。

createBrowserHistory 函数作用域内的顶层变量

export function createBrowserHistory(
  options: BrowserHistoryOptions = {}
): BrowserHistory {
  const { window = document.defaultView! } = options;
  const globalHistory = window.history;

  let blockedPopTx: Transition | null = null;

  let action = Action.Pop;

  let [index, location] = getIndexAndLocation();

  const listeners = createEvents<Listener>();
  const blockers = createEvents<Blocker>();
  ...
}

window

createBrowserHistory 接收一个 options,默认为空对象,而其中的window默认为document.defaultView,即是 Window 对象罢了

history

上面获取到 window,然后会从 window 上获取到 history

blockedPopTx

用于存储下面的 blockers.call 方法的参数,即每一个 blocker 回调函数的参数,类型如下

export interface Transition<S extends State = State> extends Update<S> {
  /**
   * 被阻塞了后调用retry可以尝试继续跳转到要跳转的路由
   */
  retry(): void;
}

export interface Update<S extends State = State> {
  /**
   * 改变location的action,有POP、PUSH、REPLACE
   */
  action: Action;

  /**
   * 新location
   */
  location: Location<S>;
}

index 与 location

index 为当前 location 的索引,即是说,history 会为每个 location 创建一个idx,放在state中, 会用于在 handlePop中计算 delta,这里稍微提下,后面分析 handlePop 如何阻止路由变化会讲到

// 初始调用
const [index, location] = getIndexAndLocation();

// handlePop中
const [nextIndex, nextLocation] = getIndexAndLocation();
const delta = index - nextIndex;
go(delta)

action

blockerslisteners 的回调会用到 action,其是通过 handlePoppushreplace 三个函数修改其状态,分别为 POPPUSHREPLACE,这样我们就可以通过判断 action 的值来做出不同的判断了。

listeners 与 blokers

我们先看下 创建 listeners 与 blokers 的工厂函数 createEvents

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

  return {
    get length() {
      return handlers.length;
    },
    push(fn: F) {
      // 其实就是一个观察者模式,push后返回unsubscribe
      handlers.push(fn);
      return function() {
        handlers = handlers.filter(handler => handler !== fn);
      };
    },
    call(arg) {
      // 消费所有handle
      handlers.forEach(fn => fn && fn(arg));
    }
  };
}

其返回了一个对象,通过 push 添加每个 listener,通过 call通知每个 listener,代码中叫做 handler

listeners 通过 call 传入 { action, location }, 这样每个 listener 在路由变化时就能接收到,从而做出对应的判断。

listener 类型如下

export interface Update<S extends State = State> {
  action: Action;
  location: Location<S>;
}

export interface Listener<S extends State = State> {
  (update: Update<S>): void;
}

blockers 通过 call 传入 { action, location, retry },比listeners多了一个 retry,从而判断是否要阻塞路由,不阻塞的话需要调用函数 retry

blocker 类型如下

export interface Transition<S extends State = State> extends Update<S> {
  retry(): void;
}

export interface Blocker<S extends State = State> {
  (tx: Transition<S>): void;
}

知道了顶层变量的作用,那我们接下来一一分析下返回 history 实例对象的每个属性。

action 与 location

  const history: BrowserHistory = {
    get action() {
      return action;
    },
    get location() {
      return location;
    },
    ...
  }

这两个属性都通过 修饰符 get,那么我们每次要获取最新的 action 或 location,就可以通过 history.actionhistory.location 。 避免了只能拿到第一次创建的值,如

const history: BrowserHistory = {
  action,
  location,
}

或需要每次调用函数才能拿到:

const history: BrowserHistory = {
  action: () => action,
  location: () => location,
}

action 我们上面已经分析了,这里我们看下获取 location 的函数。

getIndexAndLocation

即获取当前索引和 location

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

  ...

// createBrowserHistory创建的时候获取初始当前路径index和location
let [index, location] = getIndexAndLocation();

createBrowserHistory 调用的时候会获取初始当前路径 index 和 location,这个时候的 index 肯定是 undefined(请注意要打开新页面才会,否则刷新后还是有历史堆栈,导致 state.idx 有值,即 index 不为空)

所以下面会通过判断 index 是否为空,空的话会给个默认值 0

if (index == null) {
    // 初始index为空,那么给个0
    index = 0;
    // 这里replaceState后,history.state.idx就为0了
    globalHistory.replaceState({ ...globalHistory.state, idx: index }, '');
  }

通过 replaceState初始重置了历史堆栈,从而就能获取到 state 中的 idx 了。

这个时候我们再通过 history.state.idx 就能获取到

history.createHref

history.createHref 来自 createBrowserHistory 的内部函数,接收一个 To 类型的参数,返回值为字符串 href

type To = string | Partial<Path>;
interface Path {
  pathname: string;
  search: string;
  hash: string;
}

function createHref(to: To) {
    return typeof to === 'string' ? to : createPath(to);
 }

如果 to 不为字符串,会通过 createPath 函数转为字符串,即是把 pathname、search 和 hash 拼接起来罢了

export function createPath({
  pathname = '/',
  search = '',
  hash = ''
}: PartialPath) {
  return pathname + search + hash;
}

history.push

function push(to: To, state?: State) {
  const nextAction = Action.Push;
  const nextLocation = getNextLocation(to, state);
  // 跳过,后面blockers会讲到
  function retry() {
    push(to, state);
  }
  // 跳过,后面blockers会讲到,这里我们先默认为true
  if (allowTx(nextAction, nextLocation, retry)) {
    const [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);

    // try...catch because iOS limits us to 100 pushState calls :/
    // 用try  catch的原因是因为ios限制了100次pushState的调用
    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);
    }
    // 跳过,后面listeners会讲到
    applyTx(nextAction);
  }
}

首先会通过 getNextLocation,根据 tostate 获取到新的 location,注意这时候路由还没切换

const nextLocation = getNextLocation(to, state);

/**
 * @description 获取新的Location
 * @param to 新的path
 * @param state 状态
 */
function getNextLocation(to: To, state: State = null): Location {
  return readOnly<Location>({
    ...location,
    ...(typeof to === 'string' ? parsePath(to) : to),
    state,
    key: createKey()
  });
}

如果 to 是字符串的话,会通过parsePath解析对应的 pathname、search、hash(三者都是可选的,不一定会出现在返回的对象中)

/**
 * @example
 * parsePath('https://juejin.cn/post/7005725282363506701?utm_source=gold_browser_extension#heading-2')
 * {
 *   "hash": "#heading-2",
 *   "search": "?utm_source=gold_browser_extension",
 *   "pathname": "https://juejin.cn/post/7005725282363506701"
 * }
 * 从结果可看到,去掉 `hash` 、 `search` 就是 `pathname` 了
 *
 * parsePath('?utm_source=gold_browser_extension#heading-2')
 * {
 *   "hash": "#heading-2",
 *   "search": "?utm_source=gold_browser_extension",
 * }
 * parsePath('') => {}
 * 而如果只有search和hash,那么parse完也没有pathname,这里要特别注意
 *
 * @see https://github.com/ReactTraining/history/tree/master/docs/api-reference.md#parsepath
 */
export function parsePath(path: string) {
  const partialPath: PartialPath = {};

  if (path) {
    const hashIndex = path.indexOf('#');
    if (hashIndex >= 0) {
      partialPath.hash = path.substr(hashIndex);
      path = path.substr(0, hashIndex);
    }

    const searchIndex = path.indexOf('?');
    if (searchIndex >= 0) {
      partialPath.search = path.substr(searchIndex);
      path = path.substr(0, searchIndex);
    }

    if (path) {
      partialPath.pathname = path;
    }
  }

  return partialPath;
}

再根据新的 location 获取新的 state 和 url,而因为是 push,所以这里的 index 自然是加一

const [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);
/** 获取state和url */
  function getHistoryStateAndUrl(
    nextLocation: Location,
    index: number
  ): [HistoryState, string] {
    return [
      {
        usr: nextLocation.state,
        key: nextLocation.key,
        idx: index
      },
      createHref(nextLocation)
    ];
  }

最后调用history.pushState成功跳转页面,这个时候路由也就切换了

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

history.replace

replace和 push 类似,区别只是 index 不变以及调用 replaceState

function replace(to: To, state?: State) {
  const nextAction = Action.Replace;
  const nextLocation = getNextLocation(to, state);
  // 跳过,后面blockers会讲到
  function retry() {
    replace(to, state);
  }

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

    globalHistory.replaceState(historyState, '', url);
    // 跳过,后面listeners会讲到
    applyTx(nextAction);
  }
}

history.go、history.back、history.forward

用于历史堆栈的前进后退,backforward 分别是 是 go(-1)go(1) ,delta 可正负,代表前进后退

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

history.listen

const history: HashHistory = {
  ...
  listen(listener) {
    return listeners.push(listener);
  },
  ...
}

history.listen 可以往 history 中添加 listener,返回值是 unListen,即取消监听。这样每当成功切换路由,就会调用 applyTx(nextAction); 来通知每个 listener,applyTx(nextAction);pushreplacehandlePop 三个函数中成功切换路由后调用。

function push(to: To, state?: State) {
   ...
  // 跳过,后面blockers会讲到,这里我们先默认为true
  if (allowTx(nextAction, nextLocation, retry)) {
    ...
    // 下面会讲到
    applyTx(nextAction);
  }
}

function replace(to: To, state?: State) {
   ...
  if (allowTx(nextAction, nextLocation, retry)) {
    ...
    // 下面会讲到
    applyTx(nextAction);
  }
}

function handlePop() {
  if (blockedPopTx) {
    ...
  } else {
    ...

    if (blockers.length) {
    ...
    } else {
      // // 下面会讲到
      applyTx(nextAction);
    }
  }
}

function applyTx(nextAction: Action) {
  action = nextAction;
//  获取当前index和location
  [index, location] = getIndexAndLocation();
  listeners.call({ action, location });
}

即只要满足 allowTx 返回 true(push 和 replace 函数中) 或没有 blocker(handlePop 函数中) 就能通知每个 listener。那我们看下 allowTx

function allowTx(action: Action, location: Location, retry: () => void): boolean {
  return (
    !blockers.length || (blockers.call({ action, location, retry }), false)
  );
}

allowTx的作用是判断是否允许路由切换,有 blockers 就不允许,逻辑如下:

  • blockers 不为空,那么通知每个 blocker,然后返回 false
  • blockers 为空,返回 true

那么要返回 true 的话就必须满足 blockers 为空,也即是说,listener 能否监听到路由变化,取决于当前页面是否被阻塞了(block)。

history.block

上面我们说有 blocker 就会导致 listener 收不到监听,且无法成功切换路由,那我们看下 block 函数:

const history: BrowserHistory = {
  ...
  block(blocker) {
    // push后返回unblock,即把该blocker从blockers去掉
    const unblock = blockers.push(blocker);

    if (blockers.length === 1) {
      // beforeunload
      // 只在第一次block加上beforeunload事件
      window.addEventListener(BeforeUnloadEventType, promptBeforeUnload);
    }

    return function() {
      unblock();
      // 移除beforeunload事件监听器以便document在pagehide事件中仍可以使用
      // Remove the beforeunload listener so the document may
      // still be salvageable in the pagehide event.
      // See https://html.spec.whatwg.org/#unloading-documents
      if (!blockers.length) {
        // 移除的时候发现blockers空了那么就移除`beforeunload`事件
        window.removeEventListener(BeforeUnloadEventType, promptBeforeUnload);
      }
    };
  }
};

beforeunload

我们发现添加第一个 blocker 时会添加 beforeunload 事件,也就是说只要 block 了,那么我们刷新、关闭页面,通过修改地址栏 url 后 enter 都会弹窗提示:

刷新页面

关闭页面

修改地址栏

刷新会询问重新加载此网站?,而关闭 tab 或修改地址栏后 enter 是提示离开此网站?,这两种要注意区别。

这功能常用在表单提交的页面,避免用户不小心关闭 tab 导致表单数据丢失。

当然如果 unblock 时发现 blockers 为空就会移除 beforeunload 事件了。

history 如何阻止路由切换

说完上面的beforeunload 事件,我们关注下上面跳过的 block 方面的代码

对于 pushreplace,其中都会有一个 retryallowTx,这里我们再看下

function retry() {
  push(to, state);
}

if (allowTx(nextAction, nextLocation, retry)) {
  globalHistory.pushState(historyState, '', url);
  // or
  globalHistory.replaceState(historyState, '', url);
}

function allowTx(action: Action, location: Location, retry: () => void): boolean {
  return (
    !blockers.length || (blockers.call({ action, location, retry }), false)
  );
}

如果我们通过 block 添加了一个 blocker,那么每次 push 或 replace 都会判断到 blocker.length 不为 0,那么就会传入对应的参数通知每个 blocker,之后会返回 false,从而无法进入条件,导致无法触发 pushStatereplaceState,所以点击 Link 或调用 navigate 无法切换路由。

还有另外一个是在 handlePop 中,其在一开始调用 createBrowserHistory 的时候就往 window 上添加监听事件:

// PopStateEventType = 'popstate'
window.addEventListener(PopStateEventType, handlePop);

只要添加了该事件,那我们只要点击浏览器的前进、后退按钮、在 js 代码中调用 history.back()、history.forward()、history.go 方法,点击 a 标签都会触发该事件。

比如我们在 useEffect 中添加一个 blocker(详细代码可查看blocker) ,这段代码的意思是只要触发了上面的行为,那么第一和第二次都会弹窗提示,等到第三次才会调用 retry 成功切换路由

const countRef = useRef(0)
const { navigator } = useContext(UNSAFE_NavigationContext)
useEffect(() => {
  const unblock  = navigator.block((tx) => {
    // block两次后调用retry和取消block
    if (countRef.current < 2) {
      countRef.current  = countRef.current + 1
      alert(`再点 ${3 - countRef.current}次就可以切换路由`)
    } else {
      unblock();
      tx.retry()
    }
  })
}, [navigator])

我们看下 popstate的回调函数 handlePop:

function handlePop() {
  if (blockedPopTx) {
    blockers.call(blockedPopTx);
    blockedPopTx = null;
  } else {
    const nextAction = Action.Pop;
    const [nextIndex, nextLocation] = getIndexAndLocation();

    if (blockers.length) {
      if (nextIndex != null) {
        const 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(
          false,
          // TODO: Write up a doc that explains our blocking strategy in
          // detail and link to it here so people can understand better what
          // is going on and how to avoid it.
          `You are trying to block a POP navigation to a location that was not ` +
            `created by the history library. The block will fail silently in ` +
            `production, but in general you should do all navigation with the ` +
            `history library (instead of using window.history.pushState directly) ` +
            `to avoid this situation.`
        );
      }
    } else {
      applyTx(nextAction);
    }
  }
}

这里我们举个🌰比较容易理解:比如当前 url 为 http://localhost:3000/blocker,其 index 为 2,我们点击后退(其他能触发popstate的事件都可以),这个时候就立即触发了 handlePop,而此时地址栏的 url 实际上已经变化为http://localhost:3000 了,其获取到的 nextIndex 为 1(注意,这里的 index 只是我们举例用到,实际上不一定是上面的值,下面的 delta 也是)。

而由于有 blocker,所以会进行 blockedPopTx的赋值,从上面的 index 和 nextIndex 能获取到对应的 delta 为 1,那么 retry 中的 delta * -1 即为-1 了

const delta = index - nextIndex;
retry() {
  go(delta * -1)
}

然后继续走到下面的 go(delta),由于 delta是 1,那么又重新回到 http://localhost:3000/blocker

注意!!注意!!集中精神了!!此处是真正触发前进后退时保持当前 location 不变的关键所在,也就是说其实 url 已经切换了,但是这里又通过go(delta)把 url 给切换回去。

还有需要特别注意一点的是,这里调用 go(delta) 后又会触发 handlePop,那么 if (blockedPopTx)就为 true 了,自然就会调用 blockers.call(blockedPopTx),blocer 可以根据 blockedPopTx 的 retry 看是否允许跳转页面,然后再把blockedPopTx = null

那么当点击第三次后,由于我们 unblock 后 blockers 为空,且调用了 retry,即 go(-1),这个时候就能成功后退了。

也就是说,我点击后退,此时 url 为/,触发了handlePop,第一次给blockedPopTx赋值,然后go(delta)又返回了/blocker,随即又触发了handlePop,再次进入发现blockedPopTx有值,将 blockedPopTx 回调给每个 blocker,blocker 函数中 unblock 后调用 retry,即go(delta * -1)又切回了/,真是左右横跳啊~

由于 blockers 已经为空,那么 pushreplacehandlePop 中就可以每次都调用 applyTx(nextAction);,从而成功通知到对应的 listeners,这里透露下,BrowserRouterHashRouter 就是通过 history.listen(setState)收听到每次 location 变化从而 setState 触发 render 的。

export function BrowserRouter({
  basename,
  children,
  window
}: BrowserRouterProps) {
  const historyRef = React.useRef<BrowserHistory>();
  if (historyRef.current == null) {
    // 如果为空,则创建
    historyRef.current = createBrowserHistory({ window });
  }

  const history = historyRef.current;
  const [state, setState] = React.useState({
    action: history.action,
    location: history.location
  });

  React.useLayoutEffect(() => {
    /**
     * popstate、push、replace时如果没有blokcers的话,会调用applyTx(nextAction)触发这里的setState
     * function applyTx(nextAction: Action) {
     *   action = nextAction;
     * //  获取当前index和location
     *   [index, location] = getIndexAndLocation();
     *   listeners.call({ action, location });
     * }
     */
    history.listen(setState)
  }, [history]);
  // 一般变化的就是action和location
  return (
    <Router
      basename={basename}
      children={children}
      action={state.action}
      location={state.location}
      navigator={history}
    />
  );
}

这也解释了为何block后路由虽然有切换,但是当前页面没有卸载,就是因为 applyTx(nextAction) 没有执行,导致 BrowserRouter 中没有收到通知。

重新看整个createBrowserHistory

我们上面一一解析了每个函数的作用,下面我们全部合起来再看一下,相信经过上面的分析,再看这整个函数就比较容易理解了

export function createBrowserHistory(
  options: BrowserHistoryOptions = {}
): BrowserHistory {
  // 默认值是document.defaultView,即浏览器的window
  const { window = document.defaultView! } = options;
  const globalHistory = window.history;
  /** 获取索引和当前location */
  function getIndexAndLocation(): [number, Location] {
    const { pathname, search, hash } = window.location;
    const state = globalHistory.state || {};
    return [
      state.idx,
      readOnly<Location>({
        pathname,
        search,
        hash,
        state: state.usr || null,
        key: state.key || 'default'
      })
    ];
  }
  /** 用于存储下面的 blockers.call方法的参数,有 { action,location,retry }  */
  let blockedPopTx: Transition | null = null;
  /** popstate的回调, 点击浏览器 ← 或 → 会触发 */
  function handlePop() {
    // 第一次进来`blockedPopTx`没有值,然后下面的else判断到有blockers.length就会给`blockedPopTx`赋值,之后判断到if (delta)就会调用go(delta),
    // 从而再次触发handlePop,然后这里满足条件进入blockers.call(blockedPopTx)
    if (blockedPopTx) {
      // 如果参数有值,那么将参数传给blockers中的handlers
      blockers.call(blockedPopTx);
      // 然后参数置空
      blockedPopTx = null;
    } else {
      // 为空的话,给blockPopTx赋值
      // 因为是popstate,那么这里的nextAction就是pop了
      const nextAction = Action.Pop;
      // 点击浏览器前进或后退后的state.idx和location
      // 比如/basic/about的index = 2, 点击后退后就会触发handlePop,后退后的nextLocation.pathname = /basic, nextIndex = 1
      const [nextIndex, nextLocation] = getIndexAndLocation();

      if (blockers.length) {
        if (nextIndex != null) {
          // 这里的index是上一次的getIndexAndLocation得到了,下面有
          // 从上面例子 delta = index - nextIndex = 2 - 1 = 1
          const delta = index - nextIndex;
          if (delta) {
            // Revert the POP
            blockedPopTx = {
              action: nextAction,
              location: nextLocation,
              retry() {
                // 由于下面的go(delta)阻塞了当前页面的变化,那么retry就可以让页面真正符合浏览器行为的变化了
                // 这个在blocker回调中可以调用,但下面的go(delta)会触发handlePop,可是go(delta * -1)不会,为何????
                go(delta * -1);
              }
            };
            // 上面/basic/about => /basic,delta为1,那么go(1)就又到了/basic/about
            // 此处是真正触发前进后退时保持当前location不变的关键所在
            // 还有需要特别注意一点的是,这里调用go后又会触发handleProp,那么if (blockedPopTx)就为true了,那么
            // 就会调用blockers.call(blockedPopTx),blocer可以根据blockedPopTx的retry看是否允许跳转页面,然后再把blockedPopTx = null
            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(
            false,
            // TODO: Write up a doc that explains our blocking strategy in
            // detail and link to it here so people can understand better what
            // is going on and how to avoid it.
            `You are trying to block a POP navigation to a location that was not ` +
              `created by the history library. The block will fail silently in ` +
              `production, but in general you should do all navigation with the ` +
              `history library (instead of using window.history.pushState directly) ` +
              `to avoid this situation.`
          );
        }
      } else {
        // blockers为空,那么赋值新的action,然后获取新的index和location,然后
        // 将action, location作为参数消费listeners
        applyTx(nextAction);
      }
    }
  }
  /**
   * 监听popstate
   * 调用history.pushState()或history.replaceState()不会触发popstate事件。
   * 只有在做出浏览器动作时,才会触发该事件,如用户点击浏览器的前进、后退按钮、在Javascript代码中调用history.back()
   * 、history.forward()、history.go方法,此外,a 标签的锚点也会触发该事件
   *
   * @see https://developer.mozilla.org/zh-CN/docs/Web/API/Window/popstate_event
   */
  window.addEventListener(PopStateEventType, handlePop);

  let action = Action.Pop;
  // createBrowserHistory创建的时候获取初始当前路径index和location
  let [index, location] = getIndexAndLocation();
  // blockers不为空的话listeners不会触发
  const listeners = createEvents<Listener>();
  const blockers = createEvents<Blocker>();

  if (index == null) {
    // 初始index为空,那么给个0
    index = 0;
    // 这里replaceState后,history.state.idx就为0了
    globalHistory.replaceState({ ...globalHistory.state, idx: index }, '');
  }
  /** 返回一个新的href, to为string则返回to,否则返回 `createPath(to)` => pathname + search + hash */
  function createHref(to: To) {
    return typeof to === 'string' ? to : createPath(to);
  }
  /**
   * @description 获取新的Location
   * @param to 新的path
   * @param state 状态
   */
  function getNextLocation(to: To, state: State = null): Location {
    return readOnly<Location>({
      ...location,
      ...(typeof to === 'string' ? parsePath(to) : to),
      state,
      key: createKey()
    });
  }
  /** 获取state和url */
  function getHistoryStateAndUrl(
    nextLocation: Location,
    index: number
  ): [HistoryState, string] {
    return [
      {
        usr: nextLocation.state,
        key: nextLocation.key,
        idx: index
      },
      createHref(nextLocation)
    ];
  }
  /**
   * @description 判断是否允许路由切换,有blockers就不允许
   *
   * - blockers有handlers,那么消费handlers,然后返回false
   * - blockers没有handlers,返回true
   *  */
  function allowTx(action: Action, location: Location, retry: () => void): boolean {
    return (
      !blockers.length || (blockers.call({ action, location, retry }), false)
    );
  }
  /** blocker为空才执行所有的listener, handlePop、push、replace都会调用 */
  function applyTx(nextAction: Action) {
    debugger
    action = nextAction;
  //  获取当前index和location
    [index, location] = getIndexAndLocation();
    listeners.call({ action, location });
  }
  /** history.push,跳到哪个页面 */
  function push(to: To, state?: State) {
    debugger
    const nextAction = Action.Push;
    const nextLocation = getNextLocation(to, state);
    /**
     * retry的目的是为了如果有blockers可以在回调中调用
     * @example
     * const { navigator } = useContext(UNSAFE_NavigationContext)
     * const countRef = useRef(0)
     * useEffect(() => {
     *   const unblock  = navigator.block((tx) => {
     *     // block两次后调用retry和取消block
     *     if (countRef.current < 2) {
     *       countRef.current  = countRef.current + 1
     *     } else {
     *       unblock();
     *       tx.retry()
     *     }
     *   })
     * }, [navigator])
     *
     * 当前路径为/blocker
     * 点击<Link to="about">About({`<Link to="about">`})</Link>
     * 第三次(countRef.current >= 2)因为unblock了,随后调用rety也就是push(to, state)判断到下面的allowTx返回true,
     * 就成功pushState了,push到/blocker/about了
     */
    function retry() {
      push(to, state);
    }
    // 只要blockers不为空下面就进不去
    // 但是blockers回调里可以unblock(致使blockers.length = 0),然后再调用retry,那么又会重新进入这里,
    // 就可以调用下面的globalHistory改变路由了
    if (allowTx(nextAction, nextLocation, retry)) {
      const [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);

      // TODO: Support forced reloading
      // try...catch because iOS limits us to 100 pushState calls :/
      // 用try  catch的原因是因为ios限制了100次pushState的调用
      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);
    }
  }

  function replace(to: To, state?: State) {
    const nextAction = Action.Replace;
    const nextLocation = getNextLocation(to, state);
    /**
     * retry的目的是为了如果有blockers可以在回调中调用
     * @example
     * const { navigator } = useContext(UNSAFE_NavigationContext)
     * const countRef = useRef(0)
     * useEffect(() => {
     *   const unblock  = navigator.block((tx) => {
     *     // block两次后调用retry和取消block
     *     if (countRef.current < 2) {
     *       countRef.current  = countRef.current + 1
     *     } else {
     *       unblock();
     *       tx.retry()
     *     }
     *   })
     * }, [navigator])
     *
     * 当前路径为/blocker
     * 点击<Link to="about">About({`<Link to="about">`})</Link>
     * 第三次(countRef.current >= 2)因为unblock了,随后调用rety也就是push(to, state)判断到下面的allowTx返回true,
     * 就成功pushState了,push到/blocker/about了
     */
    function retry() {
      replace(to, state);
    }
    // 只要blockers不为空下面就进不去
    // 但是blockers回调里可以unblock(致使blockers.length = 0),然后再调用retry,那么又会重新进入这里,
    // 就可以调用下面的globalHistory改变路由了
    if (allowTx(nextAction, nextLocation, retry)) {
      const [historyState, url] = getHistoryStateAndUrl(nextLocation, index);

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

      applyTx(nextAction);
    }
  }
  /** eg: go(-1),返回上一个路由,go(1),进入下一个路由 */
  function go(delta: number) {
    globalHistory.go(delta);
  }
  // 这里创建一个新的history
  const history: BrowserHistory = {
    get action() {
      return action;
    },
    get location() {
      return location;
    },
    createHref,
    push,
    replace,
    go,
    back() {
      go(-1);
    },
    forward() {
      go(1);
    },
    listen(listener) {
      return listeners.push(listener);
    },
    block(blocker) {
      // push后返回unblock,即把该blocker从blockers去掉
      const unblock = blockers.push(blocker);

      if (blockers.length === 1) {
        // beforeunload
        // 只在第一次block加上beforeunload事件
        window.addEventListener(BeforeUnloadEventType, promptBeforeUnload);
      }

      return function() {
        unblock();
        // 移除beforeunload事件监听器以便document在pagehide事件中仍可以使用
        // Remove the beforeunload listener so the document may
        // still be salvageable in the pagehide event.
        // See https://html.spec.whatwg.org/#unloading-documents
        if (!blockers.length) {
          // 移除的时候发现blockers空了那么就移除`beforeunload`事件
          window.removeEventListener(BeforeUnloadEventType, promptBeforeUnload);
        }
      };
    }
  };

  return history;
}

createHashHistory 与 createBrowserHistory 的不同点

两个工厂函数的绝大部分代码是一模一样的,以下是稍微不同之处:

  1. getIndexAndLocation。createBrowserHistory 是直接获取 window.location,而 createHashHistory 是 parsePath(window.location.hash.substr(1))
function getIndexAndLocation(): [number, Location] {
  const { pathname = '/', search = '', hash = '' } = parsePath(
    window.location.hash.substr(1)
  );
  const state = globalHistory.state || {};
  return [
    state.idx,
    readOnly<Location>({
      pathname,
      search,
      hash,
      state: state.usr || null,
      key: state.key || 'default'
    })
  ];
}

parsePath我们上面已经讲了,这个给个例子

即 url 中有多个#,但是会取第一个#后面的来解析对应的 pathname、search 和 hash

  1. createHashHistory 多了监听 hashchange的事件
window.addEventListener(HashChangeEventType, () => {
  const [, nextLocation] = getIndexAndLocation();

  // Ignore extraneous hashchange events.
  // 忽略无关的hashchange事件
  // 检测到hashchange,只有前后pathname + search + hash不一样才执行handlePop
  if (createPath(nextLocation) !== createPath(location)) {
    handlePop();
  }
});
  1. createHref 会在前面拼接 getBaseHref() + '#'
function getBaseHref() {
  // base一般为空,所以下面的href一般返回空字符串
  // 如果有 类似<base href="http://www.google.com"/>,那么获取到的href就为 "http://www.google.com/",可看下面示意图
  const base = document.querySelector('base');
  let href = '';

  if (base && base.getAttribute('href')) {
    const url = window.location.href;
    const hashIndex = url.indexOf('#');
    // 有hash的话去掉#及其之后的
    href = hashIndex === -1 ? url : url.slice(0, hashIndex);
  }

  return href;
}
// 后面部分和createBrowserHistory的createHref相同
function createHref(to: To) {
  return getBaseHref() + '#' + (typeof to === 'string' ? to : createPath(to));
}

结语

我们总结一下:

  • historybrowserHistoryhashHistory,两个工厂函数的绝大部分代码相同,只有parsePath的入参不同和 hashHistory 增加了hashchange事件的监听
  • 通过 push、replace 和 go 可以切换路由
  • 可以通过 history.listen 添加路由监听器 listener,每当路由切换可以收到最新的 action 和 location,从而做出不同的判断
  • 可以通过 history.block 添加阻塞器 blocker,会阻塞 push、replace 和浏览器的前进后退。且只要判断有 blockers,那么同时会加上beforeunload阻止浏览器刷新、关闭等默认行为,即弹窗提示。且只要有 blocker,那么上面的 listener 就监听不到
  • 最后我们也透露了BrowserRouter中就是通过 history.listen(setState) 来监听路由的变化,从而管理所有的路由

最后

historyreact-router的基础,只有知道了其作用,才能在后续分析 react router 的过程中更加游刃有余,那我们下篇文章就开始真正的 react router 之旅,敬请期待~

end-cover.png

感谢留下足迹,如果您觉得文章不错 😄😄,还请动动手指 😋😋,点赞+收藏+转发 🌹🌹

往期文章

翻译翻译,什么叫 ReactDOM.createRoot

翻译翻译,什么叫 JSX

什么,React Router已经到V6了 ??