首页
/ react-router createBrowserRouter 深度指南:Data Router 创建、Hydration 数据注入与「战争迷雾」式动态路由发现

react-router createBrowserRouter 深度指南:Data Router 创建、Hydration 数据注入与「战争迷雾」式动态路由发现

2026-09-06 20:31:02作者:齐添朝

在 React Router 中,createBrowserRouter 是构建 Data Mode 应用最核心的入口函数:它基于浏览器 History API 创建一个管理应用路径的 data router,并将路由、loader/action、中间件、hydration 数据等全部串联起来。本文基于仓库官方文档 createBrowserRouter 与对应源码实现,完整覆盖其函数签名、全部 8 个配置选项(含 patchRoutesOnNavigation 的动态路由发现机制)的用法,并结合源码剖析 router 的初始化链路,读完即可独立完成一个支持服务端渲染水合、动态路由树与可观测性埋点的 Data Mode 应用。

一、createBrowserRouter 的定位:创建 Data Router

文档对该函数的定义是:创建一个通过 history.pushStatehistory.replaceState 管理应用路径的 data router。与声明式路由(<BrowserRouter> + <Routes>)不同,data router 是一个持有完整内部状态的运行时对象——它自己负责执行 loader/action、管理导航状态(loading/submitting)、处理 fetcher 请求,然后把这些状态通过 <RouterProvider> 暴露给 React 组件树。

由此产生一条关键使用约束(文档原文即如此强调):

Data Router 不应存放在 React 状态中。 你应该在 React 组件树之外只创建一次 router,然后把它传给 <RouterProvider>

也就是说,router 的创建必须发生在 React 生命周期之外(通常在入口文件的模块顶层),这样导航状态才不会与组件的重渲染产生耦合。

函数签名

function createBrowserRouter(
  routes: RouteObject[],
  opts?: DOMRouterOpts,
): DataRouter
  • routes: RouteObject[] —— 应用路由树(路由对象数组,详见 route-object 文档);
  • opts?: DOMRouterOpts —— DOM 环境专属配置项,下节逐项展开;
  • 返回一个已初始化(initialized)的 data router,供 <RouterProvider> 消费。

二、DOMRouterOpts 配置项全景

DOMRouterOpts 接口定义在 packages/react-router/lib/dom/lib.tsx,完整包含 8 个可选字段:

配置项 类型 作用
basename string 应用挂载的基础路径前缀
getContext () => RouterContextProvider 为客户端 action/loader/中间件提供 context,每次导航或 fetcher 调用都会重新生成一个实例
future Partial<FutureConfig> 开启 router 的未来特性开关
hydrationData HydrationState 服务端渲染场景下手动注入 hydration 数据(loaderData/errors/actionData)
instrumentations ClientInstrumentation[] 路由与路由级(loader/action/middleware)的可观测性埋点
dataStrategy DataStrategyFunction 覆盖默认的「并行执行 loader」数据加载策略
patchRoutesOnNavigation PatchRoutesOnNavigationFunction 在导航发生无法匹配时动态补全路由树(「战争迷雾」)
window Window 覆盖全局 window 对象,默认为全局实例

从源码实现看,这些选项会被逐项透传给底层的通用 createRouter,见 lib.tsx 中的 createBrowserRouter 实现

export function createBrowserRouter(
  routes: RouteObject[],
  opts?: DOMRouterOpts,
): DataRouter {
  return createRouter({
    basename: opts?.basename,
    getContext: opts?.getContext,
    future: opts?.future,
    history: createBrowserHistory({ window: opts?.window }),
    hydrationData: opts?.hydrationData || parseHydrationData(),
    routes,
    mapRouteProperties: defaultMapRouteProperties,
    hydrationRouteProperties,
    dataStrategy: opts?.dataStrategy,
    patchRoutesOnNavigation: opts?.patchRoutesOnNavigation,
    window: opts?.window,
    instrumentations: opts?.instrumentations,
  }).initialize();
}

这段实现透露了几个源码级事实:

  1. createBrowserRouter 本质上是对 createRouter 的薄封装createRouter 定义于 packages/react-router/lib/router/router.ts),它注入的是 browser 专属的 history 实现,最后立即调用 .initialize() 完成初始化并执行首次数据加载。同文件中的 createHashRouterL689-L707)结构完全一致,只是把 history 换成了 hash 版。
  2. window 选项被复用两次:既传入 createBrowserHistory({ window }) 用于读取初始位置,又传给 router 本体用于绑定 popstate 等事件。
  3. hydrationData 有隐式兜底opts?.hydrationData || parseHydrationData()——当你不显式传入时,会回退到读取 window.__staticRouterHydrationData 全局变量(这是 Framework Mode 自动水合的通道)。

opts.basename:应用基础路径

用于应用部署在域名下的子路径时,例如 basename: "/app" 后所有路由都在 /app 前缀下匹配。该值直接透传给 createRouter 参与路由匹配与链接生成。

opts.future:特性开关

Partial<FutureConfig> 类型,用于提前启用尚在演进中的 router 行为,保证未来版本默认切换时应用不报错。

三、opts.dataStrategy:自定义数据加载策略

默认情况下 data router 会并行执行所有匹配的 loader。dataStrategy 允许你完全接管「先加载谁、如何加载、何时执行客户端中间件」的编排,官方详细指南见 data-strategy

文档给出的标准示例展示了策略函数的入参(matchesrequestrunClientMiddleware)与返回值(以 routeId 为键的结果映射):

let router = createBrowserRouter(routes, {
  async dataStrategy({
    matches,
    request,
    runClientMiddleware,
  }) {
    const matchesToLoad = matches.filter((m) =>
      m.shouldCallHandler(),
    );

    const results: Record<string, DataStrategyResult> = {};
    await runClientMiddleware(() =>
      Promise.all(
        matchesToLoad.map(async (match) => {
          results[match.route.id] = await match.resolve();
        }),
      ),
    );
    return results;
  },
});

要点:先用 shouldCallHandler() 过滤出真正需要执行 handler 的 match,再把 match.resolve() 调用包进 runClientMiddleware——后者确保路由级 middleware 在 loader 执行前跑完。这个示例实际上保留了默认行为(Promise.all 并行),你可以在此基础上改造成串行、按优先级排队或带重试的自定义编排。

四、opts.getContext:为 loader/action/中间件注入客户端 Context

getContext 返回一个 RouterContextProvider 实例,作为 context 参数传给客户端的 actionloadermiddleware。注意其调用时机:每次导航或 fetcher 调用都会生成一个全新的 context 实例

文档示例演示了如何把 API 客户端注入到路由数据层:

import {
  createContext,
  RouterContextProvider,
} from "react-router";

const apiClientContext = createContext<APIClient>();

function createBrowserRouter(routes, {
  getContext() {
    let context = new RouterContextProvider();
    context.set(apiClientContext, getApiClient());
    return context;
  }
})

这与 React 的 createContext 配合使用:在 router 内部(loader/action 无法访问 React 树)拿到的 context,与组件中通过 apiClientContext 访问的是同一套语义化的依赖注入通道。

五、opts.hydrationData:服务端渲染数据的水合注入

当采用 SSR 且选择手动水合(opt-out of automatic hydration)时,hydrationData 让你把服务端渲染阶段拿到的数据传回客户端,避免 loader 二次请求。这份数据几乎总是 StaticHandlerquery 方法 返回的 StaticHandlerContext 值的一个子集:

const router = createBrowserRouter(routes, {
  hydrationData: {
    loaderData: {
      // [routeId]: serverLoaderData
    },
    // may also include `errors` and/or `actionData`
  },
});

部分水合数据(Partial Hydration Data)

多数场景你会传入完整的 loaderData,但进阶用法(例如 Framework Mode 的 clientLoader)可能只想为部分路由(如应用外壳/布局)提供 loader 数据,让其余路由在 hydration 期间显示 HydrateFallback 骨架并执行自己的 loader。

文档明确了 loader 在 hydration 期间会执行的两个条件:

  1. 没有提供 hydration 数据 —— 此时初始水合会渲染 HydrateFallback 组件;
  2. loader.hydrate 属性被设为 true —— 即使你已经在初次水合时渲染了内容(没有 fallback),loader 仍会在 hydration 时再跑一次(典型用途是用 hydration 数据预热缓存)。
const router = createBrowserRouter(
  [
    {
      id: "root",
      loader: rootLoader,
      Component: Root,
      children: [
        {
          id: "index",
          loader: indexLoader,
          HydrateFallback: IndexSkeleton,
          Component: Index,
        },
      ],
    },
  ],
  {
    hydrationData: {
      loaderData: {
        root: "ROOT DATA",
        // No index data provided
      },
    },
  }
);

在这个例子中 root 直接用服务端数据水合,而 index 因缺少数据先渲染 IndexSkeleton,同时执行 indexLoader。仓库的单元测试 data-browser-router-test.tsx 对此有大量覆盖,包括「首次数据获取期间渲染 HydrateFallback」「dataStrategy 返回部分结果时清除 HydrateFallback」「hydration 中间件执行期间渲染祖先/自身 HydrateFallback」等用例,验证了上述行为的真实语义。

一个容易被忽略的源码细节:当你不传 hydrationData 时,parseHydrationData() 会自动读取 window.__staticRouterHydrationData;若其中包含 errors,还会经过 deserializeErrors 将序列化的错误对象重建为真实的 RouteErrorResponse 或对应子类型的 Error(如 ReferenceError),但会清空客户端 stack trace——源码注释说明这是出于安全原因不序列化 SSR 堆栈。

六、opts.instrumentations:router 与路由级可观测性埋点

instrumentations 是一个埋点对象数组,允许你在 router 初始化之前对 router 整体和各个路由(包括后续通过 route.lazypatchRoutesOnNavigation 动态加入的路由)进行插桩,典型用途是把导航、fetch、loader/action/middleware 包上日志与性能追踪。详见 instrumentation 指南

文档给出的完整示例演示了两个挂载点——router 级(navigate/fetch)与 route 级(middleware/loader/action),每个插桩点都收到 (impl, info),你只需包裹 impl 调用即可记录耗时:

let router = createBrowserRouter(routes, {
  instrumentations: [logging]
});


let logging = {
  router({ instrument }) {
    instrument({
      navigate: (impl, info) => logExecution(`navigate ${info.to}`, impl),
      fetch: (impl, info) => logExecution(`fetch ${info.to}`, impl)
    });
  },
  route({ instrument, id }) {
    instrument({
      middleware: (impl, info) => logExecution(
        `middleware ${info.request.url} (route ${id})`,
        impl
      ),
      loader: (impl, info) => logExecution(
        `loader ${info.request.url} (route ${id})`,
        impl
      ),
      action: (impl, info) => logExecution(
        `action ${info.request.url} (route ${id})`,
        impl
      ),
    })
  }
};

async function logExecution(label: string, impl: () => Promise<void>) {
  let start = performance.now();
  console.log(`start ${label}`);
  await impl();
  let duration = Math.round(performance.now() - start);
  console.log(`end ${label} (${duration}ms)`);
}

这套装饰器模式的价值在于:埋点逻辑与业务路由代码完全解耦,且对动态补入的路由同样生效(埋点在路由加入树时即被应用)。

七、opts.patchRoutesOnNavigation:「战争迷雾」式的动态路由发现

这是 createBrowserRouter 最有分量的高级选项。理解它需要先理解默认设计的取舍:

  • 默认模式createBrowserRouter(routes) 要求你一次性提供完整路由树,好处是 React Router 可以做同步路由匹配、执行 loader、以最乐观方式渲染组件,避免任何数据瀑布;代价是初始 JS bundle 天然更大,应用增长后会拖慢启动。
  • route.lazy(v6.9.0 引入):折中方案——路由定义pathindex 等轻量部分)仍然前置提供,但路由实现loaderComponent 等重负载部分)延迟到真正导航到时才加载。路由匹配仍是同步的。
  • patchRoutesOnNavigation:当应用巨大到「前置提供所有路由定义」本身都不可承受时(某些 Micro-Frontend / Module-Federation 架构下甚至做不到),就用它在运行时按需「发现」路由树的局部。这个特性常被称为 "Fog of War"(战争迷雾)——如同游戏中世界随角色移动而展开,路由树也随用户导航而扩展,最终只加载用户真正访问过的部分。

触发时机与执行阶段

patchRoutesOnNavigation 会在 React Router 无法匹配某个 path被调用,参数包括 path、部分 matches,以及一个 patch 函数(把新路由打到树的指定位置)。执行阶段:

  • GET 请求:在导航的 loading 阶段执行;
  • GET 请求:在导航的 submitting 阶段执行。

用例 1:向已有路由打补丁注入子路由

const router = createBrowserRouter(
  [
    {
      id: "root",
      path: "/",
      Component: RootComponent,
    },
  ],
  {
    async patchRoutesOnNavigation({ patch, path }) {
      if (path === "/a") {
        // Load/patch the `a` route as a child of the route with id `root`
        let route = await getARoute();
        //  ^ { path: 'a', Component: A }
        patch("root", [route]);
      }
    },
  }
);

用户点击 /a 链接时,初次匹配失败,patchRoutesOnNavigation 收到 path = "/a" 与包含 root 匹配的 matches 数组;调用 patch('root', [route]) 后新路由挂到 root 之下,React Router 用更新后的树重新匹配,成功命中 /a,导航完成。

用例 2:补丁新的根级路由

若新路由没有父级,把 routeIdnull

const router = createBrowserRouter(
  [
    {
      id: "root",
      path: "/",
      Component: RootComponent,
    },
  ],
  {
    async patchRoutesOnNavigation({ patch, path }) {
      if (path === "/root-sibling") {
        // Load/patch the `/root-sibling` route as a sibling of the root route
        let route = await getRootSiblingRoute();
        //  ^ { path: '/root-sibling', Component: RootSibling }
        patch(null, [route]);
      }
    },
  }
);

用例 3:异步拉取整棵子树

可以按路径前缀懒加载应用的不同板块:

let router = createBrowserRouter(
  [
    {
      path: "/",
      Component: Home,
    },
  ],
  {
    async patchRoutesOnNavigation({ patch, path }) {
      if (path.startsWith("/dashboard")) {
        let children = await import("./dashboard");
        patch(null, children);
      }
      if (path.startsWith("/account")) {
        let children = await import("./account");
        patch(null, children);
      }
    },
  }
);

一个重要的执行语义(来自文档内嵌提示):若正在执行的 patchRoutesOnNavigation 被后续导航打断,被中断执行中剩余的 patch 调用不会更新路由树,因为该操作已被取消。 这意味着你可以在回调里放心执行 await import(...),不会污染已被放弃的导航。

用例 4:把路由发现与路由定义同置

不想自己写伪匹配逻辑时,可以借助部分 matches 数组和路由上的 handle 字段,让子路由定义与父路由定义保持同置:

let router = createBrowserRouter(
  [
    {
      path: "/",
      Component: Home,
    },
    {
      path: "/dashboard",
      children: [
        {
          // If we want to include /dashboard in the critical routes, we need to
          // also include it's index route since patchRoutesOnNavigation will not be
          // called on a navigation to `/dashboard` because it will have successfully
          // matched the `/dashboard` parent route
          index: true,
          // ...
        },
      ],
      handle: {
        lazyChildren: () => import("./dashboard"),
      },
    },
    {
      path: "/account",
      children: [
        {
          index: true,
          // ...
        },
      ],
      handle: {
        lazyChildren: () => import("./account"),
      },
    },
  ],
  {
    async patchRoutesOnNavigation({ matches, patch }) {
      let leafRoute = matches[matches.length - 1]?.route;
      if (leafRoute?.handle?.lazyChildren) {
        let children =
          await leafRoute.handle.lazyChildren();
        patch(leafRoute.id, children);
      }
    },
  }
);

注意示例中的注释陷阱:如果希望 /dashboard 本体属于关键路由,必须同时提供它的 index 子路由——因为导航到 /dashboard 会成功匹配父路由,patchRoutesOnNavigation 根本不会被调用,index 若缺省会直接 404。

含参路由的匹配歧义(重要设计细节)

这是该机制中最容易被误用的一点。React Router 使用带权重的路由排序为给定路径找最佳匹配,而部分已知的路由树会引入歧义:

  • 若匹配到的是完全静态的路由(如 path: "/about/contact-us"),它由纯静态 URL 段构成,可以确定这就是正确匹配,无需再询问是否存在更高分路由;
  • 但含动态参数或 splat 的路由不能做此假设——可能还存在一个尚未发现但得分更高的路由。

文档的完整示例:假设应用真实路由树是「首页 + /blog(含 new:slug 两个子路由)」,而启动时只注册了首页:

// Assume this is the full route tree for your app
const routes = [
  {
    path: "/",
    Component: Home,
  },
  {
    id: "blog",
    path: "/blog",
    Component: BlogLayout,
    children: [
      { path: "new", Component: NewPost },
      { path: ":slug", Component: BlogPost },
    ],
  },
];

// Start with only the index route
const router = createBrowserRouter(
  [
    {
      path: "/",
      Component: Home,
    },
  ],
  {
    async patchRoutesOnNavigation({ patch, path }) {
      if (path === "/blog/new") {
        patch("blog", [
          {
            path: "new",
            Component: NewPost,
          },
        ]);
      } else if (path.startsWith("/blog")) {
        patch("blog", [
          {
            path: ":slug",
            Component: BlogPost,
          },
        ]);
      }
    },
  }
);

问题推演:用户先看某篇博客(/blog/my-post),:slug 被打入路由树;随后用户访问 /blog/new 写新文章——此时会先匹配上 /blog/:slug,但这不是正确匹配!因此规则是:只要匹配到的路径中包含至少一个参数,React Router 就会额外调用一次 patchRoutesOnNavigation 并重新匹配,确认没有更高分的未发现路由。

由此引出的性能建议:如果你的 patchRoutesOnNavigation 实现代价高昂,或会向后端发起副作用 fetch,应维护一个「已发现路径」缓存,避免重复拉取:

let discoveredRoutes = new Set();

const router = createBrowserRouter(routes, {
  async patchRoutesOnNavigation({ patch, path }) {
    if (discoveredRoutes.has(path)) {
      // We've seen this before so nothing to patch in and we can let the router
      // use the routes it already knows about
      return;
    }

    discoveredRoutes.add(path);

    // ... patch routes in accordingly
  },
});

八、opts.window:window 对象覆盖

最后一个选项最为简单:覆盖默认的 window 对象引用,默认为全局 window 实例。在 createBrowserRouter 源码 中它被传递了两处——createBrowserHistory({ window }) 与 router 初始化参数,分别影响初始位置解析与浏览器事件监听绑定,可用于测试隔离或特殊环境下的多窗口场景。

九、返回值与配套 API

createBrowserRouter 返回一个已初始化的 data router,其职责链在源码中一目了然:createRouterrouter.ts)→ 注入 createBrowserHistory(基于 getUrlBasedHistory,从 history.state 中解析 masked location 以支持链接伪装)→ .initialize() 触发首次匹配与数据加载。

与它构成家族关系的其他 data router 创建函数(文档索引见 data-routers):

  • createHashRouter:同样接受 DOMRouterOpts,区别仅在于用 URL hash 而非标准 URL 存储位置,适合无法控制服务端的路由 fallback 配置;
  • createMemoryRouter:基于内存 location,主要用于测试;
  • createStaticRouter / createStaticHandler:服务端场景,与本文 hydrationDataStaticHandler.query 返回值直接对应。

最终这些 router 都交由 <RouterProvider> 挂载到 React 树上。

十、小结与实践建议

  • 创建时机:router 在 React 树外只创建一次,绝不放入 state;
  • SSR 应用:优先显式传入 hydrationData(含 loaderData/errors/actionData),部分水合场景利用「无 hydration 数据」或 loader.hydrate: true 两个触发条件精细控制骨架屏与缓存预热;
  • 大型应用:优先 route.lazy 拆实现、保留路由定义前置;只有在路由定义本身无法前置(微前端等)时才升级到 patchRoutesOnNavigation,并务必为含参路由路径做好重复匹配的准备与 discoveredRoutes 式缓存;
  • 可观测性:用 instrumentations 统一包裹 navigate/fetch 与 loader/action/middleware,避免在各处散落埋点代码;
  • 策略编排:需要串行、限流、重试或自定义中间件执行时机时,用 dataStrategy 接管 loader 执行编排。

以上行为均可在仓库中直接验证:API 文档 docs/api/data-routers/createBrowserRouter.md、实现源码 packages/react-router/lib/dom/lib.tsx、hydration 与 HydrateFallback 的单元测试 packages/react-router/tests/dom/data-browser-router-test.tsx,以及 fog-of-war 相关的集成测试 integration/fog-of-war-test.ts

登录后查看全文
热门项目推荐
相关项目推荐