React-Router原理浅析

1,578 阅读9分钟

前前言

hello各位小伙伴,我是来自推啊前端团队的 jarvis。 今天跟大家简要分享一下React-Router原理。

1. 原理初探

借助history库实现监听路由,内部支持hashbowser两种路由变化。

使用react-router-dom时首先选择的就是BrowserRouterHashRouter,对应hashbrowser两种路由规则,两个组件的源码实现基本一致,区别是调用了history库的两个不同方法创建history对象,然后通过React.Contexthistory对象共享给消费者使用,在消费者内部会注册一个更新当前组件的函数,每当路由变化时会自动触发组件更新,从而实现路由切换的效果。

在源码中二者区别如下

// BrowserRouter
import { createBrowserHistory as createHistory } from 'history';

// HashRouter
import { createHashHistory as createHistory } from 'history';

这里我们任选其一即可,就选BrowserRouter

2. 从源码探究细节

2.1. BrowserRouter

  • 作用

    • 创建history实例,
    • childrenhistory传入组件<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对象实际上是通过createBrowserHistory创建的,核心逻辑都在<Router/>组件中,<Router/>组件接收特定的history对象,history对象目前包括hash、browser、memory三种类型。

2.2. Router

  • 作用

    • 监听路由变化,存储到state
    • 通过Context向下传递historylocationmatch
  • 核心源码

    class Router extends React.Component {
      // 默认命中的路由
      static computeRootMatch(pathname) {
        return { path: '/', url: '/', params: {}, isExact: pathname === '/' };
      }
    
      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 });
        }
      }
    
      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>
        );
      }
    }
    

    首先<Router/>会声明默认命中路由的规则,即静态方法computeRootMatch,默认是匹配成功,记住这里的isExact,后续会使用。

    构造方法中监听路由变化,使用的history是在BrowserRouter中通过createBrowserHistory库创建的实例。

    注意,_isMounted_pendingLocation是为了处理懒加载组件,当路由匹配时,此时懒加载组件还未挂载,所以放到_pendingLocation临时存储,然后在componentDidMount中更新到state

2.3. Route

  • 作用

    • 通过matchPath判断当前<Route/>path是否匹配
    • 创建Provider,带着当前path是否匹配以及Context的其他内容一起共享给子组件
  • 核心源码

    class Route extends React.Component {
      render() {
        return (
          <RouterContext.Consumer>
            {context => {
              const location = this.props.location || context.location;
              // 如果没有传入path,则通过matchPath匹配
              const match = this.props.computedMatch
                ? this.props.computedMatch // 包裹<Switch/>组件时注入的prop
                : this.props.path
                ? matchPath(location.pathname, this.props)
                : context.match;
    
              const props = { ...context, location, match };
    
              let { children, component, render } = this.props;
    
              // children必须是单根节点
              if (Array.isArray(children) && isEmptyChildren(children)) {
                children = null;
              }
    
              return (
                <RouterContext.Provider value={props}>
                  {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>
        );
      }
    }
    
    1. match

      • 优先比较computedMatch,这是<Route/>组件被<Switch/>组件包裹时注入的 prop,如果有则直接使用,
      • 没有computedMatch比较path,若存在path通过matchPath处理
      • 没有path则直接使用match,这是<Router/>通过provider传递的默认匹配规则,这里会匹配成功,所以404 组件不写path
    2. 组件的返回结果:

      • match是否匹配成功
        • 匹配
          • children 是否存在
            • 存在
              • children 是函数:执行
              • 不是函数就是组件:渲染
            • 不存在
              • component 是否存在
                • 存在:createElement(component, props)
                • 不存在:
                  • render 是否存在
                    • 存在:执行
                    • 不存在:null
        • 不匹配
          • children 是函数
            • 匹配:执行
            • 不匹配:null

      由于可见,优先级分别是:children>component>render

      需要注意的一点是,component通过React.createElement创建,在React中会根据type有没有改变选择是否重用组件,所以为了避免不必要的开销,component属性最好不要写内联函数

      最后返回Provider是为了后续使用高阶组件withRouter

2.4. Switch

也被称为独占路由

  • 作用

    • 只返回匹配成功的子组件<Route/>
  • 核心源码

    class Switch extends React.Component {
      render() {
        return (
          <RouterContext.Consumer>
            {context => {
              invariant(context, 'You should not use <Switch> outside a <Router>');
    
              const location = this.props.location || context.location;
    
              let element, match;
    
              // We use React.Children.forEach instead of React.Children.toArray().find()
              // here because toArray adds keys to all child elements and we do not want
              // to trigger an unmount/remount for two <Route>s that render the same
              // component at different URLs.
              React.Children.forEach(this.props.children, child => {
                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>
        );
      }
    }
    

    为什么不使用React.Children.toArray().find(),源码中的注释已经写的很明白了,因为toArray会为所有子元素添加key,因而可能出现不同 url显示两个相同<Route/>卸载重新挂载的情况。

    首先看一下最终的渲染结果,若match为真则使用匹配成功的组件,并且为其添加locationcomputedMatch,这里重点是computedMatch,刚才已经整理,<Route/>组件在匹配时会优先使用这个computedMatch,如果没有computedMatch才会使用自己的匹配方式匹配。而<Switch/>通过匹配算法返回最后一个组件,所以最终在页面呈现的<Route/>只有一个。

    下面看匹配算法遍历所有的子节点(也就是<Route/>,对每个组件的path进行matchPath,如果匹配成功则保存到变量element中,一直遍历到最后一个子组件为止。

    在这里不得不提一下404 组件的两个条件,

    1. 不写path
    2. 放在<Switch/>的最后一个子组件

    刚才我们已经整理,Route组件没有path时会使用Router组件提供的matchmatch默认是匹配的,所以不写path可以使用默认匹配的条件; <Switch/>组件的匹配方式通过循环完成,一直遍历到最后一个为止,如果放在前面,即使404 组件被匹配到,紧接着下次循环也会被覆盖掉,最终渲染一个null,所以必须是<Switch/>最后一个子组件,保证一定是匹配到的组件。

2.5. withRouter

  • 作用

    • 这是一个高阶组件,取出<Route/>通过Provider共享的数据,给目标组件使用
  • 核心源码

    function withRouter(Component) {
      const C = props => {
        const { wrappedComponentRef, ...remainingProps } = props;
    
        return (
          <RouterContext.Consumer>
            {context => {
              return <Component {...remainingProps} {...context} ref={wrappedComponentRef} />;
            }}
          </RouterContext.Consumer>
        );
      };
    
      return hoistStatics(C, Component);
    }
    

    还记得吗,<Route/>组件最后返回一个provider,共享path的匹配情况,其实就是为了通过withRouter组件使用

2.6. Link

Link本质上也是ContextConsumer,取出history,通过执行history.replacehistory.push改变路由

2.7. Redirect

  • 作用

    • 重定向
  • 核心源码

    function Redirect({ computedMatch, to, push = false }) {
      return (
        <RouterContext.Consumer>
          {context => {
            const { history, staticContext } = context;
    
            const method = push ? history.push : history.replace;
            const location = createLocation(
              computedMatch
                ? typeof to === 'string'
                  ? generatePath(to, computedMatch.params)
                  : {
                      ...to,
                      pathname: generatePath(to.pathname, computedMatch.params),
                    }
                : to
            );
    
            return (
              <Lifecycle
                onMount={() => {
                  method(location);
                }}
                onUpdate={(self, prevProps) => {
                  const prevLocation = createLocation(prevProps.to);
                  if (
                    !locationsAreEqual(prevLocation, {
                      ...location,
                      key: prevLocation.key,
                    })
                  ) {
                    method(location);
                  }
                }}
                to={to}
              />
            );
          }}
        </RouterContext.Consumer>
      );
    }
    

    RedirectRoute都作为Switch的子组件使用,只是Redirect 组件没有渲染实体,只是为了重定向

    最后返回了Lifecycle组件,这是一个非常值得学习的内容,在我们的项目场景中可以借鉴这种方式来实现比较复杂的逻辑

    源码如下:

    class Lifecycle extends React.Component {
      componentDidMount() {
        if (this.props.onMount) this.props.onMount.call(this, this);
      }
    
      componentDidUpdate(prevProps) {
        if (this.props.onUpdate) this.props.onUpdate.call(this, this, prevProps);
      }
    
      componentWillUnmount() {
        if (this.props.onUnmount) this.props.onUnmount.call(this, this);
      }
    
      render() {
        return null;
      }
    }
    

    可以看到最后render方法返回了null表明没有渲染真实 dom,只是借助react 组件生命周期做了一些事情,

    所以Redirect 组件最终只是在挂载时执行了history.pushhistory.replace完成重定向操作

    重定向react-router-dom中有两种方式

    1. 标签式重定向:<Redirect to="/home">
    2. 编程式重定向:this.props.history.push('/home')

2.8. Prompt

有了刚才的心得,接下来又是Lifecycle 组件的一次应用

  • 作用

    • 在页面离开前做拦截询问
  • 核心源码

    function Prompt({ message, when = true }) {
      return (
        <RouterContext.Consumer>
          {context => {
            invariant(context, 'You should not use <Prompt> outside a <Router>');
    
            if (!when || context.staticContext) return null;
    
            // 通过 history 调用平台的 询问方法,默认是 window.confirm
            const method = context.history.block;
    
            return (
              <Lifecycle
                onMount={self => {
                  self.release = method(message);
                }}
                onUpdate={(self, prevProps) => {
                  if (prevProps.message !== message) {
                    self.release();
                    self.release = method(message);
                  }
                }}
                onUnmount={self => {
                  // 发起询问
                  self.release();
                }}
                message={message}
              />
            );
          }}
        </RouterContext.Consumer>
      );
    }
    

    具体的拦截方法用的是context.history.block,这是在创建Router时接收的平台特有的方法,由于当前是web 环境,所以默认是window.confirm

2.9. BrowserRouter 与 HashRouter 对⽐

之前使用vue-router遇到一个问题,都是hashbrowser区别所导致,所以一并整理了吧

  1. HashRouter 最简单,每次路由变化不需要服务端接入,根据浏览器的#的来区分 path 就可以;BrowserRouter 需要服务端解析 URL 返回页面,因此使用BrowserRouter需要在后端配置地址映射
  2. BrowserRouter 触发路由变化的本质是使⽤ HTML5 history APIpushStatereplaceStatepopstate 事件)
  3. HashRouter 不⽀持 location.keylocation.state动态路由需要通过?传递参数。
  4. Hash history 只需要服务端配置一个地址就可以上线,但线上的 web 应⽤ 很少使用这种方式。

2.10. MemoryRouter

URL历史记录保存在内存中的,不读取、不写⼊地址栏。可以用在⾮浏览器环境,如React Native

3. 思考与总结

  1. LifeCycle 组件

    关于组件化,在看Vue源码时给我的启发是组件化的本质是产生虚拟 dom,但看了react-router源码后发现组件还可以仅作为生命周期使用,这也确实是一种很好的逻辑方式,在类组件中一些只涉及生命周期的处理或许可以通过这种方式尝试着来实现,比如说websocket数据的接收或特定数据的处理展示。

  2. 跨平台

    作为一名接触前端不久的同学,见到的第一个有跨平台思路的框架就是reactreact-router也是如此,首先,源码的核心放在react-router中,目前阶段衍生出react-router-domreact-router-native两个库,关于路由的核心处理逻辑在另一个库history中,react-router只关注路由如何在react中使用,具体的平台方案交给react-router-domreact-router-native来做,这种代码低耦合的方式也是非常值得我们学习的。

    ps:React-native 什么时候发 1.0 呢?

  3. <Route/>中的渲染优先级

    children>component>render

    三者能接收到同样的[route props],包括 matchlocationhistory,但是当不匹配childrenmatch 与其他二者的情况不同会是 null

  4. 关于 children

    有时候,不管当前路由是否匹配我们都需要渲染⼀些内容,这时候可以⽤ children。参数与render 完全⼀样。

  5. 关于 component

    如果我们写了componentreact会通过createElement创建组件,所以为了避免不必要的开销,尽量不要使用内联函数

投稿来自 [【我的React笔记】 01. 战术后仰:什么是 React-Router 呀](juejin.cn/post/695044…)