前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

React Router 原理详解,history 库与路由匹配机制

首页2018-12-20 14:00:24Front-End
ReactJavaScriptReact Router前端路由

单页应用里点一个导航链接,地址栏变了,页面内容换了,浏览器却没有发起一次文档请求,后退按钮还能正常用。第一次见到这个效果的时候我挺好奇的,地址栏都变了浏览器凭什么不去请求服务器?后来去翻 React Router 的源码才发现,这里面真正干活的其实不是 React Router 本身,而是它底下那个叫 history 的库。

这篇顺着这条线往下拆。先讲 history 库怎么用三套不同的底层机制抹平环境差异,再讲 React Router 怎么把 URL 变化翻译成组件渲染,最后把点击一个 <Link> 之后系统内部发生的完整链路走一遍。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • history 库的三种实现分别对应什么环境
  • 为什么 history 里的 location 比浏览器原生的多一个 key 字段
  • hash 模式和 history 模式在前进、回退、状态存储上的具体差别
  • React Router 把「URL 与 UI 同步」拆成了哪两层
  • 用户点击 Link 之后,从 DOM 事件到组件重渲染的完整链路
  • 路由匹配算法在做什么,v6 为什么改掉了它
  • 这套原理到 React Router v7 的现状

# 一、React Router基础之history

# 1.1 History介绍

history 是一个独立的第三方 js 库,可以用来兼容在不同浏览器、不同环境下对历史记录的管理,拥有统一的 API。具体来说里面的 history 分为三类:

  • 老浏览器的 history:主要通过 hash 来实现,对应 createHashHistory
  • 高版本浏览器:通过 html5 里面的 history,对应 createBrowserHistory
  • node 环境下:主要存储在 memory 里面,对应 createMemoryHistory

先说说为什么要抹平这三者。浏览器提供的历史记录 API 本身就是分裂的,HTML5 之前只能靠 hash 变化做假路由,HTML5 之后有了 pushState 但需要服务端配合,而在 Node 里跑服务端渲染或者跑单元测试时根本没有 window。上层的 React Router 不想为这三种情况各写一套逻辑,就需要有人先把差异吃掉。

上面针对不同的环境提供了三个 API,但是三个 API 有一些共性的操作,将其抽象成了一个公共的文件 createHistory。


// 内部的抽象实现
function createHistory(options={}) {
  ...
  return {
    listenBefore, // 内部的hook机制,可以在location发生变化前执行某些行为,AOP的实现
    listen, // location发生改变时触发回调
    transitionTo, // 执行location的改变
    push, // 改变location
    replace,
    go,
    goBack,
    goForward,
    createKey, // 创建location的key,用于唯一标示该location,是随机生成的
    createPath,
    createHref,
    createLocation, // 创建location
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

这份接口清单值得盯一会儿。它里面同时包含了两类东西,push、replace、go 这些是「让 URL 变」的命令式方法,listen、listenBefore 是「URL 变了通知我」的订阅方法。整个前端路由的骨架就是这两类方法拼出来的,上层框架负责在订阅回调里决定渲染什么。

上述这些方式是 history 内部最基础的方法,createHashHistory、createBrowserHistory、createMemoryHistory 只是覆盖其中的某些方法而已。其中需要注意的是,此时的 location 跟浏览器原生的 location 是不相同的,最大的区别就在于里面多了 key 字段,history 内部通过 key 来进行 location 的操作。

function createLocation() {
  return {
    pathname, // url的基本路径
    search, // 查询字段
    hash, // url中的hash值
    state, // url对应的state字段
    action, // 分为 push、replace、pop三种
    key // 生成方法为: Math.random().toString(36).substr(2, length)
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

那为什么非要加个 key?

因为浏览器原生的 location 只是当前地址的一份描述,它不区分「你是新推进来的」还是「你是后退回来的」。而路由库需要给每一条历史记录挂上额外的数据(也就是 state),还需要在你后退回某一条时把当时的数据取回来。key 就是这份额外数据的索引,随机生成,唯一标识一条历史记录。后面第 1.2.3 节讲 sessionStorage 存储时,存进去的键就是它。

action 字段也是同一个用途,它告诉上层这次变化是 PUSH、REPLACE 还是 POP,前进和后退的动画方向、要不要销毁缓存的页面,全靠它区分。

# 1.2 内部解析

三个 API 的大致技术实现如下:

  • createBrowserHistory:利用 HTML5 里面的 history
  • createHashHistory:通过 hash 来存储在不同状态下的 history 信息
  • createMemoryHistory:在内存中进行历史记录的存储

# 1.2.1 执行URL前进

  • createBrowserHistory:pushState、replaceState
  • createHashHistory:location.hash=*** location.replace()
  • createMemoryHistory:在内存中进行历史记录的存储
// 伪代码

// createBrowserHistory(HTML5)中的前进实现
function finishTransition(location) {
  ...
  const historyState = { key };
  ...
  if (location.action === 'PUSH') {
    window.history.pushState(historyState, null, path);
  } else {
    window.history.replaceState(historyState, null, path)
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

这段是 history 模式的核心。pushState 干的事非常克制,它往浏览器历史栈里推一条记录、把地址栏改成 path,然后就没有然后了,不发请求、不刷新页面、也不触发任何事件。正因为它什么都不做,才留出了让 JS 接管后续渲染的空间。

注意第一个参数 historyState 里只放了 { key },真正的业务数据没有塞进去。原因是 pushState 的 state 有大小限制,各浏览器实现不一,稳妥的做法是只存一个索引,数据放别处。

hash 模式这边:

// createHashHistory的内部实现
function finishTransition(location) {
  ...
  if (location.action === 'PUSH') {
    window.location.hash = path;
  } else {
    window.location.replace(
      window.location.pathname + window.location.search + '#' + path
    );
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

改 window.location.hash 就等于往历史栈推一条记录,这是浏览器很早就有的行为。要实现 replace 语义就麻烦一点,得手动拼出完整 URL 再调 location.replace,因为直接改 hash 是没有替换语义的。

内存版最简单,一个数组就够了:

// createMemoryHistory的内部实现
entries = [];
function finishTransition(location) {
  ...
  switch (location.action) {
    case 'PUSH':
      entries.push(location);
      break;
    case 'REPLACE':
      entries[current] = location;
      break;
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

createMemoryHistory 平时用不到,但服务端渲染和 Jest 单元测试里全靠它。没有 window 的环境下,路由状态就是这个数组加一个游标。

# 1.2.2 检测URL回退

前进是我们主动调 API,回退是用户按浏览器按钮,得靠监听。

  • createBrowserHistory:popstate
  • createHashHistory:hashchange
  • createMemoryHistory:因为是在内存中操作,跟浏览器没有关系,不涉及 UI 层面的事情,所以可以直接进行历史信息的回退
// 伪代码

// createBrowserHistory(HTML5)中的后退检测
function startPopStateListener({ transitionTo }) {
  function popStateListener(event) {
    ...
    transitionTo( getCurrentLocation(event.state) );
  }
  addEventListener(window, 'popstate', popStateListener);
  ...
}
 
// createHashHistory的后退检测
function startPopStateListener({ transitionTo }) {
  function hashChangeListener(event) {
    ...
    transitionTo( getCurrentLocation(event.state) );
  }
  addEventListener(window, 'hashchange', hashChangeListener);
  ...
}
// createMemoryHistory的内部实现
function go(n) {
  if (n) {
    ...
    current += n;
    const currentLocation = getCurrentLocation();
    // change action to POP
    history.transitionTo({ ...currentLocation, action: POP });
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

这里有个很多人会栽的细节:pushState 和 replaceState 自己是不会触发 popstate 事件的。只有用户点浏览器的前进后退按钮,或者代码调 history.back()、history.go(),才会触发。

所以你自己写一个迷你路由的时候,光监听 popstate 是不够的,还得在自己调用 pushState 的地方手动通知一次。上面 createBrowserHistory 的 finishTransition 里为什么要有一整套 transitionTo 流程,原因就在这。它得同时覆盖「我改的」和「用户改的」两条路径。

hashchange 这边行为不一样,无论是代码改 hash 还是用户点后退,都会触发。所以 hash 模式的实现反而更省心一点。

# 1.2.3 state的存储

fe
  • 一、React Router基础之history
    • 1.1 History介绍
    • 1.2 内部解析
      • 1.2.1 执行URL前进
      • 1.2.2 检测URL回退
      • 1.2.3 state的存储
    • 1.3 小结
  • 二、react-router的基本原理
    • 2.1 优点
    • 2.2 react-router具体实现
    • 2.3 用户点击了Link组件后路由系统中到底发生了哪些变化
  • 三、匹配算法在算什么
  • 四、从 v4 到 v7 变了什么
  • 总结
  • 参考

← 浅析Promise原理与手写实现oppo快应用开通账号服务获取openid记录 →