当前位置:首页 > 文章列表 > 文章 > 前端 > ReadableStream 怎样用 TransformStream 转换数据

ReadableStream 怎样用 TransformStream 转换数据

来源:17golang原创 2026-10-15 06:14:40 0浏览 收藏

我第一次把流式响应改成“边到边处理”时,页面偶尔会把一条消息拆成两条,或者把两条消息拼成一条。问题不在 TransformStream 不能转换数据,而是我把底层传来的 chunk 误当成了完整业务消息。ReadableStream.pipeThrough() 负责连接流,真正的消息边界仍然需要由转换逻辑自己维护。

官方地址:https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/pipeThrough

readable.pipeThrough(transformStream) 会把源流写入转换流的 writable 端,并返回转换流的 readable 端。最小用法是实现 transform(chunk, controller),但网络文本还要处理解码和跨 chunk 的业务边界。

下面只围绕 pipeThrough 展开:先看三段关系,再写一个最小转换器,最后把它放进网络文本管道,并处理关闭、异常和取消。文中的图片是静态结构说明图,不是浏览器截图,也不是实际运行证据。

先把 pipeThrough 的三段关系看清

TransformStream 不是一个单独的“转换函数”,而是一对彼此连接的流:writable 接受输入,readable 提供转换后的输出。pipeThrough() 做的事情可以概括为:把当前 ReadableStream 接到转换器的输入端,然后把转换器的输出端返回给调用方。

对象职责开发时要记住的边界
源 ReadableStream产生原始 chunk管道期间通常会被锁定,不能再随意取得另一个 reader
TransformStream.writable接收输入 chunk输入会进入 transform 回调,chunk 不一定是完整业务消息
TransformStream.readable输出转换后的 chunkpipeThrough 返回的就是这一端,可继续 pipeThrough 或 pipeTo
ReadableStream、TransformStream writable 和 readable 端之间的静态关系说明图
图1:pipeThrough 的静态结构说明图,查看源流、转换输入端和转换输出端的边界关系。

因此,下面两种写法的含义不同:

// 返回转换后的 ReadableStream,可以继续接下一个转换器。
const transformed = source.pipeThrough(transform);

// pipeTo 才是把转换后的流送到最终 WritableStream 的终点操作。
await transformed.pipeTo(destination);

如果把 source 同时交给多个读取者,或者在 pipeThrough 之后又尝试从原始流取 reader,就会遇到锁定冲突。需要分成两路时,应先明确使用 tee() 等分支策略,而不是绕过管道的所有权约束。

用一个最小转换器确认 transform 的职责

最小的转换器只关心一个问题:输入 chunk 到达时,应该向输出端 enqueue 什么。下面的例子把文本片段转成大写,保留 start、transform 和 flush 三个常见生命周期位置。它没有假设 chunk 是完整句子,所以适合先理解 API 关系。

function createUpperCaseStream() {
  return new TransformStream({
    start(controller) {
      // 这里适合初始化状态;本例不需要预先输出内容。
    },
    transform(chunk, controller) {
      // chunk 只是当前到达的一段数据,不保证对应一条业务消息。
      controller.enqueue(String(chunk).toUpperCase());
    },
    flush(controller) {
      // 输入流正常结束时处理残留状态;本例没有需要刷出的缓存。
    },
  });
}

const resultStream = source.pipeThrough(createUpperCaseStream());

// reader 消费的是转换器的 readable 端,而不是 source 本身。
const reader = resultStream.getReader();
try {
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    renderText(value);
  }
} finally {
  // 无论消费正常结束还是中途抛错,都释放 reader 的锁。
  reader.releaseLock();
}

这里的 controller.enqueue() 可以输出一个 chunk,也可以在一次 transform 中输出多个 chunk。反过来,如果一个输入 chunk 还不够组成目标数据,就暂存到转换器自己的状态中,等后续 chunk 到达再输出。

网络文本为什么必须处理跨 chunk 边界

网络响应通常先是字节流。即使服务端按换行写入,浏览器收到的 chunk 也可能把一行从中间拆开。我的排查顺序是先用 TextDecoderStream 把字节转换成文本,再在自定义 TransformStream 中维护一小段未完成的尾部。

function createLineStream(onLine) {
  let pending = "";

  return new TransformStream({
    transform(chunk, controller) {
      // 当前 chunk 可能从半行开始,先和上次残留内容拼接。
      pending += chunk;
      const lines = pending.split("\n");
      pending = lines.pop() ?? "";

      for (const line of lines) {
        const value = line.replace(/\r$/, "");
        if (!value) continue;
        // 只把已经遇到换行的完整行交给业务回调。
        controller.enqueue(onLine(value));
      }
    },
    flush(controller) {
      // 输入正常结束时,最后一行可能没有换行符,不能静默丢掉。
      const value = pending.replace(/\r$/, "");
      if (value) controller.enqueue(onLine(value));
    },
  });
}

const lines = response.body
  .pipeThrough(new TextDecoderStream())
  .pipeThrough(createLineStream((line) => JSON.parse(line)));

for await (const message of lines) {
  // 每个 message 是按换行边界解析出的对象;解析错误会终止这条管道。
  renderMessage(message);
}

这个例子有两个容易遗漏的点。第一,TextDecoderStream 负责字节到字符串的解码,不负责你的业务分隔符;第二,flush 负责处理最后没有换行符的残留内容。若每行不是 JSON,应该把 JSON.parse 换成实际协议的解析函数,并在异常处保留上下文。

让多个 pipeThrough 组成可读的处理链

多个转换器可以连续连接,因为每次 pipeThrough 都返回新的 ReadableStream。下面的链路只做三件事:检查响应、解码字节、按行转换。最终的 for await...of 消费的是最后一个转换器的输出。

async function readMessages(url, signal) {
  const response = await fetch(url, { signal });
  if (!response.ok || !response.body) {
    // 在建立流式管道前先处理 HTTP 层失败,避免把错误页面当数据解析。
    throw new Error(`流式请求失败:${response.status}`);
  }

  const messageStream = response.body
    .pipeThrough(new TextDecoderStream(), { signal })
    .pipeThrough(createLineStream((line) => JSON.parse(line)), { signal });

  try {
    for await (const message of messageStream) {
      renderMessage(message);
    }
  } catch (error) {
    // 转换器抛错、源流异常或取消都会在消费端汇合到这里。
    reportStreamError(error);
    throw error;
  }
}

链式写法的价值不只是短。它把“字节解码”“消息边界”和“业务消费”分成了可替换的节点,每个节点只承担一种转换职责。若把所有逻辑塞进一个 transform,后续很难判断究竟是字符解码、分隔符处理还是业务解析出了问题。

关闭、异常和取消要按边界决定

pipeThrough(transformStream, options) 的第二个参数不是装饰项,它影响源流、转换器和下游之间的错误传播:

  • preventClose: true:源流关闭时,不自动关闭转换目标的 writable 端。
  • preventAbort: true:源流出错时,不自动 abort 目标 writable。
  • preventCancel: true:目标端出错时,不自动 cancel 源流。
  • signal:用 AbortController 取消正在进行的管道。
pipeThrough 中关闭、异常、取消和 AbortSignal 关系的静态边界说明图
图2:管道生命周期说明图,查看 close、abort、cancel 与 AbortSignal 各自影响的边界。
const controller = new AbortController();

async function consumeWithCancel(source) {
  const transformed = source.pipeThrough(createUpperCaseStream(), {
    // 取消只针对当前管道,不让业务误以为源流一定被永久关闭。
    preventClose: true,
    signal: controller.signal,
  });

  try {
    for await (const chunk of transformed) {
      renderText(chunk);
      if (shouldStop()) {
        // 统一从 AbortController 发出取消信号,避免留下半开的消费任务。
        controller.abort(new Error("用户停止读取"));
        break;
      }
    }
  } finally {
    // 调用方负责释放自己的生命周期对象;不要依赖页面自然销毁。
    controller.abort();
  }
}

不要为了“让错误消失”就把三个 prevent* 全部打开。它们改变的是传播关系:例如下游无法消费时,默认取消源流通常能及时停止网络读取;阻止取消后,源流可能继续产生数据,必须由应用自己负责清理。只有当一条管道连接了多个需要独立管理生命周期的组件时,才有理由改变默认传播。

我最后保留的排查清单

现象优先检查常见根因
一条消息被拆开transform 是否维护 pending把网络 chunk 当成业务边界
中文出现乱码是否先经过 TextDecoderStream直接把字节数组转字符串
源流突然不能再读取source.locked 与 reader 所属pipeThrough 后又从源流取 reader
取消后请求仍在继续signal 和 preventCancel 配置改变了默认传播却没有补清理逻辑

相关问题

pipeThrough 返回的是 TransformStream 吗?

不是。它返回传入转换器的 readable 端,也就是一个新的 ReadableStream,所以可以继续调用 pipeThrough 或最终调用 pipeTo。

transform 每次收到的 chunk 一定是一条完整消息吗?

不一定。chunk 是底层流交付的数据片段,可能半条、整条或多条消息。协议边界需要在转换器中用缓冲区、长度字段或明确分隔符自行维护。

什么时候直接使用 pipeTo?

当转换已经完成,目标是一个最终的 WritableStream 时使用 pipeTo。如果还要改变数据形态或继续接其他处理节点,先用 pipeThrough 保留可读输出。

我现在判断一个 pipeThrough 链是否可靠,只看三件事:每个转换节点是否只做一类工作,是否明确区分 chunk 与业务边界,以及关闭和取消后是否能回收源流。把这三件事写进代码,流式处理就不会只剩下一串看似漂亮的链式调用。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
Fetch 怎样用 AbortSignal.timeout 设置请求超时Fetch 怎样用 AbortSignal.timeout 设置请求超时
上一篇
Fetch 怎样用 AbortSignal.timeout 设置请求超时
ReadableStream 取消后怎样释放请求
下一篇
ReadableStream 取消后怎样释放请求
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    478次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    535次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    554次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    506次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    327次使用