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

React Router 路由模块懒加载(lazy Route Modules):决策记录与源码级实现解析 React Router 路由模块懒加载lazy Route Modules决策记录与源码级实现解析【免费下载链接】react-routerDeclarative routing for React项目地址: https://gitcode.com/GitHub_Trending/re/react-router本文围绕 React Router 仓库中的架构决策记录 Lazy Route Modules2023-02-21状态 accepted展开完整复盘lazy()路由模块从社区 POC 到路由内置 API 的决策过程为什么不能在用户层用React.lazy()实现、为什么path等匹配属性不可被懒加载覆盖、以及Component/ErrorBoundary字段为何伴随这项能力一同引入。读完本文你将掌握在 data router 应用中进行路由级代码分割的完整用法、边界约束中断、错误处理、SSR 水合并能对照 router.ts 中的真实实现理解每一条规则的底层依据。背景data router 为什么无法像 BrowserRouter 那样即点即加载ADR 的 Context 部分首先澄清了一个根本矛盾在非>// 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; };这条思路走得很远但受限于用户层拿不到路由内部状态有两个硬伤必须给路由挂上所有可能的属性——因为无法预知import(./route)是否会解析出errorElement。为此曾考虑引入route.use属性让用户显式声明模块导出const route { path: /, module: () import(./route), use: [loader, element], };但这把文件内容和路由定义紧耦合在了一起不理想。自动引入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前对每个匹配路由调用它返回lazyRoutePromise与lazyHandlerPromise供后续并行等待见 router.ts#L6427-L6447。最终 APIlazy()ComponentADR 给出的标准示例主包加载首页/about路由懒加载并使用了随此项工作一同引入的ComponentAPI// 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()更新路径匹配属性path、index、caseSensitive、childrenid也视为静态否数据加载属性loader、action、hasErrorBoundary、shouldRevalidate可渲染属性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 为此给出了两个典型用法静态定义一个只打 API 端点的小型loader/action。值得注意的是React Router 专门优化了这一点静态定义的 loader/action 会与lazy并行执行因为lazy反正无法覆盖它从而拿到组件代码加载与数据请求的最优并行度。这在源码中有直接体现——router.ts#L6737-L6767 中若同时存在lazyHandlerPromise与静态 handler会走并行运行静态 handler 并等待 lazy 加载完成的分支否则先await lazyHandlerPromise再运行动态加载出的 handler。在多条路由间复用一个静态定义的公共ErrorBoundary。为什么引入Component与ErrorBoundary字段v6 的路由使用element属性因为它支持静态传参并天然契合 JSX 路由树BrowserRouter Routes Route path/ element{Homepage propvalue /} / /Routes /BrowserRouter但在RouterProvider场景下路由是提前静态定义的element在 JSX 树之外就显得别扭还无法直接内联使用 hooksconst routes [ { path: /, element: Homepage propvalue /, }, ];引入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 propvalue /, }, { path: /b, // And even use use hooks without indirection Component: () { let data useLoaderData(); return Homepage data{data} /; }, }, ];最终lazy()的工作同时引入了route.Component与route.ErrorBoundary二者既可静态定义也可懒加载若与element/errorElement同时定义则优先生效但当时两者都是合法写法。ADR 还预告由于Component可以承载推断类型的loaderData未来有望成为类型安全更强、更受推荐的 API。在当前仓库中这些类型仍并列存在于 utils.ts 的BaseRouteObject上Component/ErrorBoundary均标注 Mutually exclusive withelement/errorElement见 utils.ts#L694-L713Route组件的PathRouteProps/IndexRouteProps也各自声明了lazy属性见 components.tsx#L1003 与 components.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.ts 中lazyRouteFunctionCache按路由对象缓存 promise二次进入直接复用见 router.ts#L5988-L6021更新完成后lazy属性本身会被置为undefined确保不再重复 resolverouter.ts#L6082-L6089。错误处理lazy()抛错等同于 handler 抛错ADR 的 Error Handling 规则只有一条但很关键lazy()抛出的错误会按action/loader抛错相同的逻辑捕获并冒泡到最近的errorElement。这意味着懒加载模块的导入失败例如动态import()网络错误不会导致未处理的 promise rejection而是进入框架统一的错误边界体系。后果与边界SSR 水合时有数据、没路由ADR 的 Consequences 部分列出两个值得记住的限制路由树仍须前置。这是为最高效数据加载付出的代价因此暂时无法支持旧的嵌套Routes微前端类场景ADR 提到未来会把它作为该概念的扩展来解决。DIY SSR 中的水合时序问题。使用createStaticHandlerStaticRouterProvider时服务器可能已渲染出懒加载路由并下发了水合数据但客户端 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} / );此时我们不想渲染fallbackElementSSR 内容已在页面上路由也无需初始化数据已通过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.initialized为false懒模块 resolve 后路由定义被更新。当前仓库中的实现演进源码视角补充ADR 定型的函数式lazy是骨架当前仓库的代码在此之上有了可考据的演进lazy支持函数与对象两种形态。类型定义LazyRouteDefinitionR LazyRouteObjectR | LazyRouteFunctionRutils.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.ts3000 行、lazy-discovery-test.ts、lazy-discovery-aborted-patch-test.ts 分别覆盖初始化加载、懒发现与中断场景集成层面还有 fog-of-war-test.ts 等 E2E 用例验证真实浏览器下的懒路由行为。小结这篇 ADR 是理解 React Router 路由级代码分割的最佳入口其核心结论可归纳为四点lazy()在路由器内部执行而非用户层的React.lazy()从而获得精确时机、AbortSignal与一次性加载语义字段三分类路径匹配属性不可懒加载数据/渲染属性可懒加载但不可覆盖静态定义静态loader/action会与lazy并行执行Component/ErrorBoundary是配套产物在静态路由定义场景下比element更自然且优先级更高中断不跳过 handler、错误走统一错误边界、DIY SSR 需手动预载初始匹配懒路由三者共同保证了懒路由与静态路由在行为上的完全一致。如需继续深入建议按以下路径阅读仓库决策原文 decisions/0002-lazy-route-modules.md、lazy实现主体 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。【免费下载链接】react-routerDeclarative routing for React项目地址: https://gitcode.com/GitHub_Trending/re/react-router创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考