蛮多同学可能会觉得
react-router
很复杂, 说用都还没用明白, 还从0实现一个react-router
, 其实router
并不复杂哈, 甚至说你看了这篇博客以后, 你都会觉得router
的核心原理也就那么回事
至于react-router
帮助我们实现了什么东西我就不过多阐述了, 这个直接移步官方文档, 我们下面直接聊实现
另外: react-router
源码有依赖两个库path-to-regexp
和history
, 所以我这里也就直接引入这两个库了,虽然下面我都会讲到基本使用, 但是同学有时间的话还是可以阅读以下官方文档
还有一个需要注意的点是: 下面我书写的router
原理都是使用hooks
+ 函数组件来书写的, 而官方是使用类组件书写的, 所以如果你对hooks
还不是很明白的话, 得去补一下这方面的知识, 为什么要选择hooks
, 因为现在绝大多数大厂在react
上基本都在大力推荐使用hook
, 所以我们得跟上时代不是, 而且我着重和大家聊的也是原理, 而不是跟官方一模一样的源码, 如果要1比1的复刻源码不带自己的理解的话, 那你去看官方的源码就行了, 何必看这篇博文了
在本栏博客中, 我们会聊聊以下内容:
match
对象方法history
库的使用Router
和BrowserRouter
的实现Route
组件的实现Switch
和Redirect
的实现withRouter
的实现Link
和NavLink
实现api
match
对象方法在封装之前, 我想跟大家先分享path-to-regexp
这个库
为什么要先聊这个库哈, 主要原因是因为
react-router
中用到了这个库, 我看了一下其实我们也没必要自己再去实现一个这个库(为什么没必要呢,倒并不是因为react-router
没有实现我们就不实现, 而是因为这个库实现的功能非常简单, 但是细节非常繁琐, 有非常多的因素需要去考虑到我觉得没必要), 这个库做的事情非常简单: 将一个字符串变成一个正则表达式
我们知道, react-router
的大致原理就是根据路径的不同从而渲染不同的页面, 那么这个过程其实也就是路径A
匹配页面B
的过程, 所以我们之前会写这样的代码
<Route path="/news/:id" component={
News} /> // 如果路径匹配上了/news/:id这样的路径, 则渲染News组件
那么react-router
他是怎么去判断浏览器地址栏的路径和这个Route
组件中的path
属性匹配上的?
path填写的如果是/news/:id
这样的路径, 那么/news/123
/news/321
这种都能够被react-router
匹配上
我们能够想到的方法是不是大概可以如下:
将所有的
path
属性全部转换为正则表达式(比如/news/:id
转换为/^\/news(?:\/([^\/#\?]+?))[\/#\?]?$/i
), 然后将地址栏的path
值取出来跟该正则表达式进行匹配, 匹配上了就要渲染相应的路由, 匹配不上就渲染其他的逻辑
path-to-regexp
就是做这个事情的, 他把我们给他的路径字符串转换为正则表达式, 供我们匹配
安装: yarn add path-to-regexp -S
// 我们可以来随便试试这个库
import {
pathToRegexp } from "path-to-regexp";
const keys = [];
// pathToRegexp(path, keys?, options?)
// path: 就是我们要匹配的路径规则
// keys: 如果你传递了, 当他匹配上以后, 会把相对应的参数key传递到keys数组中
// options: 给path路径规则的一些附加规则, 比如sensitive大小写敏感之类的
const result = pathToRegexp("/news/:id", keys);
console.log("result", result);
console.log(result.exec("/news/123")); // 输出 ["/news/123", "123", index: 0, input: "/news/123", groups: undefined]
console.log(result.exec("/news/details/123")); // 输出null
console.log(keys); // 输出一个数组, 数组的有一个对象{modifier: " name: "id", pattern: "[^\/#\?]+?", prefix: "/", suffix: ""}
当然, 这个库还有很多玩法, 他也不是专门为react-router
实现的, 只是刚好被react-router
拿过来用了, 对这个库有兴趣的同学可以去看看他的文档
我们使用这个库, 主要是为了封装一个公共方法,为后续我们写router
源码的时候提供一些基石, 因为我们知道, react-router
一旦路径匹配上了, 是会向组件里注入history
, location
等属性的, 这些东西我们要提前准备好, 所以我们此刻的目标很简单
如果一个
path
值跟指定的path
正则匹配上了, 那么我们要生成一个包含了location
,history
等属性的对象, 供后续使用, 说的更直白一点就是要得到react-router
中那个的match
对象
我们会发现这个功能其实是独立的, 这样拆分出来他可以用在任何地方, 只要匹配我就生成一个对象, 我也不管你拿这个对象去干嘛不关我屁事, 这也是软件开发中的一种较好的开发方式, 大家可以停下来在这里仔细思考一下这样的好处
所以接下来我要做的事情非常简单, 就是封装一个跟处理路径相关的方法, 为后续我们开发其他router
功能的时候提供基层支持
我们在react
工程中自己建立一个react-router
目录, 在其中新建一个文件pathMatch.js
这也意味着我们将不再从
npm
上拉react-router
, 而是直接在自己的工程里引用自己的react-router
pathMatch.js
中每一步都写上了注释, 应该能够帮助你很好的理解
// src/react-router/pathMatch.js
import {
pathToRegexp } from "path-to-regexp";
/** * * @param {String} path 传递进来的path规则 * @param {String} url 需要校验path规则的url * @param {Object} options 一些配置: 如是否精确匹配, 是否大小写敏感等 * * 这个函数要做的事情非常简单, 当我调用这个函数并且传递了相应 * 参数以后, 这个函数需要返回给我一个对象, 对象成员如下 * { * params: { 路径匹配成功以后的参数值, 匹配不上就是null * key: value * }, * path: path规则 * url: 跟path规则匹配的那一段url, 如果匹配不上就是null * isExact: 是否精确匹配 * } * */
function pathMatch(path = "", url = "", options = {
}) {
// 所以在这个函数内部, 我们要做的事情如下:
// 1. 调用path-to-regex库且根据配置来帮助我们进行匹配参数值
// 2. 将匹配结果返回出去
// 首先, 如果你读了这个path-to-regex的文档的话, 你会发现一个问题
// 我们在react-router中传递exact为精确匹配, 而在该库中则是使用end
// 所以我们第一步先将用户传递的配置对象变成path-to-regex想要的配置对象
const matchOptions = getOptions(options);
const matchKeys = []; // 这个matchKeys其实就是我们用来装匹配成功后参数key的数组
// 然后在path-to-regexp中得到相对应的正则表达式
const pathRegexp = pathToRegexp(path, matchKeys, matchOptions);
// 这里我们要使用对应的正则表达式来匹配用户传递的url
const matchResult = pathRegexp.exec(url);
console.log("matchResult", matchResult);
// 如果没有匹配上, 那直接返回null了
if( !matchResult ) return null;
// 如果匹配上了, 我们知道他返回的是一个类数组, 我们需要将matchKeys和类数组进行遍历
// 生成最终的match对象里的params对象
const paramsObj = paramsCreator(matchResult, matchKeys);
return {
params: paramsObj,
path,
url: matchResult[0], // matchResult作为类数组的第0项就是匹配路径规则的部分
isExact: matchResult[0] === url
}
}
/** * * @param {Object} options 配置对象 * 这个方法主要就是将用户传递的配置对象, 转换为path-to-regex 需要的配置对象 */
function getOptions({
sensitive = false, strict = false, exact = false }) {
const defaultOptions = {
sensitive: false,
strict: false,
end: false
}
return {
...defaultOptions,
sensitive,
strict,
end: exact
}
}
/** * * @param {*} matchResult * @param {*} matchKeys * 这个方法主要是将matchResult和matchKeys相组合最终生成一个新的params对象 */
function paramsCreator(matchResult = [], matchKeys = []) {
// 首先这个matchResult是一个类数组, 我们需要将它转换为真实数组
// 你可以使用Array.from, 也可以使用[].slice.call等方法都可以
// 而且我们知道matchResult的第一项是路径, 我们是不需要的, 所以直接是slice.call更方便
const matchVals = [].slice.call(matchResult, 1);
const paramsObj = {
};
matchKeys.forEach((k, i) => {
// 别忘记, 这个k是一个对象, 而我们只需要他的name属性
paramsObj[k.name] = matchVals[i];
})
return paramsObj; // 最后将这个参数对象丢出去
}
export default pathMatch;
至此, 我们的
pathMacth
模块就生成了, 每次调用pathMatch
方法, 都会根据参数返回给我们一个react-router
中的match对象,参考 前端手写面试题详细解答
history
库的使用我们知道, 当路由匹配组件以后, react-router
会向组件内部注入一些属性, 其中的match
属性我们已经有生成的方法了, 但是location
和history
还得劳烦我们自己写一写
其实location
就是history
对象身上的一个属性, 我们搞定了location
, history
自然就搞定了
有个东西我们必须搞清楚哈,
history
中的方法是用来帮助我们切换路由的, 但是我们知道, 我们的router
模式是有hash
模式,browser
(有时我们也称其为history
模式)模式, 甚至在native端有memory
模式, 当模式不同的时候,history
会帮我们操作不同的地方(比如hash
模式下, 操作的就是hash
,browser
模式下操作的就是浏览器的历史记录), 那么我们也知道,router
是根据你引入的是BrowserRouter
还是其他Router类型来判定history
需要操作哪一块的, 所以我们要做的事就是要搞出这个BrowserRouter
, 没问题吧, 由于代码量可能比较多, 但是原理都一致, 我就不写HashRouter
和memoryRouter
了
而在react-router
中他也是强依赖了我们上面说到的第三方库: history
我们先来看看
history
库的使用, 可能下一篇博客我们会直接去书写他的原理, 这个库不像path-to-regexp
, 他的原理还是很重要的, 这篇博客因为篇幅问题也就不写history
库的源码了
这个库主要实现的功能就是一个: 给你提供创建不同地址栈的history api
说的更简单一点, 就是我们调用这个库具名导出的方法, 再经过一系列包装, 我们就可以直接生成
react-router
上下文中提供的history
对象
我们可以直接来用一用这个库
import {
createBrowserHistory } from "history"; // 导入一个创建操作浏览器history api的函数
// 这个函数还可以接收一个配置对象, 你也可以不传
// createBrowserHistory(config?);
const history = createBrowserHistory({
// basename配置用于设置基路径, 大部分情况下, 我们网站的根路径是/
// 所以我们多数情况下不考虑basename, 假设你需要考虑的话, 就在这填就好了
// 填写这个的后果就是: 比如你填写basename为/news, 以后你访问/news/details
// 的时候你的pathname就会被解析成/details
basename: "/",
forceRefresh: false, // 表示是否强制刷新页面, history api是不会刷新页面的, 而如果设置该属性为true以后,
// 则你调用push等方法的时候会直接数显页面
keyLength: 6, // location对象使用的key值长度(key值用来确定唯一性, 比如你同时访问了同一个path, 如果没有key值的话就出问题了)
getUserConfirmation: (msg, cb) => cb(window.confirm(msg)), // 用来确定用户是否真的需要跳转(但是必须设置history的block函数并且页面真正进行跳转才会触发)
});
console.log("history");
输出结果如下, 我们会发现, 他其实已经和我们在react
中使用BrowserRouter
提供的上下文对象中的history
对象差不多了, 但是还有细微的区别, 我们先来看看这个history
对象中成员的逻辑判定方案, 这对我们后续写他的源码有用处
需要注意的地方就是: 同学不要觉得这个是window.location
和window.history
的结合哈, 这个是history
自己生成的对象, 他对立面的属性很多都是经过包装的, 别搞混淆了, 后续源码我们会了解的更清晰一点
createBrowserHistory
创建的时候action
固定为POP
history
的push
方法, action
变为PUSH
history
的replace
方法, action
变为REPLACE
history.go(0)
会刷新页面),正数前进, 负数退后go(-1)
go(1)
react-router
实现重新渲染页面的关键, 这个函数用于监听地址栈指针的变化, 该函数接收一个函数作为参数, 表示地址发生变化以后的回调, 回调函数又接收两个参数(location对象, action), 他返回一个函数用于解除监听, 后续我们用到的时候我相信你就懂了getUserConirmation
中Router
和BrowserRouter
的实现上面说了这么多, 主要都是在跟大家聊path-to-regexp
和history
库, 这里我们要正式实现Router
组件了
在React中, Router
组件是用来提供上下文的, 而BrowserRouter
创建了一个控制浏览器history api
的history
对象以后然后传递给Router
我们在
react-router
中新建一个文件Router.js
, 同时我们新建一个RouterContext.js
, 用于存储上下文
// react-router/RouterContext.js
import {
createContext } from "react";
const routerContext = createContext();
routerContext.displayName = "Router";
export default routerContext;
// 我们知道: 这个Router组件是一定需要一个history对象的, 他不管history对象是怎么来的, 但是必须通过属性传递给他
import React, {
useState, useEffect } from "react";
import pathMatch from "./pathMatch";
import routerContext from "./RouterContext";
/** * Router组件要做的事情就只有一个: 他要提供一个上下文 * 上下文中的内容有history, match, location * * 我们知道创建history的时候, 有createBrowserHistory, createHashHistory等 * 所以我们在Router里怎么都不能写死, 我们把history作为属性传递过来 * 而在外部我们在根据不同的组件来创建不同的history传递给Router组件, * React也是这么做的 * @param {*} props */
export default function Router(props) {
// 我们在Router中写的逻辑如下:
// 1. 将match对象, location对象和history对象都拿到然后进行拼凑
// 2. 如果一旦页面地址发生变化, Router要重新渲染以响应变化, 怎么响应, 就是通过listen方法
// 为什么要将location变成状态, 主要是因为当我们的页面地址产生变化的时候, 我们需要做的事情有几个
// - 将history里action的状态进行变更, 比如go 要变成POP, push要变成PUSH, 如果我们没有自己的状态
// 那么我们没有地方可以修改这个location了
// - 当页面地址发生变化的时候, 我们需要重新渲染组件, 我们可以使用listen来监听, 但是重新渲染组件我们
// 可以使用自己封装一个forceUpdateHook来处理, 但是如果有了location状态, 可以一石二鸟不是更好
const [locationState,