React-Router 源码解析&实际应用的实践总结 🦑🦑🦑

2,325 阅读5分钟

一·、前言

在一次业务开发里,我遇到这样一个场景:有一个功能要分批上线,即在某个时间段内,一部分人用老功能,一部分人用新功能。刚接到这个需求的时候,我一时间没反应过来,甚至没想到要用路由来实现,到后面看到了 Mentor 的实现方式,真正有种重剑无锋,大巧不工的感觉,原来 React-Router 还可以这么玩。

于是我痛定思痛,决定跳出原来 CV 配置,改下 pathcomponent就收工的粗糙玩法,对React-Router 源码做些深入了解。

观前提醒😋,本文是读书笔记,所以对源码解析做的注释更详尽,但想要系统学习,建议按照参考文献顺序学习。

二、React-Router 原理

  1. 两位主角

  • 🧑‍🔬 history:监听路由变化,将最新的 location 传递给 react-router 的组件
  • 👩‍🔬 react-router:将 location 作为上下文传递下去,渲染出匹配上location 的组件
  1. 路由更改-->组件渲染

  • 直接修改路径、前进、后退这些动作属于 popState 事件,会触发 handlePopState 函数,该函数体内会通过 setState 更新 location 对象并告知 Router,然后子组件根据最新的 location 进行匹配,符合规则的组件就能渲染。
  • history.push()这个动作属于 pushState 事件,它会通过window.history.pushState方法改变浏览器当前路由,然后通过 setState 更新 location 对象并告知 Router,然后子组件根据最新的 location 进行匹配,符合规则的组件就能渲染。

三、React-Router 项目架构&源码解析

1. 项目架构 🦑🦑🦑

仓库地址

react-router是核心包,包含了大部分逻辑和组件,处理context和路由匹配都在这里。

react-router-dom是浏览器使用的包,像Link这样需要渲染具体的a标签的组件就在这里。

react-router-nativereact-native使用的包,里面包含了androidios具体的项目。

react-router-config:是React-Router的配置处理,我们一般不需要使用

2. 关键组件的源码解析 🔬🔬🔬

2.1 BrowserRouter 🥓

class Router extends React.Component {
  // 静态方法,检测当前路由是否匹配
  // 这里会返回一个默认的 match 对象,后续的子组件会用到
  static computeRootMatch(pathname) {
    return { path: '/', url: '/', params: {}, isExact: pathname === '/' };
  }

  constructor(props) {
    super(props);

    // 后续要作为上下文传递下去,也是判定子组件是否匹配的标准
    this.state = {
      location: props.history.location,
    };

    // _isMounted 表示组件是否加载完成
    this._isMounted = false;

    // 组件未加载完毕,但是 location 发生的变化,暂存在 _pendingLocation 字段中
    this._pendingLocation = null;

    // 通过 listen 监听路由变化,并获取最新的 location
    // 在组件卸载时调用 unlisten,取消对 history 的监听
    this.unlisten = this.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 (unlisten()) {
      this.unlisten();
      this._isMounted = false;
      this._pendingLocation = null;
    }
  }
  render() {
    return (
      <RouterContext.Provider
        value={{
          history: this.props.history,
          location: this.state.location,
          match: computeRootMatch(this.state.location.pathname),
        }}
      >
        <HistoryContext.Provider
          value={this.props.history}
          children={this.props.children}
        ></HistoryContext.Provider>
      </RouterContext.Provider>
    );
  }
}

2.2 Switch 🥐

/**
 * Switch 组件会遍历子元素(Route 或 Redirect),寻找第一个能匹配上 path 的子元素,并渲      *  染对应的组件
 * 一旦找到一个匹配上的子元素,就会停止匹配;否则返回 null
 */
class Switch extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {(context) => {
          // 当前页面路径存储 Router 里,通过上下文向下传递;
          // 在后续遍历子元素时,用子元素的 path 与 location 比较
          // 如果有自定义 location,优先采用,但我实在想不到使用场景。。
          const location = this.props.location || context.location;

          // 这里的 element 用来存储第一个有效的(不一定匹配成功)元素
          // match 用来表示是否已匹配上,如果还未匹配上,值为 null
          let element, match;

          // 原码注释:
          // 使用React.Children.forEach来遍历子元素,而不能使用React.Children.toArray().find()
          // 因为toArray会给每个子元素添加一个key,这会导致两个有同样component,但是不同URL的<Route>重复渲染
          // 个人解读:
          // 这里的 React.Children.forEach 与常规写法不同,他是 React 专门用来遍历子元素的方法
          // 当子元素为空(this.props.children === null 或 undefined)时,会返回一个 null
          React.Children.forEach(this.props.children, (child) => {
            if (match === null && React.isValidElement(child)) {
              element = child;

              // 在 Route 组件里,通过 path 来匹配;在 Redirect 组件里,通过 from 来匹配
              const path = child.props.path || child.props.from;

              // 如果 path 存在就将 path 与 location 进行匹配,匹配成功后会返回 match 对象;
              // 匹配失败则返回 null
              // 如果 path 不存在则使用 computeRootMatch 生成的初始 match 对象
              match = path
                ? matchPath(location.pathName, {
                    ...child.props,
                    path,
                  })
                : context.match;
            }
          });

          // 当子元素片匹配成功后,返回匹配到的子元素的 clone
          // 这里为什么要把 match 赋值给 computedMatch,详情见 Route
          return match
            ? React.cloneElement(element, { computedMatch: match })
            : null;
        }}
      </RouterContext.Consumer>
    );
  }
}

2.3 Route 🥙

/**
 * Route 组件是在 path 匹配成功的情况下,进行组件渲染
 */
class Route extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {(context) => {
          // 当前页面路径存储 Router 里,通过上下文向下传递;
          // 在后续遍历子元素时,用子元素的 path 与 location 比较
          // 如果有自定义 location,优先采用,但我实在想不到使用场景。。
          const location = this.props.location || context.location;

          // Switch 组件会在子组件匹配成功时,将 match 赋值给子组件 Route 的 computedRoute,这里有就直接用
          // 没有 computedRoute 的话,再看 path 属性,
          // 如果 path 存在就将 path 与 location 进行匹配,匹配成功后会返回 match 对象;
          // 匹配失败则使用 computeRootMatch 生成的初始 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;

          // match 和 location 可能都更新过了,这里用 props 来存储最新的上下文信息
          const props = { ...this.props, match, location };

          // Route 组件有三种渲染方式,分别是children、component、render
          // 优先级是children > component > render
          // 其中 children 最特殊,他可以是函数,也可以是元素;
          // 而当他是函数时,无论 path 是否匹配,他都会渲染;
          // (好像平时都用的component,其他两种有啥使用场景,欢迎交流)
          let { children, component, render } = props;

          // 源码注释:children 默认是个空数组,如果是默认情况,置为 null
          if (Array.isArray(children) && children.length === 0) {
            children = null;
          }
          return (
            <RouterContext.Provider value={props}>
              {props.match
                ? children
                  ? typeof children === 'function'
                    ? children(props)
                    : children
                  : component
                  ? React.cloneElement(component, props)
                  : render
                  ? render(props)
                  : null
                : typeof children === 'function'
                ? children(props)
                : null}
            </RouterContext.Provider>
          );
        }}
      </RouterContext.Consumer>
    );
  }
}

四、实际应用场景 🦦🦦🦦

场景:分批次上线功能(前言中的例子)

我的实现思路是加一个权限码,有这个权限码的人进行路由跳转时,重定向到新功能页

<Switch>
  {hasAuthority ? (
    <Redirect path="/old" to={`/new`} />
  ) : null}
  <Route path={`/new`} component={newPage}></Route>
  <Route path={`/old`} component={OldPage} />
  <Redirect path="*" to={`/home`} />
</Switch>

五、参考文献 🐕🐕🐕

导读:2、3对react-router有全面的介绍,最好同时看,4在源码解读上更胜一筹,其他的不如2、3

  1. React Router 中文文档(v5 ) - 掘金v5.reactrouter.com
  1. 「源码解析 」这一次彻底弄懂react-router路由原理 - 掘金
  1. 手写React-Router源码,深入理解其原理 - 掘金
  1. 面试官,别再问我React-Router了!每一行源码我都看过了!
  1. 「React进阶」react-router v6 通关指南 - 掘金