前言
在 React 技术栈的学习过程中, React Router 已经成了一个必需品。它是完整的 React 路由解决方案。
本文还是希望读者不仅仅学会 React Router 的使用,更加要理解它的实现原理。
因此作者打算分以下两个板块进行讲解:
1、 React Router 理论篇:详细讲解 React Router 一系列的概念。
2、 React Router 原理篇:一步一步实现一个简单版 Router 。
React Router 理论篇
什么是前端路由
对 url 进行改变和监听,来让某个 dom 节点显示对应的视图。
基础示例
import React from "react";
import {
BrowserRouter as Router,
Switch,
Route,
Link,
useRouteMatch,
useParams
} from "react-router-dom";
export default function App() {
return (
<Router>
<div>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/about">About</Link>
</li>
<li>
<Link to="/topics">Topics</Link>
</li>
</ul>
<Switch>
<Route path="/about">
<About />
</Route>
<Route path="/topics">
<Topics />
</Route>
<Route path="/">
<Home />
</Route>
</Switch>
</div>
</Router>
);
}
function Home() {
return <h2>Home</h2>;
}
function About() {
return <h2>About</h2>;
}
function Topics() {
let match = useRouteMatch();
return (
<div>
<h2>Topics</h2>
<ul>
<li>
<Link to={`${match.url}/components`}>Components</Link>
</li>
<li>
<Link to={`${match.url}/props-v-state`}>
Props v. State
</Link>
</li>
</ul>
<Switch>
<Route path={`${match.path}/:topicId`}>
<Topic />
</Route>
<Route path={match.path}>
<h3>Please select a topic.</h3>
</Route>
</Switch>
</div>
);
}
function Topic() {
let { topicId } = useParams();
return <h3>Requested topic ID: {topicId}</h3>;
}
这是一个嵌套路由示例,最终效果是这样的:
随着 URL 的不断切换,前端路由也切换展示着相应的组件,并且不会触发页面整体刷新,这就是前端路由,不要把它想的太复杂。
在这个示例中,我们看到很多新鲜的面孔: Router 、 Switch 、 Route 等等。接下来我们就总结下, React Router 中常用的 API 的含义。
路由器
BrowserRouter
每个 React Router 应用的核心应该是路由器组件。对于 web 项目, react-router-dom 提供 BrowserRouter 和 HashRouter 路由器。
BrowserRouter 使用的是常规路径, http://baidu.com/path/a1 ,其原理是通过 JavaScript 的 history 对象实现,具体在原理篇我们会详细讲解。
HashRouter
HashRouter 使用的是 hash 路径,http://baidu.com/#/path/a1。
其原理是通过 location.hash = 'foo' 这样的语法来改变,路径就会由 baidu.com 变更为 baidu.com/#foo 。
通过 window.addEventListener('hashchange') 这个事件,就可以监听到 hash 值的变化。
路由匹配器
有两种路由匹配组件: Switch 和 Route 。 当渲染 Switch 组件时,将搜索它的 Route 子元素,它将呈现找到的第一个路径匹配的 Route 并忽略所有的其他路由。
Route
定义展示组件的相对 path ,当 path 匹配时会渲染相应组件。
<Route path="/contact">
<Contacts />
</Route>
Switch
找到第一个路径匹配的 Route,反之,如果没有 Switch 则渲染所有路径匹配的 Route 组件。
<div>
<Route path="/about">
<About />
</Route>
<Route path="/:user">
<User />
</Route>
<Route>
<NoMatch />
</Route>
</div>
当路径匹配 /about 时,3个组件全部匹配了,都会展示出来。但是如果你只想匹配一个组件,那么该如何处理呢?
<Switch>
<Route exact path="/">
<Home />
</Route>
<Route path="/about">
<About />
</Route>
<Route path="/:user">
<User />
</Route>
<Route>
<NoMatch />
</Route>
</Switch>
此时当我们再次搜索 /about 路径时,当匹配到 <Route path="/about"> 就不会再接着往下找了,则只输出 <About /> 组件。
Switch 组件的概念也类似 JavaScript switch 函数。
导航
Link 与 Redirect
跳转与重定向,这个就非常简单了。
<Link to="/">Home</Link> // 点击切换路径到根目录
<Redirect to="/login" /> // 重定向到登录切面
Hook
React Router 带有一些 hooks ,可让您访问路由器状态并从组件内部执行路由跳转。
useHistory:获取history对象,主要作用是路由调整,它等同于Link组件的功能。useLocation:获取location对象,只要URL更改,它就会返回一个新位置。useParams:获取URL匹配参数值,如路由定义/blog/:slug,当访问/blog/bar时,通过let { slug } = useParams();slug值就等于bar。
以上便是 React Router 一些核心的概念,还是非常浅显易懂的。接下来我们就一起来实现一个 Router 把。
React Router 原理
实现浏览器端的路由,通常有两种方式,一种是 hash 、一种是 H5 提供的 history 对象,本文将使用 history 对象来实现路由。
history
pushState()
history.pushState(state, title[, url])
state,代表状态对象,这让我们可以给每个路由创建自己的状态;title,暂时没有作用;url,URL路径。
实例:
history.pushState({foo:'foo'}, '', foo)
可以让 example.com 变化为 example.com/foo。
类似的方法还有 replaceState() ,它与 pushState() 非常相似,区别在于前者是替换历史记录,后者是新增历史记录。
history 不会使浏览器重新加载
通过 location.href = 'baidu.com/foo' 这种方式来跳转,是会让浏览器重新加载页面并且请求服务器的,但是 history.pushState() 的神奇之处就在于它可以让 url 改变,但是不重新加载页面,完全由用户决定如何处理这次 url 改变,这也是实现前端路由必备的特性。
history 路由刷新后会,页面会报 404
本质上是因为刷新以后是带着 baidu.com/foo 这个页面去请求服务端资源的,但是服务端并没有对这个路径进行任何的映射处理,当然会返回 404,处理方式是让服务端对于"不认识"的页面,返回 index.html ,这样这个包含了前端路由相关 JavaScript 代码的首页,就会加载你的前端路由配置表,并且此时虽然服务端给你的文件是首页文件,但是你的 url 上是 baidu.com/foo ,前端路由就会加载 /foo 这个路径相对应的视图,完美的解决了 404 问题。
history 库
React Router 官方使用的是 github.com/ReactTraini… 这个库。
它的基本使用如下:
import { createBrowserHistory } from 'history';
// 创建一个 history 对象
const history = createBrowserHistory();
// 获取 location 对象
const location = history.location;
// 监听 history 对象
const unlisten = history.listen((location, action) => {
console.log(action, location.pathname, location.state);
});
// 跳转到指定地址,往浏览器会话历史栈中推入一条新记录。
history.push('/home', { some: 'state' });
listen 方法的简单实现如下:
let listeners = [];
function listen(fn) {
listeners.push(fn);
}
解析: listeners 就是一个简单的数组,当有函数监听,就推入数组中。
push 方法的简单实现如下:
function push(to, state) {
window.history.pushState(state, '', to);
listeners.forEach(fn => fn(location));
}
解析:
- 底层是通过调用
history.pushState方法改变URL; - 然后执行订阅函数(其实就是一次简单的发布订阅过程)。
path-to-regexp 库
React Router 使用的另外一个重要的工具库就是 path-to-regexp ,用来处理 url 中地址与参数,能够很方便得到我们想要的数据。
使用实例:
import { pathToRegexp } from "path-to-regexp";
const regexp = pathToRegexp("/foo/:bar"); // 获得正则 regexp = /^\/foo\/([^\/]+?)\/?$/i
# 最后通过执行正则匹配方法,获得相应数据
regexp.exec(str);
有了这些知识储备,我们就可以动手来实现一个简单版 React Router 了。
imitation-react-router
我们回顾下 React Router 常用组件的作用:
BrowserRouter,提供history对象,并通过Context下发到所有子孙组件;Switch,找到第一个路径匹配的Route;Route,当path匹配时则渲染相应组件;Link,拦截a标签点击事件并通过history.push方法改变URL。
在正式编写 React 组件之前,我们先实现两个工具方法: Context 与 matchPath 。
Context.js
初始化一个 Context 。
import React from "react";
const Context = React.createContext(null);
export default Context;
matchPath.js
确定路由与路径是否匹配,也就是/topics/:id 与 /topics/components 是否匹配。
import { pathToRegexp } from "path-to-regexp";
export function matchPath(pathname, path) {
const keys = [];
const regexp = pathToRegexp(path, keys);// {1}
const match = regexp.exec(pathname); // {2}
if (!match) return null;
const values = match.slice(1);
return {
params: keys.reduce((memo, key, index) => {
memo[key.name] = values[index];
return memo;
}, {})
};
}
解析:
- {1},通过
pathToRegexp方法,根据传入的路径例如/topics/:id,生成对应的匹配正则/^\/topics(?:\/([^\/#\?]+?))[\/#\?]?$/i; - {2},执行正则的
exec的方法,匹配传入的实际路径,例如/topics/components; - 最后输出一个
params对象,里面包含的内容大概如下:
params:{
"id": "components"
}
BrowserRouter
创建 history 对象,使用 Context 下发到所有子孙节点,我们来看看它的具体实现吧。
import React, { useState, useCallback } from "react";
import { history } from "../lib-history/history";
import Context from "./Context";
const BrowserRouter = props => {
// {1}
const [location, setLocation] = useState(history.location);
// {2}
const computeRootMatch = useCallback(pathname => {
return { path: "/", url: "/", params: {}, isExact: pathname === "/" };
}, []);
// {3}
history.listen(location => {
setLocation(location);
});
// {4}
return (
<Context.Provider
value={{ history, location, match: computeRootMatch(location.pathname) }}
>
{props.children}
</Context.Provider>
);
};
export default BrowserRouter;
解析:
- {1},根据
history.location创建状态; - {2},初始化根路径的
match对象; - {3},监听
history对象,当它发生变化后,执行location状态,从而刷新所有组件; - {4},通过上面创建的
Context,把history,location,match对象当做value值传入。
Route
有两种用法,一种是包含在 Switch 内,一种是独立使用。
import React, { useContext } from "react";
import Context from "./Context";
import { matchPath } from "./matchPath";
const Route = props => {
// {1}
const { location, history } = useContext(Context);
// {2}
const match = props.computedMatch
? props.computedMatch
: matchPath(location.pathname, props.path);
return (
<Context.Provider value={{...match,location,history}}>
{/* 3 */}
{match ? props.children : null}
</Context.Provider>
);
};
export default Route;
解析:
- {1},使用
useContext Hook,获取context中的值; - {2},
props.computedMatch是包含在Swtich在内的时候会传入的一个属性,因此判断该属性是否存在,如果存在,则是使用Switch传入进来的match对象,如果没有props.computedMatch,表示Route是独立使用,则使用matchPath去做匹配,获取到匹配后的结果; - {3},根据匹配结果判断是否渲染组件。
Link
该组件的主要作用就是拦截 a 标签的点击事件。
import React, { useContext, useCallback } from "react";
import Context from "./Context";
const Link = ({ to, children }) => {
const { history } = useContext(Context);
const handleOnClick = useCallback(
event => {
event.preventDefault();
history.push(to);
},
[history, to]
);
return (
<a href={to} onClick={handleOnClick}>
{children}
</a>
);
};
export default Link;
Switch
找到第一个路径匹配的 Route 。
import React, { useContext } from "react";
import Context from "./Context";
import { matchPath } from "./matchPath";
const Switch = props => {
const context = useContext(Context);
const location = context.location;
let element,
match = null;
// {1}
React.Children.forEach(props.children, child => {
if (match === null && React.isValidElement(child)) {
element = child;
// {2}
const path = child.props.path;
// {3}
match = path
? matchPath(location.pathname, child.props.path)
: context.match;
}
});
// {4}
return match
? React.cloneElement(element, { location, computedMatch: match })
: null;
};
export default Switch;
解析:
- {1},通过
React.Children获取到Switch组件下的所有子孙元素,并且遍历它们; - {2},获取
Route的path属性值; - {3},通过判断
path是否有值,有值则进行路径匹配matchPath,没有值的话则使用BrowserRouter传入进行来的match对象,也就意味着如果<Route><Foo /></Route>不设置路径,则会被默认设置上根路径进行匹配。 - {4},如果匹配上了则通过
React.cloneElement方法返回一个新的克隆元素并将{ location, computedMatch: match }作为props传入。
Hooks
import React from "react";
import Context from "./Context";
export function useParams() {
return React.useContext(Context).params;
}
export function useHistory() {
return React.useContext(Context).history;
}
export function useLocation() {
return React.useContext(Context).location;
}
Hook 的实现就相对简单很多了,主要是通过 Context 拿到相应的对象,返回出去。
整体实现,没有考虑过多的边界条件,主要是为了能快速理解 Router 的原理。最后看一个简单例子:
import React from "react";
import {
BrowserRouter as Router,
Switch,
Route,
Link,
useParams,
} from "./imitation-react-router";
export default function App() {
return (
<Router>
<div>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/about">About</Link>
</li>
<li>
<Link to="/topics">Topics</Link>
</li>
<li>
<Link to={`/topics/components`}>Components</Link>
</li>
</ul>
<Switch>
<Route path="/about">
<About />
</Route>
<Route path={`/topics/:id`}>
<Topic />
</Route>
<Route path="/home">
<Home />
</Route>
</Switch>
</div>
</Router>
);
}
function Home() {
return <h2>Home</h2>;
}
function About() {
return <h2>About</h2>;
}
function Topic() {
let { id } = useParams();
return <h3>Requested topic ID: {id}</h3>;
}
一个简单的案例可以跑成功了。
总结
React Router 本身原理并不复杂,通过学习它的源码,我们还是可以 get 到很多新的知识点。
对比 Redux 的实现你会发现都是通过 Context 去下发了全局对象。而且都使用了发布订阅的方式来通知组件进行更新。
如果喜欢本文请点个赞吧!