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

ReadableStream 如何把 Fetch 响应分块显示到页面

来源:17golang原创

时间:2026-09-12 11:07:33 304浏览 收藏

页面调用 Fetch 后一直等到请求结束才显示文字,通常不是“网络慢”这么简单:如果接口持续返回内容,就应该读取 response.body 这条 ReadableStream,并在每次拿到数据块后更新 DOM。关键是把 HTTP 状态、字节块和字符解码分开处理;中文被拆在两个块里时,不能直接把每个 Uint8Array 转成字符串。

要点速览
  • response.ok 只判断 HTTP 状态,真正读取前还要确认 response.body 存在。
  • 默认 reader 读到的是字节块,使用 TextDecoder 的流式模式才能保住 UTF-8 边界。
  • 结束时 flush 解码器,异常时恢复按钮,取消时调用 reader.cancel()

一、先确认响应成功且存在可读 body

先不要急着写递归读取。404、500 也可能带有响应内容,如果不检查 response.ok,错误页会被当成正常回答追加到页面。另一方面,某些响应没有 body,直接调用 getReader() 会在运行时出错。

Fetch response、Response.body、ReadableStream 与页面输出之间的前端边界关系图
图1:先看响应状态与 body 边界,再决定是否创建 ReadableStream reader。

下面的入口把状态检查放在读取之前,并清空旧内容;示例中的接口路径只是演示,实际项目替换成自己的流式接口。

const output = document.querySelector('#output');
const button = document.querySelector('#load');

async function showStream(url) {
  button.disabled = true; // 防止一次点击创建多个 reader
  output.textContent = '';

  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`); // 先报告状态,避免误显示错误页
  }
  if (!response.body) {
    throw new Error('响应没有可读 body'); // 没有流时走统一错误分支
  }

  return response.body;
}

二、用 getReader 逐块读取 Fetch 响应

response.body.getReader() 会把流锁定给这个 reader;同一条流不能同时交给另一个消费者。每次 read() 返回 donevalue,其中 value 通常是 Uint8Array。收到块后立即追加,页面就能看到逐步增长的内容。

async function readChunks(stream, output) {
  const reader = stream.getReader(); // reader 独占这条 ReadableStream
  const decoder = new TextDecoder('utf-8');

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break; // done 为 true 时不再处理 value

      output.textContent += decoder.decode(value, { stream: true });
    }
    output.textContent += decoder.decode(); // flush 末尾暂存的字节
  } finally {
    reader.releaseLock(); // 读取结束后释放锁,便于后续管理
  }
}

这里没有用 innerHTML,因为接口返回的文本不应直接解释成 HTML。若业务确实要渲染富文本,应先使用可信的 Markdown/HTML 清洗方案,不能把流式显示当成安全边界。

三、用 TextDecoder 保住 UTF-8 字符边界

网络块的边界不等于字符边界。一个汉字的 UTF-8 编码可能被拆开,第一块只到半个字符。每次都调用普通的 TextDecoder.decode(value),就可能出现替换字符;传入 { stream: true } 后,解码器会保留不完整字节,等下一块补齐。

ReadableStream 的 Uint8Array 字节块经过 TextDecoder 流式解码后进入页面文本缓冲的关系图
图2:TextDecoder 的流式状态负责把跨块字节拼回完整文字,再交给页面输出。

有些接口按换行分隔 JSON 或事件消息,这时还要在字符串缓冲里寻找换行符,不能假定一次 read() 恰好得到一条完整消息。解码和消息切分是两层问题:先保证字符完整,再按协议分帧。

四、把异常、结束和取消收在同一个出口

把入口、读取和按钮状态连起来,才能避免请求失败后按钮永久禁用。用户离开页面或点击停止时,调用 cancel() 表达“不再消费”,并在 finally 中恢复界面。

button.addEventListener('click', async () => {
  try {
    const stream = await showStream('/api/answer');
    await readChunks(stream, output);
  } catch (error) {
    output.textContent = `读取失败:${error.message}`; // 给用户可理解的失败反馈
  } finally {
    button.disabled = false; // 成功、失败、取消都恢复按钮
  }
});

async function stopReading(reader) {
  await reader.cancel('用户停止读取'); // 通知底层不再需要剩余数据
  reader.releaseLock(); // cancel 完成后再释放 reader 锁
}

生产代码通常会把当前 reader 保存到模块状态中,再由停止按钮调用取消函数;上面的片段重点展示收尾顺序。检查清单可以压缩成四项:状态先判定、body 再取 reader、字节按流式解码、结束后 flush 并释放锁。

相关问题

为什么页面还是一次性出现全部内容?

前端只是消费流,服务端、反向代理或压缩层仍可能缓冲响应。先确认接口确实分块发送,再检查代理的缓冲策略和响应头。

可以同时调用 response.text() 和 response.body 吗?

不建议。响应 body 只能被消费一次;选择流式 reader 后,就不要再用 text() 读取同一响应。

什么时候用 TextDecoderStream?

如果只需要把字节流转换成字符串流,可以用 response.body.pipeThrough(new TextDecoderStream());需要精细控制 reader、取消或协议分帧时,手动 reader 更直观。

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