精读源码《react-router》

2,622 阅读12分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章。

前言

这是精读源码的第二篇文章。

React玩家基本都接触过react-router。截止目前为止,react-router库已经收获了4.8wstar,也足以说明这个库的受欢迎程度,这篇文章就来揭秘react-router的运行原理,本篇源码解析基于react-router最新版本6.3,这个版本也已全面拥抱hooks,使用起来更加丝滑。

诞生

在正式开始之前,我们先了解下为什么会有react-router的诞生。

大概在2016年,单页面应用的概念被提出并迅速流行起来,因为在此之前的多页面应用(MPA)用户想改变网页内容都需要等待浏览器的请求刷新获取新的页面,而单页面应用颠覆了这种模式,单页面应用实现了在仅加载一次资源,后续可以在不刷新浏览器的情况下动态改变页面展现内容从而让用户获得更好的体验。

单页面应用实现原理

实现单页面应用(single-page application,缩写SPA)实现原理是,通过浏览器的API改变浏览器的url,然后在应用中监听浏览器url的变化,根据不同变化渲染不同的页面,而这个过程中的重点是不能刷新页面。

url变化分为两种模式:hash模式和browser模式。

hash模式

hash模式的url类似于: www.baidu.com/#/a/b

url#后面的即为hash值,而在改变浏览器的hash值时是不会刷新浏览器的,所以我们可以通过window.location.hash来改变hash值,然后通过监听浏览器事件hashchange来获取hash变化从而决定如何渲染页面。

缺点:

  • 如果拿来做路由的话,原来的锚点功能就不能用了;
  • hash 的传参是基于 url 的,如果要传递复杂的数据,会有体积的限制;

browser模式

HTML5规范出来后,浏览器有了history对象。关键是这个history对象上的pushStatereplaceState方法也可以在改变浏览器url的同时不刷新浏览器。了解相关API:MDN-History

window.history.pushState(state, title, url)
// state:需要保存的数据,这个数据在触发popstate事件时,可以在event.state里获取
// title:标题,基本没用,一般传 null
// url:设定新的历史记录的 url。新的 url 与当前 url 的 origin 必须是一樣的,否则会抛出错误。url可以是绝对路径,也可以是相对路径。
//如 当前url是 https://www.baidu.com/a/,执行history.pushState(null, null, './qq/'),则变成 https://www.baidu.com/a/qq/,
//执行history.pushState(null, null, '/qq/'),则变成 https://www.baidu.com/qq/

window.history.replaceState(state, title, url)
// 与 pushState 基本相同,但她是修改当前历史记录,而 pushState 是创建新的历史记录

window.addEventListener("popstate", function() {
    // 监听浏览器前进后退事件,pushState 与 replaceState 方法不会触发
});

window.history.back() // 后退
window.history.forward() // 前进
window.history.go(1) // 前进一步,-2为后退两步,window.history.lengthk可以查看当前历史堆栈中页面的数量

react-router实现

有了上面的铺垫,react-router也就随之诞生了,react-router就是基于上述两种模式分别做了实现。

架构

react-router源码目前分四个包:

  • react-routerreact-router的核心包,下面的三个包都基于该包;
  • react-router-domreact-router用于web应用的包;
  • react-router-v5-compat:如其名,为了兼容v5版本;
  • react-router-native:用于rn项目;

除此之外,react-router还重度依赖一个他们团队开发的包history,该包主要用于配合宿主操作路由变化。

history

这里讲的historyreact-router开发团队开发的history包,不是浏览器的history。当然,history包也是依托浏览器的historyAPI,最终返回的就是一个包装过后的history对象。

export interface History {

  readonly action: Action;    // 操作类型

  readonly location: Location;    // location对象,包含state,search,path等

  createHref(to: To): string;    // 创建路由路径的方法,兼容非string类型的路径

  push(to: To, state?: any): void;    // 路由跳转指定路径

  replace(to: To, state?: any): void;    // 路由替换当前路由

  go(delta: number): void;    // 根据参数前进或后退

  back(): void;    // 类似浏览器后退按钮

  forward(): void;    // 类似浏览器前进按钮

  listen(listener: Listener): () => void;    // push和replace添加监听事件

  block(blocker: Blocker): () => void;    // push和replace添加拦截事件
}

在上面讲到的两种模式,history包分别实现了browser模式的createBrowserHistoryhash模式的createHashHistory

createBrowserHistory

看一下返回的history,很简单,有的方法就是在浏览器的historyAPI上包了一层。

history: BrowserHistory = {
    get action() {    // 使用get是为了只读,并且动态获取返回
      return action;
    },
    get location() {
      return location;
    },
    createHref,
    push,
    replace,
    go,    // 基于浏览器的API的go方法实现
    back() {
      go(-1);
    },
    forward() {
      go(1);
    },
    listen(listener) {    // 添加监听事件
      return listeners.push(listener);
    },
    block(blocker) {...},    // 添加拦截事件
  };

有没有发现,真正有点复杂度的就是pushreplace方法了,我们接下来重点看一下push的实现,这个可以说是history最核心的API

push

  function push(to: To, state?: any) {
    let nextAction = Action.Push;    // 将action设置为PUSH
    let nextLocation = getNextLocation(to, state);   // 创建一个新的location对象
    function retry() {    // 拦截后重新push
      push(to, state);
    }

    if (allowTx(nextAction, nextLocation, retry)) {    // 判断是否有拦截
      let [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);    // 基于新的location和新的index格式化

      try {
        globalHistory.pushState(historyState, "", url);    // 借助浏览器API改变url
      } catch (error) {
        window.location.assign(url);    // 错误捕获就基于url刷新页面
      }

      applyTx(nextAction);    // 触发监听事件
    }
  }

push方法整体看下来,思路很清晰,就是创建一个新的location对象,没有拦截就在原来的历史记录基础再添加一条,并且触发监听事件。

replace思路和push基本一致,主要是把globalHistory.pushState替换成了globalHistory.replace3State

除了pushreplace之外,还有个看点,就是popstate

popstate

借助MDN的一句话: 调用 history.pushState() 或者 history.replaceState() 不会触发 popstate 事件。popstate 事件只会在浏览器某些行为下触发,比如点击后退按钮(或者在 JavaScript 中调用 history.back(),history.go() 方法)。即,在同一文档的两个历史记录条目之间导航会触发该事件。

在历史记录之间切换时,url会变化,所以react-router也要加以监听并处理:

  function handlePop() {
    if (blockedPopTx) {
      blockers.call(blockedPopTx);    // 如果有拦截,就执行拦截的函数
      blockedPopTx = null;
    } else {
      let nextAction = Action.Pop;
      let [nextIndex, nextLocation] = getIndexAndLocation();

      if (blockers.length) {    // 当有拦截的情况
        if (nextIndex != null) {
          let delta = index - nextIndex;    // 通过当前下标和要跳转的下标计算出跳转步数
          if (delta) {
            // Revert the POP
            blockedPopTx = {    // 将当前有拦截的路由信息存储下来
              action: nextAction,
              location: nextLocation,
              retry() {
                go(delta * -1);
              },
            };

            go(delta);    // 当有拦截的情况再次跳转回去,就会再次触发popstate,这样就可以执行拦截函数了
          }
        } else {...}
      } else {    // 没有拦截的情况,只需要触发添加的监听事件,其余的浏览器会自行处理
        applyTx(nextAction);
      }
    }
  }

  window.addEventListener('popstate', handlePop);    // 添加popstate监听函数

当通过gobackforwardAPI触发popstate时,如果没有拦截器的情况下,只需要执行相关的监听函数,然后让浏览器跳转即可。但是如果有拦截器,这里的处理是,将跳转的路由信息存储下来,然后通过go跳转回之前页面,这时又会触发popstate,因为代码判断逻辑这次就会执行拦截器函数,而不会再次触发跳转。

这个拦截的设计可以说是很巧妙,巧妙在:

Q: 它为什么要在触发第二次popstate,并在第二次做拦截,第一次不行吗?

A: 答案肯定是不行,因为popstate是在跳转行为之后触发的,此时做拦截毫无意义。react-router的做法是,既然你跳过去了,那我就让你再跳回来,给你一种没有跳转的假象。你说是不是秀儿🐶。

createHashHistory

react-routerhash模式也是使用了浏览器history相关的API的。和browser模式的主要区别是,urlpath处理会多一个'#'的处理,还有就是多了个hashchange的监听函数。

  window.addEventListener('hashchange', () => {
    let [, nextLocation] = getIndexAndLocation();

    // Ignore extraneous hashchange events.
    if (createPath(nextLocation) !== createPath(location)) {
      handlePop();
    }
  });

这里hashchange的处理和popstate的处理是一样的,都是调用的handlePop函数。

关系图

image.png

react-router

history包讲完了,接下来看下react-router是怎么借助history完成渲染的,react-router也有browser模式和hash模式两种,分别对应BrowserRouterHashRouter

来看一段,react-router v6常规的业务写法:

<BrowserRouter>
   <Routes>
    <Route path="/" element={<Layout />}>
      <Route index element={<Home />} />
      <Route path="about" element={<About />} />
      <Route path="dashboard" element={<Dashboard />} />
      <Route path="*" element={<NoMatch />} />
    </Route>
  </Routes>
</BrowserRouter>

我们可以根据这段代码作为切入点来了解react-router内部的实现,首先是BrowserRouter

BrowserRouter

export function BrowserRouter({
  basename,
  children,
  window,
}: BrowserRouterProps) {
  let historyRef = React.useRef<BrowserHistory>();
  if (historyRef.current == null) {
    // 通过history包的createBrowserHistory获取包装后的history
    historyRef.current = createBrowserHistory({ window });
  }

  let history = historyRef.current;
  let [state, setState] = React.useState({
    action: history.action,
    location: history.location,
  });
  // 通过useLayoutEffect监听history的变化,并在history的listener中对action和location进行修改
  React.useLayoutEffect(() => history.listen(setState), [history]);    

  return (
    <Router
      basename={basename}
      children={children}
      location={state.location}
      navigationType={state.action}
      navigator={history}
    />
  );
}

这段代码最重要的两段已经加了注释,看到这里,我们就很清楚的知道react-router是怎么和history包协同工作的。就是通过useLayoutEffect监听用户对history的操作,然后通过setState分发出去。

再往下看上面的组件:

<NavigationContext.Provider value={navigationContext}>
  <LocationContext.Provider
    children={children}
    value={{ location, navigationType }}
  />
</NavigationContext.Provider>

通过React.useContext创建的两个上下文,用来存储navigationContextlocation的信息,便于子组件获取。

navigationContext

{ 
  basename, 
  navigator,    // Pick<History, "go" | "push" | "replace" | "createHref">
  static,
}

location:

{
  pathname,
  search,
  hash,
  state,
  key,
}

这就是BrowserRouter,可以看到,主要就是创建browser history并监听,然后用两个Provider分别存储navigationContextlocation的信息,方便父组件分发和子组件获取使用。接下来看内部的Routes

Routes

export function Routes({
  children,
  location,
}: RoutesProps): React.ReactElement | null {
  return useRoutes(createRoutesFromChildren(children), location);
}

createRoutesFromChildren主要是借助React.Children.forEachAPI对子元素做一个校验和props的序列化,如果有嵌套的子元素还会进行递归。

useRoutes最终返回:

<RouteContext.Provider
    children={
      match.route.element !== undefined ? match.route.element : outlet
    }
    value={{
      outlet,
      matches: parentMatches.concat(matches.slice(0, index + 1)),
    }}
  />

useRoutes主要是对子组件中的路由进行一个匹配,找到匹配的路由并渲染。

这里的匹配规则还是比较复杂的,因为Routes还有Route都是可以嵌套的,这就会让数据结构变复杂,这里做了个简单的梳理:

  • Routes返回的Provider中会存有父Routes中的matches和自己这层匹配的matches,默认是/
  • 在对子路由进行匹配时,会将子路由数组进行扁平化及优先级排序处理,优先级主要是通过路由路径和在数组中的下标计算得出;
  • 根据Routesmatches对子路由进行匹配;
  • 找出匹配的子路由数组后,遍历对子路由的paramspathname与父路由的数据进行聚合;
  • 最后对子路由数组通过reduceRight,从右到左,其实就是从最底下到最上层的element进行渲染;

还有HashRouterMemeoryRouterNativeRouter,核心原理大致相同,就不一一介绍了。

至此,整个应用就实现了路由匹配渲染,接下来就是通过react-routerAPI对路由进行切换操作。

常用API

react-router v6对路由的操作主要分为两种,对路由数据params的操作和对路由路径pathname的操作。

Link

export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
  function LinkWithRef(
    { onClick, reloadDocument, replace = false, state, target, to, ...rest },
    ref
  ) {
    ...

    return (
      <a
        {...rest}
        href={href}
        onClick={handleClick}
        ref={ref}
        target={target}
      />
    );
  }
);

Link组件是在a标签上进行了封装,a标签的href属性是可以直接改变url的,这样做是最直接的办法。

Navigate

Navigate组件在渲染时就会进行跳转,因为它本身就是useNavigate钩子的包装器。

export function Navigate({ to, replace, state }: NavigateProps): null {

  let navigate = useNavigate();
  React.useEffect(() => {
    navigate(to, { replace, state });
  });

  return null;
}

Outlet

Outlet组件可以展示匹配的路由组件,和Navigate类似,它也是useOutlet的包装器。

export function Outlet(props: OutletProps): React.ReactElement | null {
  return useOutlet(props.context);
}

useLocation

useLocation有点类似于window.location对象,我们能从这个钩子上得到pathname,hash,search,state等信息,通过这个钩子基本能满足我们获取路由数据的需求。

这个钩子的写法就很简单,直接从我们之前解析的<LocationContext.Provider />上拿数据:

export function useLocation(): Location {

  return React.useContext(LocationContext).location;
}

useNavigate

useNavigate比较强大,它既可以像go方法往前,往后跳转,也可以跳转指定路径,并携带参数,是v6中主要用来实现路由跳转的钩子。

useNavigate中会充分利用前面分析到的NavigationContext,RouteContext,LocationContext。从他们身上得到navigator,matches,pathname

最终useNavigate会返回一个navigate

  let navigate: NavigateFunction = React.useCallback(
    (to: To | number, options: NavigateOptions = {}) => {
      if (typeof to === "number") {    // 当to参数是数字时,直接通过go跳转
        navigator.go(to);
        return;
      }

      // 通过to参数和location中的pathname还有matches得到最终要跳转的路径
      let path = resolveTo(
        to,
        JSON.parse(routePathnamesJson),
        locationPathname
      );

      // basename可以理解为根路由
      if (basename !== "/") {
        path.pathname = joinPaths([basename, path.pathname]);
      }

      // 通过用户配置的options判断是replace还是push
      (!!options.replace ? navigator.replace : navigator.push)(
        path,
        options.state
      );
    },
    [basename, navigator, routePathnamesJson, locationPathname]
  );

总结

react-router整体流程已经梳理的差不多了,这里总结一个流程图:

react-router.png

小技巧

Object getter

假设有个这样的场景,一个函数返回一个对象,这个对象的属性能够动态获取并且只读。

实现:

function getterFn() {
    let a = 1;
    let b = 1;
    function add() {
        a += 1;
        b += 1;
    }
    return {
        get a() {
            return a
        },
        b
    }   
}

const obj = getterFn()
obj.add()
obj.a    // 2
obj.b    // 1

popstate拦截

因为popstate是在跳转行为之后触发的,此时做拦截毫无意义。react-router的做法是,既然你跳过去我管不住,但我可以让你再跳回来,给你一种没有跳转的假象,并且通过逻辑判断,在跳回来的时候,触发拦截器函数。

React.Children

当我们在父元素希望对子元素的每个节点做些操作时就可以考虑使用React.Children。参考React官网API

React.Children.map(children, function[(thisArg)])

React.Children.forEach(children, function[(thisArg)])

React.Children.count(children)    // 返回子节点数量

React.Children.only(children)     // 判断是否只有一个子节点

React.Children.toArray(children)    // 将 `children` 这个复杂的数据结构以数组的方式扁平展开并返回

往期回顾

  1. 第一期:精读源码《React18》:Concurrent Mode
  2. 第二期:精读源码《react-router》