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

ReadableStream.getReader 怎样逐块读取上传数据

来源:17golang原创

时间:2026-10-27 01:03:57 202浏览 收藏

如果上传文件时不想先把整个文件拼成一个大对象,可以让读取过程保持“拿到一块、处理一块”的节奏。ReadableStream.getReader() 会为流创建一个 reader,并把流锁定给它;随后反复调用 reader.read(),每次得到一个包含 done 和 value 的结果对象。对普通文件流来说,value 通常是 Uint8Array,直到 done 变成 true。

本文使用的是浏览器原生 Streams API,官方资料入口如下:

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

最小可用做法是:先用 const reader = stream.getReader() 取得读取权,再在循环中等待 reader.read();遇到 done 就结束。不要对同一个仍被锁定的流再次调用 getReader(),也不要把一次 read() 当成完整文件。

先明确接口目标:每次只消费一个数据块

在上传场景里,调用方通常需要三个结果:当前已经读了多少字节、是否还有下一块数据,以及发生异常时能否停止读取。getReader() 解决的是“谁拥有这条流的读取权”,read() 解决的是“下一块什么时候可用”。它们并不替你完成服务端分片协议、重试或断点续传。

默认调用 getReader() 会得到 ReadableStreamDefaultReader。它适合直接消费普通流,每次返回一个数据块;如果显式传入 { mode: "byob" },返回的则是 BYOB reader,这要求底层是可读字节流。两种 reader 都会让流进入 locked 状态,在释放前不能被第二个 reader 接管。

ReadableStream 通过 getReader 锁定流并由 read 返回 done、value 和 Uint8Array 数据块的静态结构说明图
图1:结构说明图,展示 ReadableStream、reader 锁定和 read() 返回对象的静态关系,不是浏览器截图或运行证据。

从文件流逐块读取并累计进度

下面的函数只负责读取,不直接上传。把职责拆开后,读取循环可以清楚地处理三种状态:done: false 表示还有数据,done: true 表示流结束,Promise 拒绝则表示读取失败。

async function readInChunks(stream, onChunk) {
  // getReader() 会锁定流,当前函数负责在 finally 中释放读取权。
  const reader = stream.getReader();
  let totalBytes = 0;

  try {
    while (true) {
      // 每次只等待下一块数据,不假设它等于完整文件。
      const { done, value } = await reader.read();
      if (done) {
        return totalBytes;
      }

      // 普通字节流的 value 通常是 Uint8Array;空块不参与进度累计。
      if (value && value.byteLength > 0) {
        totalBytes += value.byteLength;
        await onChunk(value, totalBytes);
      }
    }
  } finally {
    // 正常结束或异常离开时都释放锁,便于后续决定是否重新取得 reader。
    reader.releaseLock();
  }
}

onChunk 可以更新进度、把数据交给业务层,或写入另一个可写流。它不应修改下一次 read() 的调用顺序;如果回调需要异步处理,使用 await 可以让内存压力和业务处理速度保持同一节奏。

把读取结果接入上传请求

如果目标是把文件流直接作为请求体,可以把 file.stream() 交给 fetch。此时不需要手工把所有块拼成 Blob 或大数组;请求体会从流中持续读取。对于浏览器的流式请求体,常见实现需要显式写出 duplex: "half",但这属于 fetch 请求配置边界,最终仍要以目标浏览器和服务端支持矩阵为准。

async function uploadFile(file, uploadUrl) {
  // 文件流按需产生数据,避免先把整个文件复制到内存。
  const body = file.stream();

  const response = await fetch(uploadUrl, {
    method: "POST",
    headers: {
      // 服务端应按实际接口约定处理二进制请求体。
      "Content-Type": file.type || "application/octet-stream"
    },
    body,
    // 流式请求体在支持的浏览器中需要声明半双工模式。
    duplex: "half"
  });

  if (!response.ok) {
    // HTTP 失败不是读取循环的 done 状态,需要单独抛出并交给调用方处理。
    throw new Error(`上传失败:${response.status}`);
  }

  return response;
}

如果你必须在上传前显示精确的已读字节数,可以先用 reader 消费并把每块交给自定义上传管线;如果只需要把文件交给支持流式 body 的接口,直接传 file.stream() 更简单。两者不要混在同一个流上同时读取,否则会遇到锁定或数据所有权冲突。

文件字节分成多个 Uint8Array 数据块,经 read 循环进入 fetch body 和上传请求的静态数据关系图
图2:数据关系说明图,展示文件字节经由读取循环进入 fetch body 的静态链路,不是网络抓包或运行证据。

参数设计:默认 reader 和 BYOB reader 怎么选

默认 reader 的调用最直接:

const reader = stream.getReader();
// 默认 reader 面向普通流,value 由流在每次读取时提供。
const firstResult = await reader.read();
reader.releaseLock();

BYOB 的调用需要字节流和调用方提供的缓冲区:

async function readWithBuffer(byteStream, buffer) {
  // 只有底层按 type: "bytes" 构造时,mode: "byob" 才适用。
  const reader = byteStream.getReader({ mode: "byob" });

  try {
    // 缓冲区交给 reader 填充,返回值里的 view 代表本次实际读取范围。
    const { done, value } = await reader.read(buffer);
    return { done, bytesRead: value ? value.byteLength : 0 };
  } finally {
    // 释放 BYOB reader 的锁,不把流永久占用在当前调用方。
    reader.releaseLock();
  }
}

如果只是上传用户选择的文件,默认 reader 或直接传递 file.stream() 通常已经足够。只有在底层明确提供字节流、并且你确实需要控制接收缓冲区时,才考虑 BYOB;对普通流强行传 mode: "byob" 会触发 TypeError。

错误处理:区分结束、取消和释放锁

done 是正常结束信号,不代表请求一定成功;HTTP 状态仍要由 fetch 的响应单独判断。读取过程中如果 Promise 拒绝,应让异常进入调用方的错误处理。若用户点击取消,调用 reader.cancel() 表示不再需要剩余数据;releaseLock() 只是释放当前 reader 对流的所有权,两者作用不同。

async function readUntilCancelled(stream, signal) {
  const reader = stream.getReader();

  // 取消信号触发时终止底层流,避免只释放锁却继续产生数据。
  const abort = () => reader.cancel("用户取消上传");
  signal.addEventListener("abort", abort, { once: true });

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) {
        break;
      }
      // 这里处理当前块;示例只保留读取职责,不伪造服务端进度。
      console.log("收到字节数:", value.byteLength);
    }
  } finally {
    signal.removeEventListener("abort", abort);
    reader.releaseLock();
  }
}

不要在同一条仍被 reader 锁定的流上再次调用 getReader()。若确实要交给另一个消费者,先结束当前读取,按需要取消或等待完成,再释放锁;否则会得到“流已经锁定”的 TypeError。

兼容策略:先确认请求体和浏览器支持矩阵

MDN 将 ReadableStream.getReader() 标记为广泛可用,并说明它也可在 Web Worker 中使用。但“能读取流”和“能把流作为 fetch 请求体上传”是两个能力面,项目应分别检查目标浏览器、请求模式以及服务端是否接受分块请求体。

在不支持流式请求体的环境中,可以保留普通的 File 或 Blob 上传降级;不要因为 getReader() 可用,就假设 fetch 的 body 配置完全一致。

function uploadBody(file, supportsStreamingBody) {
  // 能力判断由应用自己的兼容矩阵提供,不能只看 API 名称是否存在。
  if (supportsStreamingBody && typeof file.stream === "function") {
    return { body: file.stream(), duplex: "half" };
  }

  // 降级到 File 本身,交给服务端按普通请求体处理。
  return { body: file };
}

一张表记住 getReader 的几个边界

问题结论调用方动作
谁取得流的读取权getReader() 创建 reader 并锁定流结束后按需调用 releaseLock()
怎样拿下一块read() 返回 Promise,结果含 done 与 value等待 Promise,不假设块大小或次数
什么时候结束done === true 表示流已经没有更多数据结束读取循环,再单独判断 HTTP 响应
什么时候取消cancel() 表示不再需要剩余数据响应用户取消,避免继续消费底层流
什么时候用 BYOB底层必须是支持字节读取的流需要缓冲区控制时再传 mode: "byob"

因此,标题中的“逐块读取上传数据”可以压缩成一个稳定的决策:普通文件流先取得默认 reader,再用 read() 处理每个块;需要把流直接作为请求体时,确认 fetch 的流式 body 支持;用户取消或读取异常时分别处理 cancel() 与异常,最后释放锁。

相关问题

为什么第二次调用 getReader() 会报错?

因为第一次调用已经把流锁定给当前 reader。先结束或取消读取,再调用 releaseLock(),才能让其他消费者重新取得读取权。

read() 一次会返回完整文件吗?

不会。value 只是当前可用的数据块,块大小和次数由底层流决定;要得到完整内容,需要持续读取直到 done 为 true。

普通 File 流能直接使用 BYOB 吗?

不要默认这样做。BYOB 要求底层是可读字节流;普通流应使用默认 reader,或先确认流的构造方式和支持矩阵。

releaseLock() 会取消上传吗?

不会。它只释放 reader 对流的锁;取消剩余数据应调用 cancel(),上传请求本身还需要结合 AbortController 或服务端协议处理。

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