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

Web ReadableStream 怎样取消未读响应

来源:17golang原创

时间:2026-11-15 00:52:40 491浏览 收藏

Web ReadableStream 不再需要剩余响应数据时,应调用并等待 cancel()。尚未取得 reader 时可用 response.body.cancel();取得 reader 后流已被锁定,应改用 reader.cancel(),最后用 reader.releaseLock() 释放锁。releaseLock 只解除 reader 对流的占有,不能替代取消。

下面做一个只显示响应前 1024 字节的预览小项目。我会把“预览够了”和“用户点击停止”分别处理:前者取消剩余响应流,后者通过传给 fetch 的 AbortSignal 中止任务。这是设计取舍说明,代码与配图不代表已经完成实测。

Web API 文档地址:https://developer.mozilla.org/en-US/docs/Web/API

项目目标:保留前缀,取消剩余内容

预览功能只需要少量文本,却容易写成读取完整响应后再截取字符串。这个项目在应用侧只保留限定字节数,达到上限便表达“不再消费”的意图。预览上限约束的是返回给应用的字节数,不是精确的网络下载配额:浏览器可能已接收更多数据,一个 read 返回的数据块也可能比上限大。

项目包含 index.html、app.js 和用于示例的 sample.txt。页面只提供“开始预览”“停止”和一个文本输出区域,不需要框架。示例目标是理解单个响应流的所有权与清理,不处理 clone、tee 或管道分支。

环境准备:先认清三个对象的职责

使用支持 Fetch、ReadableStream、默认 reader、AbortController 和 TextDecoder 的浏览器,通过本地 HTTP 服务或已有开发服务器打开页面。使用同源 sample.txt 可减少跨域配置干扰;跨域接口还需满足服务端 CORS 设置。不要用 mode: no-cors 规避限制,因为不透明响应无法提供这里需要的可读响应内容。

操作使用场合实际职责
response.body.cancel(reason)流未被 reader 锁定放弃流中剩余数据并通知底层源
reader.cancel(reason)当前 reader 持有流由持有者发起取消
reader.releaseLock()清理 reader 所有权解除锁,不主动放弃未读块
controller.abort()fetch 使用了对应 signal中止相关 fetch 与响应体消费
响应流与reader锁定关系静态结构图
静态结构图:Response 的 body 是 ReadableStream,reader 与流之间存在锁定关系;取消入口取决于当前持有者,releaseLock 只处理占有关系。

取消会丢弃排队的未读数据,之后不能依靠重新获取 reader 恢复这些内容。若目的是交接给其他消费者继续读,应释放锁并明确交接,而不是先取消。释放锁时若还有未完成的 read,其 Promise 会因 TypeError 拒绝;不要靠释放锁来结束正在等待数据的业务。

核心代码:实现受限字节预览

把下面函数放入 app.js。它先处理 HTTP 错误,再取得 reader;循环中只保存所需前缀,正常到达末尾时不再额外取消,达到上限时则等待 reader.cancel 完成。

async function previewBytes(url, { signal, maxBytes = 1024 } = {}) {
  // 限制本示例的预览大小,防止误传参数造成大量内存分配
  if (!Number.isInteger(maxBytes) || maxBytes  65536) {
    throw new RangeError("maxBytes must be 1..65536");
  }
  const response = await fetch(url, { signal });
  if (!response.ok) {
    // 尚未获取 reader,此处可以直接取消未锁定的响应流
    try { await response.body?.cancel("http-status"); } catch {}
    throw new Error("HTTP " + response.status);
  }
  if (!response.body) throw new Error("response body unavailable");

  const reader = response.body.getReader();
  const parts = [];
  let total = 0;
  let ended = false;
  let cancelRequested = false;
  try {
    while (total  0) {
        // 复制需要的前缀,不把整个网络块长期保存在预览结果中
        parts.push(value.slice(0, take));
        total += take;
      }
    }
    if (!ended) {
      cancelRequested = true;
      await reader.cancel("preview-limit");
    }
  } catch (error) {
    // 读取异常时尝试清理;清理失败不覆盖原始错误
    if (!ended && !cancelRequested) {
      try { await reader.cancel(error); } catch {}
    }
    throw error;
  } finally {
    // 释放占有关系本身不代表取消,取消已在上面的分支处理
    reader.releaseLock();
  }

  const bytes = new Uint8Array(total);
  let offset = 0;
  for (const part of parts) {
    bytes.set(part, offset);
    offset += part.byteLength;
  }
  return bytes;
}

函数最多返回 maxBytes 个字节,参数限制 65536 是本示例自己的应用限制,不是浏览器 API 的上限。使用 slice 复制片段,是为了避免小预览长期引用整个大数据块的底层缓冲区。

cancel 的 Promise 也可能拒绝。正常预览分支会把这种失败传给调用方;异常清理分支只忽略二次清理错误,保留最初的读取异常。finally 始终释放 reader 锁。不要将所有错误都吞掉后显示“预览成功”。

本地接入:增加开始和停止操作

建立以下页面,再把按钮代码接在 app.js 的函数后面,放置一个 UTF-8 文本文件 sample.txt。通过项目自己的 HTTP 开发服务访问 index.html;本文不需要为了说明代码而部署线上服务。




响应前缀预览


const start = document.querySelector("#start");
const stop = document.querySelector("#stop");
const output = document.querySelector("#output");
let active = null;

// 串行启动,停止按钮通过同一个 signal 中止当前 fetch
start.onclick = async () => {
  if (active) return;
  const controller = new AbortController();
  active = controller;
  start.disabled = true;
  stop.disabled = false;
  output.textContent = "读取中";
  try {
    const bytes = await previewBytes("./sample.txt", {
      signal: controller.signal,
      maxBytes: 1024
    });
    // 仅用于 UTF-8 文本预览;字节截断可能使末尾字符不完整
    output.textContent = new TextDecoder().decode(bytes);
  } catch (error) {
    output.textContent = controller.signal.aborted
      ? "已停止"
      : String(error);
  } finally {
    active = null;
    start.disabled = false;
    stop.disabled = true;
  }
};
stop.onclick = () => active?.abort();

这里使用同一 controller 的 signal 连接请求和停止操作。每次新任务创建新的 AbortController;被中止的 signal 不能重置成未中止状态。按钮在当前任务结束前禁止重复开始,避免旧请求回写新任务结果。

预览任务的停止信号与返回数据静态关系说明图
静态说明图:停止操作属于 AbortController 与 signal 分组,前缀数据属于预览结果分组;信号关联 fetch,字节上限约束应用保存的预览结果。

TextDecoder 在这里仅负责 UTF-8 预览。1024 字节可能截在多字节字符中间,末尾出现替换字符不一定是源文件损坏。若要提供严格的字符或行边界预览,需要另外设计增量解码和文本截断规则,不能把字节上限解释成字符数。

部署集成时保留的边界与结果判断

集成到已有页面时,让任务层管理 AbortController,让读取函数负责 reader 的生命周期。取消流表示不再需要数据,不能承诺服务器立即停止计算、回滚写入或恰好少传某个字节数。对于已经完成的服务端动作,应通过业务协议处理,而不是把浏览器取消当作事务回滚。

可自行观察的场景应用应呈现的结果
文件小于预览上限读取到 done,显示全部已读内容
文件超过预览上限最多显示限定字节数对应的预览,取消剩余消费
任务尚未完成时点击停止中止当前任务,显示已停止
HTTP 非成功状态放弃响应体并显示 HTTP 错误
断网或读取失败保留错误,不伪装成正常到达末尾

这是给读者的运行判断清单,并非本文已经得到的测试报告。小文件可能在点击停止前已完成;需要观察停止状态时,应使用自己可控的慢响应,而不是假定每个静态文件都会持续下载。

为什么 response.body.cancel 报 TypeError?

常见原因是流已被 getReader 锁定。应让持有 reader 的代码调用 reader.cancel;不要绕过持有者抢夺同一响应体。

只写 releaseLock 后为什么还能继续读取?

它保留了流里的未读块,其他 reader 可以接手。这正是解除锁与取消数据消费的区别。

fetch 已经返回 Response,abort 还有用吗?

有可能仍有用:响应头已经返回不代表响应体已经消费完。对应 signal 的中止仍可影响后续响应体读取;若任务早已完成,就没有未完成的消费可中止。

参考:WHATWG Streams 标准、MDN releaseLock、MDN abort。

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