react-router源码解析

287 阅读8分钟

概况

  前端路由的重点就是不刷新页面,现有的解决方案有 history.pushState 和 history.replaceState 两种。   react-router也是基于history这个第三方库进行封装的。采用的是发布订阅的模式,让浏览器地址发生变化时,添加并发布订阅。Router组件包裹这Route组件,Router组件主要是监听浏览器变化,并将变化后的数据通过context传递给Route组件,Route组件则负责匹配Router传过来的路径,并渲染相应的组件。

内部具体实现

  下面解析的react-router版本是v5版本,因为我们使用的Router一般就BrowserRouter和HashRouter,但它们却是由react-router-dom提供的。所以我们先整体看一下react-router-dom中index.js代码提供了哪些API?

//来源:modules/index.js
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";

  从上面代码中可以看出,react-router-dom主要提供了BrowserRouter、HashRouter、 Link 和 NavLink 四个组件,其余都是从react-router中导出的。   再看一下react-router中 index.js 代码,可以看出暴露了10个方法(hooks方法未算在内)。

//来源:modules/index.js
export { default as MemoryRouter } from "./MemoryRouter";
export { default as Prompt } from "./Prompt";
export { default as Redirect } from "./Redirect";
export { default as Route } from "./Route";
export { default as Router } from "./Router";
export { default as StaticRouter } from "./StaticRouter";
export { default as Switch } from "./Switch";
export { default as generatePath } from "./generatePath";
export { default as matchPath } from "./matchPath";
export { default as withRouter } from "./withRouter";

  接下来具体分析BrowersRouter怎么实现的。

//来源:modules/BrowserRouter.js
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} />;
  }
}

  代码很少,可以看到核心是history这个库提供的函数。BrowserRouter组件在render前先执行了 createHistory 这个函数,返回一个history的对象实例,然后通过props传给Router这个路由器,另外将所有子组件传给Router。

  接下来就很清晰了,我们看看 Router 和 history,Router是怎么使用history对象的?history对象和window.history又有何差别?Come on!

Router

  上面我们已经看到BrowsRouter传递给它了一个history对象。接下来具体分析下Router的实现,先看下Router.js文件。

//来源:modules/Router.js
import HistoryContext from "./HistoryContext.js";
import RouterContext from "./RouterContext.js";

  首先引入了两个context,这里其实就是创建的普通context,只不过拥有特定的名称而已,不具体分析了。   再看看它的constructor函数:

constructor(props) {
    super(props);
    this.state = {
      location: props.history.location
    };
    this.unlisten = props.history.listen(location => {
        this.setState({ location });
    });  
}

  可以看出 Router组件维护了一个自状态(location 对象),初始值是上面提到的在 BrowserRouter 中创建的 history 对象。   之后,执行了 history 对象提供的 listen 函数,这个函数以一个回调函数作为入参,回调函数就是用来更新当前Router自状态中(location 对象)的,关于什么时候会执行这个回调,以及listen函数具体干了啥,后面会详细剖析。

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

  等这个Router组件将要卸载时,就取消对history的监听。

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

  总结:整个Router就是一个传入了history、location和其它一些数据的context的提供者,然后它的子组件Route作为消费者,就可以共享使用这些数据,来完成后面的路由跳转、UI更新等动作。

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

  其实history是对 window.history 的二次封装。Router内部状态 location 的初始数据其实是 window.location 与 window.history.state 的重组。   history里面最重要的两个方法是 pushState 和 replaceState 这两个API,它们提供的能力就是可以增加新的 window.history 中的历史记录和浏览器地址栏上的url,但是又不会发起真正的网络请求,这是实现单页面应用的关键点。   下面我们来具体分析下createHashHistory源码,版本是v4版本。

  先整体看一下createHashHistory抛出的方法,也就是我们平时用的props.history常用的方法:

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

  接下来我们具体分析下push方法:

function push(path, state) {
    const action = 'PUSH';
    const location = createLocation(path, state, createKey(), history.location);

    transitionManager.confirmTransitionTo( location, action, getUserConfirmation, ok => {
        if (!ok) return;

        const href = createHref(location);
        const { key, state } = location;

        if (canUseHistory) {
          // 在push方法内使用pushState
          globalHistory.pushState({ key, state }, null, href);

          if (forceRefresh) {
            window.location.href = href;
          } else {
            const prevIndex = allKeys.indexOf(history.location.key);
            const nextKeys = allKeys.slice(0, prevIndex + 1);

            nextKeys.push(location.key);
            allKeys = nextKeys;

            setState({ action, location });
          }
        } else {
          window.location.href = href;
        }
      }
    );
  }

  push中的入参path,是接下来准备要跳转的路由地址。createLocation 方法先将这个path,与当前的 location 做一个合并,返回一个更新的location。   然后就是重头戏,transitionManager 这个对象,我们先关注下成功回调里面的内容。   通过更新后的location,创建出将要跳转的href,然后调用pushState方法,来更新window.history中的历史记录。   如果你在BrowserRouter中传了 forceRefresh 这个属性,那么之后就会直接修改window.lcoation.href,来实现页面跳转,但这样就相当于要重新刷新,需要重新发起请求了。   如果没有传的话,就是调用 setState 这个函数,注意这个 setState 并不是react提供的那个,而是history库自己实现的。

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

  这里用到了 transitionManager 对象的一个方法。   另外当我们执行了 pushState 后,接下来所获取到的 window.history 都是已经更新的了。   最后就剩下 transitionManager 这个点了。   transitionManager 是通过 createTransitionManager 这个函数实例出的一个对象,源码如下:

function createTransitionManager() {
  let listeners = [];
  
  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));
  }

  return {
    appendListener,
    notifyListeners
  };
}

  还记的开始时我们在Router组件中用过的那个 history.listen 方法,它其实就是调用了transitionManager.appendListener方法,源码如下:

//路由监听
function listen(listener) {
    const unlisten = transitionManager.appendListener(listener);
    checkDOMListeners(1);

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

  listen函数的执行过程是,把入参函数 listener(也就是Router中的回调函数)传入appendListener中;执行 appendListener 方法,会将回调函数推入 listeners 这个数组,并返回一个函数,用来取消监听( 即删除listeners数组中该回调函数 )。

  小结:当我们使用push切换路由时,它会执行setState方法( history里面的setState )。setState方法里面执行了 transitionManager.notifyListeners 方法,这个方法会遍历listeners,执行我们在 history.listen 里面的回调函数。而这个回调函数是用来通过 setState( react里面的setState )来更新Router组件自状态(location 对象),然后又因为这个location 对象传入了RouterContext的value中,所以当它发生变化时,所有的消费组件,都会重新render,以此来达到更新UI的目的。

Route

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

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

          const props = { ...context, location, match };
          
          let { children, component, render } = this.props;
         // 提前使用一个空数组作为children默认值,如果是这样,就使用null
          if (Array.isArray(children) && isEmptyChildren(children)) {
            children = null;
          }

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

  Route 接受上层的 Router 传入的 context,当路由发生变化时,通过判断当前 Route 的 path和更新后的context 中的location是否匹配,匹配则渲染,不匹配则不渲染。

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

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

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

  最后,我们简单看下matchPath 是如何判断 location 是否符合 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, // 用于匹配的路径
      url: path === "/" && url === "" ? "/" : url, // 匹配的url
      isExact, // 是否是全匹配
      // 返回的是一个键值对的映射
      // 比如你的 path 是 /users/:id,然后匹配的 pathname 是 /user/123
      // 那么 params 的返回值就是 {id: '123'}
      params: keys.reduce((memo, key, index) => {
        memo[key.name] = values[index];
        return memo;
      }, {})
    };
  }, null);
}

总结

  第一次解析源码,参考了很多文档,终于梳理清楚了,觉得好用就点个赞哦~

参考文档:

  1. React-Router源码解读
  2. react-router源码解析