登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  前端

前端 Request 读取一次后怎样再次发送

来源:17golang原创

时间:2026-10-18 02:35:13 301浏览 收藏

前端里最容易让人困惑的一类错误是:先用 request.json() 记录请求内容,随后再把同一个 request 交给 fetch(),结果发送阶段提示请求体已经使用。原因不是 fetch 不能发送 Request,而是 Request 的 body 属于一次性可消费的流。要再次使用,关键动作是第一次读取或发送之前先调用 request.clone(),让不同消费者拿到不同的 Request 对象。

官方资料:https://developer.mozilla.org/en-US/docs/Web/API/Request/clone

要点速览
  • bodyUsed 反映当前 Request 的请求体是否已经被消费。
  • clone() 必须在原对象的 body 被读取或发送前调用,之后再 clone 会抛出 TypeError。
  • 一个分支可以用于日志或校验,另一个分支交给 fetch;如果要修改字段,则重新构造 Request。

先理解为什么同一个 Request 不能连续读取

Request.body 可以看成承载请求数据的 ReadableStream。调用 text()、json()、formData() 或把 Request 交给发送流程,都会让这条流进入已消费状态。bodyUsed 是一个很适合排查问题的只读布尔值:还没有消费时通常为 false,读取正文后会变成 true。

// 创建带 JSON 请求体的 Request,后面演示它的消费状态变化。
const request = new Request("/api/profile", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Lin" }),
});

console.log(request.bodyUsed); // 读取前为 false
const payload = await request.json(); // 消费请求体,并得到解析后的对象
console.log(payload.name);
console.log(request.bodyUsed); // 读取后为 true

因此,下面这种写法的思路本身就有冲突:先把 request.json() 的结果用于日志,再期待原对象仍然能被 fetch 读取。实际排查时,可以先观察 bodyUsed,但它只能帮助判断状态,不能把已经消费的 body 复原。

Request、ReadableStream、bodyUsed 与 json 和 fetch 消费关系的静态结构说明图
图1:Request 请求体消费关系说明图,展示 bodyUsed 与多个消费者的对象关系,不是浏览器截图。

在第一次消费前创建 clone

clone() 的作用是创建当前 Request 的副本。最稳妥的时机是拿到 Request 后立即分支,而不是等日志读取完成后再补救。一个常见的边界层可以把原始请求交给真正的发送逻辑,把副本交给日志或校验逻辑:

async function sendWithAudit(request) {
  // 先克隆,再让不同分支分别消费自己的请求体。
  const auditRequest = request.clone();

  // 日志分支读取副本,不会消耗真正要发送的 request。
  const auditBody = await auditRequest.text();
  console.debug("request body length:", auditBody.length);

  // 原始 Request 仍由 fetch 消费,适合继续保留原始 headers 和 body。
  return fetch(request);
}

这里的重点不是“复制后就能无限复制”,而是每一次 clone 都应该发生在对应对象尚未消费时。若日志、签名和发送需要三份独立读取,可以在最初状态一次创建足够的分支;不要先消费一份,再从已消费对象继续派生。

读取一份、发送一份的完整写法

如果边界层既要检查 JSON,又要把同样的请求继续发到后端,可以把副本交给解析逻辑:

async function inspectAndForward(input, init) {
  const request = new Request(input, init);
  // 必须在 json() 和 fetch() 之前 clone,避免两个消费者争用同一条流。
  const inspectRequest = request.clone();

  const body = await inspectRequest.json();
  if (!body.traceId) {
    throw new Error("缺少 traceId"); // 校验失败时不继续转发
  }

  // request 本身还未被消费,所以可以交给 fetch。
  return fetch(request);
}

inspectAndForward("/api/order", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ traceId: "t-100", amount: 18 }),
});

如果输入本来就是一个 Request,也可以把函数参数直接定义为 request,并在函数入口 clone。对于 Service Worker 一类需要读取并转发请求的场景,这种分支方式尤其常见。

原始 Request 分出审计副本和 fetch 副本的静态关系图,包含 clone、json 和发送分支
图2:读取与发送各自使用独立 Request 分支的关系说明图,不是运行结果或界面截图。

需要改字段时不要把 clone 当成编辑器

clone() 适合保留同一份请求的语义副本,但它不会帮你修改 JSON 字段、替换 URL 或自动添加业务头。如果需求是“读取旧请求,再带一个新字段发送”,通常应先读取数据,然后用 Request 构造器创建新的请求:

async function addTraceId(request) {
  // 读取 clone,保留原 request 供其他调用方决定是否发送。
  const source = await request.clone().json();
  const nextBody = JSON.stringify({ ...source, traceId: crypto.randomUUID() });

  // 新 Request 明确指定可修改的 body 和 headers,避免误以为 clone 会改字段。
  return new Request(request, {
    body: nextBody,
    headers: { "Content-Type": "application/json" },
  });
}

构造器接收一个 Request 作为输入时,可以继承它的一部分请求属性,再通过初始化参数覆盖需要改变的内容。新对象的 body 仍然只能消费一次,所以“重新构造”只是建立新请求,不等于取消流的一次性特征。

流式大请求要留意 clone 的内存代价

MDN 对 Request clone 的提醒很重要:底层行为类似 ReadableStream.tee,两个分支的背压速度取较快的一侧,较慢分支未读取的数据会在内部排队。如果请求体很大,或者一个分支读取很慢,clone 可能让未消费数据持续占用内存。

因此,小型 JSON 审计适合 clone;上传大文件、长连接流或两个消费者速度差距明显时,要重新评估设计。可以把校验前置到原始数据生成处、只记录元数据,或者在业务层保存可重复构造的字符串和二进制源,而不是把一个大型流复制给多个慢消费者。

常见问题

已经调用过 request.json(),还能再 clone 吗?

通常不能。Request 的 body 已经使用后,调用 clone() 会抛出 TypeError。应把 clone 提前到第一次读取之前,或者从仍然保留的原始数据重新构造 Request。

clone 后可以让两个 fetch 同时发送吗?

可以把原 Request 和副本分别交给不同的发送流程,但仍需考虑请求是否幂等、服务端是否会执行两次业务,以及大 body 在两个分支上的缓冲成本。clone 解决的是 body 的消费分支,不是重复提交的业务风险。

只想修改 URL 或请求头,必须先读取 body 吗?

不一定。若不需要检查 body,可以直接用新的 Request(request, { ... }) 构造副本并覆盖对应字段;只有要解析或改变 JSON 内容时,才需要先读取一份可消费的 body。

归纳起来,Request 再次发送的判断顺序很简单:先确认谁会消费 body,再在第一次消费前创建 clone;需要改数据时重新构造新的 Request;遇到大流或慢分支,则优先减少重复消费。这样处理,日志、校验和真正的发送就不会意外争用同一个请求体。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>