模型吐出半截JSON,前端如何边收边渲染?2026年AI应用必须闯过的工程关

你做的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闭合,那流式传输对你来说,只是把等待从服务端搬到了客户端。

参考链接:

相关推荐
aixingpan1 小时前
aixingpan.cnAPI开发文档:api_docs_trichart_natal_solar_return_transit2接口指南
前端·php
FRing031 小时前
JavaScript入门到精通二十八(forEach遍历、创建对象的三种方式、构造函数、new实例化执行过程、实例成员和静态成员)
开发语言·前端·javascript
燕落南枝2 小时前
CSS 表单选择器与伪元素选择器详解
前端·css
拖孩2 小时前
一个人 + AI 做的小程序,今天,正式回本了
前端·后端·微信小程序
进击的丸子2 小时前
APP 人脸识别增值版 SDK:Camera2 无缝接入指南
前端·前端框架·代码规范
开开心心就好2 小时前
本地文件搜索工具,双击Ctrl就能呼出搜索
java·服务器·前端·支持向量机·ffmpeg·音视频·模拟退火算法
知了清语2 小时前
SVG 缩放预览:F12 无法定位子元素 & 放大模糊
前端
AN2 小时前
一个人,带着一群 AI:一个 10 年前端如何做出几十个团队在用的在线文档
前端
ByteCoder.2 小时前
班前十分钟的清单,按体检引导单来写
java·服务器·前端·助播虾