react-router 源码及原理解析 v5版本

1,174 阅读8分钟

前言,首先为什么要做react-router源码解析呢,因为之前我们有一个需求,左侧导航栏检测到路由变化的时候展示不同的样式。 由于左侧导航栏没有包裹到路由Router组件中,所以左侧导航栏无法准确监测路由发生了变化,当时我想了想可以这样解决

一、我想象中的解决方案

方案一 onhashchange

onhashchange就可以检测了,但是项目中的路由是history。onhashchange不能在history模式下用,所以这一条算废了。

方案二、onpopstate

image.png

但是mdn告诉说事情不是这么简单。mdn明确说go与back可以检测到但是pushState与replaceState检测不到,由于项目中大量使用push这些方法。所以这一条也废弃了。但是为什么项目中history.push调用的时候react-router能感应到并做出路由切换,难道是黑科技吗,想到这里我就想对react-router源码进行解析为什么react-router可以感应到history.push方法。

请注意react-router-dom可以结构出来useHistory方法,执行该方法可得到已经封装好的history对象,该对象有push方法,实际上执行的是pushState方法,replace其实也就是replaceState方法,这里说的push方法大家可以理解成执行pushState方法。

方案三、redux

当使用history.push这些方法的时候往redux传入我们要跳转到哪个路由,然后谁要监测这个路由谁就订阅这个数据源。这是个好办法,但是由于项目中使用history.push的地方有点多,也就是入口多,需要在跳转的时候都要派发一次数据虽然可以解决监测路由的问题,但是写了大量重复代码。我们在方案4中采用了redux的思想。

方案四、路由守卫

我记得vue的路由中有路由守卫概念,当进入到路由之前的话,左侧导航栏变化不同的样式。我们用react-router实现路由守卫不就可以了吗,当组件检测到路由变化的时候这时候就dispatch派发路由数据。 下面就是我们当初写的一个很low的路由守卫

import { useEffect } from 'react';
import {BrowserRouter as Router, Switch, Route, useLocation} from 'react-router-dom';
import Home from './home';
const RouteGuard = (props)=> { 
  const {pathname} = useLocation();
  useEffect(()=> {
    console.log('路由变化,',pathname);
    //可以dispatch派发pathname到redux中,然后左侧导航栏订阅这个数据就可以切换主题了,
    //这里也可以做路由权限的鉴定哈。如果没有权限写一些重定向的逻辑跳转到403页面。
    //这个return函数就相当于路由销毁的时候我们要干一些什么事情
    return ()=> {
    }
  });
  return props.children;
};
const App =()=> {
  return (
      <Router>
        <RouteGuard>
          <Switch>
            <Route path='/' exact>
              <Home/>
            </Route>
          </Switch>
        </RouteGuard>
      </Router>
  );
}
export default App;

二、react-router是怎么监听路由的改变的呢。

刚刚聊到react-router能够监测history.push方法执行并返回正确的路由。这里面react-router肯定监听了路由的改变但肯定不是用onpopstate监听的,因为onpopstate是监听不到push与replace改变的。为了解决这个谜团,上手解析源码。

注意📢

react-router源码没有多少行,如果解析源码大家看不懂,那就是我没讲明白。是我的锅。

三,源码解析

写路由一开始都是这样的

import {BrowserRouter as Router, Switch, Route} from 'react-router-dom';
import Home from './home';
const App =()=> {
  return (
      <Router>
          <Switch>
            <Route path='/' exact>
              <Home/>
            </Route>
          </Switch>
      </Router>
  );
}
export default App;

我们就一段一段解析先解析Router然后解析Switch最后解析Route

1、解析Router组件

解析Router组件也就是解析BrowserRouter,因为导入BrowserRouter的时候重新命名为Router了。所以我们看一下BrowserRouter的源码。再次强调没有多少行,大家放下包袱不用担心源码看不懂。

//我在代码中一一注释他们分别干了什么
import React from "react";
import { Router } from "react-router";
import { createBrowserHistory as createHistory } from "history";
class BrowserRouter extends React.Component {
  //createHistory(this.props),是调用了history的createBrowserHistory方法这个就是刚刚我们聊的
  //为什么执行push相当于执行原生的pushstate是因为createHistory给我们封装好了
  history = createHistory(this.props);
  render() {
     //这个就是调用react-router中的Router组件来传入了封装好的history的对象以及里面的children
    return <Router history={this.history} children={this.props.children} />;
  }
}
export default BrowserRouter;

1.1 createHistory 干了什么

这是截取的一部分核心代码,其实刚一看这些代码有点懵,毕竟代码有点多,其实我们只需要关注push这个方法就行其他的就可以猜出来,我们先看push方法,我在push方法写有注释。

function createBrowserHistory(props = {}) {
  const globalHistory = window.history;
  const canUseHistory = supportsHistory();
  const needsHashChangeListener = !supportsPopStateOnHashChange();
  const {
    forceRefresh = false,
    getUserConfirmation = getConfirmation,
    keyLength = 6
  } = props;
  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);
    }
  }

  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) {
        //看这个,这个globalHistory其实就是window.history所以我们实际上走的也是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;
            //这里就是通知组件重新渲染先不要管。我们知道react-router帮我们封装了这些方法。路由发生改变的时候
            //就会调用setState通知组件重新渲染,注意这个setState与react的setState不一样,这个setState
            //是一个封装好的函数,也就是下方的setState看一下在setState些的注释。
            setState({ action, location });
          }
        } else {
          window.location.href = href;
        }
      }
    );
  }
  function setState(nextState) {
    Object.assign(history, nextState);
    history.length = globalHistory.length;
   //transitionManager.notifyListeners调用了listeners.forEach(listener => listener(...args));
   //...args 就是location信息
   // listeners里面的数据就是在router组件中的componentDidMount调用
   //this.props.history.listen(location => 
   //    this.setState({ location });
   //});传递进去的最终就会执行this.setState({ location });使组件重新渲染
   // 
    transitionManager.notifyListeners(history.location, history.action);
  }
  
  
   //这些其实简单来说就是调用原生的方法 checkDOMListeners这个方法中
   //window.addEventListener(PopStateEvent, handlePopState);会监听go函数变化从而触发
   //setState重新渲染
  function go(n) {
    globalHistory.go(n);
  }
 //这个就是初始化的时候监听的路由改变的事件
function checkDOMListeners(delta) {
    listenerCount += delta;
    if (listenerCount === 1 && delta === 1) {
      window.addEventListener(PopStateEvent, handlePopState);

    }
}

  function goBack() {
    go(-1);
  }

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

export default createBrowserHistory;

总结以上代码createHistory帮我们封装了history对象,在push方法中触发了setState使组件重新渲染而go方法则是通过监听popstate事件触发setState方法。

1.2 Router组件干了什么

先贴精简过后的源码

import React from "react";
//HistoryContext与RouterContext都是Context.Provider组件
import HistoryContext from "./HistoryContext.js";
import RouterContext from "./RouterContext.js";

class Router extends React.Component {
  static computeRootMatch(pathname) {
    return { path: "/", url: "/", params: {}, isExact: pathname === "/" };
  }
  constructor(props) {
    super(props);
    this.state = {
      location: props.history.location
    };
  }

  componentDidMount() {
     //📢setState方法我讲的是上一节说的封装好的setState方法而不是react解构出来的setSate方法,react解构出来
     //的方法我会说成this.setState大家注意不要记乱了
     //我们讲到过路由变化最终会触发setState方法,而setState方法最终会调用
     //listeners.forEach(listener => listener(...args));这个方法,而listeners里面的数据已经通过
     //this.props.history.listen(location => 
      //  this.setState({ location });
     //});挂载了。所以会执行 this.setState({ location });重新渲染组件,我在之前的掘金文章中提到过执行
     //setState对props.children无用,但是这是Context组件,会打上forceUpdate的tag标签从而重新渲染。
    //剩下的事情就交给Switch组件了
    this.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,
          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;

这两个setState方法有点乱,大家去看一下react-router源码并debugger以下就能更好的区分了。我这里尽力去讲。

2、解析Switch组件

我们聊完了Router组件,接下来就是Switch组件了,我们看一下

import React from "react";
import RouterContext from "./RouterContext.js";
import matchPath from "./matchPath.js";
class Switch extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          const location = this.props.location || context.location;
          let element, match;
          React.Children.forEach(this.props.children, child => {
             //这个就是switch组件的里面处理逻辑他是匹配switch中的第一个符合条件的route组件
            if (match == null && React.isValidElement(child)) {
              element = child;
              const path = child.props.path || child.props.from;
              match = path
                ? matchPath(location.pathname, { ...child.props, path })
                : context.match;
            }
          });
          return match
            ? React.cloneElement(element, { location, computedMatch: match })
            : null;
        }}
      </RouterContext.Consumer>
    );
  }
}
export default Switch;

其实Switch组件源码就是可简单,就是拿到子组件的path或者form属性与context传递的location属性进行比较,如果符合就返回该route组件。如果我们去掉switch组件的话直接写route组件,这个页面也会正常渲染的,但是容易出现bug如果匹配到两个相同的路由他就会讲两个相同的路由显示到页面上,而不是只显示匹配到的第一个路由.

3、解析route组件

route解析完成之后我们的react-router源码解析工作也快要结束了,话不多少上源码

import React from "react";
import RouterContext from "./RouterContext.js";
import matchPath from "./matchPath.js";
function isEmptyChildren(children) {
  return React.Children.count(children) === 0;
}
class Route extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          const location = this.props.location || context.location;
          //通过传递过来的location与path来比较如果匹配成功就显示,如果匹配不成功就不显示,其实很简单的
          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 } = this.props;
          if (Array.isArray(children) && isEmptyChildren(children)) {
            children = null;
          }
          return (
            <RouterContext.Provider value={props}>
              {props.match? children: null}
            </RouterContext.Provider>
          );
        }}
      </RouterContext.Consumer>
    );
  }
}

export default Route;

总结一下 history.push 会触发 自己封装的 setState({ action, location }) 这个方法,这个方法会调用 transitionManager.notifyListeners(history.location, history.action);去执行listeners.forEach(listener => listener(...args)); 而listeners在我们挂载组件的时候已经向listeners数组中传递了 location => { this.setState({ location }) }; 所以经过这一系列流程我们history.push会调用到this.setState方法,从而利用context模式重新渲染。

好了,我们的react-router源码解析也到此结束了,hash模式没有讲,因为其实和history没什么区别就留给大家debuger吧,哈哈,我们下期再见 下期计划

    1、写一个keep-alive缓存组件
    2、react源码解析