你做的AI功能,还在等JSON完全吐完才渲染吗?
模型生成一段完整的结构化JSON往往需要几百个Token、耗时5到15秒。如果你在reader.read()循环里傻等到最后一个}才调用JSON.parse(),用户就得对着空白卡片干等十几秒——流式传输的意义被你自己亲手抹掉了。
但“边收边渲染”不是加一行partial-json就能搞定的。它有三个具体的工程关卡:半截JSON怎么补、补出来怎么不闪烁、补出来的数据怎么不被误用。
一、先分清:你处理的是两层完全不同的JSON
很多刚接触流式结构化输出的开发者会混淆一件事:“我在SSE里不是已经用buffer处理过半截数据了吗?”
你处理的是外层信封,不是内层业务JSON。
外层是SSE传输信封,一行一条,收到换行符\n就完整了,几毫秒的事:
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,不会把前面已经解析好的字段还给你。
所以你需要一个“尽力而为”的补全器。它的工作可以拆成三步:去烂尾、补引号、补括号。
去烂尾:削掉悬空的尾巴
模型可能停在各种尴尬的位置。补全器需要先判断当前截断的状态,然后做对应的“清理”:
补括号:用一个栈记住欠了什么
怎么知道最后该补]还是}]?答案是一个简单的栈:
从头扫描字符,维护两个状态:
- 是否在字符串内:遇到未转义的"就切换状态。字符串里的{、[不参与括号计数。
- 括号栈:在字符串外部,每遇到{就往栈里压一个},遇到[就压一个];遇到闭合的}或]就弹栈。
需要渲染时,先把末尾悬空的逗号、冒号、半截Key削掉;如果停在字符串值中间就补一个";最后把栈里剩下的括号倒序拼到末尾。
下面这段35行的极简代码就是核心原理,生产环境直接用partial-json,但看懂这35行你就知道为什么有些字段会提前出现、有些字段会被暂时丢弃:
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次降到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接入代码。
// 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接入:
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的赋值函数即可。
五、工具怎么选?一张表对号入座
不用纠结重复造轮子,按项目情况直接选:
六、三条生产安全底线:半截数据“长得太像真的”
这是全文最容易被忽略、但后果最严重的一部分。补全器吐出来的对象看起来和完整数据几乎没有区别,但里面可能少了一半字段。
底线一:半截数据只做展示,绝不提交
网络中断、用户点“停止生成”、模型触发最大长度截断——这些情况下补全器依然会给你一个合法的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闭合,那流式传输对你来说,只是把等待从服务端搬到了客户端。
参考链接:
本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。
Are you still waiting for JSON to throw up?
Models generate a complete structured JSON that often takes several hundred Tokens, 5 to 15 seconds. If you wait in the reader.read() loop until the last time you call JSON.parse(), the user will have to wait ten seconds for the blank card -- the meaning of the current transmission has been erased by yourself.
But it's not a line of partial-json. It has three specific engineering levels: how to fix JSON, how to make it up, how not to blink, how to keep the data from being misused.
One: Distinction: You're dealing with a completely different JSON.
A lot of developers who have recently come into contact with flow-based structured outputs are confused with one thing: "Didn't I already process half the data with buffer in SSE?"
You're dealing with the outer envelope, not the inner business JSON.
The outer layer is the SSE transmission envelope, one line, and the line break is complete.
data: {"choices":[{"delta":{"content":"{\"title\":"}}]}
Data:
The inner layer is the business JSON after all delta.content is put together, and the model may stop on any character:
Number 1:
Number 2:
Number 3:
It's true that the outer layer is deciphered by a line by \n, by a row of JSON.parse. But the inner layer can't wait -- you have to make it a legal object when the model is only half out.
How does JSON “supplete”? The core is a cage.
The original JSON.parse of the browser is a strict solver of "all or none". At the end, one quote and one parenthesis are missing, and it throws Syntax Error, and it does not return to you field, which has been deciphered.
So you need a "do your best" patch. Its work can be broken down into three steps: to rot, to quote, to brackets.
Go to the rotting tail: cut off the suspended tail.
Models may be parked in various awkward positions. The completer needs to determine the state of the current cut-off and then do the corresponding "clean-up":
Bracket: Remember what's owed with a stack.
How do you know the end of it? The answer is a simple cage:
Scanning characters from the head, maintaining two states:
- Whether in a string: toggle when you encounter something that has not been converted. {, [do not participate in brackets] in a string.
- Brackets: Every time you encounter a string outside, press one, encounter [one]; encounter a closed } or] a bar.
When rendering is required, the end of the suspended commas, colons, semi-keys should be removed; if you stop at the middle of the string, one should be added”; and the rest of the brackets in the stack should be reworded to the end.
The very simple code for the 35 lines below is the core principle and the production environment is used directly as partial-json, but you can see why some fields appear earlier and some fields are temporarily discarded:
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('');
}
How can the page be “no flashes, no shaking, no cards” when it is completed?
It's only a first step to decipher half-object objects. If you throw half-finished products directly to UI, you'll have three experience problems: part white screen error, number crazy flashing, long JSON analysis of the dead line.
Question one: Half-number flash
Pricefield goes from {price” to {price” – {price” – {price” :12 – {price” – {price” – {123], and if each frame is rendered, the user sees the price jump.
Method: Excludes semi-numbers in the completeer configuration. Party-json's Allow bit mask allows only strings, objects, arrays to be semi-stopped, and numbers must wait until complete.
Question two: Every time a chunk comes, set State, the main route is full.
The model could throw up tens of thousands of chunks. The real data is intuitive:
The number of decompositions dropped from 27,000 to more than 500, from “calculated death” to “sliding”.
The throttle strategy is set by scene:
- Animated sensitive (ordinary card): alignment screen refreshing rate with requestationFrame at most 16 ms under 60Hz
- Reorganization/Virtual Dom framework (React/Vue large table): Timer voltage with 50 ms to 80 ms, only 12 to 20 updates per second, eye-to-eye consistency, 70% reduction in retrospect costs
Issue three: Component crashes when field is missing
Semi-data is likely to contain items that do not exist, and summy may be empty strings. The UI layer must perform a downgrade default for each field instead of directly accessing data.items.map().
One possible mode is to write a matching function for toCardView (partialObj) that returns a safe place value for each field loss or type at an irregular time, and UI only consumes the result after adaptation.
iv. Opened with: pure JS core, original/Vue/React generic
Splits the logic into two layers: the bottom is a pure JS stream consumption function on which the zero frame depends, and the upper layer is a UI access code of less than 10 lines.
/ PartialStream.js - Native, Vue, React, Applet Common
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 {/* too few characters, wait for the next */ *
};
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)); // Final complete verification
onUpdate({ data: finalJson, done: true });
}
React access:
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 access is the same, and onupdate will pass directly to a ref grant function.
Five: How do you choose the tools? A table pair.
Without replicating the wheel, the selection is based on the project:
VI. Three production safety floors: semi-data "too much real"
This is one of the most easily ignored but with the most serious consequences. The object out of the completeer appears to be almost identical to the complete data, but it may be half of field missing.
Bottom one: semi-data is shown only and never submitted
Network disruption, user point “stop generation”, model trigger maximum length cut-off - In these cases, the completer will still give you a legitimate JSON object. The only criterion for writing a database, submitting forms, triggering next step interfaces must be done = = true and fully verified by the original JSON.parse or zod.safeParse.
Bottom 2: Agenttool call parameter, never executed until complete
The model is generating a tool call to remove the directory, semi-parameter {Path”: "/data completion is lawful {Path”: "/data"}, but the model was to write /data/tmp/cache. The parameters that can be deciphered at the current stage can only be used for the UI preview, and the real implementation tool must wait for the flow to be completed and validated.
Bottom three: Structure is not equal to security
If the card contains URLfield, such as link, avartar, etc., the agreement must be verified before rendering is https://, preventing the model from being induced to export javascript:alert(1) false agreement. Normal field maintains the text plug-in values (textContent, {title}, {title}) and does not plug directly into innerHTML or v-html.
At the end.
In 2026, the front end of the AI application was no longer just a chat box. The model is returning to structured data - commodity cards, flow tables, form fills, Agenttool call parameters. And the flow of structured output has a engineering reality that cannot escape: Until the last one gets there, you'll always have half a JSON.
To turn this semi-JSON into a reciprocated UI, three things are required: a completeer capable of rotting tails with brackets; a throttle by frame or milliseconds; and a set of “half-stop data showing only non-submission”.
The meaning of streaming is not “to finish” but “to show users useful things earlier”. If you're still waiting for JSON to shut down, the flow is just moving the waiting from the service to the client.
References:
Auto-fetched and translated by AI Atlas. All rights belong to the original author.