react-router-dom使用及源码实现

441 阅读8分钟

react-router-dom使用及源码实现

React-router-dom 是专门用于web应用的路由解决方案。

1、Router、Link、Route

1.1、基本使用

首先编写一个简单的示例:

import React from "react";
import {
  BrowserRouter as Router,
  Route,
  Link
} from "react-router-dom";

export default function App() {
  return (
    <div className="App">
      <Router>
        <Link to="/">首页</Link>
        <Link to="/user">用户中心</Link>
        <Link to="/login">登录</Link>
        <Link to="/product/123">商品</Link>

        <Route
          path="/"
          children={() => HomePage({ data: "children" })}
          component={HomePage}
          render={() => <HomePage data="render" />}
        />
        <Route path="/user" component={UserPage} />
        <Route path="/login" component={LoginPage} />
      </Router>
    </div>
  );
}

function HomePage(props) {
  console.log("index", props);
  return <div>首页 - {props.data}</div>;
}

function UserPage(props) {
  return <div>用户中心</div>;
}

function LoginPage(props) {
  return <div>登录</div>;
}

示例中使用了 react-router-dom 的三个组件 BrowserRouterRouteLink

Router 组件就好比路由器,Link 就是路由器的网线接口和网线,Route 就是和路由器连接的各种设备。

有了路由器,网线和设备才有存在的意义,所以 Route 和 Link 组件必须放在 Router 组件中。

Link 组件的 to 属性指定了点击该链接后会跳转的路由,Route 组件的 path 属性指定了该 Route 组件会匹配的路由,如果匹配上了就渲染 children , component , render 属性指定的组件(这三个属性都是用来指定待渲染组件的),三者区别如下:

children

即使路由不匹配也会渲染出指定的组件

component

只有路由匹配了才会渲染出指定组件,最好不要采用内联函数的方式传值,这样会导致每次重新渲染都会生成新的组件进行挂载,卸载之前的组件,而不是在之前组件上进行更新。如果要使用内联函数的形式,最好使用 render 或 children

render

适合传递内联函数,在路由匹配时执行该函数

三者的优先级为 children > component > render

1.2、源码实现

要想实现路由相关组件,我们需要借助一个库 history,这个库提供了一个方法 createBrowserHistory,可以用来创建一个 history 对象,保存路由历史记录等信息,也可以监听路由变化。

1.2.1 Link 组件

先从最简单的开始,那就是 Link 组件,Link 组件的实质就是一个 a 标签

<Link to="/">首页</Link>
<Link to="/user">用户中心</Link>
<Link to="/login">登录</Link>
<Link to="/product/123">商品</Link>

image-20210908154131866.png

import { useContext } from "react";
import RouterContext from "./RouterContext";

export default function Link(props) {
  const { to, children } = props;
  const context = useContext(RouterContext);

  function onClick(e) {
    // 阻止默认行为
    e.preventDefault();
    // 使用 Router 组件传递的 context 中的 history 对象来改变路由
    context.history.push(to);
  }
  return (
    <a href={to} onClick={onClick}>
      {children}
    </a>
  );
}

Link 组件的 to 属性就是 a 标签的 href 属性,但是点击 a 标签的默认行为会刷新页面,而我们的SPA应用在跳转路由时是不会刷新页面的,因此需要需要给 a 标签添加 onClick 方法,阻止默认行为,然后使用 history 对象的 push 方法(这里的 history 对象是通过 context 进行传递的)。推理一下可以知道,这里的 context 其实就是从 Router 组件传递下来的,这就是为什么 Link、Route 等组件必须写在 Router 组件中的原因。

1.2.2 Router 组件

接下来看一下 Router 组件的实现

import React, { useState, useEffect } from "react";
import RouterContext from "./RouterContext";

export default function Router(props) {
  const { history } = props;
  const [state, setState] = useState({ location: history.location });

  function computeRootMatch(pathname) {
    return { path: "/", url: "/", params: {}, isExact: pathname === "/" };
  }

  useEffect(() => {
    const unlisten = history.listen(({ location }) => {
      console.log("location", location);
      setState({ location });
    });
    return () => {
      unlisten();
    };
  });

  const routeObj = {
    history: history,
    location: state.location,
    match: computeRootMatch(state.location.pathname)
  };

  return (
    <RouterContext.Provider value={routeObj}>
      {props.children}
    </RouterContext.Provider>
  );
}

Router 组件的 props 中有一个 history 对象,主要用来获取路由的 location 信息,以及监听路由的变化。

同时 Router 组件还利用 Context 将 history、location 等路由信息传递给下层组件,所以我们需要创建一个 Context 对象

import React from "react";

const RouterContext = React.createContext();

export default RouterContext;

当 Link 组件被点击时,会调用 history.push 方法将路由地址加入到 history 对象中,然后Route组件中的 history 对象就能通过 listen 方法监听到路由变化,使用 setState 更新路由,触发组件渲染,这样 Router 组件下的 Route 组件也会触发更新,Route 组件更新的时候就拿着最新的路由地址和自己的 path 属性进行匹配,匹配成功就渲染 Route 组件 component 或者 render 属性指定的组件。所以 Route 组件的实现思路大致也有了。

实现 Route 组件之前,我们先看看 Router 组件 props 上的 history 怎么来的。

react-router-dom 中 Router 组件有几个不同的类型,例如:BrowserRouterHashRouter

因此可以推断出 Router 组件的上层就是这些类型的组件,是他们将不同类型的 history 对象传递给 Router 组件的。

BrowserRouter 组件的实现

import React from "react";
import { createBrowserHistory } from "history";
import Router from "./Router";

export default function BrowserRouter(props) {
  const history = createBrowserHistory();
  return <Router history={history}>{props.children}</Router>;
}
1.2.3 Route 组件

接下来就可以实现最关键的 Route 组件了

import React, { useContext } from "react";
import { matchPath } from "react-router-dom";
import RouterContext from "./RouterContext";

export default function Route(props) {
  const context = useContext(RouterContext);
  const { path, children, component, render, computedMatch } = props;
  const { location } = context;

  const match = computedMatch
    ? computedMatch
    : path
    ? matchPath(location.pathname, props)
    : context.match; // location.pathname === path;

  const routeProps = { ...context, match };

  // 1. match 匹配:就按照 children component render 的优先级渲染组件
  // 2. 不匹配:如果 children 为 function,则调用,否则渲染null
  let result = null;
  if (match) {
    if (children) {
      result = typeof children === "function" ? children(routeProps) : null;
    } else if (component) {
      result = React.createElement(component, routeProps);
    } else if (render) {
      result = render(routeProps);
    }
  } else {
    result = typeof children === "function" ? children(routeProps) : null;
  }

  return (
    <RouterContext.Provider value={routeProps}>{result}</RouterContext.Provider>
  );
}

首先我们通过 props 获取 Route 组件上的一些属性 path, children, component, render ,然后从 context 中获取当前的 location 对象,里面保存着当前路由地址。然后拿着 path 和当前 location 信息进行匹配,这里为了方便我们使用了 react-router-dom 提供的一个方法 matchPath 来进行路由匹配,并返回匹配信息

const match = matchPath('/users/123', {
  path: '/users/:id',
  exact: true,
  strict: false
})

// 返回的 match 对象格式如下:
{
  path: '/users/:id',
  url: '/user/123',
  isExact: true,
  params: {
    id: '123'
  }
}

获取到 match 对象后,说明匹配成功,然后就按照 children > component > render 的顺序,对组件进行渲染。

到这里我们最基础的 react-router-dom 就完成了。

总结一下:

1、Router 组件将 history 对象通过 Context 传递给下层的 Link 和 Route 组件;

2、点击 Link 组件触发 history 对象的更新;

3、Route 组件监听 history 对象的变化,拿到最新的路由信息和自身的 path 进行比较,如果匹配上就去渲染 component 或 render 属性指定的组件。

2、Switch

2.1、基本使用

将 Route 组件放在 Switch 组件中,就只会渲染第一个与路由地址匹配到的 Route 组件。

如果只是一堆 Route 组件,当路由地址为 /about 时,About、User、NoMatch 三个组件都将被渲染,

如果用 Switch 包裹,就只会渲染 About 组件。

<Route path="/about" component={About}/>
<Route path="/:user" component={User}/>
<Route component={NoMatch}/>

<Switch>
  <Route exact path="/" component={Home}/>
  <Route path="/about" component={About}/>
  <Route path="/:user" component={User}/>
  <Route component={NoMatch}/>
</Switch>

2.2、源码实现

可以猜想一下 Switch 的实现方式:对 Switch 的子组件进行遍历,找到第一个与当前路由地址匹配的组件,然后进行渲染。

import React, { useContext } from "react";
import { matchPath } from "react-router-dom";
import RouterContext from "./RouterContext";

export default function Switch(props) {
  const { location, match } = useContext(RouterContext);
  let computedMatch = null; // 标记是否匹配
  let matchEle = null; // 标记匹配到的元素

  // 使用 React.Children.forEach 来遍历 Switch 的子组件
  React.Children.forEach(props.children, (child) => {
    if (!computedMatch && React.isValidElement(child)) {
      matchEle = child;
      computedMatch = child.props.path
        ? matchPath(location.pathname, child.props)
        : match;
    }
  });
  
  let result = match ? React.cloneElement(matchEle, { computedMatch }) : null;

  return result;
}

上述代码的核心就是使用 React.Children.forEach 方法来遍历 Switch 的子组件。

因为 props.children 的值存在三种可能:

  1. 没有子节点,为 undefined
  2. 一个子节点,为一个 object
  3. 多个子节点,为一个对象数组

自己遍历不太好处理,React 就提供了这个方法来方便遍历子组件。找到第一个匹配的子组件后,使用 React.cloneElement 克隆一个该组件,并把匹配信息(即 matchPath 方法返回的 match 对象)传给该组件。

3、Redirect

3.1、基本使用

渲染 <Redirect> 将导航到一个新的地址。这个新的地址会覆盖 history 栈中的当前地址,类似服务器端(HTTP 3xx)的重定向。

<Link to="/user">用户中心</Link>
<Link to="/login">登录</Link>
<Link to="/product/123">商品</Link>
<Link to="/redirect">重定向</Link>

<Route path="/user" component={UserPage} />
<Route
   path="/redirect"
   render={() => <Redirect to="/user"></Redirect>}
/>

当点击链接 重定向 时,就会渲染 Route 组件,Route 组件实际会渲染 Redirect 组件,然后就会导航到一个新的地址,即 /user,从而渲染出 UserPage 组件。

3.2、源码实现

import { useContext, useEffect } from "react";
import RouterContext from "./RouterContext";

export default function Redirect(props) {
  const context = useContext(RouterContext);
  const { history } = context;
  const { to } = props;

  return <LifeCycle onMount={() => history.push(to)}></LifeCycle>;
}

function LifeCycle(props) {
  useEffect(() => {
    props.onMount();
  });

  return null;
}

4、类组件和函数组件如何获取 history 对象

在前面实现 Route 组件的时候,我们会将 context 对象传递给待渲染的组件,也就是 history,location,match 三个对象。

  if (match) {
    if (children) {
      console.log("children", children);
      result = typeof children === "function" ? children(routeProps) : null;
      console.log("result", result);
    } else if (component) {
      result = React.createElement(component, routeProps);
    } else if (render) {
      result = render(routeProps);
    }
  } else {
    result = typeof children === "function" ? children(routeProps) : null;
  }

因此我们渲染出的组件的props中可以拿到这三个属性,例如:this.props.location

但是如果我们渲染出的组件下还有子组件,在子组件里就拿不到这三个对象了,就没办法使用 history.push 进行路由跳转,使用 location.search 获取路由参数,除非父组件再把这三个属性传给子组件,但是每次子组件都要传太麻烦了。所以 react-router-dom 给我们提供了一些方法,可以直接拿到 history 等路由对象:

4.1、基本使用
  1. 类组件,可以使用 withRouter 高阶组件将我们的组件进行包装,然后就可以在 props 中拿到 history 等对象了。

    class Detail extends Component {
      constructor(props) {
        super(props)
        console.log('Detail', props)
      }
      render() {
        return <div>详情页面</div>
      }
    }
    Detail = withRouter(Detail)
    

    如果不用 withRouter 组件进行包装,Detail 组件的 props 就没有 history 等属性

image-20210910150339930.png

  1. 函数组件,可以使用 useHistory,useLocation,useRouteMatch,useParams 这几个 hooks

    function DetailFunc(props) {
      const history = useHistory()
      const location = useLocation()
      const match = useRouteMatch()
      const params = useParams()
      console.log('DetailFunc routeProps', { history, location, match, params })
      console.log('DetailFunc', props)
      return <div>函数式组件Detail</div>
    }
    

image-20210910150729386.png

4.2、源码实现

1、withRouter

本质是一个函数,接收一个组件为参数,返回一个新的函数式组件。它只做了一件事,就是获取到 Context 对象,然后传递给入参组件。

import RouterContext from './RouterContext'

const withRouter = (WrappedComponent) => (props) => {
  return (
    <RouterContext.Consumer>
      {(context) => {
        return <WrappedComponent {...props} {...context} />
      }}
    </RouterContext.Consumer>
  )
}

export default withRouter

2、hooks

hooks 的实现更简单,就是使用 useContext 获取到 context 对象,再把里面的 history,location,match 等对象返回就好了。

// useHistory,
// useLocation,
// useRouteMatch,
// useParams,

import { useContext } from 'react'
import RouterContext from './RouterContext'

export function useHistory() {
  return useContext(RouterContext).history
}

export function useLocation() {
  return useContext(RouterContext).location
}

export function useRouteMatch() {
  return useContext(RouterContext).match
}

export function useParams() {
  const match = useContext(RouterContext).match
  return match ? match.params : {}
}

至此,react-router-dom 的基本功能就实现完成了。

演示代码:my-router-dom