React Router 框架模式 HTTP Headers 权威指南:读取请求头、设置响应头与多路由合并策略

原创2026-09-07 22:49:041,349 阅读
文章标签:前端路由Web框架

React Router 框架模式 HTTP Headers 权威指南:读取请求头、设置响应头与多路由合并策略

HTTP Headers(HTTP 头)在服务端渲染应用中承担着安全策略下发、缓存控制、Cookie 透传、性能指标上报等关键职责。本文基于 React Router(本仓库 react-router 包)框架模式的官方指南 docs/how-to/headers.md,系统讲解如何在路由处理函数中读取请求头、如何在路由模块与 entry.server.tsx 中构建响应头,并结合仓库源码与集成测试剖析"最深匹配路由胜出"的合并算法、Set-Cookie 自动保留机制等底层细节。读完本文,你将掌握一套可落地、可复用的 HTTP Headers 编排方案。

适用模式说明:该文档头部标注 [MODES: framework],即本指南仅适用于 React Router 的框架模式(framework mode,基于 react-router.config.ts 与 Vite 插件构建 SSR 应用),而非数据路由(data routers)或声明式路由模式。

一、读取请求头:标准 Fetch Request 直接取值

在框架模式下,路由模块 loader、action 接收到的 request 就是一个标准 Web Fetch API 的 Request 对象。因此读取请求头无需任何额外 API,直接使用 request.headers 上标准 Headers 提供的方法即可:

export async function loader({
  request,
}: Route.LoaderArgs) {
  // 标准的 Headers 方法都可以使用
  const userAgent = request.headers.get("User-Agent");
  const hasCookies = request.headers.has("Cookie");

  // ...
}

常用方法包括 get()(读取单个头)、has()(判断是否存在)、getAll()/getSetCookie()(读取多值头,如 Set-Cookie)以及 entries() 迭代。这类请求头读取常用于:

  • 基于 User-Agent 做设备区分或 UA 嗅探;
  • 判断 Cookie、Authorization 等鉴权信息是否存在;
  • 依据 Accept、Accept-Language 头决定返回内容的形态或本地化语言。

二、设置响应头的三条路径总览

响应头在 React Router 框架模式中有两种主要定义场所:

  1. 路由模块的 headers 导出(主路径,可按路由粒度定制);
  2. entry.server.tsx 的 handleRequest 导出(全局路径,可附加服务端全局头)。

此外,当某个响应头依赖 loader/action 的动态数据时,还可以先用 data() 包装返回值携带头部,再由 headers 导出显式透出(详见第四节)。

从源码类型看,路由模块支持的响应头形态有两种:routeModules.ts 中将服务端路由模块的 headers 声明为 HeadersFunction | { [name: string]: string },即既可以是一个返回 Headers | HeadersInit 的函数,也可以是一个扁平的头名到值的映射对象。

从路由模块(Route Modules)设置

最典型的写法是导出 headers 函数:

import { Route } from "./+types/some-route";

export function headers(_: Route.HeadersArgs) {
  return {
    "Content-Security-Policy": "default-src 'self'",
    "X-Frame-Options": "DENY",
    "X-Content-Type-Options": "nosniff",
    "Cache-Control": "max-age=3600, s-maxage=86400",
  };
}

返回值既可以是 Headers 实例,也可以是 HeadersInit(如上面的普通对象字面量)。对应类型定义见 route-module-annotations.ts 与 routeModules.ts:

type HeadersArgs = {
  loaderHeaders: Headers;
  parentHeaders: Headers;
  actionHeaders: Headers;
  errorHeaders: Headers | undefined;
};

export interface HeadersFunction {
  (args: HeadersArgs): Headers | HeadersInit;
}

如果不需要读取任何参数,也可以直接导出一个静态对象作为响应头:

export const headers = {
  "X-Frame-Options": "DENY",
  "X-Content-Type-Options": "nosniff",
};

若某个路由没有导出 headers,则该路由对文档响应头不施加任何影响,直接沿用父路由收集到的头(见 headers.ts 中 headersFn == null 的分支逻辑:此时创建 new Headers(parentHeaders) 作为该层结果并向下传递)。

三、从 loader 和 action 中设置动态响应头

当头部值依赖 loader 数据(例如把数据库查询耗时写入 Server-Timing)时,需要两步配合完成。

第 1 步:用 data() 包装返回值并携带 headers

import { data } from "react-router";

export async function loader({ params }: LoaderArgs) {
  let [page, ms] = await fakeTimeCall(
    await getPage(params.id),
  );

  return data(page, {
    headers: {
      "Server-Timing": `page;dur=${ms};desc="Page query"`,
    },
  });
}

data() 是 react-router 导出的服务端工具函数,其签名在 single-fetch.ts 中定义为 export function data(value: Serializable, init?: number | ResponseInit),其第二个参数等价于 ResponseInit,因此可以携带 status 与 headers。这类由 data() 携带的头在服务端渲染流程中会被归入该路由的 loaderHeaders(loader 返回)或 actionHeaders(action 返回)。

第 2 步:在 headers 导出中显式透出

需要注意的是:从 loader/action 返回的头不会自动发送给浏览器,必须显式地在 headers 导出中把它们返回出去。下面的工具函数判断 loader 头与 action 头谁有内容就返回谁:

function hasAnyHeaders(headers: Headers): boolean {
  return [...headers].length > 0;
}

export function headers({
  actionHeaders,
  loaderHeaders,
}: HeadersArgs) {
  return hasAnyHeaders(actionHeaders)
    ? actionHeaders
    : loaderHeaders;
}

集成测试 integration/headers-test.ts 中有多处用例验证了这一行为:例如 can use the loader headers when parents don't have loaders、can use action headers 等测试会构建带 headers({ loaderHeaders }) / headers({ actionHeaders }) 的路由,并断言最终文档响应携带 ROOT_HEADER_KEY 等预期头。这也从侧面说明:loaderHeaders/actionHeaders 与 headers 导出是两个正交的机制,二者通过参数显式衔接。

例外:Set-Cookie 自动保留

一个值得强调的例外是 Set-Cookie 头。来自 headers 导出、loader、action(包括父路由中设置的)Set-Cookie 会被自动保留,即使子路由没有导出 headers 函数也不会丢失。

实现细节见 headers.ts:每一层路由头计算完毕后,都会调用 prependCookies 把父路由、loader、action 乃至错误头中的 Set-Cookie 追加到本层结果中。prependCookies(见 headers.ts)内部使用 cookie-es 的 splitSetCookieString 拆分多条 Cookie,并通过 Set 对字符串做去重,避免同一 Cookie 被重复附加。集成测试 integration/headers-test.ts 中 automatically includes cookie headers from normal responses、automatically includes cookie headers from thrown responses、does not duplicate thrown cookie headers from boundary route、does not duplicate set-cookie headers also returned via headers() function 等用例专门验证了该自动保留与去重语义。

四、父子路由响应头的合并策略

考虑如下嵌套路由:

route("pages", "pages-layout-with-nav.tsx", [
  route(":slug", "page.tsx"),
]);

如果父、子路由模块都导出了 headers,会发生什么?

默认行为:最深匹配路由的响应头胜出

子路由导出的 headers 默认会以父路由的收集结果作为 parentHeaders 输入,并以其自身返回值作为该路由层的最终响应头。换句话说,越深的路由越"靠近"最终响应,其返回的头会覆盖同名的父头。这一"逐层累加、子覆盖父"的归约逻辑完整实现于 getDocumentHeadersImpl:它遍历当前匹配到的路由列表,用 matches.reduce 让每一层的输出作为下一层的 parentHeaders,最终归约出文档头。

因此,当父子路由都要设置头、且子路由不希望丢弃父路由的头时,就需要在子路由中手动合并。

方式一:追加(Appending)

最省事的合并方式是向 parentHeaders 上 append。append 不会覆盖父路由可能已经设置的同名头,而是追加为多值头,适用于"两者都重要、都希望保留"的场景:

export function headers({ parentHeaders }: HeadersArgs) {
  parentHeaders.append(
    "Permissions-Policy",
    "geolocation=()",
  );
  return parentHeaders;
}

方式二:覆盖(Setting)

有时子路由必须整体覆盖父路由设置的某个头(例如父布局设置了宽松的 Cache-Control,而某张页面需要更精确的缓存策略),此时应改用 set 而不是 append:

export function headers({ parentHeaders }: HeadersArgs) {
  parentHeaders.set(
    "Cache-Control",
    "max-age=3600, s-maxage=86400",
  );
  return parentHeaders;
}

set 会移除同名旧值并写入新值,从而确保子路由的语义覆盖父路由。

实践建议:只在"叶子路由"定义头

要彻底规避合并复杂度,官方建议:只在叶子路由(index 路由以及没有子路由的普通子路由)中定义 headers,父路由一律不导出 headers。这样每个响应只有一个"源头"负责产生头,天然不存在冲突与覆盖问题,代码意图也更清晰。

补充:错误边界场景(Error Boundary)

HeadersArgs 中还提供了第四个参数 errorHeaders(见 route-module-annotations.ts)。当渲染过程中抛出错误并命中错误边界时,从边界路由向下查找"带响应头但无数据"的 action/loader 返回,将其响应头作为 errorHeaders 只在叶子(被渲染的边界)路由的 headers() 中暴露一次,避免经 parentHeaders 重复传递(见 headers.ts)。集成测试中 returns bubbled headers from failed /parent/child GET requests 等用例即覆盖了这一错误响应头冒泡行为。

五、在 entry.server.tsx 中追加全局响应头

框架模式的默认服务端入口 entry.server.tsx 导出的 handleRequest 会把路由模块汇总好的响应头作为 responseHeaders 参数传入。适合在这里追加每个响应都必须携带的全局头,例如应用版本号、全局安全头等。

export default async function handleRequest(
  request,
  responseStatusCode,
  responseHeaders,
  routerContext,
  loadContext,
) {
  // set、append 全局响应头
  responseHeaders.set(
    "X-App-Version",
    routerContext.manifest.version,
  );

  return new Response(await getStream(), {
    headers: responseHeaders,
    status: responseStatusCode,
  });
}

从服务端实现看,这个 responseHeaders 正是由 server.ts 中 renderHtml 阶段调用 getDocumentHeaders(context, build) 生成的——它汇总了全部匹配路由(含错误边界场景)的 headers 导出结果与自动保留的 Set-Cookie,并作为文档响应构造函数的 headers 传入(对 304 等无响应体状态码会直接返回空 body 携带头的 Response)。

若项目中还没有 entry.server.tsx,可在项目根目录执行 reveal 命令生成默认入口文件,之后再做自定义:

react-router reveal

该命令会"揭示"(生成)可编辑的默认入口文件,包括 entry.server.tsx、entry.client.tsx 等。生成文件的默认签名、参数类型说明可参考官方文档 entry.server.tsx 与 entry.client.tsx。

六、分层全景:响应头的完整生成链路

结合上述源码路径,可以把框架模式下一次文档请求的响应头生成链路总结为:

  1. 客户端请求到达,服务端经 createStaticHandlerDataRoutes 构建数据路由,由 staticHandler.query 执行各路由的 middleware、loader/action,并把通过 data() 携带的头分别存入 loaderHeaders/actionHeaders;
  2. 进入 renderHtml(server.ts)后调用 getDocumentHeaders(context, build);
  3. getDocumentHeadersImpl(headers.ts)从根路由向叶子路由逐层 reduce:无 headers 导出的路由继承 parentHeaders;有导出的路由以其返回值覆盖/追加,并在每层自动 prependCookies 去重合并 Set-Cookie;
  4. 汇总结果作为 responseHeaders 传入 entry.server.tsx 的 handleRequest,追加全局头后随 HTML Response 返回浏览器。

七、完整落地清单

编写生产级响应头策略时,可按以下清单逐项核对:

关注点 推荐做法 依据
读取请求头 request.headers.get()/has(),标准 Fetch API headers.md 第一节
静态安全头 路由模块导出 headers 对象或函数(CSP、X-Frame-Options、X-Content-Type-Options) headers.md 第二节
依赖数据的头 loader 内 return data(value, { headers }),再由 headers({ loaderHeaders }) 透出 headers.md 第三节
Cookie 设置 在任意层的 loader/action/headers 返回 Set-Cookie,系统自动保留并去重 headers.md 第三节;headers.ts
父子合并 叶子路由用 append/set 操作 parentHeaders,或约定只在叶子路由定义头 headers.md 第四节
全局头 在 entry.server.tsx 的 handleRequest 中对 responseHeaders set/append headers.md 第五节
无入口文件 执行 react-router reveal 生成后自定义 headers.md 第五节

如需深入了解路由模块的完整导出约定(loader、action、headers、meta 等),可继续阅读框架模式文档 route-module.md;data() 工具的更多参数(如自定义 status)见 data.md。

登录后查看全文
react-router