React Router 的 dataStrategy 设计决策:从 Single Fetch 需求到 match.resolve 的 API 演进
本文基于 React Router 仓库中的架构决策记录 decisions/0003-data-strategy.md(2024-01-31 提出,状态为 accepted)展开。该文档记录了 dataStrategy 配置项从最初为 Remix 实现 "Single Fetch" 而生,到最终演化为 match.resolve() 统一 API 的完整设计过程。读完后,你将理解 React Router 数据加载管线的四个职责边界、每个被否决的 API 提案背后的权衡,以及这一决策如何在当前源码中落地,并掌握如何用它实现自定义中间件、单次数据请求等高级场景。
背景:为什么需要 dataStrategy
React Router 默认以并行方式执行所有路由的 loader,这对大多数应用是合理的默认行为。但官方认为数据获取不存在"一刀切"的方案——典型诉求就是 Remix 的 Single Fetch 议题(ADR 原文引用了该 issue 与对应 RFC):把所有 loader 的数据合并为一次到服务器的 fetch 调用,而不是 N 个并行请求。
要实现这一点,就必须向应用层暴露对内部数据获取行为的控制权。dataStrategy 的定位由此而来:它是 createBrowserRouter / createHashRouter 等数据路由初始化选项中的一个可选配置,让应用接管 action / loader 的"调用时机"(when),但"如何调用"(how)——即用正确的参数调用 handler——仍由 React Router 负责。ADR 中有一句关键定性:
dataStrategyis control when handlers are called, not how. RR is in charge of calling them with the right parameters.
数据管线的四个职责:dataStrategy 只接管第 3 步
ADR 先厘清了 React Router 执行某个 URL 的 loader 时的 4 项职责:
- 为 URL 匹配路由(Match routes for URL)
- 决定哪些路由需要加载(via
shouldRevalidate) - 并行调用
loader函数 - 解码 Response(Decode Responses)
dataStrategy 的设计目标就是接管第 3 步,其余步骤保留在 React Router 内部。这一职责划分是理解整个 API 设计的钥匙:后续所有"暴露哪些输入、返回什么输出"的讨论,都围绕"给用户多大控制权才够,且不会把实现细节泄漏给用户"展开。
输入设计:从 defaultStrategy 到 match.resolve
最初方案:defaultStrategy(match)
第一个输入设计是传入一个 defaultStrategy(match) 参数,让用户可以方便地在并行与串行之间切换,而不用自己重新实现 loader 调用、Response 解码等逻辑:
function dataStrategy({ matches }) {
// 并行调用
return Promise.all(matches.map((m) => defaultStrategy(m)));
// 或串行调用
let results = [];
for (let match of matches) {
results.push(await defaultStrategy(match));
}
return results;
}
最终 defaultStrategy 被废弃,取而代之的是 match.resolve——把"调用 handler"的能力下放到每个 match 上,而不是提供一个全局函数。
被否决的 type 字段
早期还计划暴露 type: 'loader' | 'action' 字段,让用户直接判断该调用 match.route.loader 还是 match.route.action。这与 defaultStrategy 一并被 match.resolve 取代:resolve 内部自己知道该调 loader 还是 action,用户不需要感知请求类型。
输出设计:从 DataResult 联合类型到 DataStrategyResult
最初计划把内部的 DataResult 联合类型(SuccessResult / ErrorResult / RedirectResult / DeferResult)直接公开。但在"调用 loader"与"解码结果"两个阶段被内部解耦重构后,团队意识到真正需要公开的只是一个极简的 HandlerResult:
interface HandlerResult {
type: "success" | "error"; // 源码中为 "data" | "error"
result: any;
}
用户按 match 返回这种结构后,React Router 内部负责把它转换成完整的 DataResult:
- 若
result是Response:内部负责解包数据、处理重定向(可能产出SuccessResult、ErrorResult或RedirectResult); - 若
result是DeferredData实例:转换为DeferResult; - 其他任何值:原样透传,按
type归为成功或错误。
这个"透传"设计很重要:它允许用户自行选择解码策略——你返回 Response,框架帮你解码;你不返回 Response(比如已经自己用 turbo-stream 之类解过码),框架就绝不动你的数据。当前源码中这个类型定名为 DataStrategyResult,定义在 utils.ts:
export interface DataStrategyResult {
type: "data" | "error";
result: unknown; // data, Error, Response, DeferredData, DataWithResponseInit
}
而当初设想过给第 4 步"解码"再单开一个 decodeResponse 配置项的方案也被否决——用标准 Fetch API(res.json() 等)解码的负担足够小,不值得一等公民的配置。
route.lazy 的处理:按路由串行,而非全局预加载
ADR 指出四步职责之外还有一个被忽略的细节:如果路由使用了 route.lazy,必须先加载路由模块才能执行其 loader。两个选项:
- 在调用
dataStrategy前预执行所有route.lazy; - 把
route.lazy的执行交给dataStrategy自行编排。
选项 1 有明显的性能问题——它会阻塞所有 loader,直到所有 lazy 路由加载完成:
|-- route a lazy --> |-- route a loader --------------->|
|-- route b lazy ------------------------>|-- route b loader --> |
ADR 选择了选项 2,让"加载路由"成为"加载数据"步骤的一部分,每个路由各自串行推进,互不阻塞:
|-- route a lazy -->|-- route a loader ---------------> |
|-- route b lazy ------------------------>|-- route b loader -->|
当时为此引入了 DataStrategyMatch:类似 RouteMatch,但 match.route 是一个 Promise<Route>,同时把 id、index、path 等静态属性直接平铺到 match.route 上,使静态定义的 loader 可以与 route.lazy 并行执行。这个"match.route 是 Promise"的设计后来同样被 match.resolve 吸收——resolve 内部等待 route.lazy,用户不再需要手动 await 一个 route Promise。当前源码中,lazy 加载状态保留在 DataStrategyMatch 的私有字段 _lazyPromises 上(见 utils.ts),由框架在 callDataStrategyImpl 中统一 await(见下文源码验证)。
shouldRevalidate:坚持"预过滤",不让重验证逻辑泄漏到策略层
对于"哪些路由该跑 handler",ADR 比较了两种思路:
- 预过滤:只把
matchesToLoad(通过shouldRevalidate判定需要加载的子集)交给用户; - 交给用户全量 matches,再传一个
defaultShouldRevalidate(match)让用户自己过滤。
结论是选项 1,理由有二:一是最小化 API,避免绕开 shouldRevalidate 产生"第二条退出重验证的路径"——重验证开关本来就有官方 API,应坚持单一入口;二是选项 2 为了支持用户自己做重验证决策,必须把 currentUrl、currentParams、nextUrl、nextParams、submission、actionResult 等一大批上下文全部暴露出来,API 会变得混乱。
注意:这里的"预过滤"是早期结论,在后文中间件讨论中被迫修正——
matches最终改为传递全量匹配结果,过滤职责转交给了shouldCallHandler()这个 match 级 API。这是整个 ADR 中最关键的一次设计反转,也是理解最终 API 的必经之路。
actions 与 fetchers:统一为单元素数组
上述讨论都围绕"一次导航跑多个 loader"。那么只针对单个叶子路由的 action 和 fetcher 怎么办?答案是把它们统一成长度为 1 的 matches 数组:
// loaders:全量需要加载的 matches
let results = await dataStrategy({ request, params, matches: matchesToLoad, ... });
// action:只含目标 match 的单元素数组
let actionMatch = getTargetMatch(request, matches);
let [actionResult] = await dataStrategy({ request, params, matches: [actionMatch], ... });
// fetcher loader/action:同理
let [fetcherResult] = await dataStrategy({ request, params, matches: [fetcherMatch], ... });
这样用户实现永远只面对 matches 数组一种形态,对导航、提交、fetcher 三种场景通用。当前源码中,fetcher 场景通过 DataStrategyFunctionArgs.fetcherKey 进一步区分(见 utils.ts):导航执行时为 null,fetcher 执行时为对应 fetcher 的 key,用户无需再靠数组长度猜测请求来源。
中间件困境:预过滤的破产与三个选项
ADR 中最精彩的一段是"中间件"的推演。团队意识到"为路由处理数据"这件事不该局限于 loader/action——middleware、context 这类数据相关 API 同样落在 dataStrategy 的能力伞下。一个良好的 dataStrategy 甚至可以让早期用户自己实现中间件,等模式成熟后再提升为一等 API。
中间件的语义是:自上而下串行执行,且发生在 loader 之前;context 则只能看到当前层级及上方路由的上下文。作者先给出一个"用户态实现":先在 handle.context / handle.middleware 上按序跑上下文与中间件,再把截断的上下文作为第二个参数传给 loader。
但紧接着打了一个大大的 ❌:这个实现不成立。根因正是前面选定的"基于 shouldRevalidate 预过滤"——中间件要求即使某个父路由本身不需要重新加载,也必须先跑它的中间件再跑子路由 loader。因此 dataStrategy 至少得拿到目标层级及以上的全部 matches,要能完整实现中间件则必须拿到全部 matches。一旦暴露多个 matches,就还得告诉用户"哪些 match 真的要跑 handler,哪些只是陪跑"。于是出现三个候选方案:
选项 1:routeMatches + handlerMatches 双数组
传两个数组:完整的 routeMatches 供中间件遍历,预过滤后的 handlerMatches 供 handler 执行。保留了预过滤,重验证逻辑不进 dataStrategy。
async function dataStrategy({ request, params, routeMatches, handlerMatches, type }) {
let contexts = {};
for (let match of routeMatches) { /* 串行跑中间件 */ }
return Promise.all(handlerMatches.map(async (m, i) => { /* 并行跑 loader */ }));
}
选项 2:DataStrategyMatch 上加字段
既然已为 route.lazy 引入了 DataStrategyMatch 这一新类型,不妨在 match 上暴露一个 shouldLoad: boolean(由 shouldRevalidate 计算得出),让用户自行过滤:
let dataStrategyMatches = [
{ route: { id: "root", loader() {} }, shouldLoad: false }, // 无需重验证
{ route: { id: "b", loader() {} }, shouldLoad: true },
];
let matchesToLoad = matches.filter((m) => m.shouldLoad);
选项 3:match.resolve() 函数
作者指出前两个选项都"泄漏实现细节":为什么用户要手动过滤?为什么要手动传 loader 参数?为什么要靠 type 字段决定调什么?为什么要在调 loader 前等一个 match.route Promise?这些毛刺太多、太难文档化、太容易用错。选项 3 的思路是把这一切包进一个 match.resolve() 函数,它负责:
- 等待
route.lazy解析完成(如需要); - 若该路由本次无需重验证则 no-op(当时还开放了"no-op 时返回当前数据还是 undefined"的疑问,最终决定暂不暴露既有数据,因为缺乏明确用例);
- 自己知道该调
loader还是action; - 支持通过参数向 handler 注入额外参数,服务中间件/上下文场景。
最终选定的正是选项 3,并保留 shouldLoad 字段(即选项 2 的产物)作为辅助。ADR 给出了四段递进的示例代码:
// 1. 最简:等价于当前默认行为——所有 loader 并行执行
function dataStrategy({ matches }) {
return Promise.all(matches.map((m) => m.resolve()));
}
// 2. 进阶:串行执行并透传自定义 context
async function dataStrategy({ matches }) {
let ctx = {};
let results = [];
for (let m of matches) {
// 传入的函数是 "handlerOverride":handler 收到的参数
// 会成为 loader/action 的第二个参数
let result = await m.resolve((handler) => handler(ctx));
results.push(result);
}
return results;
}
// 3. 高性能:中间件串行 + loader 并行
function dataStrategy({ matches }) {
let context = runMiddlewares(matches);
return Promise.all(
matches.map((m) => m.resolve(context)), // context 会作为 loader({ request }, context) 的第二参
);
// 注意:这里没有任何过滤——无需加载的 match,resolve 会自动 no-op
}
// 4. Single Fetch:连 handler 都不调,直接映射单次请求的结果
async function dataStrategy({ matches }) {
let singleFetchData = await makeSingleFetchCall();
// 约定返回 { data: { [routeId]: unknown }, errors: { [routeId]: unknown } }
let results = [];
for (let m of matches) {
let result = await m.resolve(() => {
if (singleFetchData.errors?.[m.route.id]) {
return { type: "error", result: singleFetchData.errors[m.route.id] };
}
return { type: "data", result: singleFetchData.data?.[m.route.id] };
});
results.push(result);
}
return results;
}
最后一段正是 Single Fetch 的完整拼图:resolve 的 handlerOverride 允许用户完全跳过 handler 调用,直接把预取数据按 routeId 映射成结果返回。
状态码问题:让 handlerOverride 能"携带状态码地返回数据"
最后一个坑出在错误处理上。最初设想 handlerOverride 直接 return 或 throw,框架内部再转换成结果类型。普通 Response 没问题(框架能解码并得知状态码),但当用户做自定义解码(如 Single Fetch 中的 turbo-stream)时,就无法在返回数据的同时携带响应状态码——除非引入一个同时持有 status 与 data 的结构。结论是把 HandlerResult(即最终公开的 DataStrategyResult)作为公开 API,并给它加一个可选的 status 字段。由此形成一条清晰的使用契约:只调 resolve() 不传 override,你完全不需要知道结果类型的存在;一旦传入 handlerOverride,你就必须返回带 type: "data" | "error" 的规范结果对象。
源码验证:决策在当前仓库中的最终形态
ADR 写于 2024 年初,而当前仓库中的实现已经走过更多演进(例如 shouldLoad 被 shouldCallHandler() 取代)。对照源码可以看到决策的完整落地轨迹:
1. 类型定义(utils.ts):DataStrategyMatch 继承 RouteMatch,除 ADR 遗留的 shouldLoad(已标记 @deprecated)外,新增了 shouldRevalidateArgs(本次请求传给路由 shouldRevalidate 的实参,非重验证 loader 时为 null)、shouldCallHandler(defaultShouldRevalidate?)(把"过滤"从布尔字段升级为可注入默认重验证行为的函数)以及 resolve(handlerOverride?)。DataStrategyFunctionArgs 则包含 request、params、matches、runClientMiddleware、fetcherKey 五个字段——其中 runClientMiddleware 是 ADR 时代之后为路由级 middleware 增补的组合入口。
2. 调用编排(router.ts 的 callDataStrategyImpl):与 ADR 第 9 节"route.lazy 交给 dataStrategy 编排"的决策一致,框架在调用用户策略前会先 await 所有 _lazyPromises.middleware,调用后再统一 await _lazyPromises.handler 与 _lazyPromises.route 以收敛挂起 Promise;注释明确写着"Send all matches here to allow for a middleware-type implementation"——即全量传递 matches 以支持中间件形态的实现,印证了后文对预过滤的修正。
3. 默认策略(router.ts):defaultDataStrategy 就是对 ADR"最简示例"的精确实现——按 shouldLoad 过滤后 Promise.all(matchesToLoad.map((m) => m.resolve())),把结果按 route.id 归入 Record<string, DataStrategyResult>;defaultDataStrategyWithMiddleware 在其上叠加客户端中间件管道,并在无任何 route.middleware 时短路回纯并行版本。
4. 结果转换(router.ts 的 convertDataStrategyResultToDataResult):对应 ADR"输出"一节——用户返回的 DataStrategyResult 在这里被翻译为内部的 DataResult 联合类型,Response 走解码与重定向处理,DeferredData 转 DeferResult,其余原样透传。
实战:在应用中编写自定义 dataStrategy
官方 how-to 文档 docs/how-to/data-strategy.md 给出了当前版本的完整用法,可作为 ADR 愿景的对照物。基本形态(以加日志为例):
let router = createBrowserRouter(routes, {
async dataStrategy({ matches, request, runClientMiddleware }) {
// 决定本次需要执行 handler 的 matches:
// - 加载型导航:新路由 + 需重验证的既有路由为 true
// - 提交型导航:仅 action 路由为 true
// - fetcher 调用:仅 fetcher 路由为 true
const matchesToLoad = matches.filter((m) => m.shouldCallHandler());
const results: Record<string, DataStrategyResult> = {};
await runClientMiddleware(() =>
Promise.all(
matchesToLoad.map(async (match) => {
console.log(`Processing ${match.route.id}`);
results[match.route.id] = await match.resolve();
}),
),
);
return results;
},
});
要点与 ADR 一脉相承但 API 已现代化:返回值从"与 matches 平行的数组"变为以 route.id 为键的 Record;过滤从"框架预过滤"变为用户调用 shouldCallHandler() 显式过滤(ADR 中遗留的 shouldLoad 布尔字段因此被废弃,迁移方式即"先过滤、只对 matchesToLoad 调 resolve()"——因为新语义下 resolve() 被调用即意味着执行)。文档同时覆盖三类高级场景,恰好对应 ADR 选项 3 设想的三个用例:
- 自定义重验证:向
shouldCallHandler(defaultShouldRevalidate)传入自定义默认值,配合match.shouldRevalidateArgs读取路由级shouldRevalidate的完整入参; - 自定义中间件:在
handle.middleware上定义中间件,dataStrategy内先串行执行、把共享context经resolve((handler) => handler(context))注入 loader 第二参——即 ADR 选项 3 第 3 段示例的定妆版; - 完全接管数据获取:路由设
loader: true占位、把 GraphQL fragment 挂在handle.gql上,dataStrategy里合并 fragment 发一次请求、再按routeId拆回结果,全程不调用match.resolve()——与 ADR 的 Single Fetch 示例(示例 4)思路完全一致。
runClientMiddleware 还支持把独立的 dataStrategy 实现组合进来:
const loggingDataStrategy: DataStrategyFunction = () => { /* ... */ };
let router = createBrowserRouter(routes, {
async dataStrategy({ runClientMiddleware }) {
return await runClientMiddleware(loggingDataStrategy);
},
});
需要提醒的是,官方文档对此 API 有明确警告:这是面向高级场景的底层 API,它覆盖 React Router 对 action/loader 执行的内部处理,使用不当会直接破坏应用代码,应谨慎使用并充分测试。
总结
dataStrategy 的决策史浓缩了 React Router 数据层的一条设计主线:控制权要给用户,实现细节不能泄漏给用户。API 的三次收敛都印证了这一点——defaultStrategy 收敛为 match.resolve(把"调哪个 handler、何时可调"收进框架)、decodeResponse/DataResult 联合类型收敛为极简的 DataStrategyResult(把"解码与否"的决策权还给用户)、预过滤的 matchesToLoad 收敛为"全量 matches + shouldLoad/shouldCallHandler"(为中间件让路)。最终形态以 matches 全量传入、resolve(handlerOverride?) 统一执行、Record<routeId, DataStrategyResult> 归集结果,既满足了 Single Fetch 这类"一次请求喂饱所有路由"的激进场景,也为后来的一等公民 middleware/context API 预留了生长土壤——ADR 中"早期用户先用 dataStrategy 自己实现中间件、再观察哪些模式胜出"的预判,已在仓库的 middleware 机制中兑现。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0626
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00