React Router 6 是如何工作的

因为想系统地了解下 Remix 的实现,于是就顺藤摸瓜看了下 React Router (v6.4,下面简称 RR) 的工作原理。

我们都知道 React 是一个 UI Library,按照 React 规范书写的代码可以被渲染到某一个 Dom 节点上,并进行交互,所以用 React 来写单页应用(SPA)很方便。但网站通常会有多个页面,页面之间有公共部分(如 header,sidebar),也可以通过链接跳转。如果不借助第三方类库,最直接的方式就是为多个页面建立入口文件,然后在页面内再引入 React,就像这样:

但这样实在是不够便捷,有了 RR 可以省去很多重复性的步骤(新建文件、定义 root element 等),让页面加载更快(无页面刷新、并行请求 URL 等),写起来方便,功能还很强大。对于后端来说无论怎样的请求路径,都返回同样的 JS 代码就行了(假设没有 split code),这些 JS 会自己去识别 URL,完成页面布局,加载正确的组件等等。

先来看看 React Router 的使用:

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    errorElement: <ErrorPage />,
  },
  {
    path: "contacts/:contactId",
    element: <Contact />,
  },
]);

ReactDOM.createRoot(document.getElementById("root")).render(
  <RouterProvider router={router} />
);

只需要两个步骤:

  1. 创建 Router
  2. 把创建好的 Router 作为参数传给 RouteProvider

route 的定义也很直观:

{
  path: "/", // 匹配怎样的 URL
  element: <Root />, // 匹配到了后,渲染哪个 Component
  errorElement: <ErrorPage />, // 如果出错展示哪个 Component
}

假如有两个 URL:/dashboard/settings/dashboard/analytics,可以这样定义:

const router = createBrowserRouter([
  {
    path: "/dashboard/settings", 
    element: <Settings />
  },
  {
    path: "/dashboard/analytics", 
    element: <Analytics />
  },
]);

但这样的话,每个 element 对应的 Component 就要自己去处理组件复用,也无法体现组件之间的关系,当 URL 多起来后,分散的 route 也不容易维护(如果维护过 CSS 应该就有体会,想象每一个 route 都是一条 CSS 规则)。

RR 支持嵌套 route,就像这样:

const router = createBrowserRouter([
  {
    path: "/", 
    element: <Root />,
    children: [
      {
        path: "dashboard", 
        element: <Dashboard />,
        children: [
          { path: "settings", element: <Settings /> },
          { path: "analytics", element: <Analytics /> },
        ]
      },
    ]
  },
]);

当访问 /dashboard/settings URL 时,React Router 就能根据配置,找到所需的 Components 来构建最终的页面。

拿到了 Root, Dashboard, Setting 这三个组件后,如何把它们组成一个页面呢?这就要使用 Outlet 了,可以把它理解为一个 Slot,也就是为 children 准备好的一块地方。children 的内容会渲染在 parent 的 Outlet 区域内。

除了能布局组件,React Router 还可以用来管理数据,就像这样:

<Route
  path="/"
  loader={async ({ request }) => {
    const res = await fetch("/api/user.json", {
      signal: request.signal,
    });
    const user = await res.json();
    return user;
  }}
  element={<Root />}
>

初看起来好像没什么,甚至会觉得有点管得太多了,组件内部完全可以在 useEffect 里自己去加载数据并展示,但结合上面的路由配置,RR 可以拿到每个组件对应的 loader。有了全局视角,就可以做很多事了,比如可以并行请求这些 loader(通过 Promise.all),中断这些组件的请求(切换到了另一个 URL),并在合适的时机再重新请求等等。

使用时,只需要 useLoaderData 就行了

import { useLoaderData } from "react-router-dom";
import { getContacts } from "../contacts";

export async function loader() {
  const contacts = await getContacts();
  return { contacts };
}

export default function Root() {
  const { contacts } = useLoaderData();

  return (
    //...
  );
}

除了这些,RR 还支持 Ranked Routes(每个路由会有一个分数,分高者优先匹配)、Redirects、Data Mutation、Optimistic UI(假定请求会成功,先用本地数据更新 UI)等等,功能很齐全。

了解了 RR 的使用后,再来看看 RR 内部是如何工作的。可以分为三个部分:

- router // 平台无关,实现核心逻辑,可以简单理解为一个状态机
- react-router // 适配 react,包括 components, context, hooks
- react-router-dom // web 端的实现,如 Link、Form 等

这三个部分的主要组成部分如下:

router

简单理解的话,router 就是一个状态机,提供了一些可以改变状态的方法,上层可以监听状态的变化来做 reaction。来看下比较常用的方法:

createRouter

方法签名如下:

export interface RouterInit {
  basename?: string;
  routes: AgnosticRouteObject[];
  history: History;
  hydrationData?: HydrationState;
}

export function createRouter(init: RouterInit): Router {
  //...
}

使用方构建好 RouterInit 后作为参数传入,就能拿到一个 Router 实例。RouterInit 里的 routes 是一个 AgnosticRouteObject 数组,它的定义如下:

export interface AgnosticRouteObject {
  caseSensitive?: boolean;
  children?: AgnosticRouteObject[];
  index?: boolean;
  path?: string;
  id?: string;
  loader?: LoaderFunction;
  action?: ActionFunction;
  hasErrorBoundary?: boolean;
  shouldRevalidate?: ShouldRevalidateFunction;
  handle?: any;
}

基本上 route 相关的信息都有了,除了 element,这个就交给上层去定义和使用了,因为不同的实现,element 都会不一样。 createRouter 方法内部把 init 的内容重新组织后作为变量保存起来,供 Router 相关的方法使用,同时也会初始化其他的内部变量。

export function createRouter(init: RouterInit): Router {
  let dataRoutes = convertRoutesToDataRoutes(init.routes);
  let initialMatches = matchRoutes(
    dataRoutes,
    init.history.location,
    init.basename
  );
  //... other variable initiation

  router = {
    get basename() { return init.basename; },
    get state() { return state; },
    get routes() { return dataRoutes; },
    initialize,
    subscribe,
    navigate,
    fetch,
    //..
  };

  return router;
}

router.initialize

initialize 方法做了两件事:

  1. 监听 history 的变化
  2. 开始首次导航(startNavigation)
function initialize() {
  // 监听 history 的变化,然后执行 navigate 的跳转逻辑
  // 这里监听的是 history 的 `popstate` 事件,对应用户点击浏览器的前进、后退按钮行为
  // 其他的 url 改变行为都是内部行为(比如执行 `router.navigate(path)`),不需要监听
  unlistenHistory = init.history.listen(
    ({ action: historyAction, location }) =>
      startNavigation(historyAction, location)
  );

  if (!state.initialized) {
    startNavigation(HistoryAction.Pop, state.location);
  }

  return router;
}

router.subscribe

subscribe 方法可以注册一个 listener,该 listener 在 state 改变后会被通知到。

function subscribe(fn: RouterSubscriber) {
  subscribers.add(fn);
  return () => subscribers.delete(fn);
}

function updateState(newState: Partial<RouterState>): void {
  state = {
    ...state,
    ...newState,
  };
  subscribers.forEach((subscriber) => subscriber(state));
}

router.navigate

navigate 方法可以让当前的路由状态发生改变,同时修改 history

上图只是描述了大致的流程,navigate 方法处理的细节很多,比如 matchRoutes 会将树状的 routes 扁平化(flatten)、给每个 route 打分,并按照分数倒序排列。

假设原先的 routes 是这样的:

{
  path:"/", 
  children: 
    [ 
      { path: "about" },
      { path: "posts", 
       children:
         [{ path:"all" }]
      }
    ]
}

扁平化后会变成这样:(score 得分为示意)

[
  { path: "/posts/all", score: 100, routesMeta: [rootMeta, postsMeta, postsAllMeta]},
  { path: "/posts", score: 80, routesMeta: [rootMeta, postsMeta]},
  { path: "/about", score: 80, routesMeta: [rootMeta, aboutMeta]},
  { path: "/", score: 50, routesMeta: [rootMeta]},
]

这样对 routes 进行遍历,找到的第一条就是最匹配目标 path 的那个 route

router.fetch

fetch 是提供给上层使用的 API,不会改变 history,会让 state.fetchers 发生改变,使用方可以监听 state.fetchers 的变化,来拿到 fetch 的结果。

react-router

react-router 分为三个部分:components, contexthooks

  • components 可以用来构建 route
  • context 可以存储 router.state,并在 router.state 变化时触发 rerender
  • hooks 可以从 context 中拿对应的 value,也可以直接调用 router 的方法

components.RouterProvider

在一开始讲 RR 的使用时,就有提到 RouterProvider,它是这么用的:

ReactDOM.createRoot(document.getElementById("root")).render(
  <RouterProvider router={router} />
);

RouterProvider 做了两件事:

  1. 通过 useSyncExternalStoreShimrouter.state 建立连接
  2. 嵌套 4 个 context.Provider
export function RouterProvider({
  fallbackElement,
  router,
}: RouterProviderProps): React.ReactElement {
  // 当 `router.state` 变化时会收到通知,并拿到最新的 state
  // 参见:https://reactjs.org/docs/hooks-reference.html#usesyncexternalstore
  let state: RouterState = useSyncExternalStoreShim(
    router.subscribe,
    () => router.state,
    () => router.state
  );

  //...

  return (
    // 将 `router` 的不同部分放到对应的 context provider value 中
    <DataRouterContext.Provider
      value={{ router, navigator, ... }}
    >
      <DataRouterStateContext.Provider value={state}>
        // 这里展开来的话,也是两个嵌套的 Provider
        <Router
          basename={router.basename}
          location={router.state.location}
          navigationType={router.state.historyAction}
          navigator={navigator}
        >
          {router.state.initialized ? <Routes /> : fallbackElement}
        </Router>
      </DataRouterStateContext.Provider>
    </DataRouterContext.Provider>
  );
}

components.Routes

除了通过 createBrowserRoutes 来创建 routes 外,也可以通过 Routes 组件来定义 routes,就像这样:

export default function App() {
  return (
    <div>
      <p> Hello, world! </p>
      <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>
    </div>
  );
}

Routes 内部主要用到了 useRoutes 这个 hook

export function useRoutes(
  routes: RouteObject[],
  locationArg?: Partial<Location> | string
): React.ReactElement | null {
  //...

  // 1.
  let matches = matchRoutes(routes, { pathname: remainingPathname });

  // 2.
  let renderedMatches = _renderMatches(
    matches,
    parentMatches,
    dataRouterStateContext || undefined
  );

  // 3.
  if (locationArg) {
    return (
      <LocationContext.Provider
        value={{
          location: {...},
          navigationType: NavigationType.Pop,
        }}
      >
        {renderedMatches}
      </LocationContext.Provider>
    );
  }

  return renderedMatches;
}
  1. 这里的 matchRoutes 用的是 router 里的实现,用来找到匹配 pathnameroutes
  2. 通过 _renderMatches 方法得到 renderedMatches,这是 matchRoutes 方法最重要的一块
  3. 包一个 LocationContext.Provider 如果有必要的话,这样就能通过 useLocation 来拿 location

来看一下 _renderMatches 里面大概是怎样的:

export function _renderMatches(
  matches: RouteMatch[] | null,
  parentMatches: RouteMatch[] = [],
  dataRouterState?: RemixRouter["state"]
): React.ReactElement | null {
  //...

  // 1.
  return matches.reduceRight((outlet, match, index) => {
    // 去掉了 error 相关的处理逻辑

    let getChildren = () => (
      <RenderedRoute
        match={match}
        // 2.
        routeContext={{
          outlet,
          matches: parentMatches.concat(renderedMatches.slice(0, index + 1)),
        }}
      >
        {error
          ? errorElement
          : match.route.element !== undefined
          ? match.route.element
          : outlet}
      </RenderedRoute>
    );

    return getChildren();
  }, null as React.ReactElement | null);
}
  1. 刚开始看到 reduceRight 时,没有反应过来,为什么要从右往左 reduce,想明白后觉得甚为精妙。还记得之前将 routeObjects 扁平化么,每个数组元素都有一个 routesMeta 数组,里面放的就是 AgnosticRouteObject,更重要的是,这些 Objects 的顺序也是有讲究的,最左边是根,最右边是叶子。
[
  { path: "/posts/all", score: 100, routesMeta: [rootMeta, postsMeta, postsAllMeta]},
  { path: "/posts", score: 80, routesMeta: [rootMeta, postsMeta]},
  ...
]

matches.reduceRight((outlet, match, index) => {}) 就是从最右边的叶子开始作为 outlet 传递给 parent,如果 parent 组件有 Outlet 占位组件,就用传入的 outlet 组件填充。这样依次从右往左遍历(以上面的代码为例,就是先把 postsAllMeta 作为 outlet 传给 postsMeta,再把 postsMeta 作为 outlet 传给 rootMeta)。

  1. outlet 作为 context value 包在 match.route.element 外,因为 React Context 获取 Context Value 是就近匹配原则,也就是向上找到的第一个符合的 Context Provider。这样外面有再多的匹配的 Context Provider 都不会有影响。

hooks

react-routerhooks 除了 useRoutes 外,其他的大多是从 Context 里去取对应的 Value,这里就不展开讲了。

context

react-routercontext 主要有 4 个:

- LocationContext
- NavigationContext
- DataRouterContext // 把 Router 作为 value
- DataRouterStateContext // 把 Router.state 作为 value

每个 context 对应 router 的不同部分, 当 router.state 变化时,这些 context 的 value 就会被更新,hooks 就能拿到最新的 value。

react-router-dom

react-router-dom 按照浏览器的特性又进一步封装了 hookscomponents。先来看一下 createBrowserRouter

export function createBrowserRouter(
  routes: RouteObject[],
  opts?: {
    basename?: string;
    hydrationData?: HydrationState;
    window?: Window;
  }
): RemixRouter {
  return createRouter({
    basename: opts?.basename,
    history: createBrowserHistory({ window: opts?.window }),
    hydrationData: opts?.hydrationData || window?.__staticRouterHydrationData,
    routes: enhanceManualRouteObjects(routes),
  }).initialize();
}

createRoutercreateBrowserHistory 都由 router 提供,routes (RouteObject[]) 则是在 react-router 中定义。

再来看看 Link 组件,用 Link 组件构造的链接,点击后可以不刷新页面(指定 reloadDocument 后,可以回退到正常的链接模式)

export const Link = React.forwardRef(
  function LinkWithRef(
    { onClick, to, ... },
    ref
  ) {
    let href = useHref(to, { relative });
    let internalOnClick = useLinkClickHandler(to, {...});

    function handleClick(event) {
      if (onClick) onClick(event);

      if (!event.defaultPrevented) {
        internalOnClick(event);
      }
    }

    return (
      <a
        href={href}
        onClick={reloadDocument ? onClick : handleClick}
        ref={ref}
        ...
      />
    );
  }
);

可以看到 Link 内部是一个 a 链接,同时绑定了 handleClick,默认用 useLinkClickHandler 来处理

export function useLinkClickHandler(to: To, {...}) (event) => void {
  let navigate = useNavigate();
  let location = useLocation();
  let path = useResolvedPath(to, { relative });

  return React.useCallback(
    (event) => {
      if (shouldProcessLinkClick(event, target)) {
        event.preventDefault();

        navigate(to, { replace, state, preventScrollReset, relative });
      }
    },
    [ location, navigate, path, ... ]
  );
}

方法内部用了 router.navigate 方法来完成跳转。Form 组件也类似,先禁用默认行为(event.preventDefault())再用 router.fetch 来提交数据。

小结

React Router 功能很强大,使用也很广泛,这篇文章也只是大致剖析了下 RR 的内部实现,更细致的内容(如错误处理、请求管控、状态变更时机等)还是要啃源码才行,希望这篇文章能够让你对 RR 有更多的了解。水平有限,如有错误欢迎指出和讨论。

❤️