React Router 原理详解,history 库与路由匹配机制
单页应用里点一个导航链接,地址栏变了,页面内容换了,浏览器却没有发起一次文档请求,后退按钮还能正常用。第一次见到这个效果的时候我挺好奇的,地址栏都变了浏览器凭什么不去请求服务器?后来去翻 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 里面的historycreateHashHistory:通过hash来存储在不同状态下的history信息createMemoryHistory:在内存中进行历史记录的存储
# 1.2.1 执行URL前进
createBrowserHistory:pushState、replaceStatecreateHashHistory: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:popstatecreateHashHistory:hashchangecreateMemoryHistory:因为是在内存中操作,跟浏览器没有关系,不涉及 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 模式的实现反而更省心一点。