首页
/ React Router 路由模块懒加载(lazy Route Modules):决策记录与源码级实现解析

React Router 路由模块懒加载(lazy Route Modules):决策记录与源码级实现解析

2026-09-05 20:24:52作者:裘晴惠Vivianne

本文围绕 React Router 仓库中的架构决策记录 Lazy Route Modules(2023-02-21,状态 accepted)展开,完整复盘 lazy() 路由模块从社区 POC 到路由内置 API 的决策过程:为什么不能在用户层用 React.lazy() 实现、为什么 path 等匹配属性不可被懒加载覆盖、以及 Component/ErrorBoundary 字段为何伴随这项能力一同引入。读完本文,你将掌握在 data router 应用中进行路由级代码分割的完整用法、边界约束(中断、错误处理、SSR 水合),并能对照 router.ts 中的真实实现理解每一条规则的底层依据。

背景:data router 为什么无法像 BrowserRouter 那样"即点即加载"

ADR 的 Context 部分首先澄清了一个根本矛盾:

  • 非 data-aware 应用(<BrowserRouter> + 嵌套 <Routes>)中,你可以自由地把 <React.Suspense>React.lazy() 组合起来,在用户导航时动态加载路由树的"新"部分。代价是 render-then-fetch 循环:先渲染、再发起请求,产生网络瀑布和嵌套 loading spinner——这正是 <RouterProvider> 应用要消除的两件事。
  • data-aware 应用(<RouterProvider>)中,路由必须在渲染目标路由之前就知道完整的路由树,才能提前完成路径匹配并执行 loader/action。因此"渲染时动态发现路由"这条路被堵死了。

ADR 指出,当时确实存在手动代码分割的办法,但写法冗长繁琐。社区随后提交了 Remix 风格的 Route Modules 提案及一个用户层 POC 实现,这才催生了本决策。

从用户层 POC 到路由内置:一次典型的"API 演进"

最初的 POC 思路

原 POC 在用户层工作:把 element/errorElement 转换为 React.lazy() 调用,loader/action 则先加载模块再执行其中的同名导出:

// Assuming route.module is a function returning a Remix-style route module
let Component = React.lazy(route.module);
route.element = <Component />;
route.loader = async (args) => {
  const { loader } = await route.module();
  return typeof loader === "function" ? loader(args) : null;
};

这条思路走得很远,但受限于用户层拿不到路由内部状态,有两个硬伤:

  1. 必须给路由挂上所有可能的属性——因为无法预知 import('./route') 是否会解析出 errorElement。为此曾考虑引入 route.use 属性让用户显式声明模块导出:

    const route = {
      path: "/",
      module: () => import("./route"),
      use: ["loader", "element"],
    };
    

    但这把"文件内容"和"路由定义"紧耦合在了一起,不理想。

  2. 自动引入 React.lazy 与目标相悖——RouterProvider 的目标是减少 spinner,对预期在渲染前就已取回数据的元素再套 Suspense 边界在语义上站不住脚。

最终决策:把逻辑放进路由器内部

ADR 的结论是:data router 本来就存在一条异步的 pre-render 流程,正好可以挂载这段逻辑。路由内置实现的四大优势:

  • 可以在路由内部更精确的时机点触发加载;
  • 可以拿到导航的 AbortSignal,处理 lazy() 被中途打断的情况;
  • 加载一次后就地更新内部路由定义,后续导航不再重复执行 lazy()
  • 更新 UI 状态之前路由定义已经就绪,因此"是否存在 errorElement"不再成谜。

实现形态即最终 API:每当进入 submitting/loading 状态时,路由器先检查 route.lazy 定义,先解析该 promise,再用结果更新内部路由定义。在仓库源码中,这一流程对应 router.ts 里的 loadLazyRoute 逻辑——进入 submitting/loading 前对每个匹配路由调用它,返回 lazyRoutePromiselazyHandlerPromise 供后续并行等待(见 router.ts#L6427-L6447)。

最终 API:lazy() + Component

ADR 给出的标准示例:主包加载首页,/about 路由懒加载,并使用了随此项工作一同引入的 Component API:

// app.jsx
const router = createBrowserRouter([
  {
    path: "/",
    Component: Layout,
    children: [
      {
        index: true,
        Component: Home,
      },
      {
        path: "about",
        lazy: () => import("./about"),
      },
    ],
  },
]);

about.jsx 文件导出需要懒加载定义的路由属性:

// about.jsx
export function loader() { ... }

export function Component() { ... }

官方文档中对 lazy 的说明可参考 route-object 文档lazy 章节。

路由字段的三分类:哪些能懒加载,哪些不能

ADR 的 Choices 部分把路由字段划分为三类,这是理解 lazy() 边界的关键:

类别 字段 可否被 lazy() 更新
路径匹配属性 pathindexcaseSensitivechildrenid 也视为静态)
数据加载属性 loaderactionhasErrorBoundaryshouldRevalidate
渲染属性 handle、框架感知的 element/errorElement/Component/ErrorBoundary

原因很直接:必须先完成路径匹配才能识别出"哪条匹配路由带 lazy()",因此匹配所需的字段必须静态存在。当前源码中,若 lazy() 返回了不支持的键会打印警告并忽略,例如 router.ts#L6062-L6070 中的两条 console 警告:

  • "xxx is not a supported property to be returned from a lazy route function. This property will be ignored."
  • "Route \"...\" has a static property \"xxx\" defined but its lazy function is also returning a value... The lazy route property ... will be ignored."

第二条同时印证了另一条规则:已静态定义的数据加载/渲染属性不可被 lazy() 覆盖,尝试覆盖会打印 console 警告。ADR 为此给出了两个典型用法:

  1. 静态定义一个只打 API 端点的小型 loader/action。值得注意的是,React Router 专门优化了这一点:静态定义的 loader/action 会与 lazy 并行执行(因为 lazy 反正无法覆盖它),从而拿到"组件代码加载"与"数据请求"的最优并行度。这在源码中有直接体现——router.ts#L6737-L6767 中,若同时存在 lazyHandlerPromise 与静态 handler,会走"并行运行静态 handler 并等待 lazy 加载完成"的分支,否则先 await lazyHandlerPromise 再运行动态加载出的 handler。
  2. 在多条路由间复用一个静态定义的公共 ErrorBoundary

为什么引入 ComponentErrorBoundary 字段

v6 的路由使用 element 属性,因为它支持静态传参并天然契合 JSX 路由树:

<BrowserRouter>
  <Routes>
    <Route path="/" element={<Homepage prop="value" />} />
  </Routes>
</BrowserRouter>

但在 RouterProvider 场景下,路由是提前静态定义的,element 在 JSX 树之外就显得别扭,还无法直接内联使用 hooks:

const routes = [
  {
    path: "/",
    element: <Homepage prop="value" />,
  },
];

引入 lazy() 后更尴尬——懒加载文件被迫导出一个根级 JSX 元素:

// home.jsx
export const element = <Homepage />

function Homepage() { ... }

而静态路由定义场景下真正需要的只是"组件本身":

const routes = [
  {
    path: "/",
    Component: Homepage,
  },
];

Component 字段带来了三档灵活度,均无需间接层:

const routes = [
  {
    path: "/",
    // You can include just the component
    Component: Homepage,
  },
  {
    path: "/a",
    // Or you can inline your component and pass props
    Component: () => <Homepage prop="value" />,
  },
  {
    path: "/b",
    // And even use use hooks without indirection 💥
    Component: () => {
      let data = useLoaderData();
      return <Homepage data={data} />;
    },
  },
];

最终 lazy() 的工作同时引入了 route.Componentroute.ErrorBoundary,二者既可静态定义也可懒加载;若与 element/errorElement 同时定义则优先生效,但当时两者都是合法写法。ADR 还预告:由于 Component 可以承载推断类型的 loaderData,未来有望成为类型安全更强、更受推荐的 API。在当前仓库中,这些类型仍并列存在于 utils.tsBaseRouteObject 上(Component/ErrorBoundary 均标注 "Mutually exclusive with element/errorElement",见 utils.ts#L694-L713),<Route> 组件的 PathRouteProps/IndexRouteProps 也各自声明了 lazy 属性(见 components.tsx#L1003components.tsx#L1095),说明 ADR 中的 API 设计被完整保留了下来。

中断语义:lazy() 被打断时 handler 依然会被调用

引入 lazy() 之前,action/loader 静态前置定义,链接点击或表单提交会立即执行,调用 handler 之前不存在可中断窗口。而 lazy() 引入了"handler 执行前的时间窗"——用户可能在这期间点向新位置。

ADR 给出的行为约定是:lazy() 函数被中断,React Router 依然会调用它返回的 handler,以保持懒加载路由与静态路由行为一致。用户可在 handler 内通过 request.signal.aborted 自行短路。

这一约定之所以重要,是因为 lazy() 在应用会话中只执行一次:完成后路由被就地更新,此后所有对该路由的导航都走已静态化的属性。若首次导航因中断而跳过 handler、后续导航却会执行,就会引入"首次导航与后续导航行为不一致"这类隐蔽难查的 bug。

另一个细节:若多个导航并行打到同一条路由,**第一个解析完成的 lazy() 调用"胜出"**并更新路由,其余调用的返回值被忽略。ADR 认为实践中影响不大——现代打包器会对重复的 import() 复用同一个 promise,首次调用仍然胜出。源码层面这一点通过 WeakMap 缓存兑现:router.tslazyRouteFunctionCache 按路由对象缓存 promise,二次进入直接复用(见 router.ts#L5988-L6021);更新完成后 lazy 属性本身会被置为 undefined,确保"不再重复 resolve"(router.ts#L6082-L6089)。

错误处理:lazy() 抛错等同于 handler 抛错

ADR 的 Error Handling 规则只有一条但很关键:lazy() 抛出的错误,会按 action/loader 抛错相同的逻辑捕获,并冒泡到最近的 errorElement。这意味着懒加载模块的导入失败(例如动态 import() 网络错误)不会导致未处理的 promise rejection,而是进入框架统一的错误边界体系。

后果与边界:SSR 水合时"有数据、没路由"

ADR 的 Consequences 部分列出两个值得记住的限制:

  1. 路由树仍须前置。这是为最高效数据加载付出的代价,因此暂时无法支持旧的嵌套 <Routes> 微前端类场景,ADR 提到未来会把它作为该概念的扩展来解决。

  2. DIY SSR 中的水合时序问题。使用 createStaticHandler + StaticRouterProvider 时,服务器可能已渲染出懒加载路由并下发了水合数据,但客户端 hydration 时该路由模块尚未加载:

    const routes = [{
      path: '/',
      lazy: () => import("./route"),
    }]
    let router = createBrowserRouter(routes, {
      hydrationData: window.__hydrationData,
    });
    
    // ⚠️ At this point, the router has the data but not the route definition!
    
    ReactDOM.hydrateRoot(
      document.getElementById("app")!,
      <RouterProvider router={router} fallbackElement={null} />
    );
    

    此时我们想渲染 fallbackElement(SSR 内容已在页面上),路由也无需"初始化"(数据已通过 hydrationData 提供);但如果 hydration 落点路由包含 lazy,则必须先加载该懒路由。ADR 指出的根治方案是像 Remix 那样预先匹配路由、预载模块、以同步路由定义进行 hydration——这个过程不轻量,不能期待每个 DIY SSR 场景都做到。因此路由器的行为是:在初始匹配的懒路由加载完成前不初始化,水合需要被延迟。

    ADR 推荐的替代做法是:在创建 router 之前手动匹配初始 location 并加载更新懒路由:

    // Determine if any of the initial routes are lazy
    let lazyMatches = matchRoutes(routes, window.location)?.filter(
      (m) => m.route.lazy
    );
    
    // Load the lazy matches and update the routes before creating your router
    // so we can hydrate the SSR-rendered content synchronously
    if (lazyMatches && lazyMatches.length > 0) {
      await Promise.all(
        lazyMatches.map(async (m) => {
          let routeModule = await m.route.lazy!();
          Object.assign(m.route, { ...routeModule, lazy: undefined });
        })
      );
    }
    
    // Create router and hydrate
    let router = createBrowserRouter(routes)
    ReactDOM.hydrateRoot(
      document.getElementById("app")!,
      <RouterProvider router={router} fallbackElement={null} />
    );
    

    这条"初始化前必须先装载初始匹配懒路由"的规则在当前源码中依然生效:router.ts#L1155-L1156 处,初始化流程检测到 initialMatches 中存在 route.lazy 时,会等待所有初始匹配路由装载完毕才将 initialized 置真(对应 lazy-test.ts 中 "fetches lazy route functions on router initialization" 用例:router.initialize()router.state.initializedfalse,懒模块 resolve 后路由定义被更新)。

当前仓库中的实现演进(源码视角补充)

ADR 定型的"函数式 lazy"是骨架,当前仓库的代码在此之上有了可考据的演进:

  • lazy 支持函数与对象两种形态。类型定义 LazyRouteDefinition<R> = LazyRouteObject<R> | LazyRouteFunction<R>utils.ts#L642-L644)表明,lazy 如今既可以写成 ADR 中的 () => import("./about") 整体模块函数,也可以写成按属性拆分的对象形式(如 { loader: () => import(...), Component: () => import(...) }),对象形式配合 lazyRoutePropertiesToSkip 参数实现"静态 loader 与懒属性并行加载"的精细化调度(router.ts#L5925-L5987 中按 key 逐个解析并带 WeakMap 缓存)。
  • 属性更新后自清理。无论是函数式还是对象式,路由属性解析完成后都会把 lazy 字段置空,保证"一次加载、永久静态"(对象式在 router.ts#L5973-L5979,函数式在 router.ts#L6082-L6089),与 ADR 的中断语义承诺完全一致。
  • 错误冒泡的落点。data strategy 在冒泡错误前会 await match._lazyPromises?.routerouter.ts#L6281-L6285),确保懒加载错误边界就绪后错误才向上冒泡——这正是 ADR "Error Handling" 一节在现行代码中的落点。
  • 测试覆盖。单元层面,lazy-test.ts(3000+ 行)、lazy-discovery-test.tslazy-discovery-aborted-patch-test.ts 分别覆盖初始化加载、懒发现与中断场景;集成层面还有 fog-of-war-test.ts 等 E2E 用例验证真实浏览器下的懒路由行为。

小结

这篇 ADR 是理解 React Router 路由级代码分割的最佳入口,其核心结论可归纳为四点:

  1. lazy() 在路由器内部执行,而非用户层的 React.lazy(),从而获得精确时机、AbortSignal 与一次性加载语义;
  2. 字段三分类:路径匹配属性不可懒加载,数据/渲染属性可懒加载但不可覆盖静态定义,静态 loader/action 会与 lazy 并行执行;
  3. Component/ErrorBoundary 是配套产物,在静态路由定义场景下比 element 更自然,且优先级更高;
  4. 中断不跳过 handler、错误走统一错误边界、DIY SSR 需手动预载初始匹配懒路由,三者共同保证了懒路由与静态路由在行为上的完全一致。

如需继续深入,建议按以下路径阅读仓库:决策原文 decisions/0002-lazy-route-modules.mdlazy 实现主体 packages/react-router/lib/router/router.ts、路由对象类型 packages/react-router/lib/router/utils.ts<Route> 属性声明 packages/react-router/lib/components.tsx,以及官方使用说明 docs/start/data/route-object.md

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