流式输出不完整 JSON解析方法详解(大纲增量解析方案)

大纲流式输出时,每个 SSE 块(chunk)都可能落在 JSON 的任意位置,直接 JSON.parse 必然抛错。项目里采用的方案是「缓冲拼接 + 增量修复」组合:先在内存里把不完整片段攒成缓冲区,拼上后续块形成完整 JSON 后再解析;同时用大括号/中括号配对扫描,把已到达的安全前缀补全成合法 JSON 先行渲染。两层策略分别解决「等到能解析」与「尽早展示」两个诉求,缺一不可。

一、问题的根源:块边界不等于 JSON 边界

SSE 按行推数据,但网络分块、代理缓冲都会在 JSON 中间截断。一个完整的树形大纲 JSON 可能被切成这样到达前端:

块1: {"title":"AI 爆款","children":[{"title":"开头","childre
块2: n":[]},{"title":"正文","children":[{"title":"背景","children":[{"title":"现状
块3: "}]}]}]}

块1 和块2 都不是合法 JSON。若在每次收到块时直接 JSON.parse,会抛 Unexpected end of JSON input,大纲节点就永远渲染不出来,只能等最后一个块到达后一次性解析。这种做法把流式体验完全抹掉了。

二、方案对比:三种处理路径

方案 渲染时机 实现复杂度 适用场景
全量等待解析 流结束后一次性渲染 最低 数据量小、无实时诉求
缓冲拼接后解析 每个完整消息到达时 中 大纲、正文等行级结构
增量修复 + 截断补全 每个块到达即渲染 高 嵌套树、卡片类长 JSON

项目最终采用第二、三种混合:行级消息用缓冲拼接,树形大纲用增量修复。

三、缓冲拼接:兜底方案

思路是把当前不完整的行暂存,等下一个块到来时先拼上前一段,再尝试解析。这是流式 JSON 处理最常用的做法。

let buffer = ''

async function consume(reader: ReadableStreamDefaultReader, decoder: TextDecoder) {
  while (true) {
    const { value, done } = await reader.read()
    if (done) break

    // 拼上上一段未解析完的残片
    buffer += decoder.decode(value, { stream: true })

    // 按行切分,完整的行尝试解析
    const lines = buffer.split('\n')
    buffer = lines.pop() ?? '' // 最后一行可能不完整,留到下一轮

    for (const line of lines) {
      const trimmed = line.replace(/^data:\s*/, '').trim()
      if (trimmed === '[DONE]') return
      if (!trimmed.startsWith('{')) continue
      try {
        const node = JSON.parse(trimmed)
        emit(node)
      } catch {
        // 这一行仍不完整,并入 buffer 下轮再拼
        buffer = line + '\n' + buffer
      }
    }
  }
}

这段代码有个细节:解析失败的行要原样退回 buffer 开头,而不是丢弃。只有完整行才出结果,残片永远留在缓冲区等待拼接。

四、增量修复:不等完整就渲染

大纲的嵌套结构决定它是一整棵树,光靠拼接要等到最后一个块才出树。项目里改用「扫描截断 + 补全闭合」:

  1. 从左到右扫描当前已接收的文本,维护一个栈记录打开的 {、[;
  2. 记录最近一次「合法可截断点」的位置,比如完整 key-value 之后;
  3. 截断到安全点,再把栈里未闭合的括号补上;
  4. 用补全后的字符串做一次 JSON.parse,成功即渲染。
function repairOutlineFragment(fragment: string): Record<string, unknown> | null {
  // 模拟:扫描括号配对,补全闭合
  const stack: ('{' | '[')[] = []
  let safeEnd = 0
  let inString = false
  let escaped = false

  for (let i = 0; i < fragment.length; i++) {
    const ch = fragment[i]
    if (inString) {
      if (escaped) escaped = false
      else if (ch === '\\') escaped = true
      else if (ch === '"') inString = false
      continue
    }
    if (ch === '"') inString = true
    else if (ch === '{' || ch === '[') stack.push(ch)
    else if (ch === '}' || ch === ']') {
      if (stack.length) stack.pop()
      if (stack.length === 0) safeEnd = i + 1 // 又回到顶层,记安全点
    }
  }

  // 取安全前缀并补全未闭合括号
  const prefix = fragment.slice(0, safeEnd || fragment.length)
  const suffix = stack
    .map(s => (s === '{' ? '}' : ']'))
    .reverse()
    .join('')
  try {
    return JSON.parse(prefix + suffix)
  } catch {
    return null // 补全后仍不合法,交给缓冲拼接
  }
}

4.1 关键边界:字符串与转义

扫描时必须在字符串内跳过字符,否则大纲标题里的 {、[ 会被误判为结构符。inString 与 escaped 两个标志位保证转义符 \" 和普通引号都能正确处理。这也是手写扫描器最容易出 bug 的地方,稳妥做法是先让缓冲拼接方案兜底,增量修复只作为加速渲染的增强。

五、两种方案如何协作

  1. SSE 块到达 → 先进缓冲拼接,尝试逐行解析;
  2. 解析失败 → 交给增量修复,扫描括号补全后尝试解析;
  3. 增量修复成功 → 立即渲染树,同时保留缓冲继续等完整 JSON;
  4. 完整 JSON 到达 → 用权威结果覆盖一次渲染,消除增量渲染可能的偏差。

最后一步很关键:增量修复出来的树可能存在「当前块尚未推送完」的节点,等完整 JSON 到达后必须做一次整体替换,保证数据一致。

这套组合让大纲在生成过程中实时可见:节点随模型输出逐个冒出来,用户边看边等,等全文结束时树已渲染完成,编辑与拖拽立即可用。相比全量等待,首屏渲染时间从「流结束」提前到「第一个完整节点出现」,体感差异明显。

常见问题(FAQ)

Q1:直接等完整 JSON 再渲染不行吗?

可以,但会失去流式体验,首屏等整个流结束才出结果。

Q2:增量修复会不会把残缺数据当正确数据?

会临时渲染,完整 JSON 到达后会整体覆盖,保证最终一致。

Q3:为什么解析失败要退回 buffer 而不是丢弃?

残片与下一块拼接后才是合法 JSON,丢弃会导致数据永久丢失。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
赵其鑫的头像赵其鑫管理团队

相关推荐

返回顶部