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 就行了

app.tsimport { 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

方法签名如下:

router.tsexport 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, context 和 hooks。

  • 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. 通过 useSyncExternalStoreShim 与 router.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,就像这样:

app.tsxexport 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 里的实现,用来找到匹配 pathname 的 routes
  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-router 的 hooks 除了 useRoutes 外,其他的大多是从 Context 里去取对应的 Value,这里就不展开讲了。

context

react-router 的 context 主要有 4 个:

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

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

react-router-dom

react-router-dom 按照浏览器的特性又进一步封装了 hooks 和 components。先来看一下 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();
}

createRouter 和 createBrowserHistory 都由 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 有更多的了解。水平有限,如有错误欢迎指出和讨论。

Related Posts