首页
/ React Router 的 dataStrategy 设计决策:从 Single Fetch 需求到 match.resolve 的 API 演进

React Router 的 dataStrategy 设计决策:从 Single Fetch 需求到 match.resolve 的 API 演进

2026-09-06 16:53:52作者:余洋婵Anita

本文基于 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 中有一句关键定性:

dataStrategy is 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 项职责:

  1. 为 URL 匹配路由(Match routes for URL)
  2. 决定哪些路由需要加载(via shouldRevalidate
  3. 并行调用 loader 函数
  4. 解码 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

  • resultResponse:内部负责解包数据、处理重定向(可能产出 SuccessResultErrorResultRedirectResult);
  • resultDeferredData 实例:转换为 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。两个选项:

  1. 在调用 dataStrategy 前预执行所有 route.lazy
  2. 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>,同时把 idindexpath 等静态属性直接平铺到 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 比较了两种思路:

  1. 预过滤:只把 matchesToLoad(通过 shouldRevalidate 判定需要加载的子集)交给用户;
  2. 交给用户全量 matches,再传一个 defaultShouldRevalidate(match) 让用户自己过滤。

结论是选项 1,理由有二:一是最小化 API,避免绕开 shouldRevalidate 产生"第二条退出重验证的路径"——重验证开关本来就有官方 API,应坚持单一入口;二是选项 2 为了支持用户自己做重验证决策,必须把 currentUrlcurrentParamsnextUrlnextParamssubmissionactionResult 等一大批上下文全部暴露出来,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——middlewarecontext 这类数据相关 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 的完整拼图:resolvehandlerOverride 允许用户完全跳过 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 年初,而当前仓库中的实现已经走过更多演进(例如 shouldLoadshouldCallHandler() 取代)。对照源码可以看到决策的完整落地轨迹:

1. 类型定义utils.ts):DataStrategyMatch 继承 RouteMatch,除 ADR 遗留的 shouldLoad(已标记 @deprecated)外,新增了 shouldRevalidateArgs(本次请求传给路由 shouldRevalidate 的实参,非重验证 loader 时为 null)、shouldCallHandler(defaultShouldRevalidate?)(把"过滤"从布尔字段升级为可注入默认重验证行为的函数)以及 resolve(handlerOverride?)DataStrategyFunctionArgs 则包含 requestparamsmatchesrunClientMiddlewarefetcherKey 五个字段——其中 runClientMiddleware 是 ADR 时代之后为路由级 middleware 增补的组合入口。

2. 调用编排router.tscallDataStrategyImpl):与 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.tsconvertDataStrategyResultToDataResult):对应 ADR"输出"一节——用户返回的 DataStrategyResult 在这里被翻译为内部的 DataResult 联合类型,Response 走解码与重定向处理,DeferredDataDeferResult,其余原样透传。

实战:在应用中编写自定义 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 布尔字段因此被废弃,迁移方式即"先过滤、只对 matchesToLoadresolve()"——因为新语义下 resolve() 被调用即意味着执行)。文档同时覆盖三类高级场景,恰好对应 ADR 选项 3 设想的三个用例:

  • 自定义重验证:向 shouldCallHandler(defaultShouldRevalidate) 传入自定义默认值,配合 match.shouldRevalidateArgs 读取路由级 shouldRevalidate 的完整入参;
  • 自定义中间件:在 handle.middleware 上定义中间件,dataStrategy 内先串行执行、把共享 contextresolve((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 机制中兑现。

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