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

Fetch API ReadableStream 的增量 JSON 解析

来源:17golang原创

时间:2026-10-28 13:41:46 385浏览 收藏

我第一次把大 JSON 接口改成流式读取时,误以为网络收到的每个 chunk 就是一条完整记录。实际情况是:Response.body 只提供字节流,分块位置由传输和浏览器决定,既可能把一个汉字拆开,也可能把两条记录合在一起。可靠的做法是让服务端输出 NDJSON(一行一个完整 JSON),浏览器先用 TextDecoderStream 解码,再按换行拼出完整记录,最后逐条调用 JSON.parse。

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

ReadableStream 解决“数据还在到达时先消费”,NDJSON 解决“什么时候算一条 JSON”,两者不能互相替代。

先把传输分块和 JSON 记录边界分开

fetch() 返回响应头后就能得到 Response,但 response.json() 会把响应体读完再解析。需要边到边处理时,应该读取 response.body。它返回的是字节流,读取到的 Uint8Array 不是 JSON 对象,也不保证对应一行。

因此文章只采用下面这种服务端格式,每行都是独立、完整的 JSON 值:

{"id":1,"message":"第一条"}
{"id":2,"message":"第二条"}

如果接口返回的是一个普通数组,例如 [{"id":1},{"id":2}],仅靠换行不能判断对象是否闭合;这时要么让服务端改成 NDJSON,要么引入专门的增量 JSON tokenizer。不要把“网络 chunk”误当成“JSON 边界”。

Response.body 字节块经过 TextDecoderStream 解码后按换行形成 NDJSON 记录并交给 JSON.parse 的结构说明图
图1:Response.body、UTF-8 文本块和 NDJSON 记录边界的结构说明图;这是原创静态结构图,不是截图或运行证据。

用 TextDecoderStream 保留跨块 UTF-8 字符

直接把每个字节块交给字符串转换,容易在多字节字符跨块时得到乱码。TextDecoderStream 会把字节流变成文本流,并在解码器内部保留尚未完成的 UTF-8 序列。对 Fetch 响应可以用 pipeThrough() 接起来:

async function* textChunks(url, signal) {
  // 先判断 HTTP 状态;fetch 不会因为 404 自动 reject。
  const response = await fetch(url, { signal });
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  if (!response.body) {
    throw new Error("响应没有可读取的 body");
  }

  // TextDecoderStream 默认按 UTF-8 解码,避免手动拼接半个汉字。
  const stream = response.body.pipeThrough(new TextDecoderStream());
  for await (const chunk of stream) {
    // chunk 只是文本片段,仍然可能包含半行或多行。
    yield chunk;
  }
}

这里的关键是职责分离:解码器只负责“字节变成文本”,不会替你寻找换行,也不会替你调用 JSON.parse。如果接口的字符集不是 UTF-8,应先确认服务端响应约定,再选择对应的解码方式。

按换行积累完整记录并逐条 JSON.parse

我在实际页面里最容易漏掉的是“半行”。一个文本 chunk 可能在 JSON 中间结束,所以需要把它追加到 buffer,只在找到换行时取出完整行。下面的生成器把每条对象交给调用方:

async function* parseNdjson(url, signal) {
  let buffer = "";

  for await (const chunk of textChunks(url, signal)) {
    buffer += chunk;
    let newlineIndex;

    // 一次处理当前缓冲区中的全部完整记录,剩余半行继续保留。
    while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
      const line = buffer.slice(0, newlineIndex).trim();
      buffer = buffer.slice(newlineIndex + 1);
      if (!line) continue;

      try {
        // 只有一整行到齐后才解析,避免把半个对象交给 JSON.parse。
        yield JSON.parse(line);
      } catch (error) {
        // 给出原始行,便于定位服务端输出的哪条记录损坏。
        throw new SyntaxError(`NDJSON 记录无效:${line}`);
      }
    }
  }

  // 最后一条记录可能没有换行,流结束时仍应处理它。
  const tail = buffer.trim();
  if (tail) {
    yield JSON.parse(tail);
  }
}

async function render(url, signal) {
  // 消费端可以边收到边更新列表,不必等待整个响应结束。
  for await (const item of parseNdjson(url, signal)) {
    console.log(item.id, item.message);
  }
}

trim() 只用于剔除行首尾的空白;真正的记录分隔仍然是换行。坏记录是否应该终止整个消费过程,要按业务决定:示例选择抛错,让调用方知道数据流已经不可信,而不是静默跳过。

补齐尾行、错误处理与取消

流式读取的代价是生命周期更长,错误也可能在响应进行到一半时发生。调用方至少要区分三类情况:HTTP 状态失败、记录 JSON 语法错误、用户主动取消。AbortController 可以把取消信号传给 fetch:

const controller = new AbortController();

async function loadMessages() {
  try {
    // signal 让关闭页面或点击“停止”时可以中断网络读取。
    await render("/api/messages.ndjson", controller.signal);
  } catch (error) {
    if (error.name === "AbortError") {
      console.log("用户取消了读取");
      return;
    }
    // HTTP 错误和 SyntaxError 都应进入统一的错误提示或重试策略。
    console.error("流式 JSON 读取失败", error);
  }
}

loadMessages();

// 例如在组件卸载或用户点击停止时调用。
function stopLoading() {
  controller.abort();
}

如果页面组件会频繁刷新,还要避免旧请求继续写入新页面;在卸载时调用 abort(),并让渲染层丢弃已经失效的请求。不要把解析错误伪装成网络错误,也不要在没有协议约定时随意吞掉坏行。

buffer 保留半行并形成完整 JSON 记录后交给 JSON.parse,同时连接 SyntaxError 和 AbortController 取消边界的结构图
图2:增量解析中的缓冲区、错误分支和取消边界关系示意图;这是原创静态结构图,不是截图或运行证据。

常见问题

为什么不能直接对每个 ReadableStream chunk 调用 JSON.parse?
因为 chunk 是传输层分块,不保证是完整 JSON;它可能只包含对象前半段,也可能包含多条记录。

TextDecoderStream 能自动把 JSON 拆成一条条对象吗?
不能。它只负责字节到文本的解码,换行缓冲和 JSON.parse 仍由应用层完成。

普通 JSON 数组可以增量解析吗?
可以做,但不能只靠换行。若服务端可调整,NDJSON 更容易定义记录边界;若不能调整,则需要真正理解括号、字符串和转义的增量 tokenizer。

最小落地清单是什么?
服务端约定 NDJSON;客户端检查 response.ok;使用 TextDecoderStream;保留半行;流结束处理尾行;区分 SyntaxError 与 AbortError。

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