由两个问题引发的React-Router源码浅浅浅析

🤔 遇到了两个问题

目前保险业务用的是react技术栈,在开发过程中遇到了两个路由相关的奇怪问题: 【示例代码】

1⃣️.【问题1】在使用React-Router的basename功能的时候发现,无论是使用 HashRouter 还是 BrowserRouter ,路由中有没有basename都能够匹配到对应的组件,哪怕我们已经给路由加上了 exact 即严格匹配规则.

2⃣️.【问题2】 在排查 【问题1👆】 的时候发现在浏览器的 Console 中对hash进行修改能正常切换页面,但是用history修改url却不会. 然而在代码中使用history API(history.push)修改路由却又是可以的😵 (猜测是React-Router重写了history的API).

更改hash能正确切换路由

使用history.pushState不能正确切换路由 上面2个问题涉及到了【React-Router是如何匹配对应路径组件】,【React如何重写window.history API】 以及 【window.history API内部逻辑】等等问题. 所以我就借着解决上面两个问题的想法去了解了一下React-Router的源码。在下面讲解文章的过程中,顺带解释一下业务中遇到的问题以及原因.

😺 React-Router

React-Router的源码同学们可以在 github.com/ReactTraini... 下载.

项目结构

打开下载好的React-Router项目,如下图所示,可以看到最最核心部分是packages文件下的四个文件夹,分别是 react-routerreact-router-configreact-router-domreact-router-native ,这个四个文件夹每个都可以作为单独的包进行发布,但是React把它们都放在一个仓库中,是一种典型的 monorepo 代码组织方式. 👉 什么是monorepo

  • 那这四个文件夹分别有什么用呢?
  • react-router :是React-Router的核心库,处理一些共用的逻辑.
  • react-router-config :是React-Router的配置处理,我们一般不需要使用
  • react-router-dom :浏览器上使用的库,会引用react-router核心库.
  • react-router-native :支持React-Native的路由库,也会引用react-router核心库. 从上面的解释来看对于我们前端H5开发的同学来说最重要的就是 react-routerreact-router-dom 这两个文件了.
  • 打开 react-router-dom 根文件index.js 可以看到除了BrowserRouter,HashRouter,Link以及NavLink这四个组件是react-router-dom 自己独立实现的,其他的(比如最基础的Router,Route,Switch组件等等)都来自react-router里的公用组件.
javascript 复制代码
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";

HashRouter & BrowserRouter

让我们先从下面一个简单的例子开始吧:

javascript 复制代码
//或者在这里 import {HashRouter as Router } from 'react-router-dom'
import { Switch, BrowserRouter as Router, HashRouter, Route, Link } from 'react-router-dom'

<Router basename="h5">
<div>
  <h2>React-Router</h2>
  <ul>
    <li>
      <Link to="/router1">Router-1 </Link>
    </li>
    <li>
      <Link to="/router2">Router-2</Link>
    </li>
  </ul>

  <Switch>
    <Route path="/:pathname" children={<Child />} />
  </Switch>
</div>
</Router>

可以看到,HashRouter/BrowserRouter 是所有组件Switch、Route、Link等组件的父组件,也是一个根组件,所以我们以它作为入口看下它实现了一些什么功能:

  • HashRouter
scala 复制代码
import { createHashHistory as createHistory } from "history";
import { Router } from "react-router";

class HashRouter extends React.Component {
  history = createHistory(this.props);
  render() {
    return <Router history={this.history} children={this.props.children} />;
  }
}
export default HashRouter;
  • BrowserRouter
scala 复制代码
// 与HashRouter唯一的区别在这里,引入的是createBrowserHistory
// 而不是createHashHistory
import { createBrowserHistory as createHistory } from "history";
import { Router } from "react-router";

class BrowserRouter extends React.Component {
  history = createHistory(this.props);
  render() {
    return <Router history={this.history} children={this.props.children} />;
  }
}
export default BrowserRouter;

显然,HashRouter 和 BrowserRouter 只是一个壳子(HOC),它们作用只是把通过构造函数 createHistory构造出的的 history实例 以及children(可能是React Component ,也可能是Route组件或者switch/Link组件)作为props的属性传给 Router 这个路由器。

所以我们的重点自然而然就放在了 Router组件history实例对象 上了.

✨ Router

先整体看下Router组件源码里面长什么样:

kotlin 复制代码
class Router extends React.Component {

1.默认命中路由
  static computeRootMatch(pathname) {
    return { path: "/", url: "/", params: {}, isExact: pathname === "/" };
  }

2. 构造函数部分,创建对history的监听
  constructor(props) {
    super(props);
    this.state = {
      location: props.history.location
    };
    this._isMounted = false;
    this._pendingLocation = null;

    if (!props.staticContext) {
      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 });
    }
  }
  
3. 卸载组件之前,取消监听对history的监听
  componentWillUnmount() {
    if (this.unlisten) {
      this.unlisten();
      this._isMounted = false;
      this._pendingLocation = null;
    }
  }

  render() {
    return (
    
4.创建context,传入必要信息,供子组件使用
      <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;
  • 比较重要部分的是构造函数这块:
kotlin 复制代码
constructor(props) {
  super(props);
  this.state = {
    location: props.history.location
  };
  this._isMounted = false;
  this._pendingLocation = null;

  if (!props.staticContext) {
    this.unlisten = props.history.listen(location => {
      if (this._isMounted) {
        this.setState({ location });
      } else {
        this._pendingLocation = location;
      }
    });
  }
}
  • Router有一个内部的location对象 ,初始值为props.history.location,即history实例中的location属性
  • 通过history对象上的listen方法传入回调函数,这个回调函数会在路由发生变化之后更新组件内部的location对象 ------ this.setState({ location }). 提前剧透一下,React就是通过这样的方式来更新路由并重新渲染路由匹配组件的。
  • 具体是怎么注册的监听函数,怎么触发的回调,在什么时机触发,在下一部分 History 会详细分析.

🍁 History

上文提到了HashRouter 和 BrowserRouter的唯一区别在于引入的history实例对象的构造函数不一样,而history是React封装的三方库,具体见这里👉 History.

history在整个React-Router中有着举足轻重的地位,也是实现React路由的核心所在,简单来说它主要有俩作用:

  1. 监听路由变化,触发对应的回调函数
  2. 对push,replace,go等路由方法进行重写

为了搞明白文章开头的那几个问题,不扒开这个库的内部看个清楚是怎么个实现法是不行的,😭路漫漫其修远兮,吾将上下而求索.

项目结构

打开这个库的index.js文件可以发现里面的内容很简单,按功能主要分为四大模块:

☑️ 各种公用函数

  • 其中最重要的是一个叫做createTransitionManager的构造函数,它是一个针对路由切换时的相同操作抽离的一个公共方法/过度管理工具,路由切换的操作器,拦截器和订阅者都存在于此 ☑️ createMemoryHistory(主要用于ssr,本文不考虑)
    ☑️ createBrowserHistory
    ☑️ createHashHistory

因为hash路由和browser路由实现方式大差不差,所以之后将以createHashHistory 为例进行讲解,具体到两者本身的差别的时候再具体分析.

createHashHistory

大概浏览一下构造函数createHashHistory内部,主要有:

  • 相关的工具函数
  • getDOMLocation --- 获取location对象:【问题1】的答案就在该方法里
  • handlePop - 使用go方法来回退或者前进时,对页面进行的更新
  • revertPop --- 使用prompt时候用户点击取消时候的hack
  • creatHref - 获取完整的location.href
  • 监听路由变化的函数
  • listen - 注册Router里的回调函数
  • checkDOMListeners - 注册/注销监听事件
  • 原生方法重写&封装
  • push, replace, go, replace, goBack, goForward
  • history对象 即上文中提到的用于传递给子组件的history实例对象

这个history实例对象返回的东西很多,我们挑几个比较重要的有代表性的来说:

1. listen

前文中说到,在Router组件里使用了history的listen方法来进行注册监听事件,传入回调。那我们就来看看listen方法到底做了些什么:

scss 复制代码
 function listen(listener) {
  var unlisten = transitionManager.appendListener(listener);
  checkDOMListeners(1);
  return function() {
    checkDOMListeners(-1);
    unlisten();
  };
}

//注册hash变化监听函数
function checkDOMListeners(delta) {
  listenerCount += delta;
  //这样写的目的是为了防止重复监听 只运行一个且只有一个的hashchange事件监听函数
  if (listenerCount === 1 && delta === 1) {
    window.addEventListener(HashChangeEvent$1, handleHashChange);
    //只有listenerCount为0的时候才会取消监听
  } else if (listenerCount === 0) {
    window.removeEventListener(HashChangeEvent$1, handleHashChange);
  }
}

从代码可以看出,它又是由 transitionManager 内部 appendListener 方法实现的,前文提到了 transitionManager 是一个抽离的一个公共方法,用于过度管理的工具,它里面主要维护了一个listener队列,以及四个方法分别是 setPrompt , confirmTransitionTo , appendListenernotifyListeners .

前两个函数主要跟block,prompt弹窗有关,和本文内容没有太多的联系,有兴趣的同学可以自己看看,不过作者貌似打算移除这个令人头秃的功能了,见此 🧭.

所以我们主要看 appendListenernotifyListeners 这两个方法干了些什么:

  • appendListener & notifyListeners
javascript 复制代码
function appendListener(fn) {
  var isActive = true;
  function listener() {
    if (isActive) fn.apply(void 0, arguments);
  }
  listeners.push(listener);
  return function() {
    isActive = false;
    listeners = listeners.filter(function(item) {
      return item !== listener;
    });
  };
}

function notifyListeners() {
  for (
    var _len = arguments.length, args = new Array(_len), _key = 0;
    _key < _len;
    _key++
  ) {
    args[_key] = arguments[_key];
  }
  listeners.forEach(function(listener) {
    return listener.apply(void 0, args);
  });
}

这两个方法的代码量都很少,目的也非常清晰。appendListener就是将注册的事件回调函数添加进内部维护的listener队列中,notifyListeners的任务则是遍历该队列并执行这些回调们.

  • ❓那什么时候才会调用notifyListeners触发回调函数呢? 我在history.js文件中全局搜索了一下notifyListeners,发现只有一个叫做setState的方法调用了它(注意此setState非React的setState),具体见👇
ini 复制代码
function setState(nextState) {
  _extends(history, nextState);
  history.length = globalHistory.length;
  transitionManager.notifyListeners(history.location, history.action);
}

而setState函数在很多地方都会被调用,比如push,replace,go等等操作改变Route的方法中以及 hashchange事件的回调函数里.

所以到这里已经很清晰整个React-Router路由回调触发逻辑了:

📖 总结一下:

  • HashRouter/BrowserRouter创建了History实例对象通过props传递给根组件Router
  • Router通过props.history.listen(callback)传入路由变化的回调函数callback
  • 该callback会通过history中实例化的transitionManager的appendListener方法推入内部维护的listeners队列中
  • 在路由变化的时候(比如push,replace等操作)会调用history的setState函数,该函数内部会触发transitionManager.notifyListener,遍历执行listeners队列中所有任务

2. push

之前我们猜测很有可能是React-Router重写了History API才导致了【问题2】------ 【代码中使用history API(history.push)对url进行修改能切换路由,但在console中使用window.history.pushState却不能】

在探究这个问题之前,我们首先需要知道为什么在console中window.history.pushState不能如预料的那样切换页面: MDN中对history.pushState API中第三个参数URL解释道

请注意,浏览器不会在调用pushState() 之后尝试加载此URL,但可能会稍后尝试加载URL,例如在用户重新启动浏览器之后。

也就是说我们在console中使用pushState添加一个新的路由,并不会导致浏览器加载该新路由 ,浏览器甚至不会检查该路由是否存在。React-Router也正是利用这一点实现前端SPA路由的.

现在咱们再来看看 createBrowserHistory 里的push方法是怎么实现的:

ini 复制代码
function push(path, state) {
  var action = "PUSH";
  //1. 根据path返回一个新的location对象
  var location = createLocation(path, state, createKey(), history.location);
  //2. 执行跳转
  transitionManager.confirmTransitionTo(
    location,
    action,
    getUserConfirmation,
    function(ok) {
      if (!ok) return;
      var href = createHref(location);
      var key = location.key,
        state = location.state;
      if (canUseHistory) {
        globalHistory.pushState(
          {
            key: key,
            state: state
          },
          null,
          href
        );
        if (forceRefresh) {
          window.location.href = href;
        } else {
          var prevIndex = allKeys.indexOf(history.location.key);
          var nextKeys = allKeys.slice(0, prevIndex + 1);
          nextKeys.push(location.key);
          allKeys = nextKeys;
          3. 新的location对象会传入setState,setState内部会进行更新操作
          setState({
            action: action,
            location: location
          });
        }
      } else {
        window.location.href = href;
      }
    }
  );
}
  1. 首先,函数第一个入参path,是准备跳转的新url. 通过createLocation方法将这个path与当前的location做一个合并,返回一个更新的location.
  2. 然后,通过transitionManager.confirmTransitionTo方法进行页面跳转,该方法内部的回调函数里面还有一些小细节,比如 forceRefresh, 如果前端路由使用的是BrowserRouter,并且传入该字段, 那在路由发生变化的时候,React就会通过强制修改location.href进行页面跳转了(页面重新刷新)
ini 复制代码
if (forceRefresh) {
   window.location.href = href;
} 
  1. 最后,如果 forceRefresh为false, 代码会走到 setState({action, location}) 这个分支, 然后同样的 -> 触发notifyListener -> 遍历listeners队列执行回调. 所以也这也是我们在【问题2】中所说的,在代码里面使用history.push API的时候能顺利执行路由切换的原因.

❓最后还有一个小问题,那为什么在console中更改location.hash可以进行页面更换呢?我们来看一下两种方式注册的监听函数是怎么样的吧,我把相关代码都放在下面了:

ini 复制代码
var PopStateEvent = "popstate";
var HashChangeEvent = "hashchange";

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

//createHashHistory
function checkDOMListeners(delta) {
 listenerCount += delta;
 if (listenerCount === 1 && delta === 1) {
   window.addEventListener(HashChangeEvent$1, handleHashChange);
 } else if (listenerCount === 0) {
   window.removeEventListener(HashChangeEvent$1, handleHashChange);
 }
}

可以看到,Hash路由注册的是 hashchange 事件,而Browser路由注册的是 popstate 事件,到这里,聪明如你应该明白怎么肥事了吧:我们在 console 中更改hash会触发 hashchange 事件 -> setState更新路由 -> 页面切换,但是使用history.pushState却不会触发popstate事件,所以无法切换到对应路径的页面了。

3.getDOMLocation

特意讲一下这个方法主要是为了解答一下【问题1】

lua 复制代码
function getDOMLocation() {
  var path = decodePath(getHashPath());
  warning(
    !basename || hasBasename(path, basename),
    "You are attempting to use a basename on a page whose URL path does not begin " +
      'with the basename. Expected path "' +
      path +
      '" to begin with "' +
      basename +
      '".'
  );
  if (basename) path = stripBasename(path, basename);
  return createLocation(path);
}
  
function stripBasename(path, prefix) {
  return hasBasename(path, prefix) ? path.substr(prefix.length) : path;
}

function hasBasename(path, prefix) {
  return (
    path.toLowerCase().indexOf(prefix.toLowerCase()) === 0 &&
    "/?#".indexOf(path.charAt(prefix.length)) !== -1
  );
}

在使用basename的时候,React在获取location信息时会特意查看path中是否带有basename,如果有就会去掉. 比如,basename为h5,当我们的path为/h5/index,React就会自动把basename过滤为/index,在存入location里面.

这也就是为什么我们在去掉basename之后仍然能给匹配到相应的组件的原因. 我猜测React之所以这么做也许是为了兼容吧(实在想不出其他的原因了😂 orz)

Two Tips

⭕️ history.pushState() 和 history.replaceState() 都不会触发 popstate 事件. popstate事件只会在浏览器某些行为下触发, 比如点击后退、前进按钮(或者在JavaScript中调用history.back()、history.forward()、history.go()方法)。

⭕️ 注意到一个有趣的地方,在BrowserHistory中如果 needsHashChangeListener 为true,那React会使用hash路由的方式,我们再看看什么时候 needsHashChangeListener === true 呢:

csharp 复制代码
var needsHashChangeListener = !supportsPopStateOnHashChange();
......
/**
 * Returns true if browser fires popstate on hash change.
 * IE10 and IE11 do not.
 */
function supportsPopStateOnHashChange() {
  return window.navigator.userAgent.indexOf("Trident") === -1;
}

使用Trident内核的浏览器,果然又是IE(除了IE10/11) 是不能使用history API的.

❄️ Route

🤔 到这里,对于前端路由我们还剩一个小问题, 路由发生变化之后,React是如何匹配到对应组件的呢?

老样子,show guys the code:

kotlin 复制代码
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}>
            //通过match来判断Route的path是否匹配location
              {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>
    );
  }
}

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

忽略开发模式_dev_下的情况,Route 渲染的内容有三种类型,分别是:

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

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

ini 复制代码
//Route.js
 const location = this.props.location || context.location;
 const match = this.props.computedMatch
  // <Switch> already computed the match for us
   ? this.props.computedMatch
   : this.props.path
   ? matchPath(location.pathname, this.props)
   : context.match;

//macthPath.js
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, 
    url: path === "/" && url === "" ? "/" : url, 
    isExact, 
    params: keys.reduce((memo, key, index) => {
      memo[key.name] = values[index];
      return memo;
    }, {})
  };
}
  • 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.computedMatchmatchPath(location.pathname, this.props)context.match都为null则代表与该路由不匹配,不进行渲染.

📖 总结

文章最后,我们来梳理一下React-Router的整个路由流程:

  1. HashRouter/BrowserRouter中通过history第三方库提供的createHashHistory/createBrowserHistory创建了一个history实例对象,并通过props传递给组件Router.
  2. Router内部维护了一个location对象,初始值为props.history.location. Router给props.history.listen方法中传入了一个监听回调函数,在该回调函数里会更新location对象.
  3. props.history.listen是通过transitionManager.appendListener方法把回调推入history实例内部维护的listeners队列中.
  4. 当路由改变的时候(push,replace,点击Link)会触发history对象的setState方法 -> 调用transitionManager.notifyListeners方法 -> 遍历执行listeners中所有回调任务.
  5. listeners的回调任务中有在Router中注册的回调函数,也会被执行,所以会更新Router内部维护的location对象,因为location对象是通过context传递给比如Switch,Route等消费组件的,所以此时所有的消费组件也会更新,进行重新渲染.
相关推荐
野生的程序媛1 分钟前
重生之我在学Vue--第10天 Vue 3 项目收尾与部署
前端·javascript·vue.js
烟锁池塘柳01 小时前
技术栈的概念及其组成部分的介绍
前端·后端·web
加减法原则1 小时前
面试题之虚拟DOM
前端
故事与他6451 小时前
Tomato靶机攻略
android·linux·服务器·前端·网络·web安全·postcss
jtymyxmz1 小时前
mac 苍穹外卖 前端环境配置
前端
烛阴1 小时前
JavaScript Rest 参数:新手也能轻松掌握的进阶技巧!
前端·javascript
chenchihwen1 小时前
ITSM统计分析:提升IT服务管理效能 实施步骤与操作说明
java·前端·数据库
陌上烟雨寒1 小时前
es6 尚硅谷 学习
前端·学习·es6
拉不动的猪1 小时前
刷刷题32(uniapp初级实际项目问题-1)
前端·javascript·面试
拉不动的猪1 小时前
刷刷题33(uniapp初级实际项目问题-2)
前端·javascript·面试