React Router 入门与原理

2,340 阅读4分钟

前言

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

这是一个嵌套路由示例,最终效果是这样的: QQ20201029-060223.gif

随着 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>;
}

QQ20201031-064043.gif

一个简单的案例可以跑成功了。

总结

React Router 本身原理并不复杂,通过学习它的源码,我们还是可以 get 到很多新的知识点。 对比 Redux 的实现你会发现都是通过 Context 去下发了全局对象。而且都使用了发布订阅的方式来通知组件进行更新。

点击查看>>>代码托管地址

如果喜欢本文请点个赞吧!