大纲流式输出时,每个 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 开头,而不是丢弃。只有完整行才出结果,残片永远留在缓冲区等待拼接。
四、增量修复:不等完整就渲染
大纲的嵌套结构决定它是一整棵树,光靠拼接要等到最后一个块才出树。项目里改用「扫描截断 + 补全闭合」:
- 从左到右扫描当前已接收的文本,维护一个栈记录打开的
{、[; - 记录最近一次「合法可截断点」的位置,比如完整 key-value 之后;
- 截断到安全点,再把栈里未闭合的括号补上;
- 用补全后的字符串做一次
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 的地方,稳妥做法是先让缓冲拼接方案兜底,增量修复只作为加速渲染的增强。
五、两种方案如何协作
- SSE 块到达 → 先进缓冲拼接,尝试逐行解析;
- 解析失败 → 交给增量修复,扫描括号补全后尝试解析;
- 增量修复成功 → 立即渲染树,同时保留缓冲继续等完整 JSON;
- 完整 JSON 到达 → 用权威结果覆盖一次渲染,消除增量渲染可能的偏差。
最后一步很关键:增量修复出来的树可能存在「当前块尚未推送完」的节点,等完整 JSON 到达后必须做一次整体替换,保证数据一致。
这套组合让大纲在生成过程中实时可见:节点随模型输出逐个冒出来,用户边看边等,等全文结束时树已渲染完成,编辑与拖拽立即可用。相比全量等待,首屏渲染时间从「流结束」提前到「第一个完整节点出现」,体感差异明显。
常见问题(FAQ)
Q1:直接等完整 JSON 再渲染不行吗?
可以,但会失去流式体验,首屏等整个流结束才出结果。
Q2:增量修复会不会把残缺数据当正确数据?
会临时渲染,完整 JSON 到达后会整体覆盖,保证最终一致。
Q3:为什么解析失败要退回 buffer 而不是丢弃?
残片与下一块拼接后才是合法 JSON,丢弃会导致数据永久丢失。