你做的AI功能,还在等JSON完全吐完才渲染吗?
模型生成一段完整的结构化JSON往往需要几百个Token、耗时5到15秒。如果你在
reader.read()循环里傻等到最后一个}才调用JSON.parse(),用户就得对着空白卡片干等十几秒------流式传输的意义被你自己亲手抹掉了。但"边收边渲染"不是加一行
partial-json就能搞定的。它有三个具体的工程关卡:半截JSON怎么补、补出来怎么不闪烁、补出来的数据怎么不被误用。
一、先分清:你处理的是两层完全不同的JSON
很多刚接触流式结构化输出的开发者会混淆一件事:"我在SSE里不是已经用buffer处理过半截数据了吗?"
你处理的是外层信封,不是内层业务JSON。
外层是SSE传输信封,一行一条,收到换行符\n就完整了,几毫秒的事:
css
data: {"choices":[{"delta":{"content":"{\"title\":"}}]}
data: {"choices":[{"delta":{"content":"\"降噪耳"}}]}
内层是把所有delta.content拼起来之后的业务JSON,模型可能停在任何一个字符上:
第1刻:{"title": 第2刻:{"title":"降噪耳 第3刻:{"title":"降噪耳机"}
外层用buffer按\n切行、JSON.parse一行一行解析,是对的。但内层不能等------你必须在模型只吐出半截JSON的时候,就把它"补"成一个合法对象,然后渲染。
二、半截JSON怎么"补"?核心是一个栈
浏览器原生的JSON.parse是一个"全有或全无"的严格解析器。末尾少一个引号、少一个括号,它直接抛SyntaxError,不会把前面已经解析好的字段还给你。
所以你需要一个"尽力而为"的补全器。它的工作可以拆成三步:去烂尾、补引号、补括号。
去烂尾:削掉悬空的尾巴
模型可能停在各种尴尬的位置。补全器需要先判断当前截断的状态,然后做对应的"清理":
| 断在哪里 | 收到的半截文本 | 补全策略 |
|---|---|---|
| 字符串值中间 | {"title":"AI 周 |
补右引号 + 补右括号 |
| 转义符中间 | {"text":"中\u4e |
削掉 未完成的\u4e,再补引号和括号 |
| 半截Key | {"title":"周报","ite |
Key没写完毫无意义,回退删掉 "ite |
| 有Key没Value | {"title":"周报","items": |
回退删掉 悬空的"items": |
| 尾逗号 | [{"name":"A"}, |
删掉末尾悬空的逗号 |
| 半截布尔 | {"ok":tr |
t开头只可能是true,顺手补全 |
补括号:用一个栈记住欠了什么
怎么知道最后该补]还是}]?答案是一个简单的栈:
从头扫描字符,维护两个状态:
- 是否在字符串内 :遇到未转义的
"就切换状态。字符串里的{、[不参与括号计数。 - 括号栈 :在字符串外部,每遇到
{就往栈里压一个},遇到[就压一个];遇到闭合的}或]就弹栈。
需要渲染时,先把末尾悬空的逗号、冒号、半截Key削掉;如果停在字符串值中间就补一个";最后把栈里剩下的括号倒序拼到末尾。
下面这段35行的极简代码就是核心原理,生产环境直接用partial-json,但看懂这35行你就知道为什么有些字段会提前出现、有些字段会被暂时丢弃:
javascript
function miniCompleteJson(partialStr) {
let inString = false;
let escaped = false;
const closers = [];
for (const ch of partialStr) {
if (inString) {
if (escaped) escaped = false;
else if (ch === '\\') escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '{') closers.push('}');
else if (ch === '[') closers.push(']');
else if (ch === '}' || ch === ']') closers.pop();
}
let fixed = partialStr;
if (inString) {
if (escaped) fixed = fixed.slice(0, -1);
fixed += '"';
}
fixed = fixed.replace(/,\s*"[^"]*"?\s*:?\s*$/, '').replace(/[,:\s]+$/, '');
return fixed + closers.reverse().join('');
}
三、补出来之后,怎么让页面"不闪、不抖、不卡"?
能解析出半截对象只是第一步。如果直接把半成品丢给UI,你会遇到三个体验问题:组件白屏报错、数字疯狂闪烁、长JSON解析卡死主线程。
问题一:半截数字闪烁
price字段从{"price":变成{"price":1再变成{"price":12再变成{"price":123,如果每一帧都渲染,用户会看到价格疯狂跳动。
解法 :在补全器配置中排除半截数字 。partial-json的Allow位掩码可以只允许字符串、对象、数组处于半截状态,数字必须等到完整才输出。
问题二:每来一个chunk就setState,主线程被吃满
模型可能吐出几万个chunk。实测数据很直观:
| 策略 | 27000个chunk的总耗时 |
|---|---|
每个chunk都完整JSON.parse |
约32.7秒(O(n²)平方级爆炸) |
每个chunk都增量解析,但每次都JSON.parse |
约8.3秒 |
| 按帧/定时节流(每50个chunk解析一次) | 约0.53秒 |
解析次数从27000次降到500多次,从"卡死"变成"丝滑"。
节流策略按场景选:
- 动画敏感型(普通卡片) :用
requestAnimationFrame对齐屏幕刷新率,60Hz下最多16ms解析渲染一次 - 重组件/虚拟DOM框架(React/Vue大表格) :用50ms到80ms的定时器节流,每秒只更新12到20次,人眼看起来依然连贯,框架重渲染开销再降70%
问题三:组件在字段缺失时崩溃
半截数据里items可能还不存在,summary可能是空字符串。UI层必须对每个字段做降级默认值 ,而不是直接访问data.items.map()。
一个可用的模式是写一个toCardView(partialObj)的适配函数,在每个字段缺失或类型不对时返回一个安全的占位值,UI只消费适配后的结果。
四、开箱即用:纯JS核心,原生/Vue/React通用
把逻辑拆成两层:底层是零框架依赖的纯JS流消费函数,上层是不到10行的UI接入代码。
javascript
// partialStream.js ------ 原生、Vue、React、小程序通用
import { parse, Allow } from 'partial-json';
function extractJsonBody(raw) {
const fenceMatch = raw.match(/```(?:json)?\s*\n?([\s\S]*)/i);
if (fenceMatch) return fenceMatch[1].replace(/\s*```\s*$/, '');
const firstBracket = raw.search(/[{[]/);
return firstBracket === -1 ? '' : raw.slice(firstBracket);
}
export async function consumePartialJson(body, onUpdate, throttleMs = 40) {
const reader = body.pipeThrough(new TextDecoderStream()).getReader();
let rawText = '';
let timerId = null;
const flush = () => {
timerId = null;
const jsonCandidate = extractJsonBody(rawText);
if (!jsonCandidate.trim()) return;
try {
const partialObj = parse(jsonCandidate, Allow.STR | Allow.OBJ | Allow.ARR);
onUpdate({ data: partialObj, done: false });
} catch { /* 字符太少,等下一拍 */ }
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
rawText += value;
if (timerId === null) timerId = setTimeout(flush, throttleMs);
}
if (timerId !== null) clearTimeout(timerId);
const finalJson = JSON.parse(extractJsonBody(rawText)); // 最终完整校验
onUpdate({ data: finalJson, done: true });
}
React接入:
jsx
const [state, setState] = useState({ data: {}, done: false });
async function generate() {
const res = await fetch('/api/product-card', { method: 'POST' });
await consumePartialJson(res.body, setState);
}
Vue 3接入同理,onUpdate直接传给一个ref的赋值函数即可。
五、工具怎么选?一张表对号入座
不用纠结重复造轮子,按项目情况直接选:
| 你的场景 | 推荐方案 | 理由 |
|---|---|---|
| 常规自建SSE/Fetch流 | partial-json | 零依赖、体积小,Allow位掩码可关掉半截数字防闪烁 |
| 模型爱输出单引号、注释、尾逗号 | jsonrepair | 容错极强,能把各种"坏JSON"修成标准格式 |
| Next.js/React + AI SDK | Vercel AI SDK的useObject |
前后端一体,自动补全、节流、Schema校验 |
| 超大JSON、几千行流式列表 | @streamparser/json | 事件驱动的真正O(n)解析器,每个数组元素闭合就回调,不重扫前文 |
六、三条生产安全底线:半截数据"长得太像真的"
这是全文最容易被忽略、但后果最严重的一部分。补全器吐出来的对象看起来和完整数据几乎没有区别,但里面可能少了一半字段。
底线一:半截数据只做展示,绝不提交
网络中断、用户点"停止生成"、模型触发最大长度截断------这些情况下补全器依然会给你一个合法的JSON对象 。写数据库、提交表单、触发下一步接口,必须以done === true且通过原生JSON.parse或zod.safeParse完整校验为唯一标准。
底线二:Agent工具调用参数,完整前绝不执行
模型正在生成一个删除目录的工具调用,半截参数{"path": "/data补全后是合法的{"path": "/data"},但模型原本要写的是/data/tmp/cache。流式阶段解析出的参数只能用于UI预览,真正执行工具必须等流结束并校验通过。
底线三:结构化不等于安全
如果卡片里有link、avatar等URL字段,渲染前必须校验协议是否为https://,防止模型被诱导输出javascript:alert(1)伪协议。普通文本字段坚持走文本插值 (textContent、{{ title }}、{title}),不要直接塞进innerHTML或v-html。
写在最后
2026年,AI应用的前端已经不再只是"聊天框"了。模型正在返回结构化数据 ------商品卡片、流式表格、表单填充、Agent工具调用参数。而流式结构化输出有一个逃不掉的工程现实:在最后一个}到达之前,你手里永远只有半截JSON。
把这半截JSON变成可渲染的UI,需要三件事:一个能去烂尾、补括号的补全器;一道按帧或按毫秒的节流阀;一套"半截数据只展示不提交"的安全纪律。
流式传输的意义不是"快点传完",而是"让用户更早看到有用的东西"。 如果你的渲染还在等JSON闭合,那流式传输对你来说,只是把等待从服务端搬到了客户端。
参考链接: