无 URL 怎么管理路由?来一探 Memory History 的究竟

2,410 阅读9分钟

上一篇文章,我们以 Browser History 为例分析了 history.js 的工作原理以及会话历史管理方式,为理解 react-router 路由体系打下了基础。针对不同使用场景,除了 Browser history 外,history.js 还提供了 Browser history 与 Memory history,本文来重点讲解下三者的使用场景与区别。

前文链接:搞不懂路由跳转?带你了解 history.js 实现原理 - 掘金

浏览器环境的路由

我们知道,前端路由体系的基础就是对路径的追踪与监听,而强行修改 URL 会导致页面的重加载,也就失去了单页应用与路由体系的作用。所以最直观的做法就是对 URL 变化进行监听,劫持 URL 变化并记录在 history 堆栈中,以钩子的形式暴露给上层框架注册页面组件加载、事件触发等行为。

image

可以看到,浏览器导航栏中的 URL 通常由四个部分组成,想必大家都不陌生。从 URL 的角度,history.js 以及 react-router 系统提供了对 pathname 或者 hash 的两种监听方式。

我们改编一下 react-router 官方 Demo 代码来进行演示:

原地址:github.com/remix-run/r…

// 入口文件
import React from "react";
import ReactDOM from "react-dom";
import { unstable_HistoryRouter as HistoryRouter } from "react-router-dom";
import App, { history } from "./App.tsx";

ReactDOM.render(
  // 支持自定义传入 history
  // react-router 提供的 BrowserRouter 其实就是 HistoryRouter 传入了
  // createBrowserHistory 创建的 history 对象
  <HistoryRouter history={history}>
    <App />
  </HistoryRouter>
  document.getElementById("root")
);

// App.tsx
import { Routes, Route, Outlet } from "react-router-dom";
export default function App() {
  return (
    <div>
      // 省略头部的主体部分

      <Routes>
        <Route path="/" element={<Layout />}>
          <Route index element={<Home />} />
          <Route path="about" element={<About />} />
          <Route path="dashboard" element={<Dashboard />} />
          <Route path="*" element={<NoMatch />} />
        </Route>
      </Routes>
    </div>
  );
}

// 省略 <About />, <NoMatch />, <Home />, <Dashboard /> 的实现

export const history = /** 待填充 */;

function Layout() {
  return (<div>
      <nav>
        <ul>
          <li><div onClick={() => history.push('/')}>Home</div></li>
          <li><div onClick={() => history.push('/about')}>About</div></li>
          <li><div onClick={() => history.push('/dashboard')}>Dashboard</div></li>
          <li><div onClick={() => history.push('/nothing-here')}>Nothing Here</div></li>
        </ul>
      </nav>
      // 暂时忽略,下一篇文章会讲作用
      // 大致就是在 <Outlet /> 处展示上面 <Route /> 里的 children 组件
      <Outlet />
    </div>);
}

监听 Pathname

对于 Browser History 场景,我们在上面 35 行引用 createBrowserHistory 方法创建 history 对象:

import { createBrowserHistory } from 'history';
export const history = createBrowserHistory();

image

可以看到,在点击事件触发时,pathname 发生了改变,点击区域下方的文案也会相应变化,而整个过程中只加载了一次 HTML 文件。

监听 hash

现在让我们监听 hash,重新演示一遍上面的例子,可以看到,这回变动区域变成了地址的 hash 部分:

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

image

从上一篇文章的解析中,我们也知道,Browser History 与 Hash History 的区别就仅仅是监听 URL 的位置由 location 变为 hash 而已。所以本文的重心将放在第三种路由实现方式 —— Memory History

非浏览器运行时的路由

在浏览器运行时下,借助 URL 这一利器可以很好的进行路由定位与追踪,但如果在 RN 等非浏览器运行时环境下,显然监听 pathname 与 hash 就没办法满足需求了。针对这种场景,history.js 提供了 MemoryHistory 的模式,将路由关系映射储存于内存中,就可以实现基于内存的路由管理了。

现在我们用 createMemoryHistory 来改造之前 Demo:

import { createMemoryHistory } from 'history';
export const history = createMemoryHistory();

image

我们利用上面的 GIF 演示来模拟非浏览器环境:可以看到,在完美复刻上一部分中的功能的前提下,所有的路由跳转并没有伴随 URL 的变化,这意味着所有的地址 & 页面组件的路由映射是保存在内存中的,不需要借助 URL 来做辅助。

Memory History 的实现原理

上一篇文章我们知道,Browser History 与 Hash History 本质上是对 window.location 的监听,以及对 window.history 原生state、原生跳转/回溯能力的封装。而在非浏览器环境中,Memory History 的核心思路就是模拟 location 构造映射关系,然后通过操作 location 映射关系实现原生 history 的能力。

createMemoryHistory API 的整体逻辑大致如下:

export function createMemoryHistory(options: MemoryHistoryOptions = {}): MemoryHistory {
  let { initialEntries = ['/'], initialIndex } = options;
  let entries: Location[] = initialEntries.map((entry) => {
    let location = readOnly<Location>({
      pathname: '/',
      search: '',
      hash: '',
      state: null,
      key: createKey(),
      ...(typeof entry === 'string' ? parsePath(entry) : entry)
    });

    return location;
  });
  let index = clamp(
    initialIndex == null ? entries.length - 1 : initialIndex,
    0,
    entries.length - 1
  );

  let action = Action.Pop;
  let location = entries[index];
  let listeners = createEvents<Listener>();
  let blockers = createEvents<Blocker>();

  // 省略大量逻辑...

  let history: MemoryHistory = {
    get index() { return index },
    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) { return blockers.push(blocker) }
  };

  return history;
}

可以看到,整体结构与返回的 API 类型与 Browser/Hash History 几乎相同。不过不同于浏览器环境下只需将 window 对象传入即可,Memory History 可选提供 initialEntriesinitialIndex ,用于初始化映射表(用于模拟 history,记录路由变化以及标识初次进入的页面所在映射表的位置(用于模拟当前路由在堆栈的位置)

如果你现在看的还很懵,不着急,我们来拆解整个函数,看看具体与 Browser/Hash History 有什么不同

映射关系构建

映射关系构建的逻辑如下: image 可以看到,针对传入的 initialEntries构建了 entries 数组,同时用 index 字段来表示当前所在的路由位置,默认取 entries 数组的最后一条为初始位置。

Location

上面截图的最后一行定义了 location 字段,默认取最后一条 entries 记录,用于模拟浏览器的 window.location

let location = entries[index];

可以理解为,location 就是在不借助 URL 的场景下,当前页面所对应的路由。

在上面的 Demo 中,我们没有给 createMemoryHistory 传入参数,这意味着打开页面的入口默认以根路由('/')作为首次加载的页面组件。此时 entries 中只有根路由('/')一个元素,location 也对应到根路由:

image

而如果需要指定页面入口为某个路由,我们可以在 initialEntriesinitialIndex 中配置,例如:

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

我们注册了三个路径,并指定了初始 index 指向第一条,也就是 '/about' 这一路由,也就意味着初始化页面时,location 会指向 '/about' 路由,上层的 react-router 就会识别并加载 <About /> 组件。

image

其余初始化

与浏览器环境下的 History 类似,除了 locationindex 字段外,actionlistenersblockers 也会在此时初始化。不过不同的是,非浏览器环境下无需关心除交互事件以外可能造成页面跳转的操作(例如浏览器环境下点击导航栏“前进/返回”按钮)。由于 Memory History 的所有跳转都是由 createMemoryHistory 生成的 history 对象完成的,就意味不需要监听并处理 window 的 hashchange 与 popstate 等事件,极大程度上简化了复杂度。

Memory History 是如何实现跳转与拦截的

在浏览器环境下,跳转、拦截与监听都极大程度上依赖 History API 与 window.location,而在 Memory History 中,无需监听 URL 变化,一切操作都基于映射表,跳转与拦截操作也极大程度被简化。

首先我们先来看几个工具函数,这对我们理解后面的逻辑十分有用:

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

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

这一部分的实现与 Browser/Hash History 完全一致,也恰恰说明了在 Memory History 模式下,location 也被处理成了同 Browser/Hash History 的数据结构,更有便于我们分析。

跳转

同样,跳转能力可以分为三种:

  • push:堆栈叠加

  • replace:堆栈元素替换

  • go(n):堆栈中进行位置穿梭n为(n 可为负数)

image

push

image

可以看到,push 操作实际上是将 index 递增,随后将后面位置的所有 entries 元素都删除,然后插入新的 location 元素。在 index 之前的 entries 所有元素都会被保留,这也是为了方便回溯,可以使用 go(-delta) 访问之前的堆栈元素。entries 数组在这之中也起到了 history 的作用,可以追溯 location 的变化。

replace

image

类似地,replace 操作就是将 entries 数组中当前元素直接替换为新的 location 元素,非常直观。

go(n)

image

由于我们前面说到,entries 数组记录了全部 location 的变化,就意味着前进或倒退等方式,也可以访问到对应的 location。go 方法就起到了这样的作用,传入的 delta 值可以为负,意味【返回】,也可以为正,意味【前进】。

监听与拦截

由于少了监听和浏览器环境下的 handlePopup 逻辑函数,监听与拦截相对来说就十分简单了。通常,上层库会注册 listeners,实现监听路由的跳转;此外,想要弹出 Alert 等方式挽留用户,也可以直接注册 blockers。

对比 Browser/Hash History 的实现

回顾上一篇文章,浏览器环境下 history 的跳转实际上就是对 window.history 对应 go/replaceState/pushState 的封装;监听与拦截的方式也类似,生成的 history 实例对象调用 listen/block 方法,参数中传入监听/阻塞函数即可,不过浏览器环境下需要额外监听 beforeunload 事件,进而在关闭浏览器窗口时页面取消注册的事件。总体来说处理方式都是类似的,只是借助 window.history 与自己维护 entries 堆栈的区别。

事实上,市面上绝大多数的会话历史都是基于 URL 的管理方案,因为借助 URL 可以最简单且清晰地帮助追溯路由变化,且稳定性高;且利用内存管理路由,不当的操作还可能导致运行时内存膨胀,损耗性能(虽然不当的 URL 操作也会…)。但是了解 Memory History 的实现原理仍然是有必要的,它不仅有助于理解路由的实现与 history 原理,更提供了一种拓宽解决思路的思考方式。

最后总结来说,Memory History 的模式主要运用于以下场景:

  • 没有 URL 或 window 的无浏览器环境,例如 RN
  • 不希望将路由变化通过 URL 暴露出来时

至此,我们算是将 history.js 的全部细节都解析透彻了。作为 react-router 的底层库,了解 history.js 的实现原理对理解前端路由有着至关重要的作用。下一篇文章我们会正式进行 react-router 的源码解析,也欢迎大家一起讨论。