React-Router 源码解析

1,096 阅读8分钟

背景

在使用 React-Router 开发的时候,会遇到以下一些相关的问题:

  • React-Router 是如何匹配路径组件的;
  • React-Router 是否对 history API 进行了重写,导致代码中 push 可以切换,而在 console 中使用 history.pushState 不能切换页面;
  • Window history API 内部的逻辑是怎么的;

带着这些问题和困惑,想了解下 React Router 的源码,看能否解释下上面这些问题的发生原因;

React-Router

项目源码 v5.2.0:github.com/ReactTraini…

项目结构

打开项目,可以看出,React-Router 采用 monorepo 的方式组织 react-routerreact-router-configreact-router-domreact-router-native ;每个文件的包都可以作为单独的包进行发布;

这 4 个包大概的作用是:

  • react-router:提供 React Router 核心路由功能,处理公用的路由逻辑;
  • react-router-config:React Router 配置文件,主要是暴露 matchRoutes、renderRoutes 这两个 API;
  • react-router-dom:浏览器上使用的库,依赖于 react-router 库;
  • react-router-native:在 React-Native 上使用的库,同样依赖于 react-router 库;

所以看起来比较核心的就是 react-router-dom、react-router 了;

react-router-dom

进入 react-router-dom 文件夹,进入入口文件 modules/index.js,可以看到除了 BrowserRouter、HashRouter、Link、NavLink 是 react-router-dom 自己实现的外,其他的比如 Router、Route、Switch等方法都是由 react-router 来实现的;

export {
  MemoryRouter,
  Prompt,
  Redirect,
  Route,
  Router,
  StaticRouter,
  Switch,
  generatePath,
  matchPath,
  withRouter,
  useHistory,
  useLocation,
  useParams,
  useRouteMatch
} from "react-router";

export { default as BrowserRouter } from "./BrowserRouter.js";

export { default as HashRouter } from "./HashRouter.js";

export { default as Link } from "./Link.js";

export { default as NavLink } from "./NavLink.js";

所以我们接下来就从 BrowserRouter、HashRouter 看起;

BrowserRouter/HashRouter

  • BrowserRouter
import React from "react";
import { Router } from "react-router";
import { createBrowserHistory as createHistory } from "history";


class BrowserRouter extends React.Component {

  history = createHistory(this.props);

  render() {

    return <Router history={this.history} children={this.props.children} />;
  }
}
  • HashRouter
import React from "react";
import { Router } from "react-router";
import { createHashHistory as createHistory } from "history";

class HashRouter extends React.Component {

  history = createHistory(this.props);

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

}

从上可以看出,HashRouter 和 BrowserRouter 只是一个高阶组件,作用是把通过第三方库 history 的函数 createHistory 构造出的的 history 实例,以及 children (可能是React Component/Route/switch/Link组件) 作为 props 的属性传给 Router 这个组件。

所以核心还是在 Router组件 和 history 实例对象上了;

Router 实例

先看下 Router 实例是怎么定义的:

import React from "react";
import PropTypes from "prop-types";
import warning from "tiny-warning";
import HistoryContext from "./HistoryContext.js";
import RouterContext from "./RouterContext.js";

/**

 * The public API for putting history on context.

 */

class Router extends React.Component {

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

  constructor(props) {
    super(props);

    // 维护location对象,初始值是props中第三方history实例的location

    this.state = {
      location: props.history.location
    };

    // This is a bit of a hack. We have to start listening for location
    // changes here in the constructor in case there are any <Redirect>s
    // on the initial render. If there are, they will replace/push when
    // they mount and since cDM fires in children before parents, we may
    // get a new location before the <Router> is mounted.
    this._isMounted = false;
    this._pendingLocation = null;
    if (!props.staticContext) {
      // 创建对 location 的监听,并传入回调函数
      // 通过这个回调函数实现的对于location的变化更新,并重新渲染路由匹配组件
      // 注意:history 原生没有 listen 方法,这里的 history 是第三方库 history 的实例
      this.unlisten = props.history.listen(location => {
        if (this._isMounted) {
          this.setState({ location });
        } else {
          this._pendingLocation = location;
        }
      });
    }
  }

  componentDidMount() {
    this._isMounted = true;

    if (this._pendingLocation) {
      this.setState({ location: this._pendingLocation });
    }
  }

  componentWillUnmount() {
    if (this.unlisten) {
      this.unlisten();
      this._isMounted = false;
      this._pendingLocation = null;
    }
  }

  render() {
    return (
      <RouterContext.Provider
        value={{
          history: this.props.history,
          location: this.state.location,
          match: Router.computeRootMatch(this.state.location.pathname),
          staticContext: this.props.staticContext
        }}
      >
        <HistoryContext.Provider
          children={this.props.children || null}
          value={this.props.history}
        />
      </RouterContext.Provider>
    );
  }
}

export default Router;

接下来,我们需要看下 history 这个第三方库具体的实现,是怎么触发的回调函数,以及在什么场景和时机下触发;

history

history v.4.9.0 源码:github.com/ReactTraini…

从 BrowserRouter、HashRouter 的初始化实例可以看出,他们的不同在于传入的不同的 history 实例;因此 history 的实现至关重要;

从总的来看:

  • 监听路由变化,触发绑定的回调函数
  • 实现 push、replace、go 等路由切换方法

history 项目结构

打开 packages 下面的 history 目录,打开 index.ts 文件,可以看到文件很简单,主要是由 4个部分组成:

  • createBrowserHistory
  • createHashHistory
  • createMemoryHistory
  • 各种 utils 方法,提供工具方法

下面就以 createHashHistory 为例子看下去;

createHashHistory

主要是有以下的实现:

  • 函数

    • getDOMLocation — 获取 location 对象
  • handlePop - 使用 go 方法来回退或者前进时,对页面进行的更新

  • revertPop — 使用 prompt 时候用户点击取消时候的 hack

  • creatHref - 获取完整的 location.href

  • 创建事件监听:

    • listen:添加监听的回调函数
    • checkDOMListeners:绑定/注销 hashChange 事件
  • 方法重写:

    • push、replace、go、goBack、goForward 方法
  • history 对象:即上文中提到的用于传递给子组件的history实例对象

  const history = {
    length: globalHistory.length,
    action: 'POP',
    location: initialLocation,
    createHref,
    push,
    replace,
    go,
    goBack,
    goForward,
    block,
    listen
  };

从返回的 history 来看,可以重点看下 listen 函数、checkDOMListeners 函数:

  function listen(listener) {
    // 注册回调,append listener
    const unlisten = transitionManager.appendListener(listener);
    checkDOMListeners(1);

    return () => {
      checkDOMListeners(-1);
      unlisten();
    };
  }

  // 看看 checkDOMListeners 函数
    function checkDOMListeners(delta) {

    listenerCount += delta;

    if (listenerCount === 1 && delta === 1) {
      // 为了防止重复监听 只运行一个且只有一个的hashchange事件监听函数
      window.addEventListener(HashChangeEvent, handleHashChange);
    } else if (listenerCount === 0) {
      window.removeEventListener(HashChangeEvent, handleHashChange);
    }

  }

可以看出 listen 使用了公共工具 transitionManager 来对回调事件进行管理,内部维护了 listener 队列,我们可以来看下其中的主要方法 appendListener、notifyListeners 做了什么:

  function appendListener(fn) {

    let isActive = true;

    function listener(...args) {
      if (isActive) fn(...args);

    }

    listeners.push(listener);

    return () => {
      isActive = false;
      listeners = listeners.filter(item => item !== listener);
    };
  }

  function notifyListeners(...args) {
    listeners.forEach(listener => listener(...args));
  }

可以看出 appendListener 就是将注册的事件回调函数添加进内部维护的 listener 队列中,notifyListeners 的任务则是遍历该队列并执行这些回调;

那么调用 notifyListeners 的时机在哪里呢?在 createHashHistory.js 文件中搜索一下 notifyListeners,发现是在一个叫 setState 函数中进行调用的:

  function setState(nextState) {
    Object.assign(history, nextState);
    history.length = globalHistory.length;
    transitionManager.notifyListeners(history.location, history.action);
  }

再来看一下 setState 会在哪些情况下被调用呢?搜索文件发现在 push、replace、handleHashChange 函数中都在调用,所以我们可以梳理出整个 React-Router 路由回调触发逻辑,画了下简图:

为什么调用 history.pushState 不能进行更新页面?

回到问题,之前我猜测,React-Router 是否对 history API 进行了重写,导致代码中 push 可以切换,而在 console 中使用 history.pushState 不能切换页面,在仔细看 push 方法之前,我们可以来先看看在 MDN 中对 pushState 方法的第三个参数 url 定义:

也就是说我们在 console 中使用 pushState 添加一个新的路由,并不会导致浏览器加载该新路由 ,浏览器甚至不会检查该路由是否存在;而在代码中通过调用第三方history的 push 函数,逻辑是会通过上述的 setState 方法,利用 transitionManager 的 notifyListeners 方法,遍历 listeners 队列执行回调,顺利切换路由;

browserRouter、hashRouter 监听的区别

const PopStateEvent = 'popstate';

const HashChangeEvent = 'hashchange';

  

 // BrowserHistory

 function checkDOMListeners(delta) {

    listenerCount += delta;



    if (listenerCount === 1 && delta === 1) {

      window.addEventListener(PopStateEvent, handlePopState);



      if (needsHashChangeListener)

        window.addEventListener(HashChangeEvent, handleHashChange);

    } else if (listenerCount === 0) {

      window.removeEventListener(PopStateEvent, handlePopState);



      if (needsHashChangeListener)

        window.removeEventListener(HashChangeEvent, handleHashChange);

    }

 }

  

  // HashHistory

function checkDOMListeners(delta) {

    listenerCount += delta;



    if (listenerCount === 1 && delta === 1) {

      window.addEventListener(HashChangeEvent, handleHashChange);

    } else if (listenerCount === 0) {

      window.removeEventListener(HashChangeEvent, handleHashChange);

    }

 }

下面是对 MDN 上 popstate 的解释,需要注意的是调用 history.pushState() 或history.replaceState() 不会触发popstate事件,popstate 事件只会在浏览器某些行为下触发,比如点击后退、前进按钮或者调用 history.back()、history.forward()、history.go() 方法。所以这也是为什么调用 history.pushState 不能进行更新页面的原因;

BrowserRouter 和 HashRouter 的剩下的实现逻辑大同小异,在此就不再赘述了,感兴趣的可以在 modules/createBrowserHistory.js 中查看;

Route 是如何匹配组件的

路由发生变化之后,React是如何匹配到对应组件的呢?

class Route extends React.Component {

  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          invariant(context, "You should not use <Route> outside a <Router>");

          const location = this.props.location || context.location;
          // 计算 match
          const match = this.props.computedMatch
            ? this.props.computedMatch // <Switch> already computed the match for us
            : this.props.path
            ? matchPath(location.pathname, this.props)
            : context.match;

          const props = { ...context, location, match };

          let { children, component, render } = this.props;

          // Preact uses an empty array as children by

          // default, so use null if that's the case.

          if (Array.isArray(children) && isEmptyChildren(children)) {

            children = null;

          }

          return (

            <RouterContext.Provider value={props}>

            // 看到这个一串真的是实力劝退,先把 __DEV__ 去掉看

            // 通过 match 来判断Route的path是否匹配location

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

可见,React Router 是通过 props.match 来判断 Route 的 path 是否匹配 location 的,如果都没有匹配上则不渲染,如果匹配上了就渲染;

Route 渲染的内容有三种类型,分别是:

  1. children — children如果是函数就直接执行,如果只是一个子元素就直接渲染出来
  2. component — 组件, 然后去 render 组件
  3. render — render 方法, 通过方法去 render 这个组件

所以重点还是在于这个 match 是如何计算出来的:

/**

 * 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);

}

总结一下就是:

  • this.props.computedMatch 是当我们在 Route 外层使用了 Switch 组件的时候,Switch组件会自动帮我们计算是否匹配上了路径,并把 match 作为 props 属性传递给Route;
  • 在不使用 Switch 的情况下,我们直接取 Route 上的 path ( this.props.path ) 和location进行对比匹配;
  • 如果 Route上没有 path 属性,React会判断是否是匹配上了根路径/ (context.match),也就是根组件 Router 中的 computeRootMatch;
  • 如果 this.props.computedMatch ( Switch 中计算的 )、matchPath(location.pathname, this.props)(工具方法计算得出)、context.match (根组件 Router 中的 computeRootMatch)都为 null 则代表与该路由不匹配,不进行渲染;

参考链接:

history v.4.9.0 源码:github.com/ReactTraini…

react-router 源码 v5.2.0:github.com/ReactTraini…