React系列之手写rect-router-dom

354 阅读6分钟

react-router 是 React 官方维护的一个路由库,它通过管理 URL,实现组件的切换和状态的变化。

react-router 包含3个库,react-router、react-router-dom、react-router-native。react-router 提供最基本的路由功能,在实际使用中我们不会直接安装 react-router,而是根据应用运行的环境选择安装 react-router-dom (在浏览器中使用) 或 react-router-native (在 rn 中使用)。react-router-dom和 react-router-native都依赖react-router,所以在安装时,react-router也会自动安装。

react-router-dom

react-router-dom是React Router的DOM绑定, 提供了浏览器环境下的功能, 比如<Link>, <BrowserRouter>等组件;

下面,我们来实现一个简单版的 react-router-dom:

RouterContext 组件实现

import React from 'react';

export const RouterContext = React.createContext();

Router 组件实现

在 Router 的构造函数中,声明 this.state.location,然后使用 history 的监听函数 listen 对 history.location 进行监听,并将 history.listen 的返回值赋值给 this.unlisten,用于取消监听。

在 中, 使用 <RouterContext.Provider> 进行路由数据传递(history,location, match)。

import React, { Component }  from 'react';
import { RouterContext } from './Context';

export default class Router extends Component {
  constructor(props) {
    super(props);
    this.state = {
      location: props.history.location
    }
    // 监听 location 变化
    this.unlisten = props.history.listen(location => this.setState({location})
  }

  static computeRootMatch(pathname) {
    return { path: '/', url: '/', params: {}, isExact: pathname === '/' }
  }

  componentWillUnMount() {
    // 取消监听
    if (this.unlisten) this.unlisten()
  }

  render() {
    return (
      // 使用 <RouterContext.Provider> 进行路由数据传递
      <RouterContext.Provider
        value={{
          history: this.props.history,
          location: this.state.location,
          match: Router.computeRootMatch(this.state.location.pathname)
        }}
      >
        {this.props.children} // 渲染<Router>的子组件内容
      </RouterContext.Provider>
    )
  }

}

BrowserRouter 组件实现

在 中,使用 history 的 createBrowserHistory 方法,将 props 作为参数,创建一个 history 实例,并将 history 传入 Router 组件中。

import React, { Component } form 'react';
import { createBrowserHistory } from 'history';
import Router from './Router';

export default class BrowserRouter extends Component {
  constructor(props) {
    super(props);
    this.history = createBrowserHistory();
  }

  render() {
    return <Router history={this.history}>{this.props.children}</Router>
  }
}

HashRouter 组件实现

import React, { Component, Children } from 'react';
import { createHashHistory } from 'history';
import { RouterContext } from './RouterContext';
import Router from './Router';

export default class HashRouter extends Component {
    constructor(props) {
    super(props);
    this.history = createHashHistory();
  }

  render() {
    return <Router history={this.history} children={this.props.children} />
  }
}

MemoryRouter 组件实现

把 URL 的历史记录保存在内存中的 (不读取,不写入地址栏)。在测试和非浏览器环境中非常有用,如 React Native.

import React, { Component, Children } from 'react';
import { createMemoryHistory } from 'history';
import { RouterContext } from './RouterContext';
import Router from './Router';

export default class MemoryRouter extends Component {
  constructor(props) {
    super(props);
    this.history = createMemoryHistory();
  }

  render() {
    return <Router history={this.history} children={this.props.children} />
  }
}

Route 组件实现

可能是 react-router 中最重要的组件,其职责是在路径与当前 URL 匹配时渲染对应的UI组件。

与其他路由组件一样,使用 <RouterContext.Consumer> 接收全局路由信息。使用 <RouterContext.Provider> 进行路由数据传递。

import React, { Component } from 'react';
import { RouterContext } from './Context';
import matchPath from './matchPath';

export default class Route extends Component {
  render() {
    return (
      <RouterContext.Consumer>
        {
          context => {
            const location = context.location;
            const { path, children, component, render } = this.props;
            const match = this.props.computedMatch 
              ? this.props.computedMatch
              : path
                ? matchPath(location.pathname, this.props)
                : context.match;

            const props = {
              ...context,
              match
            }

            return (
              // 使用 <RouterContext.Provider> 进行路由数据传递
              <RouterContext.Provider value={props}>
                {
                  match
                    ? children // 渲染方式为 children
                      ? typeof children === "function"
                        ? children(props) // 传递给 children 的是一个 function,执行该function
                        : children // 传递给 children的 是节点元素
                      : component // 渲染方式为 component
                        ? React.createElement(component, props) // 创建一个新的 React Element
                        : render // 渲染方式为 render   
                          ? render(props) 
                          : null // Route 组件的三种渲染方式都没有时
                    : typeof children === "function"
                      ? children(props) // 路由不匹配时,仅渲染children 为 function的情形
                      : null
                }
              </RouterContext.Provider>
            )
          }
        }
      </RouterContext.Consumer>
    )
  }
}

的渲染方式有三种,分别是 children、component、render。由上面的代码可知,这三种渲染方式是互斥。每次只能用其中的一种渲染方式。三者的渲染优先级是 children > component > render。

无论 props.match 是否为 true,当 的 children 为 函数时都会进行渲染。

matchPath 方法实现

主要用于匹配路由, 匹配成功则返回一个match对象, 若是匹配失败, 则返回null;

import pathToRegexp from "path-to-regexp";

const cache = {};
const cacheLimit = 10000;
let cacheCount = 0;

function compilePath(path, options) {
  const cacheKey = `${options.end}${options.strict}${options.sensitive}`;
  const pathCache = cache[cacheKey] || (cache[cacheKey] = {});

  if (pathCache[path]) return pathCache[path];

  const keys = [];
  const regexp = pathToRegexp(path, keys, options);
  const result = { regexp, keys };

  if (cacheCount < cacheLimit) {
    pathCache[path] = result;
    cacheCount++;
  }

  return result;
}

/**
 * Public API for matching a URL pathname to a path.
 */
function matchPath(pathname, options = {}) {
  if (typeof options === "string" || Array.isArray(options)) {
    options = { path: options };
  }

  const { path, exact = false, strict = false, sensitive = false } = options;

  const paths = [].concat(path);

  return paths.reduce((matched, path) => {
    if (!path && path !== "") return null;
    if (matched) return matched;

    const { regexp, keys } = compilePath(path, {
      end: exact,
      strict,
      sensitive
    });
    const match = regexp.exec(pathname);
    if (!match) return null;

    const [url, ...values] = match;
    const isExact = pathname === url;

    if (exact && !isExact) return null;

    return {
      path, // the path used to match
      url: path === "/" && url === "" ? "/" : url, // the matched portion of the URL
      isExact, // whether or not we matched exactly
      params: keys.reduce((memo, key, index) => {
        memo[key.name] = values[index];
        return memo;
      }, {})
    };
  }, null);
}

export default matchPath;

Link 组件实现

实现了路由的跳转;
import React, { Component } from 'react';
import { RouterContext } from './Context';

export default class Link extends Component {
  static contextType = RouterContext;
    handleClick = e => {
    e.preventDefault();
    this.context.history.push(this.props.to);
  };

    render() {
    const { children, to, ...restProps } = this.props;
    return (
      <a href={to} {...restProps} onClick={this.handleClick}>
        { children }
      </a>
    )
  }
}

Switch 组件实现

使用 <RouterContext.Consumer> 进行数据接收; 对路由组件( 或者是 ) 进行顺序匹配,找到第一个匹配的 或者 。

使用 React.Children.forEach 方法 对 的子组件进行遍历,其遍历逻辑如下:

首先使用 React.isValidElement 判断子组件是否为有效的 element:

  • 无效,则结束当前循环,进行下一轮循环
  • 有效,则获取通过 child.props.path 或者 child.props.from 获取 path

PS: 使用 path 进行路由地址声明, 使用 from 进行重定向来源地址声明

获取 path 之后,接着判断 path 是否存在:

  • 若存在path: 表示子组件存在路由映射关系, 使用matchPath对path进行匹配, 判断路由组件的路径与当前location.pathname是否匹配:
  • 若是匹配, 则对子组件进行渲染, 并将matchPath返回的值作为computedMatch传递到子组件中, 并且不再对其他组件进行渲染;
  • 若是不匹配, 则直接进行下次循环; 注意: location可以是外部传入的props.location, 默认为context.location;
  • 若不存在path: 表示子组件不存在路由映射关系, 直接渲染该子组件, 并将context.match作为computedMatch传入子组件中;
import React, { Component } from 'react';
import { RouterContext } from './Context';
import matchPath from './matchPath';

export default class Switch extends Component {
  render() {
    return (
      <RouterContext.Consumer>
        {
          context => {
            const { location } = context;
            let match = undefined; // 匹配的 match
            let element = undefined; // 匹配的元素
            React.Children.forEach(this.props.children, child => {
              // child 是 Route 或 Redirect
              if (match == null && React.isValidElement(child)) {
                element = child;
                // <Route> 使用 path 进行路由地址声明,<Redirect> 使用 from 进行重定向来源地址声明
                const path = child.props.path || child.props.from;
                match = path
                  ? matchPath(location.pathname, child.props)
                    : context.match;
              }
            });

            return match
              ? React.cloneElement(element, {computedMatch: match})
                : null;
          }
        }
      </RouterContext.Consumer>
    )
  }
 }

withRouter 高阶组件实现

是一个高阶组件,支持传入一个组件,返回一个能访问路由数据的路由组件,实际上是将组件作为 <RouterContext.Consumer> 的子组件,并将 context 的路由信息作为 props 注入组件中。

import React, { Component } from './react';
import { RouterContext } from './Context';

// withRouter 是一个高阶组件
const withRouter = WrappedComponent => props => {
  return (
    <RouterContext.Consumer>
      {
        context => <WrappedComponent {...props} { ...context } />
      }
    </RouterContext.Consumer>
  )
}

export default withRouter;

Redirect 组件实现

使用 接收路由数据。

通过传入的 push 确定其跳转方式是 push 还是 replace。

import React, { Component } from 'react';
import { RouterContext } from './Context';
import LifeCycle from './LifeCycle';

export default Class Redirect extends Component {
    render() {
    return (
      <RouterContext.Consumer>
        {
          context => {
            const { to, push=false } = this.props;
            return (
              <LifeCycle
                onMount={() => {
                  // 通过 传入的 push 确定跳转方式是 push 还是 replace
                  push ? context.history.push(to) : context.history.replace(to);
                }}
              />
            )
          }
        }
      </RouterContext.Consumer>
    )
  }
}

Prompt 组件实现

import React from 'react';
import { RouterContext } from './Context';
import LifeCycle from './LifeCycle';

export default function Prompt({ message, when=true }) {
  return (
    <RouterContext.Consumer>
      {
        context => {
          if (!when) return null;
          let method = context.history.block;
          return (
            <LifeCycle
              onMount={ self => self.replace = method(message)}
              onUnMount={ self => self.release() }
            />
          )
        }
      }
    </RouterContext.Consumer>
  )
}

LIfeCycle 组件实现

组件支持传入的 onMount、onUpdate 及 onUnMount 三个方法,分贝代表着 componentDidMount、componentDidUpdate、componentWillUnMount

import React, { Component } from 'react';
export default class LifeCycle extends Component {
    componentDidMount() {
    if (this.props.onMount) this.props.onMount.call(this, this);
  }

  componentWillUnmount() {
    if (this.props.onUnMount) this.props.onUnMount.call(this, this);
  }

  componentDidUpdate(prevProps) {
    if (this.props.onUpdate) this.props.onUpdate.call(this, this, prevProps);
  }

  render() {
    return null;
  }
}

hooks 实现

hooks 可以让我们在组件中获取到路由的状态并且执行导航。如果需要使用 hooks,使用的React版本应该在16.8以上。

hooks 利用 React 提供的hooks: useContext,让我们可以在组件中访问到 RouterContext 中的数据。

hooks.js

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

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

index.js 实现

index.js

import BrowserRouter from "./BrowserRouter";
import Route from "./Route";
import Link from "./Link";
import Switch from "./Switch";
import {useRouteMatch, useHistory, useLocation, useParams} from "./hooks";
import withRouter from "./withRouter";
import Prompt from "./Prompt";
import Redirect from "./Redirect";

export {
  BrowserRouter,
  Route,
  Link,
  Switch,
  useRouteMatch,
  useHistory,
  useLocation,
  useParams,
  withRouter,
  Prompt,
  Redirect,
};