React 路由原理

1,173 阅读8分钟

前言

实际开发时我们并不会直接使用 react-router,而是在网页应用中使用 react-router-dom,在native 中对应使用 react-router-native。由于一直是做网页开发,此处也主要针对 react-router-dom 的源码进行分析,安装该依赖,会自动安装 react-router 及 history ,无需额外安装。

此次分析基于 react-router-dom v5.2.0 (对应 history v4.10.1)。

react-router-dom 简要介绍

一般我们会这么使用:

import { BrowserRouter, Link, Route, Switch, Redirect } from 'react-router-dom'

function App(){
  return (
     <BrowserRouter>
     	<Link to="/">home</Link> | <Link to="/about">About</Link>
        <Switch>
          <Route path="/" exact component={Home} />
          <Route path="/about" exact component={About} />
          <Redirect to="/" />
        </Switch>
     </BrowserRouter>
  )
}

上述示例使用的组件可以分为三类,主要也就分为三类:

  • routers : 如 BrowserRouterHashRouter
  • route matchers: 路由匹配组件,如 RouteSwitch
  • navigation(route changers):如 LinkNavLinkRedirect

底层原理

BrowserRouter 组件

在实例化一个 BrowserRouter 组件时,会在构造函数中调用 createBrowserHistory() 方法创建一个 history 对象,并将其传递给 Router 组件。

// react-router-dom/modules/BrowserRouter.js
import { Router } from "react-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} />;
  }
}

BrowserHistory 对象

那我们先来看看 history 对象都包含些什么,其接口类似于:

const history = {
  length,  // 对应于 window.history.length
  location,  // 常量,仅在调用 createBrowserHistory 函数时根据 window.location 计算得到
  createHref, // 函数,返回一个 basename+path,basename 是传递给 createBrowserHistory 的
  push,
  replace,
  go,  // 对应于 window.history.go()
  goBack, // 对应于 go(-1)
  goForward, // 对应于 go(1)
  block,
  listen
}

history.push()

当我们调用 history.push() 方法时,若允许跳转则会调用 window.location.pushState()setStatehistory.replace()操作的处理流程类似,只不过调用的是 replaceState 方法。

我们来看一下 history.push 方法的实现。

// history/modules/createBrowserHistory.js 
//省略部分判断逻辑的代码
function push (path, state) {
  const action = 'PUSH'
  const location = createLocation(path,state, createKeys(), history.location)
  transitionManager.confirmTransitionTo(
    location,
    action,
    getUserConfirmation,
    ok => {
      const href = createHref(location)
      const { key, state } = location
      // 重点1:改变 history 状态栈
      globalHistory.pushState({key, state}, null, href)
      // 重点2:改变 history 对象及广播变更
      setState({action, location})
    }
  )
}

setState()

上述可以看到,执行 push 操作会改动 history 状态栈并同时调用了 setState 方法,该方法定义于 createBrowserHistory() 函数内部,主要用于更新 history 对象并通知监听器。代码如下:

// history/modules/createBrowserHistory.js 
function setState(nextState) {
  // 这就是为什么 history 是 mutable,因为使用了 assign 来改变内部字段的值
  Object.assign(history, nextState);
  history.length = globalHistory.length;
  transitionManager.notifyListeners(history.location, history.action);
}

// history/modules/createTransitionManager.js
function notifyListeners(...args){
  listeners.forEach(listener => listener(...args))
}

setState 内部会广播更新,此时会遍历执行所有的监听器,那是如何注册监听器的呢?又是何时注册的呢?

注册监听器主要通过history.listen()方法实现。

history.listen()

function listen(listener){
  // 注册自定义监听器
  const unlisten = transitionManager.appendListener(listener)
  // 为popstate 和 hashchange(若使用) 注册监听器,对应也会执行 setState 方法
  checkDOMListener(1) 
  
  return () => {
    // 移除 popstate 和 hashchange 事件的监听器
    checkDOMListener(-1)
    // 移除自定义监听器
    unlisten()
  }
}

可以看到,当注册自定义监听器时,还会检查是否已经为 popstate 事件注册了回调,若没有则会执行注册。

Router 组件

Router 组件的构造函数中会调用 history.listen()方法,此时会注册一个监听器,Router 组件有一个内部状态 location,注册的回调主要用于更新这个状态。

// react-router/modules/Router.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
    }
   
    this.unlisten = 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>
    )
  }
}

可以看出,Router 组件主要是提供一个上下文供子组件消费,而 location 作为内部状态。

路由变更此时则有两种方式:

  • 调用 history.push 或 history.replace
  • 触发 popstate 事件(如点击浏览器的前进、后退按钮,或者调用 go 等方法)

这两种方式都会调用 history 内部的 setState 方法,不同的是前者需要手动更新 history 状态栈以增加或修改一条记录,后者不会对history状态栈有变更,只是会更改当前状态的指针。

我们可以使用 Link 组件或手动调用 history.push 或 history.replace 方法来改变路由,push() 方法的实现方式上面已经讲过了,接下来我们看看 Link 组件的实现。

Link 组件

const LinkAnchor = ({navigate, onClick, ...rest})=> {
  const { target } = rest
  
  const props = {
    ...rest,
    onClick: ev => {
        try {
          if (onClick) onClick(event);
        } catch (ex) {
          event.preventDefault();
          throw ex;
        }
        if (
          !event.defaultPrevented && //表示当前事件是否调用了 preventDefault 方法
          event.button === 0 && // 0 为鼠标左键点击触发,1为中键,2为右键
          (!target || target === "_self") && // 处理 "target=_blank" 等设置
          !isModifiedEvent(event) // ignore clicks with modifier keys
        ) {
          event.preventDefault();
          navigate();
        }
    }
  }
  
  return <a {...props} />;
}

const Link = forwardRef(
  (
    { to, replace, component = LinkAnchor, innerRef, ...rest},
    forwardedRef
  ) => {
    return (
      <RouterContext.Consumer>
       {context => {
          const { history } = context;
          const location = normalizeToLocation(
            resolveToLocation(to, context.location),
            context.location
          );
          const href = location ? history.createHref(location) : "";
          const props = {
            ...rest,
            href,
            navigate() {
              const location = resolveToLocation(to, context.location);
              const method = replace ? history.replace : history.push;
              method(location);
            }
          };
          
          //... 省略计算 ref 相关内容
          
          return React.createElement(component, props);
       }}
      </RouterContext.Consumer>
    )
  }
)

Link 组件需要消费 context,所以其外层必须有一个 Router 组件,Link 组件主要是渲染了一个 a 标签,它会根据设置的 to 属性计算出 href 的值,在点击 a 标签时,若不符合页内导航的条件,则会触发默认行为根据 href 的值进行跳转,若符合页内导航的条件,则会阻止默认事件,调用 navigate 函数,该函数会根据 replace 属性的值来选择调用 history.push 或 history.replace 方法。

Route 组件

为了渲染内容,我们需要使用 Route 组件,我们先来看看实现。

class Route extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          const location = this.props.location || context.location
          // computedMatch 属性是由 Switch 组件计算传递下来的
          const match = this.props.computedMatch
            ? this.props.computedMatch 
            : this.props.path
              ? matchPath(location.pathname, this.props)
              : context.match;
          
          const props = {...context, location, match}
          let {children, component, render} = this.props
          
          if(Array.isArray(children) && isEmptyChildren(children)){
            children = null
          }
          
          return (
            <RouterContext.Provider value={props}>
              {props.match 
                ? children 
                  ? typeof children === 'function'  ? children(props) : children
                  : component 
                    ? React.createElement(component, props)
                    : render 
                      ? render(props)
                      : null
                : typeof children === 'function'
                  ? children(props)
                  : null
              }
            </RouterContext.Provider>
          )
        }}
      </RouterContext.Consumer>
    )
  }
}

可以看出,Route 组件是一定会渲染的,只不过对应的内容是否渲染,则需要经过一系列条件判断。当 location.pathname 与 Route 的参数(含 path 、exact 等)匹配时,match 是与 path 属性“首次”匹配所得,当不匹配时,match 的值为 null.

Route 组件提供了新的上下文,若渲染的内容中含有上下文的消费者,则会优先使用 Route 组件提供的上下文。

  1. 这里之所以使用“首次”两个字,是因为 path 属性可以是一个数组,但是 matchPath 方法在计算所得的 match 是一个 truthy 值时,会在 reduce 的新一轮循环中直接返回这个值,不会进行后续计算。
  2. 还需要注意的是,不管是否匹配,若指定了Route组件的 children 属性,则一定会调用 children 函数,在该函数中可以读取 match 的值来进行一些操作。

Switch 组件

有时候多个 Route 会同时都匹配一个路径,此时我们就可以使用 Switch 组件来优先渲染第一个匹配的组件。

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 => {
            // 当前没有匹配且子元素是 React 元素
            if(match == null && React.isValidElement(child)){
              // 存储子元素
              element = child;
              
              const path = child.props.path || child.props.from
              // 更新 match,若match 非null,则下一轮循环无法进入if语句块
              match = path
                ? matchPath(location.pathname, {...child.props, path})
                : context.match
            }
          })
          
          return match 
            ? React.cloneElement(element, {location, computedMatch: match})
            : null
        }}
      </RouterContext.Consumer>
    )
  }
}

从上述源码可以看出,Switch 组件会读取当前的 location ,遍历所有子组件,只渲染第一个匹配的子组件,若一个也不匹配,则渲染 null。

一般在 Switch 内部会使用 Route 组件和 Redirect 组件,Route 组件已经介绍了,是为了匹配就会渲染对应的内容。下面来看一下 Redirect 组件。

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 和 to 计算  */)
        
        if(staticContext){
          method(location)
          return null
        }
        
        // Lifecycle 组件主要是为了在生命周期钩子中执行对应的函数,渲染的仍是 null
        return (
          <Lifecycle 
            onMount = {()=> method(location)}
            onUpdate={()=> {/* ... */}}
            to={to}
          />
        )
      }}
    </RouterContext.Consumer>
  )
}

可以看出,只要渲染 Redirect 组件,就会在该组件挂载后调用 history.push(或history.replace)导航至指定的 location。

总结

实例化一个 Router 组件后,会监听 location 的变更,一旦 location 变化,则会更新 Router 组件内部状态,从而触发 Router 组件重新渲染。

触发location 的变更则主要通过两种方式:

  1. 调用 history.pushhistroy.replace 方法;
  2. 触发 popstate 事件:主要通过点击浏览器的前进、后退按钮或调用 go、goBack、goForward 方法

第一种方式会引起 history 状态栈的变化,如新增一条记录(栈会变化,当前状态指针也会变化)或改变了某条记录(栈会变化,当前状态指针不会改变),第二种方式不会改变 history 状态栈,只会移动当前状态指针。

两种方式都会更新 history 对象一些字段(length、location、action)的值,并通知所有的监听器。

当点击一个 Link 组件或者渲染一个 Redirect 组件时,此时会采用第一种方式更改 location,history 状态栈会发生改变,同时改变 history 对象,然后执行所有的监听器,如此就会更新 Router 组件内部状态 location,触发 Router 组件重新渲染。

当组件的 render 遇到 Switch 组件时,会判断子组件中是否有匹配当前 location 的,然后只会渲染第一个匹配的组件,当子组件没有设置 path/from 属性,此时则依赖于 context.match 来决定是否渲染此组件。

当组件的 render 遇到 Route 组件时,会首先计算是否 match,若 match 则会渲染对应的子元素,否则是渲染 null,需要注意的是,若设置了 Route 组件的 children 属性,则不管是否 match,都会调用该函数。