react-router-dom源码揭秘

909 阅读8分钟

react-router-dom源码揭秘

React Router中核心内容:

  • router 组件(BrowserRouter)
  • route matching 组件(Route,Switch,Redirect)
  • navigation 组件(Link)
  • hooks方法(useRouteMatch, useHistory, useLocation, useParams)
  • 高阶组件(withRouter)
  • 实用组件(Prompt)

背景

相信很多小伙伴和我一样,在学习react-router的时候会出现很多疑问,比如路由的三种渲染方式component, render,children以及他们的优先级,Redirect的重定向原理和Switch独占路由的实现...,今天我们一起来揭秘。今天这些实现只是做了个简单的处理,不考虑兼容性问题。

router 组件

BrowserRouter

import React, {Component} from "react";
import {createBrowserHistory} from "history";
import Router from "./Router";  // 这里暂不关注,但是这是BrowserRouter中的核心组件

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

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

分析一下其实很简单,BrowserRouter中依赖了一个history库,他的主要作用就是传入history参数。使得children中的props中包含history对象。可以调用一些history上的方法,比如push,goBack,go,replace,listen....

补充一下history接口参数,方便大家知道history对象上不可以使用到的方法和参数。这里对泛型约束不做过多的解释,感兴趣的可以参考history库的github地址 https://github.com/ReactTraining/history

interface History {
    length: number;
    action: 'PUSH' | 'POP' | 'REPLACE';
    location: Location<HistoryLocationState>;
    push(path: Path, state?: HistoryLocationState): void;
    push(location: LocationDescriptor<HistoryLocationState>): void;
    replace(path: Path, state?: HistoryLocationState): void;
    replace(location: LocationDescriptor<HistoryLocationState>): void;
    go(n: number): void;
    goBack(): void;
    goForward(): void;
    block(prompt?: boolean | string | TransitionPromptHook<HistoryLocationState>): UnregisterCallback;
    listen(listener: LocationListener<HistoryLocationState>): UnregisterCallback;
    createHref(location: LocationDescriptorObject<HistoryLocationState>): Href;
}

接着我们来说一下BrowserRouter中引用的Router组件,这是核心,在这里不考虑兼容性问题,使用React.createContext()来构建RouterContext。为了方便简单些,这里不构建HistoryContext。Router 组件会调用 history 的 listen 方法进行 路由监听,将监听到的 location 的值放在 RouterContext 中,location一旦更新, 子组件则会重新渲染。这就是为什么一定得通过coontext将值传递下去的主要原因。computeRootMatch这个方法,是保证path不存在的时候,也能返回一个参数对象。相当于一个默认值。


import React, {Component} from "react";
const RouterContext = React.createContext()
export default class Router extends Component {
  static computeRootMatch(pathname) { //路由匹配方法
    return {path: "/", url: "/", params: {}, isExact: pathname === "/"};
  }
  constructor(props) {
    super(props);
    this.state = {
      location: props.history.location
    };
    // location发生变化,要执行这里的回调
    this.unlisten = props.history.listen(location => {
      this.setState({location});
    });
  }

  componentWillUnmount() {
    if (this.unlisten) {
      this.unlisten();
    }
  }

  render() {
    return (
      <RouterContext.Provider
        value={{
          history: this.props.history,
          location: this.state.location,
          // path url params isExact 四个属性
          match: Router.computeRootMatch(this.state.location.pathname)
        }}>
        {this.props.children}
      </RouterContext.Provider>
    );
  }
}

函数式组件的实现:

import React, { useState, useEffect } from "react";
import { RouterContext } from "./Context"; // 等价于 const RouterContext = React.createContext() ,但是尽量把RouterContext放在一个目录下,方便其他组件消费。
export default function Router(props) {
  const [location, setLocation] = useState(props?.history?.location);
  useEffect(() => {
    // setLocation()
    // location发生变化,要执行这里的回调
    const unlisten = props.history.listen((location) => {
      setLocation(location);
    });
    return () => {
      if (unlisten) {
        unlisten();
      }
    };
  }, [props.history]);
  const computeRootMatch = (pathname) => {
    return { path: "/", url: "/", params: {}, isExact: pathname === "/" };
  };
  return (
    <RouterContext.Provider
      value={{
        history: props.history,
        location: location,
        // path url params isExact 四个属性
        match: computeRootMatch(location.pathname),
      }}
    >
      {props.children}
    </RouterContext.Provider>
  );
}

route matching 组件

Route

Route组件应该是react-router-dom的核心组件了,这里面包含了路由路径匹配的实现,以及路由渲染的逻辑,在这里,你会明白路由的三种渲染方式的优先级,以及他们是做了什么处理才能实现路由的匹配和children组件的方式是否匹配都会渲染的问题。现在让我们一一来剖析。 首先matchPath这个方法主要做的是去判断路由是否匹配的功能。函数返回值的列表如下:

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;
  }, {})
};

请看下面代码,match参数的计算是首先判断是否存在computeMatch,这个props参数主要是为了实现独占路由,如果存在这个,则取computeMatch,这个computeMatch是Switch中传递下来的,主要是破坏Route中原来的匹配规则,使得不匹配时,就算传递children也不渲染。具体在哪请看Switch的实现。如果没有这个则去path,path是Route中的参数,如果有则通过matchPath计算出来match,没有的话则取默认的match,上面Router上写了一个这个的静态的方法computeRootMatch,主要是在这里起作用。

const {children, component, render, path, computedMatch} = this.props;
const match = computedMatch
      ? computedMatch
      : path
      ?matchPath(location.pathname, this.props)
      : context.match;

接下来的核心是组件的三种渲染方式的实现,渲染优先级children>component>render。看了源码你就会明白为啥是这样。首先match匹配的情况是存在则首先判断是否存在children,如果children是函数则children(props),否则渲染children组件,同理判断component和render。match不匹配的时候,判断children,在这里你就会明白如果就是路由不匹配,如果存在children组件也会被渲染。

<RouterContext.Provider value={props}>
    {match
    ? children
      ? typeof children === "function"
        ? children(props)
        : children
      : component
      ? React.createElement(component, props)
      : render
      ? render(props)
      : null
      : typeof children === "function"
      ? children(props)
      : null}
</RouterContext.Provider>

Route核心代码

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

export default class Route extends Component {
  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          const location = context.location;
          const {children, component, render, path, computedMatch} = this.props;
          const match = computedMatch
            ? computedMatch
            : path
            ? matchPath(location.pathname, this.props)
            : context.match;
          const props = {
            ...context,
            match
          };
          // match children, component, render,  null
          // 不match children(function), null
          return (
            <RouterContext.Provider value={props}>
              {match
                ? children
                  ? typeof children === "function"
                    ? children(props)
                    : children
                  : component
                  ? React.createElement(component, props)
                  : render
                  ? render(props)
                  : null
                : typeof children === "function"
                ? children(props)
                : null}
            </RouterContext.Provider>
          );
        }}
      </RouterContext.Consumer>
    );
  }
}

Link

Link 组件 内部实现也是很简单的,主要是a标签,然后禁止a链接的默认跳转的行为。Link组件身上有个很重要的属性to,这个属性表示点击后要跳转的路由。此外这里使用了context,主要是接收到history对象。class组件和函数式组件实现代码如下:

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

export default class Link extends Component {
  static contextType = RouterContext;
  handleClick = event => {
    event.preventDefault();
    // 事件做跳转
    this.context.history.push(this.props.to);
  };
  render() {
    const {to, children, ...otherProps} = this.props;
    return (
      <a href={to} {...otherProps} onClick={this.handleClick}>
        {children}
      </a>
    );
  }
}

函数式组件实现

export default function Link(props) {
  const context = useContext(RouterContext);
  const { to, children, ...restProps } = props;
  const handleClick = (event) => {
    event.preventDefault();
    // 事件做跳转
    context.history.push(to);
  };
  return (
    <a href={to} {...restProps} onClick={handleClick}>
      {children}
    </a>
  );
}

Switch

Switch是独占路由,在路由匹配过程中只能匹配第一个与之匹配的路由组件。核心就是去遍历Switch下的children组件,去找寻与之匹配路由。核心函数是React.Children.forEach,React.Children.forEach(children, function[(thisArg)])在 children 里的每个直接子节点上调用一个函数,并将 this 设置为 thisArg。如果 children 是一个数组,它将被遍历并为数组中的每个子节点调用该函数。注意如果 children 是一个 Fragment 对象,它将被视为单一子节点的情况处理,而不会被遍历。React.cloneElement(element, {computedMatch: match}),上面谈论到的Route中的computedMatch方法就是这里传递下去的,为的就是破坏Route中的匹配规则,这样才能实现独占路由。React.cloneElement(element,[props],[...children])以 element 元素为样板克隆并返回新的 React 元素。返回元素的 props 是将新的 props 与原始元素的 props 浅层合并后的结果。新的子元素将取代现有的子元素,而来自原始元素的 key 和 ref 将被保留。 React.isValidElementType 方法用于判断目标是不是一个有效的 React 元素类型,以下类型会被认为是有效的:string、function、ReactSymbol

import React, {Component} from "react";
import {RouterContext} from "./Context";
import matchPath from "./matchPath";
export default class Switch extends Component {
  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          //match 是否匹配
          //element 记录匹配的元素
          const {location} = context;
          let match, element;
          React.Children.forEach(this.props.children, child => {
            if (match == null && React.isValidElement(child)) {
              element = child;
              const {path} = child.props;
              match = path
                ? matchPath(location.pathname, child.props)
                : context.match;
            }
          });
          return match
            ? React.cloneElement(element, {
                computedMatch: match
              })
            : null;
        }}
      </RouterContext.Consumer>
    );
  }
}

Redirect

Redirect是路由重定向,主要是重定向到指定的路由,里面有个to属性,代表要重定向要指定的路由。LifeCycle组件是实现路由跳转逻辑的功能,class组件render过程无法在指定的生命周期中做逻辑,故而借助这个中间组件。

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

export default class Redirect extends Component {
  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          const {history} = context;
          const {to, push = false} = this.props;
          return (
            <LifeCycle
              onMount={() => {
                push ? history.push(to) : history.replace(to);
              }}
            />
          );
        }}
      </RouterContext.Consumer>
    );
  }
}

class LifeCycle extends Component {
  componentDidMount() {
    if (this.props.onMount) {
      this.props.onMount.call(this, this);
    }
  }
  render() {
    return null;
  }
}

hooks方法

hooks方法实现起来就简单很多,话不多说直接撸代码,相信你一看就明白。

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

export function useHistory() {
  //获取history对象
  return useContext(RouterContext).history;
}

export function useLocation() {
  //获取location
  return useContext(RouterContext).location;
}

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

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

是不是很简单呢。当然你会问如果是class组件,用不了hooks,那这些参数方法该怎么获取呢,不用着急,其实你知道的,没错就是高阶组件withRouter。

withRouter

withRouter就是用来获取params、location、history、routeMatch,看代码,实现起来是不是很简单。

import React from "react";
import {RouterContext} from "./Context";

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

export default withRouter;

实用组件

当你想离开页面时都会弹出一个提示框(alert),让你选择是否残忍离开。React路由也为我们准备了这样的组件,就是prompt。 标签有两个属性: message:用于显示提示的文本信息。 when:传递布尔值,相当于标签的开关,默认是 true,设置成 false 时,失效。

import React from "react";
import { RouterContext } from "./Context";
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.release = method(message);
            }}
            onUnMount={(self)=>{
              self.release();
            }}
          ></LifeCycle>
        );
      }}
    </RouterContext.Consumer>
  );
}

class LifeCycle extends React.Component {
  componentDidMount() {
    if (this.props.onMount) {
      this.props.onMount.call(this, this);
    }
  }
  componentWillUnmount() {
    if (this.props.onUnMount) {
      this.props.onUnMount.call(this, this);
    }
  }
  render() {
    return null;
  }
}

到这里,就该结束了,当然还有一个NavLink组件,不过你会了Link的实现,这个也是很好实现的,它只是在Link上接了一些属性,这里就不再重复了。你有没有很好的理解react-router-dom呢,其实它内部的核心实现就这些,只不过它比较严谨,还做了一些环境的判断和一些warning和错误处理。git地址https://github.com/wjb-code/react-router-dom.git欢迎查看完整的代码。