React Router 底层原理解析(含源码分析)

2,168 阅读11分钟

此次分析的源码版本:v5.2.0

1 概述

React Router 是 React 的官方路由库,可以用于 web 端、node.js 服务端,和 React Native。从代码层面讲,它是一个 React 组件、hooks 和工具函数的集合。

React Router 是一个动态路由,在渲染阶段可以进行更改;与之相对应的是静态路由,它是一个脱离于运行中的应用的配置,需要事先定义,不可动态更改。这意味着 React Router 中几乎所有东西都是一个组件。

React Router 由三部分组成:

  • react-router:包含了 React Router 的大多数核心功能,包括路由匹配算法和大多数核心组件和 hooks。
  • react-router-dom:除了 react-router 的内容之外,还添加了一些 DOM 相关的 API,如 <BrowserRouter>, <HashRouter>, <Link> 等。
  • react-router-native:除了 react-router 的内容之外,还添加了一些 React Native 相关的 API,如 <NativeRouter> 和 native 版的 <Link>

当安装 react-router-dom 和 react-router-native 时,会自动包含 react-router 作为依赖,并且还会自动导出 react-router 里的所有东西。所以使用时只需要安装 react-router-dom 或 react-router-native 即可,不要直接使用 react-router。

总之,对于 web 浏览器(包括服务端渲染),使用 react-router-dom;对于 React Native apps,使用 react-router-native。

React Router中包含三种基础类型的组件:

  • 路由器(routers)

    • <BrowserRouter>
    • <HashRouter>
  • 路由匹配组件(route matchers)

    • <Route>
    • <Switch>
  • 路由导航组件(navigation / route changers)

    • <Link>
    • <NavLink>
    • <Redirect>

下面是一个典型的使用示例:

image.png

2 基础知识

了解实现原理前,让我们先了解底层涉及到的一些浏览器全局对象和事件。

2.1 全局对象

2.1.1 Location 对象

表示链接到的位置(URL),可以通过 window.location 访问。

它的主要属性包括:

  • href:整个URL。
  • protocol:URL 的协议,最后有一个“:”。
  • host:域名,末尾可能带有一个“:”和端口号。
  • hostname:域名,不包含端口号。
  • port:端口号。
  • pathname:URL 路径部分,开头有一个“/“。
  • search:URL 参数,开头有一个“?”。
  • hash:片段标识符,开头有一个“#”。

主要方法包括:

  • assign(url):加载给定的 URL。
  • reload():刷新当前页面。
  • replace(url):加载给定的 URL 替换当前页面。和 assign 不同的是不会保存到会话历史中,因此无法回退到上一页。
  • toString():返回整个字符串,是 location.href 的只读版。

2.1.2 History 对象

表示浏览器一个标签页的会话历史(session history),可以通过 window.history 访问。

主要属性包括:

  • length 只读:返回会话历史中的条目数,包括当前加载页。
  • state 只读:会话历史栈(session history stack)中当前条目的状态(state)。

方法包括:

  • back():前往上一页,效果同浏览器左上角返回按钮,等价于 history.go(-1)。
  • forward():前往下一页,效果同浏览器左上角前进按钮,等价于 history.go(1)。
  • go(delta):以当前页面为起点前进或后退多少页。
  • pushState(state, title[, url]):向会话历史栈中添加一个 state。
  • replaceState(state, title[, url]):替换会话历史栈中当前条目的 state。

例如下面的示例,在浏览器左上角返回或前进按钮上点击鼠标右键弹出的菜单中,就罗列了当前标签页保存在 history 中的条目。右图通过 history.state 访问了当前条目的 state。

image.png

2.2 全局事件

2.2.1 hashchange 事件

这个很好理解,就是当 URL 的 hash 部分改变时触发的事件。

window.addEventListener('hashchange', function() {
  if (location.hash === '#cool-feature') { 
    console.log("You're visiting a cool feature!"); 
  }
}, false);

2.2.2 popstate 事件

history 中处于激活状态的条目发生变化时触发该事件,举个很简单的例子,在一个标签页中,打开控制台写好监听的代码,然后鼠标点击浏览器左上角前进或后退按钮,就会发现该事件被触发了。

image.png

需要注意,history.pushState()history.replaceState() 并不会触发该事件,只有在做出浏览器动作时,才会触发该事件,如用户点击浏览器的回退按钮(或调用 history.back() 或者 history.forward() 等方法)

使用 history.pushState()history.replaceState() 可以改变地址栏的url,而不刷新页面,这也是前端路由得以实现的关键。我们可以接管刷新的动作,通过路由组件匹配到想要显示的组件,渲染在页面的局部区域中。

window.addEventListener('popstate', (event) => {
  console.log("location: " + document.location + ", state: " + JSON.stringify(event.state));
});
history.pushState({page: 1}, "title 1", "?page=1");
history.pushState({page: 2}, "title 2", "?page=2");
history.replaceState({page: 3}, "title 3", "?page=3");
history.back(); // Logs "location: http://example.com/example.html?page=1, state: {"page":1}"
history.back(); // Logs "location: http://example.com/example.html, state: null
history.go(2);  // Logs "location: http://example.com/example.html?page=3, state: {"page":3}

2.2.3 beforeunload 事件

当浏览器窗口关闭或者刷新时触发。

在事件处理程序中调用 preventDefault() 能够触发一个确认对话框,询问用户是否真的要离开该页面。如果用户确认,浏览器将导航到新页面,否则导航将会取消。

image.png

但是请注意,并非所有浏览器都支持此方法,而有些浏览器需要事件处理程序实现两个遗留方法中的一个作为代替:

  • 将字符串分配给事件的 returnValue 属性
  • 从事件处理程序返回一个字符串。
window.addEventListener('beforeunload', (event) => {
  // Cancel the event as stated by the standard.
  event.preventDefault();
  // Chrome requires returnValue to be set.
  event.returnValue = '';
});

3 源码分析

3.1 history-5.0.0 源码分析

history 是 React Router 内部的一个核心依赖,里面包含了路由管理的核心逻辑。

前面提到,React Router 提供了三种 Router,也就是 BrowserRouter、HashRouter 以及 NativeRouter。他们之间的区别就在于底层的 history 不同。history 库分别提供了创建这几种 history 的方式。

3.1.1 createHashHistory

export function createHashHistory(options: HashHistoryOptions = {}): HashHistory {
  
  // 1. 拿到 window 对象,并缓存全局 history 对象
  // defaultView 返回当前 document 对象所关联的 window 对象
  let { window = document.defaultView! } = options;
  let globalHistory = window.history;
  let blockedPopTx: Transition | null = null;
  
  // 2. 监听popstate事件,旧浏览器监听 hashchange
  window.addEventListener(PopStateEventType, handlePop); // popstate
  // popstate does not fire on hashchange in IE 11 and old (trident) Edge
  window.addEventListener(HashChangeEventType, () => { // hashchange
    let [, nextLocation] = getIndexAndLocation();
    // Ignore extraneous hashchange events.
    if (createPath(nextLocation) !== createPath(location)) {
      handlePop();
    }
  });
  let action = Action.Pop;
  
  // 3. 获取当前state下标和location
  let [index, location] = getIndexAndLocation();
  
  // 4. 初始化两个回调函数:收集器和拦截器
  let listeners = createEvents<Listener>();
  let blockers = createEvents<Blocker>();
  
  // 5. 往 history 栈中添加一个初始 state,下标为 0
  if (index == null) {
    index = 0;
    globalHistory.replaceState({ ...globalHistory.state, idx: index }, '');
  }
  
  // 6. 创建并返回一个history对象
  let history: HashHistory = {
    get action() {
      return action; // POP PUSH REPLACE
    },
    get location() {
      return location;
    },
    createHref, // 返回pathname + search + hash
    push,
    replace,
    go,
    back() {
      go(-1);
    },
    forward() {
      go(1);
    },
    listen(listener) {
      return listeners.push(listener);
    },
    block(blocker) {
      let unblock = blockers.push(blocker);
      if (blockers.length === 1) {
        // beforeunload 浏览器窗口关闭或刷新时触发一个确认对话框
        window.addEventListener(BeforeUnloadEventType, promptBeforeUnload);
      }
      return function() {
        unblock();
        // Remove the beforeunload listener so the document may
        // still be salvageable in the pagehide event.
        // See https://html.spec.whatwg.org/#unloading-documents
        if (!blockers.length) {
          window.removeEventListener(BeforeUnloadEventType, promptBeforeUnload);
        }
      };
    }
  };

  return history;
}

看下 handlePop 内部的实现:

  function handlePop() {
    if (blockedPopTx) {
      blockers.call(blockedPopTx);
      blockedPopTx = null;
    } else {
    
      // 1. 触发 popstate 的时候url已经变了,所以获取的是目标location
      let nextAction = Action.Pop;
      let [nextIndex, nextLocation] = getIndexAndLocation();
      
      // 2. 如果拦截器里有回调函数,就先执行回调函数再跳转
      if (blockers.length) {
        if (nextIndex != null) {
          let delta = index - nextIndex;
          if (delta) {
           blockedPopTx = {
              action: nextAction,
              location: nextLocation,
              retry() {
                go(delta * -1);
              }
            };
            go(delta);
          }
        }
      } else {
      
        // 3. 如果没有拦截器则直接进行导航
        applyTx(nextAction);
      }
    }
  }
  
  function applyTx(nextAction: Action) {
    action = nextAction;
    
    // 4. 更新当前index和location的值
    [index, location] = getIndexAndLocation();
    
    // 5. 调用收集的回调函数
    listeners.call(location);
  }

在第 4 步中,调用了 getIndexAndLocation 来更新当前 index 和 location 的值。这个方法内部调用了 parsePath 方法来拿到 pathname、search、hash,为什么要多此一举呢?为什么不能直接在 window.location 中调用相应的属性来拿呢?注意对于使用了 HashHistory 的应用来说,url 中的第一个 # 后的内容,在语义上来讲并不是 hash,(尽管对浏览器来说是),其后面的一长串内容包含了很多路由的关键信息,如 pathname、search,而最后一个(也就是第二个)# 后的内容才是有意义的 hash。例如:https://www.example.com/#/page1?name=react#first

  function getIndexAndLocation(): [number, Location] {
    let { pathname = '/', search = '', hash = '' } = parsePath(
      window.location.hash.substr(1) // str.substr(start[, length])
    );
    let state = globalHistory.state || {};
    return [
      state.idx,
      readOnly<Location>({
        pathname,
        search,
        hash,
        state: state.usr || null,
        key: state.key || 'default'
      })
    ];
  }

总结一下,createHashHistory 其实就做了两件事:

  1. 创建一个 history,里面包含很多有用的方法;
  2. 定义路由变化时需要做的事情,即调用收集到的回调函数

3.1.2 createBrowserHistory

与 createHashHistory 唯一的不同支持就在于只监听了 popstate。

export function createBrowserHistory(
  options: BrowserHistoryOptions = {}
): BrowserHistory {
  // ...
  window.addEventListener(PopStateEventType, handlePop); // popstate
  // ...

以及,在获取路由信息的时候只需要访问相关属性即可。

function getIndexAndLocation(): [number, Location] {
  let { pathname, search, hash } = window.location;
  // ...
}

3.2 react-router-5.2.0 源码分析

3.2.1 <Router>

BrowserRouter 和 HashRouter 仅仅是调用了 createBrowserHistory 和 createHashHistory 来创建一个 history 对象,然后返回同样的 Router 组件,并将各自的 history 作为 props 传进去。

class BrowserRouter extends React.Component {
  history = createBrowserHistory(this.props);

  render() {
    return <Router history={this.history} children={this.props.children} />;
  }
}

class HashRouter extends React.Component {
  history = createHashHistory(this.props);

  render() {
    return <Router history={this.history} children={this.props.children} />;
  }
}

在 Router 组件内部

class Router extends React.Component {
 
  // 1. 创建一个默认的match对象
  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;

    // 2. 调用刚刚传入的 history 里面的 listen 方法来添加回调函数
    this.unlisten = props.history.listen(location => {
      if (this._isMounted) {
        this.setState({ location });
      } else {
        this._pendingLocation = location;
      }
    });
 }

  componentDidMount() {
    this._isMounted = true;

    // 3. 组件unmount前取消订阅
    if (this._pendingLocation) {
      this.setState({ location: this._pendingLocation });
    }
  }

  componentWillUnmount() {
    if (this.unlisten) this.unlisten();
  }

  // 4. 把 history location match 等等关键信息放到 context 里
  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>
    );
  }
}

其中第二步尤其关键,在分析 history 时,我们讲过监听到路由变化时会调用收集到的回调函数。这里是使用 setState 更新了 location,然后触发整个 Router 的重新渲染。看看 listen 方法是怎么订阅的:

listen(listener) {
  return listeners.push(listener);
}

let listeners = createEvents<Listener>();

function createEvents<F extends Function>(): Events<F> {
  let handlers: F[] = [];

  return {
    get length() {
      return handlers.length;
    },
    push(fn: F) {
      handlers.push(fn);
      return function() {
        handlers = handlers.filter(handler => handler !== fn);
      };
    },
    call(arg) {
      handlers.forEach(fn => fn && fn(arg));
    }
  };
}

history 在其内部维护了一个数组,这个数组收集了所有的回调函数,当路由变化时调用 call 来逐个执行里面的回调函数。

3.2.2 <Switch>

class Switch extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {context => {
         
          // 1. 拿到 location
          const location = this.props.location || context.location;

          let element, match;

          // 2. 遍历子元素 Route 并对 location 和 props.path 进行匹配
          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;
            }
          });

          // 4. 返回第一个匹配的 Route
          return match
            // 以 element 元素为样板克隆并返回新的 React 元素
            // props浅合并,新的子元素将取代现有的子元素,保留原始元素的key 和 ref
            ? React.cloneElement(element, { location, computedMatch: match })
            : null;
        }}
      </RouterContext.Consumer>
    );
  }
}

看下 matchPath 算法是如何实现的:

function matchPath(pathname, options = {}) {
  if (typeof options === "string" || Array.isArray(options)) {
    options = { path: options };
  }

  // 1. 拿到匹配条件
  const { path, exact = false, strict = false, sensitive = false } = options;

  const paths = [].concat(path);

  return paths.reduce((matched, path) => {
    if (!path && path !== "") return null;
    if (matched) return matched;

    // 2. 编译成正则
    const { regexp, keys } = compilePath(path, {
      end: exact,
      strict,
      sensitive
    });
    
    // 3. 正则匹配
    const match = regexp.exec(pathname);

    if (!match) return null;

    const [url, ...values] = match;
    const isExact = pathname === url;

    if (exact && !isExact) return null;

    // 4. 成功返回match,失败返回null
    return {
      path, // the path used to match
      url: path === "/" && url === "" ? "/" : url, // the matched portion of the URL
      isExact, // whether or not we matched exactly
      params: keys.reduce((memo, key, index) => {
        memo[key.name] = values[index]; // { bar: ‘123’ }
        return memo;
      }, {})
    };
  }, null);
}

export default matchPath;

看完可以发现,Switch 一旦拿到第一个匹配的 Route 时,就不再往下匹配了,所以在日常项目的开发中,最好将 pathname 更具体的 Route 写在前面,确保能够顺利匹配。

3.2.3 <Route>

class Route extends React.Component {
  render() {
    return (
      <RouterContext.Consumer>
        {context => {
          
          // 1. 拿到location
          const location = this.props.location || context.location;
          
          // 2. 拿到match,如果没有的话就自己算
          const match = this.props.computedMatch
            ? this.props.computedMatch
            : this.props.path
              ? matchPath(location.pathname, this.props)
              : context.match;

          // 3. 组合新的props
          const props = { ...context, location, match };

          let { children, component, render } = this.props;

          return (
          
            // 4. 给渲染的组件提供新的props
            <RouterContext.Provider value={props}>
            
              // 5. 渲染方法的优先级:children(不管是否匹配都会渲染)、component、render
              {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>
    );
  }
}

3.2.4 <Link> 导航

Link 中定义了一个navigate方法,并渲染了一个a标签。

a标签绑定了click事件,点击时会调用navigate(),把我们写在Link组件上的导航url传给history.push或history.replace。

这个history就又回到了最初从history库创建的那个history对象了。

调用push方法会先检查有没有blocker,简单理解就是阻止页面跳转的回调函数,也就是最开始提到的beforeunload事件,可以利用它来向用户二次确认是否真的要离开当前页面。其次也可以在这里添加一些回调函数,用来做路由跳转前的监控和上报。

如果上一步没有拦截,就可以顺利走到这一步。在这里会调用全局history对象的pushState方法,给history栈添加状态,并改变地址栏的url,而不会刷新页面。

最后一步是调用注册到listener里的监听回调函数,并把最新的location传进去。

最初在Router里做了一件事,就是调用了history.listen,并传入了一个回调函数。这个回调函数只做了一件事,那就是调用了this.setState({location})。引发React组件更新。

更新的时候,将新的location以Context的形式传递给Switch,Switch根据新location执行匹配算法,找出第一个匹配的Route,Route渲染对应的组件,完成页面的更新。

至此一个完整的导航流程完成了。

下面是Link相关的源码,已经标出关键路径。

image.png

image.png

4 总结

现在,让我们来总结一下整个流程。

  1. 创建 history 并监听 hashchange 或 popstate 事件,一旦触发这两个事件就执行所有回调函数;
  2. Router 向 history 添加一个回调函数,该函数会执行 setState 更新 location 并重新渲染所有组件;
  3. 点击 Link 组件或调用 history.push(url, state)
  4. 执行 pushState 或 replaceState 更新浏览器url,页面不刷新;
  5. 调用之前收集到的所有回调函数(setState(location));
  6. Router 执行 render;
  7. Switch 遍历子组件,选出第一个匹配的 Route;
  8. 渲染 Route,这时就可以看到当前 url 需要展示的 UI 视图了。