一个常见误解:AI 对话产品的前端开发,就是把聊天界面用 React 或 Vue 写出来,然后接上 SSE 接口。很多团队确实这么干了------用 React 写了个聊天列表组件,用 useEffect 监听 SSE 数据流,收到一段就往列表里 append 一条消息。开发快、效果好、Demo 一天就能跑通。
但产品上线后问题开始堆积。消息里的表格渲染错位、图表在流式输出时疯狂闪烁、用户发了一条消息但 AI 回复中间断了导致整个界面卡死。团队排查发现不是 bug,是架构选型本身有问题------传统前端框架的渲染模型,从底层就不匹配流式 AI 对话场景。
一、传统前端框架的渲染模型:状态驱动,整树重绘
React 和 Vue 的核心抽象是声明式渲染------开发者描述状态(state),框架负责把状态映射到 DOM。状态变了,框架执行虚拟 DOM diff,算出最小变更集,批量更新真实 DOM。这套模型对传统 Web 应用非常高效:用户点了按钮,状态变了,框架精确地更新那一个按钮的文字。
但 AI 对话的渲染场景完全不同。SSE 推来的不是"状态变更",而是"半成品内容"。模型输出"今天天气"时,前端收到的是四个字的一段文本;过了一秒又收到"很好适合";又过了一秒收到"户外活动"。传统框架的处理方式是:每收到一段就更新消息状态,触发 re-render。问题在于,每段内容到达时消息的 DOM 结构都在变化------先是纯文本,然后变成文本加列表,再变成文本加列表加表格加图表。每次结构变化都是一次完整的组件树卸载和重建。
从 JBoltAI 踩过的坑来看,这种"重建式渲染"在高频流式场景下有两个致命问题。第一,性能塌方------一条包含表格和图表的消息在流式输出过程中可能触发几十次重绘,每次重绘都包含虚拟 DOM diff 和真实 DOM 操作,CPU 飙到 100%。第二,视觉闪烁------表格在构建过程中列宽反复跳动,图表在数据不完整时画了一半又擦掉重画,用户体验极差。
二、流式渲染的正确姿势:增量追加,而非整树重绘
AI 对话消息的本质不是"状态",而是"内容流"。一条消息不是某个时刻的快照,而是一个随时间增长的内容序列。渲染模型必须围绕增量追加设计,而非"状态映射"。
增量追加的核心原则是:新内容只做追加操作,永远不修改已渲染的内容。模型输出了文本的前半段,渲染为文本节点追加到消息容器;接着输出了一个列表项,渲染为列表节点追加到文本后面;又输出了表格的表头,渲染为表头节点追加到列表后面。已渲染的文本和列表节点不会被碰触,不会触发任何重排。
这个设计的关键挑战是"结构边界的感知"。流式数据是一串字符,前端怎么知道"这里该开始一个表格了""那个列表已经结束了"?答案是标记解析------TokUI 用方括号标签(如 [table]、[/table])标记结构边界,解析器维护一个状态机,遇到开标签就创建容器,遇到文本就追加到当前容器,遇到闭标签就结束当前容器。
从 JBoltAI 的实践经验来看,增量追加模型的性能优势是数量级的。一条 2000 字的复杂消息(含表格、图表、列表),传统框架可能触发 50 次 re-render,每次涉及数百个 DOM 操作;增量追加模型只做 50 次 append,每次只涉及 1 到 5 个 DOM 节点的新增,零修改零删除。
三、组件系统的设计分歧:编译时还是运行时
传统前端框架的组件是编译时抽象------你写 JSX 或 SFC,编译器把它转成 JavaScript 渲染函数。组件树在编译时就确定了结构,运行时通过 diff 做局部更新。这种模型假设:页面结构在编译时基本已知,运行时只做数据和状态的变更。
AI 对话场景打破了这条假设。消息的 UI 结构在编译时完全未知------模型可能输出纯文本,可能输出一个表格,可能输出一个仪表盘,也可能输出一个表单。结构完全由模型运行时的输出决定。传统框架处理这种"动态结构"的方式是条件渲染------写一堆 if-else 分支判断模型输出类型,动态加载对应组件。分支越多代码越混乱,组件树越深性能越差。
TokUI 选择的是运行时解析 方案。模型输出的不是 HTML 或 JSX,而是一套轻量级标记语言(DSL)。前端解析器在运行时读取标记,按标记类型创建对应组件。组件不需要预编译、不需要条件分支------解析器看到 [chart t:bar ...] 就创建柱状图组件,看到 [table ...] 就创建表格组件,看到 [card ...] 就创建卡片容器。整个渲染过程是一台状态机驱动的流式解析器,而非一棵需要 diff 的虚拟 DOM 树。
从 JBoltAI 的架构设计经验来看,运行时解析方案的代价是放弃了编译时优化(Tree Shaking、静态提升等),收益是获得了完全的动态结构支持------任何组件可以在任何位置出现,组合方式不受预定义模板限制。对于 AI 对话这种结构完全动态的场景,这笔交易是值得的。
四、图表渲染的分歧:Canvas 还是 SVG
大部分前端图表库(ECharts、Chart.js)基于 Canvas 渲染。Canvas 是即时模式------每帧重绘整个画布。这在传统数据看板场景表现很好:数据变了,重绘整个图表,一帧搞定。
但 AI 对话场景中,图表不是一次性渲染的------它是在 SSE 流中逐步构建的。模型先输出图表类型和标签,再逐条输出数据点。Canvas 方案每次新数据到达都要清空画布重绘整个图表,在数据量增长过程中用户看到的是反复闪烁的画布。
TokUI 选择了纯 SVG 方案。SVG 是保留模式------每个图形元素是一个独立 DOM 节点,可以单独增删改而不影响其他元素。新数据点到达时,只需在 SVG 容器里追加一个图形节点,已渲染的元素完全不受影响。流式构建图表就像搭积木------每来一条数据加一块积木,永远不推倒重来。
从 JBoltAI 在实际产品中的验证来看,SVG 方案在流式图表渲染场景下的体验优势非常明显。20 种图表类型全部用 SVG 实现,没有引入任何第三方图表库。代价是复杂图表(如包含上千个数据点的散点图)的渲染性能不如 Canvas------但 AI 对话场景中图表数据量通常在几十到几百个点之间,SVG 完全能胜任。
五、安全模型:为什么 DSL 比 Markdown 更安全
AI 对话产品有一个容易被忽视的安全问题:模型输出的内容怎么安全地渲染到用户浏览器里?
如果模型直接输出 HTML,XSS 攻击就是噩梦------模型可能被注入恶意 Prompt,输出包含 script 标签的 HTML,前端一旦不做转义就直接渲染,等于给攻击者开了一扇门。Markdown 相对安全一些,但 Markdown 扩展语法(如嵌入 HTML、图片标签)同样存在注入风险。
TokUI 的 DSL 在安全模型上有一个根本性优势:它不是 HTML 的子集,而是一套独立的标记语言。解析器只认预定义的组件标签和属性,任何不认识的标签都被当作纯文本处理。不存在"脚本注入"的通道------DSL 里根本没有 script 这种标签。属性值被严格限定为预定义的字段名和值类型,不存在任意 HTML 属性注入。
从 JBoltAI 的工程实践来看,DSL 的安全模型设计让 AI 对话产品在前端渲染层天然免疫 XSS。这在企业级场景中尤其重要------企业用户可能上传各种文档、提问各种问题,模型输出内容不可预测。用 DSL 做渲染层,相当于在前端建了一道防火墙,模型输出再"出格"也不会突破浏览器安全边界。
渲染模型对比
| 维度 | 传统前端框架 | TokUI 流式渲染 |
|---|---|---|
| 渲染模型 | 状态驱动,整树 diff 重绘 | 内容流驱动,增量追加 |
| 动态结构 | 条件分支,预编译模板 | 运行时解析,完全动态 |
| 图表渲染 | Canvas 即时模式,全量重绘 | SVG 保留模式,逐元素追加 |
| 安全边界 | HTML 注入风险,需手动防护 | DSL 独立标记语言,天然免疫 XSS |
| 流式性能 | 高频 re-render,CPU 飙升 | 零重绘,纯 append |
AI 对话产品的前端不是"写一个聊天界面"那么简单。它需要一个从底层渲染模型就为流式内容设计 的引擎------增量追加 而非整树重绘、运行时解析 而非编译时抽象、SVG 保留模式而非 Canvas 即时模式。
从 JBoltAI 开源 TokUI 的实践经验来看,这些架构决策不是优化选项,而是 AI 对话产品从 Demo 走向生产环境的必经之路。
用传统前端框架硬扛流式渲染,短期跑得起来,长期一定会被性能和体验问题拖垮。