Response.clone 的隐藏缓冲:两份响应为什么要一起管理消费速度
- 发布时间:2026-10-23 21:58:45
- 本文热度:浏览 3 赞 0 评论 0
- 文章标签: JavaScript fetch Streams
- 全文共1字,阅读约需1分钟
为了同时解析响应和写缓存,先调用一次 response.clone(),看上去只是多了一个对象。真正昂贵的部分发生在后面:一份正文读得很快,另一份很久没人读,流分叉以后可能替慢的一侧保留不断增加的数据。
clone() 不会立即把整份正文复制成两个完整数组,也不是一份没有生命周期成本的只读视图。它给响应体建立两条消费分支,必须按两条流的速度和退出方式理解。
下面只用浏览器标准 API 构造有限输入,不发网络请求、不测虚构内存数字。把代码保存为 clone-demo.html,就能观察“原始输入已经生产完,另一条分支仍没消费”的事实。
先让快的一侧把源头读完
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<title>Response clone 分支实验</title>
<button id="run" type="button">运行实验</button>
<pre id="output"></pre>
<script>
async function run() {
let produced = 0;
const total = 4;
const stream = new ReadableStream({
pull(controller) {
if (produced === total) {
controller.close();
return;
}
produced += 1;
controller.enqueue(new TextEncoder().encode(`chunk-${produced}\n`));
}
});
const original = new Response(stream);
const copy = original.clone();
const first = await original.text();
if (produced !== total || copy.bodyUsed) {
throw new Error('unexpected branch state');
}
const lines = [
`produced=${produced}, copy.bodyUsed=${copy.bodyUsed}`,
'original consumed, copied branch has not been consumed'
];
const second = await copy.text();
if (first !== second) throw new Error('branch data changed');
lines.push('late copy received the same complete content');
const locked = new Response('locked example');
const reader = locked.body.getReader();
let rejected = false;
try { locked.clone(); } catch (error) {
rejected = error instanceof TypeError;
}
reader.releaseLock();
if (!rejected) throw new Error('locked response was cloned');
lines.push('locked body rejects clone even before reading');
await locked.text();
rejected = false;
try { locked.clone(); } catch (error) {
rejected = error instanceof TypeError;
}
if (!rejected) throw new Error('used response was cloned');
lines.push('consumed body rejects clone');
return lines.join('\n');
}
document.querySelector('#run').addEventListener('click', async () => {
const output = document.querySelector('#output');
try { output.textContent = await run(); }
catch (error) { output.textContent = `failed: ${error.message}`; }
});
</script>
</html>
正常结果会说明已经生产四块,复制分支 bodyUsed 仍为 false,随后那份延迟读取的副本还能得到完整内容。这里没有公开 API 让代码直接读取 tee 的内部队列容量,因此不要把这个实验写成“测得占用多少兆内存”。它证明的是数据生命周期:慢侧没读取,快侧仍推动源头前进,慢侧以后还能得到之前的数据。
把四块改成持续输入,资源问题就不再只是理论。慢侧落后的部分需要被保留,而快侧可能一直要求更多内容。流具备背压,并不意味着每一种分叉结构都按最慢消费者限速。
bodyUsed 只回答有没有被扰动,不回答全部可读状态
示例还锁住了一份尚未读取的正文。getReader() 建立锁以后,clone() 会失败;这时 bodyUsed 仍可能是 false。只用 if (!response.bodyUsed) 作为克隆前检查,并不能保证下一行一定成功。
更稳妥的结构,是在取得响应后立即决定消费关系,再把明确的所有权交给后续步骤。不要让日志插件、业务解析器和缓存插件各自随时获取 reader,最后再由一个通用函数猜响应还能不能复制。
已经读过的正文不能通过 clone 倒回去。调用 text()、json() 或其他完整读取方法之后,需要复用的是已经解析出来的数据,或者自己保留的受限字节副本,而不是对已用响应再克隆一次。
异常时也一样。一次读取中途失败,不代表响应可以重新读。再次请求与重读同一个流是不同操作,重试还需要考虑接口是否允许以及业务结果是否已经发生。
两份完整消费,和两条独立管线是不同需求
小型 JSON 响应通常没有必要为了日志再保留一个完整 clone。可以只读一次并校验,业务使用解析结果,日志只记录必要字段与长度。这样更容易限制敏感信息和数据尺寸。
若两个调用方确实需要相同小正文,先在明确上限内读取成字节,再分别构建需要的对象,至少能让缓存容量由应用控制。不过这会主动物化整份正文,不适合无界流,也不能伪装成零拷贝。
要同时把大响应流向网络和磁盘,应明确哪一侧能够拖慢另一侧,缓冲区最多容纳多少,以及慢侧失败后整条处理要不要继续。通用 clone 方便,并不等于它恰好满足这份资源合同。
Service Worker 的“返回网络响应,同时写缓存”是常见使用场景,但同样要看响应尺寸、缓存写入速度和失败处理。不能因为某个教程用了 clone,就把所有下载、长连接和不断增长的数据流都照搬进去。
日志采样也不要求完整复制响应。若只需要前若干字节,应在自己拥有的读取管线中明确采样范围,并让后续字节继续流向业务消费者。直接克隆整条流,再只读日志分支开头却不取消剩余部分,仍然留下一个没有人继续推进的分支,采样字数少并不能证明保留的数据少。
放弃一侧以后,取消也有两端关系
一条分支不再需要时,应取消它,而不是丢掉对象后假定其内部数据立刻没有责任人。分叉流的取消与源流取消存在协调关系,单侧取消并不总会立即取消仍被另一侧需要的源。
尤其不要先无限等待一侧的取消 Promise,然后才安排另一侧继续消费;在需要双方完成条件的管线中,这样的顺序可能让自己等住。取消动作可以先发出,但整条流的收尾应与另一侧的消费或取消一起管理,并按实际实现验证。
也不能为了省事同时取消所有分支,却对业务说“只是停了日志”。如果业务响应依赖相同源,这个决定会改变用户收到的数据。消费计划必须在共享源的层次作出,不能由一个底层插件单方面处理。
测试这类代码,除正常同时读完,还应覆盖副本从不读取、副本很慢、一侧提前失败、正文已锁和正文已用。小输入能验证语义,大输入才用于评估自己的预算;不要拿一次小文件成功推导整个流程没有容量风险。
clone 的便利来自建立第二个消费入口,它的成本也来自第二个入口。只要调用方能明确回答每一侧何时读取、何时放弃、最多保留多少,复制响应才不会悄悄变成一个没有上限的暂存区。