微信号:QunarTL

介绍:Qunar技术沙龙是去哪儿网工程师小伙伴以及业界小伙伴们的学习交流平台.我们会分享Qunar和业界最前沿的热门技术趋势和话题;为中高端技术同学提供一个自由的技术交流和学习分享平台.

react-router v4 源码分析

2018-10-16 08:00 赵聪

赵聪


个人介绍:赵聪,2017 年 11 月加入去哪儿网技术团队。目前在大住宿事业部/大前端/国际业务组,参与开发了 EB 系统、大住宿任务调度系统、国际酒店 Touch 端等项目。个人对前端及 Node.js 相关技术有浓厚兴趣。

在最近接的一些新项目中都有用到 react-router,每次都是照着老工程抄过来,碰到问题也都是试来试去浪费过多的时间,因此有必要了解一下其工作原理来提高工作效率。

1 阅读前注意

React16.3 对 Context 已经正式支持,并提供了全新的API(https://reactjs.org/docs/context.html),react-router 从 18 年 1 月 29 日开始了 Context 相关代码的逐步升级(https://github.com/ReactTraining/react-router/pull/5908),安装时可通过 npm install react-router@next 获取到最新非正式版。

本文中所引用的代码来自 react-router@4.3.1 目前最新的正式版本,使用的还是老的实验版 Context API,与 next 版可能存在些许不同,请注意。

2 什么是 react-router

react-router v4 是一个使用纯 react 实现的路由解决方案,可以理解为是对 history 库的 react 封装,v4 与其之前的版本在设计方式和理念上有较大的差别,为重写库。

react-router 包含有两个具体实现,react-router-dom 和 react-router-native,他们在核心库的基础上提供了自己平台的专属组件和函数。因日常开发主要围绕 touch 端展开,所以本次源码分析内容以 react-router-dom 为主。

2.1 history

在开始源码分析前,需要先了解一些基本概念。

history 这个概念来自浏览器的 history(历史记录),可以对用户所访问的页面按时间顺序进行记录和保存. 这就不得不提一下 history 库,history 库借鉴了浏览器 history 的概念,对其进行封装或实现,使得开发者可以在任何js运行环境中实现历史会话操作,react-router 使用 history 库对其路由状态进行监听和管理,使得他能在非浏览器环境下运行。

history 库提供了三种路由的实现方式,browser history,hash history 和 memory history,无论使用的是哪一种,其所创建出来的 history 对象都包含以下属性和方法。

 
           
  1. {

  2.    length, // 历史堆栈高度

  3.    action, // 当前导航动作有pushpopreplace三种

  4.    location: {

  5.      pathname, // 当前url

  6.      search, // queryString

  7.      hash, // url hash

  8.    },

  9.    push(path[state]), // 将一个新的历史推入堆栈 (可以理解为正常跳转)

  10.    replace(path[state]), // 替换当前栈区的内容 (可以理解为重定向)

  11.    go(number), // 移动堆栈指针

  12.    goBack(number), // 返回上一历史堆栈指针 -1

  13.    goForward(number), // 前进到下一历史堆栈指针 +1

  14.    block(string | (location, action) => {}) // 监听并阻止路由变化

  15.  }

3 构成

以下为 react-router 和 react-router-dom 的项目结构对比:

 
           
  1. react-router               react-router-dom

  2. ├── README.md              ├── README.md

  3. ├── modules                ├── modules

  4.   ├── MemoryRouter.js       ├── BrowserRouter.js

  5.   ├── Prompt.js             ├── HashRouter.js

  6.   ├── Redirect.js           ├── Link.js

  7.   ├── Route.js             ├── MemoryRouter.js

  8.   ├── Router.js             ├── NavLink.js

  9.   ├── StaticRouter.js       ├── Prompt.js

  10.   ├── Switch.js             ├── Redirect.js

  11.   ├── generatePath.js       ├── Route.js

  12.   ├── index.js             ├── Router.js

  13.   ├── matchPath.js         ├── StaticRouter.js

  14.   └── withRouter.js         ├── Switch.js

  15. ├── package-lock.json         ├── generatePath.j

  16. ├── package.json             ├── index.js

  17. ├── rollup.config.js         ├── matchPath.js

  18. └── tools                     └── withRouter.js

  19.    ├── babel-preset.js    ├── package-lock.json

  20.    └── build.js           ├── package.json

  21.                           ├── rollup.config.js

  22.                           └── tools

  23.                               ├── babel-preset.js

  24.                               └── build.js

可以发现有很多相同的组件,事实上 react-router-dom 中的同名组件就是从 react-router 核心库中 re-export 的,所以可以从这些共用的核心组件开始分析。

4 <Router />

Router 组件是所有路由组件的父级组件,为子组件提供当前路由状态并监听路由改变并触发重新渲染。

4.1 props

Router 组件接受一个必要属性 history,不同的平台有自己的 history 实现。

4.2 源码分析

Router 组件设置了一个 context,以供所有子组件能获取到路由状态,可以看到设置 context.router 的时候继承了 this.context.router 的所有属性,具体原因在这里(https://github.com/ReactTraining/react-router/issues/4650),其实是因为与其他第三方库的 context 重名了。

 
           
  1. getChildContext() {

  2.    return {

  3.      router: {

  4.        ...this.context.router,

  5.        history: this.props.history, // history 库生成的 history 对象

  6.        route: {

  7.          location: this.props.history.location,

  8.          match: this.state.match

  9.        }

  10.      }

  11.    };

  12.  }

组件拥有一个名为 match 的 state,用来表示当前路由是否匹配(match),match 由 computeMatch() 函数计算得出,这个函数在 Route 组件中也会出现,作用相同,Router 中为默认值,设置其默认值的原因会在后文讲到。

 
           
  1. state = {

  2.    match: this.computeMatch(this.props.history.location.pathname)

  3.  };

  4.  computeMatch(pathname) {

  5.    return {

  6.      path: "/",

  7.      url: "/",

  8.      params: {},

  9.      isExact: pathname === "/"

  10.    };

  11.  }

传入的 history,在生命周期函数 componentWillMount() 中设置了监听,当路由发生变化的时候重新设置 match 状态,因 react 的运行机制,父组件的 state 发生改变时,如果设置了 context,会调用 getChildContext() 重新计算 context,并重新渲染。

将监听放置在 componentWillMount() 中是为了适配服务器渲染,因为 componentWillMount() 会在服务器端执行而 componentDidMount() 不会,所以 Redirect 组件在重定向时所改变的状态会在服务器端渲染时得到响应,如源码中的注释所说。有关 Redirect 组件的内容将会在后文提到。

 
           
  1. componentWillMount() {

  2.    const { children, history } = this.props;

  3.    // Do this here so we can setState when a <Redirect> changes the

  4.    // location in componentWillMount. This happens e.g. when doing

  5.    // server rendering using a <StaticRouter>.

  6.    this.unlisten = history.listen(() => {

  7.      this.setState({

  8.        match: this.computeMatch(history.location.pathname)

  9.      });

  10.    });

  11.  }

  12.  componentWillUnmount() {

  13.    this.unlisten();

  14.  }

Router 组件只允许其拥有一个子元素,具体原因(https://github.com/ReactTraining/react-router/issues/5706),以下为中文解释:

Router 组件经常会被作为顶级组件放到 ReactDOM.render() 里,像是这样:

 
           
  1. ReactDOM.render(

  2.  <Router>

  3.    <div />

  4.    <div />

  5.  </Router>

  6. )

但是其实 Router 并没有创建任何 DOM 节点,所以等价于这样:

 
           
  1. ReactDOM.render(

  2.  <div />

  3.  <div />

  4. )

这种写法是不被 React 允许的。

5 <Route />

路由组件,设置并根据当前路由来判断是否渲染内容。

5.1 props

path :路由匹配参数;

exact strict sencitive :path 的三种匹配模式;

component render children :Route 组件提供的三种子组件渲染方式,具体区别会在后文提到。

5.2 源码分析

当初始化或是路由发生改变的时候,会调用 computedMatch() 方法来计算设置的 path 是否匹配当前路由。

由上文可知,当路由状态改变时,context 会被重新计算. 此时会造成子组件的重新渲染。与 props 类似,context 在改变时,生命周期函数 componentWillReceiveProps() 也会被触发,使得其 state.match 被重新计算。

与 Router 组件相同,this.state.match 依靠 this.computeMatch() 方法重新计算当前 url 是否匹配当前 Route 设置的路由。

Route 组件如果被 Switch 组件包裹,Switch 组件会为其计算好 match 信息并通过属性的形式传入,所以 this.computeMatch() 在第一步会判断是否存在 computedMatch 属性以免重复计算,有关 computedMatch 的计算方式会在后文 组件源码分析部分提到。

在默认情况下,Route 组件会选取当前 history location 与 path 做匹配,但也同时支持使用自定义 location,官方文档中提供了一个使用过渡动画的例子(https://reacttraining.com/react-router/web/example/animated-transitions)来描述该应用场景。

 
           
  1. computeMatch(

  2.  { computedMatch, location, path, strict, exact, sensitive },

  3.  router

  4. ) {

  5.  if (computedMatch) return computedMatch; // <Switch> already computed the match for us

  6.  const { route } = router;

  7.  // 如果设置了location属性优先使用

  8.  const pathname = (location || route.location).pathname;

  9.  return matchPath(pathname, { path, strict, exact, sensitive }, route.match);

  10. }

  11. state = {

  12.  match: this.computeMatch(this.props, this.context.router)

  13. };

  14. componentWillReceiveProps(nextProps, nextContext) {

  15.  this.setState({

  16.    match: this.computeMatch(nextProps, nextContext.router)

  17.  });

  18. }

5.2.1 matchPath()

computeMath() 在对数据简单转换后,会调用 matchPath.js 文件中的 matchPath() 方法进行路由匹配计算。

 
           
  1. const matchPath = (pathname, options = {}, parent) => {

  2.  if (typeof options === "string") options = { path: options };

  3.  const { path, exact = false, strict = false, sensitive = false } = options;

  4.  // 当没有path参数的时候采用context.router也就是父级元素的路由信息

  5.  if (path == null) return parent;

  6.  const { re, keys } = compilePath(path, { end: exact, strict, sensitive });

  7.  const match = re.exec(pathname);

  8.  if (!match) return null;

  9.  const [url, ...values] = match;

  10.  const isExact = pathname === url;

  11.  if (exact && !isExact) return null;

  12.  // 匹配成功

  13.  return {

  14.    path,

  15.    url: path === "/" && url === "" ? "/" : url, // 待匹配url也就是当前pathname

  16.    isExact, // 是否完全匹配

  17.    params: keys.reduce((memo, key, index) => {

  18.      memo[key.name] = values[index];

  19.      return memo;

  20.    }, {})

  21.  };

  22. };

compilePath() 方法调用 path-to-regexp 库,将 path 转换为正则表达式方便匹配,并根据匹配模式建立缓存。

 
           
  1. const patternCache = {};

  2. const cacheLimit = 10000;

  3. let cacheCount = 0;

  4. const compilePath = (pattern, options) => {

  5. // 根据正则的生成条件分类建立缓存

  6.  const cacheKey = `${options.end}${options.strict}${options.sensitive}`;

  7.  const cache = patternCache[cacheKey] || (patternCache[cacheKey] = {});

  8. // 如果当前生成条件下已存在生成好的匹配用正则则直接使用不再耗时重复生成

  9.  if (cache[pattern]) return cache[pattern];

  10. // 正则计算过程

  11.  const keys = []; // 用于储存在path中匹配出来的key

  12.  const re = pathToRegexp(pattern, keys, options);

  13.  const compiledPattern = { re, keys }; // 返回正则和匹配出来的路由参数

  14. // 如果缓存数量到达上限(10000)则之后的新正则都不再缓存重新生成

  15.  if (cacheCount < cacheLimit) {

  16.    cache[pattern] = compiledPattern;

  17.    cacheCount++;

  18.  }

  19.  return compiledPattern;

  20. };

生成的缓存为如下结构(举例):

 
           
  1. {

  2.    falsefalsefalse: {

  3.      '/routeOne': { re, keys },

  4.      '/routeThree': { re, keys },

  5.      '/routeTwo': { re, keys }

  6.    },

  7.    truefalsefalse: {

  8.      '/': { re, keys },

  9.    },

  10.    ...

  11. }

5.2.2 关于 path-to-regexp 库

pathToRegExp 提供了四种不同的正则生成方式:

  1. sensitive:大小写敏感模式,如 /api 和 /Api 不匹配。

  2. strict:严格模式,在确切匹配的基础上,区分 path 结尾的分隔符,如:/api 和 /api/ 不匹配。

  3. end:匹配到尾模式:匹配到 path 字符串结尾,默认为 true 如当 start 为默认值时:/api 和 /api/userName 不匹配。

  4. start:从头匹配模式:从 path 字符串的头部开始匹配,默认为 true。

react-router 选用了其中前三种匹配方式,并将 end 更名为 exact。

匹配正则的具体使用方法举例如下:

 
           
  1. var keys = []

  2. // 生成正则

  3. var re = pathToRegexp('/foo/:bar', keys)

  4. // re = /^\/foo\/([^\/]+?)\/?$/i

  5. // keys = [{ name: 'bar', prefix: '/', delimiter: '/', optional: false, repeat: false, pattern: '[^\\/]+?' }]

  6. // 使用正则

  7. var match = re.exec('/foo/aaa');

  8. // match = ['/foo/aaa', 'aaa']

正则被调用后,从返回数组的 1 号元素开始是匹配出的参数的值,computeMath() 更进一步,将其拼接为 key: value 的形式,挂载在返回值的 params 属性下。

5.2.3 子元素渲染方式

Route 组件支持三种子元素渲染方式,component render children 三个属性,如果存在,则优先匹配,并且都传入 { match, location, history, staticContext } 路由信息。

 
           
  1. render() {

  2.  const { match } = this.state;

  3.  const { children, component, render } = this.props;

  4.  const { history, route, staticContext } = this.context.router;

  5.  const location = this.props.location || route.location;

  6.  const props = { match, location, history, staticContext };

  7.  // 如果存在则优先匹配

  8.  // 传入类型为ReactElement

  9.  if (component) return match ? React.createElement(component, props) : null;

  10.  // 传入类型

  11.  if (render) return match ? render(props) : null;

  12.  if (typeof children === "function") return children(props);

  13.  if (children && !isEmptyChildren(children))

  14.    return React.Children.only(children);

  15.  return null;

  16. }

component 和 render 属性会根据是否匹配 path 来判断是否渲染。children 则较为特殊,无论当前路由是否匹配,都会渲染传入的内容,较为自由,让开发者自己判断要显示的内容,react-router-dom 中的 NavLink 组件就是一个很好的应用例子。

6 <Switch />

功能十分简单,只渲染匹配成功的第一个路由组件。

包裹 Route 组件,同样调用 matchPath() 方法,代理 Route 组件计算是否 match,因源码十分简单,不做过多解析。

 
           
  1. render() {

  2.    const { route } = this.context.router;

  3.    const { children } = this.props;

  4.    const location = this.props.location || route.location;

  5.    let matchchild;

  6.    React.Children.forEach(children, element => {

  7.      // match一旦被赋值说明已出现匹配成功的Route组件后面的直接跳过

  8.      if (match == null && React.isValidElement(element)) {

  9.        const {

  10.          path: pathProp,

  11.          exact,

  12.          strict,

  13.          sensitive,

  14.          from // Redirect的属性后面会提到

  15.        } = element.props;

  16.        const path = pathProp || from;

  17.        child = element;

  18.        // 计算是否匹配

  19.        match = matchPath(

  20.          location.pathname,

  21.          { path, exact, strict, sensitive },

  22.          route.match

  23.        );

  24.      }

  25.    });

  26.    return match

  27.      // 使用cloneElement为child添加新属性

  28.      ? React.cloneElement(child, { location, computedMatch: match })

  29.      : null;

  30.  }

7 <Redirect />

重定向组件,相当于 history 的 push 或是 replace 方法的组件化封装。

7.1 props

当属性 push 为 true 时,路由切换方式改为跳转而非重定向。

7.2 源码分析

调用 isStatic() 方法可以得知当前是否处在服务器渲染模式下,如在在此模式下,便在 componentWillMount 生命周期函数执行时进行路由跳转,否则在 componentDidMount 时跳转。

 
           
  1. isStatic() {

  2.    // 只有服务器渲染模式下 staticContext 有值

  3.    return this.context.router && this.context.router.staticContext;

  4.  }

  5.  componentWillMount() {

  6.    if (this.isStatic()) this.perform();

  7.  }

  8.  componentDidMount() {

  9.    if (!this.isStatic()) this.perform();

  10.  }

当出现如下情况时(https://github.com/ReactTraining/react-router/issues/5003),需要组件更新后重新判断是否跳转:

某一 Route 组件匹配成功并开始渲染内容,其子组件中包含一个 Redirect 组件并开始执行重定向,但由于重定向地址和当前地址相同,所以只是重新渲染,这时即使再更新子组件的状态也不会重定向了,除非重新创建 Redirect 组件。

以下代码便是为这种情况服务的:

 
           
  1. componentDidUpdate(prevProps) {

  2.    const prevTo = createLocation(prevProps.to);

  3.    const nextTo = createLocation(this.props.to);

  4.    if (locationsAreEqual(prevTo, nextTo)) {

  5.      warning(

  6.        false,

  7.        `You tried to redirect to the same route you're currently on: ` +

  8.          `"${nextTo.pathname}${nextTo.search}"`

  9.      );

  10.      return;

  11.    }

  12.    this.perform();

  13.  }

perform() 用来进行重定向操作,其调用 computeTo() 来计算重定向的 url,跳转的时候分为两种情况,如果有 computedMatch 的话说明有需要传递的参数,则在计算后返回 url,否则直接返回。

例如:当前 location.pathname="/user/123",所以某 path="/user/:id" 的 Route 组件匹配成功,其包含一个 Redirect 子组件 to="/id/:id",则在重定向后地址为 "/id/123"。

具体应用场景:

 
           
  1. <Switch>

  2.  <Redirect from='/users/:id' to='/users/profile/:id'/>

  3.  <Route path='/users/profile/:id' component={Profile}/>

  4. </Switch>


 
           
  1. computeTo({ computedMatch, to }) {

  2.    if (computedMatch) {

  3.      if (typeof to === "string") {

  4.        return generatePath(to, computedMatch.params);

  5.      } else {

  6.        return {

  7.          ...to,

  8.          pathname: generatePath(to.pathname, computedMatch.params)

  9.        };

  10.      }

  11.    }

  12.    return to;

  13.  }

7.2.1 generatePath()

用于生成跳转用 url 的方法,与 matchPath 方法类似,是其逆操作,同样为了提高响应速度使用了缓存。

核心逻辑为调用 path-to-regexp 的 compile() 方法。

 
           
  1. const patternCache = {};

  2. const cacheLimit = 10000;

  3. let cacheCount = 0;

  4. const compileGenerator = pattern => {

  5. // 使用待匹配url作为cache key

  6.  const cacheKey = pattern;

  7.  const cache = patternCache[cacheKey] || (patternCache[cacheKey] = {});

  8.  if (cache[pattern]) return cache[pattern];

  9.  // 核心逻辑

  10.  const compiledGenerator = pathToRegexp.compile(pattern);

  11.  if (cacheCount < cacheLimit) {

  12.    cache[pattern] = compiledGenerator;

  13.    cacheCount++;

  14.  }

  15.  return compiledGenerator;

  16. };

  17. /**

  18. * Public API for generating a URL pathname from a pattern and parameters.

  19. */

  20. const generatePath = (pattern = "/", params = {}) => {

  21.  if (pattern === "/") {

  22.    return pattern;

  23.  }

  24.  const generator = compileGenerator(pattern);

  25.  return generator(params, { pretty: true });

  26. };

8 <Prompt />

用来做路由拦截的组件,唯一的作用就是在路由发生改变的时候拦截它并弹窗提醒。

8.1 props

属性 when 默认为 true,如为 false 则不拦截任何路由变化。

属性 message 为拦截时的提示信息,内容格式与 history.block() 方法参数格式相同,可为 string 或是 (location, action): (string | boolean) => {}。

8.2 源码分析

非常简单,一看就明白了。

 
           
  1. // 重设message如果之前设置过就先清除

  2. enable(message) {

  3.    if (this.unblock) this.unblock();

  4.    this.unblock = this.context.router.history.block(message);

  5.  }

  6.  disable() {

  7.    if (this.unblock) {

  8.      this.unblock();

  9.      this.unblock = null;

  10.    }

  11.  }

  12.  componentWillMount() {

  13.    if (this.props.when) this.enable(this.props.message);

  14.  }

  15.  componentWillReceiveProps(nextProps) {

  16.    if (nextProps.when) { // 当when发生变化变为true或是messge改变时触发

  17.      if (!this.props.when || this.props.message !== nextProps.message)

  18.        this.enable(nextProps.message);

  19.    } else {

  20.      this.disable();

  21.    }

  22.  }

  23.  componentWillUnmount() {

  24.    this.disable();

  25.  }

9 <Link />

对 a 标签的封装,实现 history 控制的路由跳转。

9.1 props

继承了 a 标签的所有参数,使用属性 to 来指明跳转位置,replace 指明是否为重定向。

9.2 源码分析

因为 onClick 事件优先级比 href 跳转的高,所以优先处理。

 
           
  1.  handleClick = event => {

  2.    if (this.props.onClick) this.props.onClick(event);

  3.    if (

  4.      !event.defaultPrevented && // 如已取消默认操作则跳过

  5.      event.button === 0 && // 忽略非左键单击事件

  6.      !this.props.target && // 如果设置了target参数则跳过

  7.      !isModifiedEvent(event) // 忽略组合键

  8.    ) {

  9.      event.preventDefault();

  10.      const { history } = this.context.router;

  11.      const { replaceto } = this.props;

  12.      if (replace) {

  13.        history.replace(to);

  14.      } else {

  15.        history.push(to);

  16.      }

  17.    }

  18.  };

如果 onClick 事件没有执行,或是被 prevent 了,则采用 href 内的值来跳转。

调用 history 的 createLocation() 生成跳转后的 location,这个方法接收 4 个参数,(path,state,key,currentLocation)。因为走浏览器跳转已经脱离了 history 的控制范围,用于表示路由附加参数和当前路由表示的 state 与 key 参数无效,用 null 来占位。

 
           
  1. render() {

  2.    const { replace, to, innerRef, ...props } = this.props;

  3.    const { history } = this.context.router;

  4.    //

  5.    const location =

  6.      typeof to === "string"

  7.        ? createLocation(to, null, null, history.location)

  8.        : to;

  9.    // 根据变化后的location生成url

  10.    const href = history.createHref(location);

  11.    return (

  12.      <a {...props} onClick={this.handleClick} href={href} ref={innerRef} />

  13.    );

  14.  }

10 withRouter()

高阶函数,包裹组件,用来为组件添加当前路由状态。

10.1 源码分析

非常好理解,实际上就是使用 Route 组件对目标组件做了包裹处理。

为了避免组件被代理的时候出现静态属性丢失的情况(https://github.com/ReactTraining/react-router/pull/4838),使用了一个叫做 hoist-non-react-statics (字面直译: 提升非react静态属性) 的库(https://github.com/mridgway/hoist-non-react-statics)。

 
           
  1. const withRouter = Component => {

  2.  const C = props => {

  3.    const { wrappedComponentRef, ...remainingProps } = props;

  4.    return (

  5.      <Route

  6.        children={routeComponentProps => (

  7.          <Component

  8.            {...remainingProps}

  9.            {...routeComponentProps}

  10.            ref={wrappedComponentRef} // 提供被代理组件的ref

  11.          />

  12.        )}

  13.      />

  14.    );

  15.  };

  16.  C.displayName = `withRouter(${Component.displayName || Component.name})`;

  17.  C.WrappedComponent = Component;

  18.  C.propTypes = {

  19.    wrappedComponentRef: PropTypes.func

  20.  };

  21. // 将传入组件的静态属性提升里C来

  22.  return hoistStatics(C, Component);

  23. };

参考

https://reacttraining.com/react-router/core/api

https://github.com/ReactTraining/react-router

 
Qunar技术沙龙 更多文章 机器学习之 scikit-learn 开发入门(5) 提高网页设计里文本的易读性 机器学习之 scikit-learn 开发入门(4) 机器学习之 scikit-learn 开发入门(3) 一百行 python 代码告诉你国庆哪些景点爆满
猜您喜欢 手残党、懒癌患者、深度强迫症?拯救计划来了,了解一下 国庆加班是一种什么样的体验? 全球php开发框架排行榜 Visual Studio 2015正式发布 不许死亡、不准生育,这个北极小镇,藏着人类应对世界末日的最后希望