react-router源码解析

5,416 阅读11分钟

前言

上篇文章介绍了前端路由的两种实现原理,今天我想从react-router源码分析下他们是如何管理前端路由的。因为之前一直都是使用V4的版本,所以接下来分析的也是基于react-router v4.4.0版本的(以下简称 V4),欢迎大家提出评论交流。Let's get started。

学前知识

在分析源码前先回顾以下相关知识,有利于更好的理解源码设计。

  1. react中如何实现不同的路由渲染不同的组件?

react 作为一个前端视图框架,本身是不具有除了 view(数据与界面之间的抽象)之外的任何功能的;上篇文章中我们是通过触发的回调函数来操作 DOM ,而在 react 中我们不直接操作 DOM,而是管理抽象出来的 VDOM 或者说 JSX,对 react 的来说路由需要管理组件的生命周期,对不同的路由渲染不同的组件。

  1. history(第三方库)的使用

因为React-Router 是基于history这个库来实现对路由变化的监听,所以我们下面会先对这个库进行简单的分析。当然我们主要分析它的监听模式listen是如何实现的,这对实现路由是至关重要的,想了解更多其他的API,请移步history学习更多。

history

history基本用法是这样的:

import { createBrowserHistory } from 'history';

const history = createBrowserHistory();

// 获取当前location。
const location = history.location;

// 监听当前location的更改。
const unlisten = history.listen((location, action) => {
  // location是一个类似window.location的对象
  console.log(action, location.pathname, location.state);
});

// 使用push、replace和go来导航。
history.push('/home', { some: 'state' });

// 若要停止监听,请调用listen()返回的函数.
unlisten();

我们查看源码modules下面的index.js,可以看出history 暴露出了七个方法:

export { default as createBrowserHistory } from './createBrowserHistory';
export { default as createHashHistory } from './createHashHistory';
export { default as createMemoryHistory } from './createMemoryHistory';
export { createLocation, locationsAreEqual } from './LocationUtils';
export { parsePath, createPath } from './PathUtils';

通过上面的例子我们来简单比较createBrowserHistorycreateHashHistory:

  • createHashHistory使用 URL 中的 hash(#)部分去创建形如 example.com/#/some/path的路由。

    createHashHistory源码简析:

      function getHashPath() {
        // 我们不能使用window.location.hash,因为它不是跨浏览器一致- Firefox将预解码它!
        const href = window.location.href;
        const hashIndex = href.indexOf('#');
        return hashIndex === -1 ? '' : href.substring(hashIndex + 1);//函数返回hush值
      }
    
  • createBrowserHistory使用浏览器中的 History API 用于处理 URL,创建一个像example.com/some/path这样真实的 URL 。

    createBrowserHistory.js源码简析:

    //createBrowserHistory.js
    const PopStateEvent = 'popstate';//变量,下面window监听事件popstate用到
    const HashChangeEvent = 'hashchange';//变量,下面window监听事件hashchange用到
    
    function createBrowserHistory(props = {}) {
        invariant(canUseDOM, 'Browser history needs a DOM');
    
        const globalHistory = window.history; // 创建一个使用HTML5 history API(包括)的history对象
        const canUseHistory = supportsHistory();
        const needsHashChangeListener = !supportsPopStateOnHashChange();
        //...
        //push方法
        function push(path, state) {
            if (canUseHistory) {
                globalHistory.pushState({ key, state }, null, href);//在push方法内使用pushState
              ...
        }
        //replace方法
        function replace(path, state) {
            if (canUseHistory) {
                globalHistory.replaceState({ key, state }, null, href);//在replaceState方法内使用replaceState
            }
        }
        let listenerCount = 0;
        //注册路由监听事件
        function checkDOMListeners(delta) {
            listenerCount += delta;
    
            if (listenerCount === 1 && delta === 1) {
                window.addEventListener(PopStateEvent, handlePopState);//popstate监听前进/后退事件
    
                if (needsHashChangeListener)
                window.addEventListener(HashChangeEvent, handleHashChange);//hashchange监听 URL 的变化
            } else if (listenerCount === 0) {
                window.removeEventListener(PopStateEvent, handlePopState);
    
                if (needsHashChangeListener)
                window.removeEventListener(HashChangeEvent, handleHashChange);
            }
        }
        //路由监听
        function listen(listener) {
            const unlisten = transitionManager.appendListener(listener);
            checkDOMListeners(1);
        
            return () => {
                checkDOMListeners(-1);
                unlisten();
            };
         }
    

listen如何触发监听:

上面createBrowserHistory.js中也有介绍如何注册路由监听,我们再看下如何触发路由监听者。 分析事件监听的回调函数handlePopState ,其最终是通过setState 来触发路由监听者,其中notifyListeners 会调用所有的listen 的回调函数,从而达到通知监听路由变化的监听者。

//createBrowserHistory.js
const transitionManager = createTransitionManager();

  function setState(nextState) {
    Object.assign(history, nextState);
    history.length = globalHistory.length;
   // 调用所有的listen 的回调函数,从而达到通知监听路由变化的监听者
    transitionManager.notifyListeners(history.location, history.action);
  }
  //事件监听的回调函数handlePopState
  function handlePopState(event) {
    // 忽略WebKit中无关的popstate事件。
    if (isExtraneousPopstateEvent(event)) return;
    handlePop(getDOMLocation(event.state));
  }

  let forceNextPop = false;
  //回调执行函数
  function handlePop(location) {
    if (forceNextPop) {
      forceNextPop = false;
      setState();
    } else {
      const action = 'POP';

      transitionManager.confirmTransitionTo(
        location,
        action,
        getUserConfirmation,
        ok => {
          if (ok) {
            setState({ action, location });
          } else {
            revertPop(location);
          }
        }
      );
    }
  }
// createTransitionManager.js
    function notifyListeners(...args) {
        //调用所有的listen 的回调函数
        listeners.forEach(listener => listener(...args));
    }

react-routerRouter 组件的componentWillMount 生命周期中就调用了history.listen调用,从而达到当路由变化, 会去调用setState 方法, 从而去Render 对应的路由组件。

// react-router/Router.js
constructor(props) {
    super(props);

    this.state = {
      location: props.history.location
    };
    //调用history.lesten方法监听,setState渲染组件
    this.unlisten = props.history.listen(location => {
      this.setState({ location });
    });
  }

  componentWillUnmount() {
    //路由监听
    this.unlisten();
  }

小结:以上分析了react-router如何使用第三方库history监听路由变化的过程,下面将介绍react-router是如何结合history做到SPA路由变化达到渲染不同组件的效果的,我们先看下react-router的基本使用,梳理解析思路。

react-router基本使用

//引用官方例子
import React from "react";
import { BrowserRouter as Router, Route, Link } from "react-router-dom";     

function BasicExample() {
  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>

        <hr />

        <Route exact path="/" component={Home} />
        <Route path="/about" component={About} />
        <Route path="/topics" component={Topics} />
      </div>
    </Router>
  );
}

function Home() {
  return (
    <div>
      <h2>Home</h2>
    </div>
  );
}

function About() {
  return (
    <div>
      <h2>About</h2>
    </div>
  );
}

function Topics({ match }) {
  return (
    <div>
      <h2>Topics</h2>
      <ul>
        <li>
          <Link to={`${match.url}/rendering`}>Rendering with React</Link>
        </li>
        <li>
          <Link to={`${match.url}/components`}>Components</Link>
        </li>
        <li>
          <Link to={`${match.url}/props-v-state`}>Props v. State</Link>
        </li>
      </ul>

      <Route path={`${match.path}/:topicId`} component={Topic} />
      <Route
        exact
        path={match.path}
        render={() => <h3>Please select a topic.</h3>}
      />
    </div>
  );
}

function Topic({ match }) {
  return (
    <div>
      <h3>{match.params.topicId}</h3>
    </div>
  );
}

export default BasicExample;

V4 将路由拆成了以下几个包:

  • react-router 负责通用的路由逻辑
  • react-router-dom 负责浏览器的路由管理
  • react-router-native 负责 react-native 的路由管理

用户只需引入 react-router-domreact-router-native 即可,react-router 作为依赖存在不再需要单独引入。

React Router中有三种类型的组件:

  • 路由器组件(<BrowserRouter>、<HashRouter>)
  • 路由匹配组件(<Route>、<Switch>)
  • 导航组件(<Link>、<NavLink>、<Redirect>)。

上面Demo中我们也正使用了这三类组件,为了方便分析源码,我们可以梳理一个基本流程出来:

  • 使用<BrowserRouter>创建一个专门的history对象,并注册监听事件。
  • 使用<Route>匹配的path,并渲染匹配的组件。
  • 使用<Link>创建一个链接跳转到你想要渲染的组件。

下面我们就根据上述流程步骤,一步一步解析react-router 代码实现。

BrowserRouter

/* react-router-dom/BrowserRouter.js */
//从history引入createBrowserHistory
import { createBrowserHistory as createHistory } from "history";
import warning from "warning";
//导入react-router 的 Router 组件
import Router from "./Router";

class BrowserRouter extends React.Component {
  //创建全局的history对象,这里用的是HTML5 history
  history = createHistory(this.props);

  render() {
  //将 history 作为 props 传递给 react-router 的 Router 组件
    return <Router history={this.history} children={this.props.children} />;
  }
}

BrowserRouter 的源码在 react-router-dom 中,它是一个高阶组件,在内部创建一个全局的 history 对象(可以监听整个路由的变化),并将 history 作为 props 传递给 react-routerRouter 组件(Router 组件再会将这个 history 的属性作为 context 传递给子组件)。如下,借助 context 向 Route 传递组件,这也解释了为什么 Router 要在所有 Route 的外面。

//react-router/Router.js
import React from "react";

import RouterContext from "./RouterContext";

//获取history、location、match...
function getContext(props, state) {
  return {
    history: props.history,
    location: state.location,
    match: Router.computeRootMatch(state.location.pathname),
    staticContext: props.staticContext
  };
}

class Router extends React.Component {
  //定义Router组件的match属性字段
  static computeRootMatch(pathname) {
    return { path: "/", url: "/", params: {}, isExact: pathname === "/" };
    /*
     * path: "/", // 用来匹配的 path
     * url: "/", // 当前的 URL
     * params: {}, // 路径中的参数
     * isExact: pathname === "/" // 是否为严格匹配
     */
  }
  
  constructor(props) {
    super(props);

    this.state = {
      location: props.history.location
    };
    //监听路由的变化并执行回调事件,回调内setState
    this.unlisten = props.history.listen(location => {
      this.setState({ location });
        /*
         *hash: "" // hash
         *key: "nyi4ea" // 一个 uuid
         *pathname: "/explore" // URL 中路径部分
         *search: "" // URL 参数
         *state: undefined // 路由跳转时传递的 state
        */
    });
  }

  componentWillUnmount() {
    //组件卸载时停止监听
    this.unlisten();
  }

  render() {
    const context = getContext(this.props, this.state);

    return (
      <RouterContext.Provider
        children={this.props.children || null}
        value={context}<!--借助 contextRoute 传递组件-->
      />
    );
  }
}
export default Router;

相比于在监听的回调里setState 做的操作,setState 本身的意义更大 —— 每次路由变化 -> 触发顶层 Router 的回调事件 -> Router 进行 setState -> 向下传递 nextContextcontext 中含有最新的 location)-> 下面的 Route 获取新的 nextContext 判断是否进行渲染。

Route

源码中有这样介绍:"用于匹配单个路径和呈现的公共API"。简单理解为找到location<router>path匹配的组件并渲染。

//react-router/Route.js
//判断Route的子组件是否为空
function isEmptyChildren(children) {
  return React.Children.count(children) === 0;
}
//获取history、location、match...
//父组件没传的话使用context中的
function getContext(props, context) {
  const location = props.location || context.location;
  const match = props.computedMatch
    ? props.computedMatch // <Switch> already computed the match for us
    : props.path
      ? matchPath(location.pathname, props)//在./matchPath.js中匹配location.pathname与path
      : context.match;

  return { ...context, location, match };
}
class Route extends React.Component {

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

          const props = getContext(this.props, context);
          //context 更新 props 和 nextContext会重新匹配
          let { children, component, render } = this.props;
          // 提前使用一个空数组作为children默认值,如果是这样,就使用null。
          if (Array.isArray(children) && children.length === 0) {
            children = null;
          }

          if (typeof children === "function") {
            children = children(props);

            if (children === undefined) {

              children = null;
            }
          }

          return (
            <RouterContext.Provider value={props}>
              {children && !isEmptyChildren(children)
                ? children    
                : props.match//对应三种渲染方式children、component、render,只能使用一种
                  ? component
                    ? React.createElement(component, props)
                    : render
                      ? render(props)
                      : null
                  : null}
            </RouterContext.Provider>
          );
        }}
      </RouterContext.Consumer>
    );
  }
}

Route 接受上层的 Router 传入的 contextRouter 中的 history 监听着整个页面的路由变化,当页面发生跳转时,history 触发监听事件,Router 向下传递 nextContext,就会更新 Routepropscontext 来判断当前 Routepath 是否匹配 location,如果匹配则渲染,否则不渲染。

是否匹配的依据就是 matchPath 这个函数,在下文会有分析,这里只需要知道匹配失败则 matchnull,如果匹配成功则将 match 的结果作为 props 的一部分,在 render 中传递给传进来的要渲染的组件。

从render 方法可以知道有三种渲染组件的方法(childrencomponentrender)渲染的优先级也是依次按照顺序,如果前面的已经渲染后了,将会直接 return。

  • children (如果children 是一个方法, 则执行这个方法, 如果只是一个子元素,则直接render 这个元素)
  • component (直接传递一个组件, 然后去render 组件)
  • render(render 是一个方法, 通过方法去render 这个组件)

接下来我们看下 matchPath 是如何判断 location 是否符合 path 的。

function matchPath(pathname, options = {}) {
  if (typeof options === "string") options = { path: options };

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

  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, // 用来进行匹配的路径,其实是直接导出的传入 matchPath 的 options 中的 path
    url: path === "/" && url === "" ? "/" : url, // URL的匹配部分
    isExact, // url 与 path 是否是 exact 的匹配
    // 返回的是一个键值对的映射
    // 比如你的 path 是 /users/:id,然后匹配的 pathname 是 /user/123
    // 那么 params 的返回值就是 {id: '123'}
    params: keys.reduce((memo, key, index) => {
      memo[key.name] = values[index];
      return memo;
    }, {})
  };
}

Link

class Link extends React.Component {
  static defaultProps = {
    replace: false
  };

  handleClick(event, context) {
    if (this.props.onClick) this.props.onClick(event);

    if (
      !event.defaultPrevented && // 阻止默认事件
      event.button === 0 && // 忽略除左击之外的所有内容
      !this.props.target && // 让浏览器处理“target=_blank”等。
      !isModifiedEvent(event) // 忽略带有修饰符键的单击
    ) {
      event.preventDefault();

      const method = this.props.replace
        ? context.history.replace
        : context.history.push;

      method(this.props.to);
    }
  }

  render() {
    const { innerRef, replace, to, ...props } = this.props;
    // eslint-disable-line no-unused-vars

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

          const location =
            typeof to === "string"
              ? createLocation(to, null, null, context.location)
              : to;
          const href = location ? context.history.createHref(location) : "";

          return (
            <a
              {...props}
              onClick={event => this.handleClick(event, context)}
              href={href}
              ref={innerRef}
            />
          );
        }}
      </RouterContext.Consumer>
    );
  }
}

从render来看,Link其实就是一个<a>标签,在 handleClick中,对没有被 preventDefault的 && 鼠标左键点击的 && 非 _blank 跳转 的&& 没有按住其他功能键的单击进行 preventDefault,然后 pushhistory 中,这也是前面讲过的 —— 路由的变化 与 页面的跳转 是不互相关联的,V4Link 中通过 history 库的 push 调用了 HTML5 historypushState,但是这仅仅会让路由变化,其他什么都没有改变。还记不记得 Router 中的 listen,它会监听路由的变化,然后通过 context 更新 propsnextContext 让下层的 Route 去重新匹配,完成需要渲染部分的更新。

总结

让我们回想下我们看完基础用法梳理的流程:

  • 使用<BrowserRouter>创建一个专门的history对象,并注册监听事件。
  • 使用<Route>匹配的path,并渲染匹配的组件。
  • 使用<Link>创建一个链接跳转到你想要渲染的组件。

结合源码我们再分析下具体实现

  1. 使用BrowserRouterrender一个Router时创建了一个全局的history对象,并通过props传递给了Router,而在Router中设置了一个监听函数,使用的是history库的listen,触发的回调里面进行了setState向下传递 nextContext。
  2. 当点击页面的Link是,其实是点击的a标签,只不过使用了 preventDefault 阻止 a 标签的页面跳转;通过给a标签添加点击事件去执行 hitsory.push(to)
  3. 路由改变是会触发 RoutersetState 的,在 Router 那章有写道:每次路由变化 -> 触发顶层 Router 的监听事件 -> Router 触发 setState -> 向下传递新的 nextContextnextContext 中含有最新的 location)。
  4. Route 接受新的 nextContext 通过 matchPath 函数来判断 path 是否与 location 匹配,如果匹配则渲染,不匹配则不渲染。