【流式输出】基于Vue实现增量渲染
随着AI应用的逐步普及,流式输出(Streaming Output)成为AI交互中的关键技术,指模型在生成完整回答前逐步、分块返回部分内容,类似“逐字输出”的效果。
一、SSE和websocket
实现流式输出,首先要选择合适的流式传输协议。那么常用的协议就是SSE和Websocket两种。
SSE(Server-Sent Events)与 WebSocket 对比
特性 | SSE(Server-Sent Events) | WebSocket |
---|---|---|
通信方向 | 单向(服务器 → 客户端) | 全双工(服务器 ↔ 客户端) |
协议 | 基于 HTTP(长轮询) | 独立协议(ws:// 或 wss:// ) |
数据格式 | 纯文本(可自定义格式,如 JSON) | 二进制或文本 |
连接开销 | 低(复用 HTTP 连接) | 较高(需独立握手) |
断线重连 | 自动支持(客户端内置重试机制) | 需手动实现 |
浏览器兼容性 | 除 IE 外主流浏览器均支持 | 主流浏览器均支持(包括 IE 10+) |
适用场景 | 实时通知、股票行情、新闻推送等 | 在线聊天、游戏、实时协作等 |
实现复杂度 | 简单(无需额外协议) | 较复杂(需处理握手、帧协议等) |
安全性 | 依赖 HTTPS | 支持 wss:// (加密) |
数据量支持 | 适合高频小数据流 | 适合大数据或二进制传输 |
关键差异总结
- SSE 适合服务器主动推送数据的场景,如实时监控或日志流。
- WebSocket 适合需要双向交互的场景,如即时通讯或多人协作工具。
- SSE 默认不支持二进制数据,输出的是文本(UTF-8)流,而 WebSocket 支持灵活的数据类型。
在通用场景下,AI问答采用SSE协议即可满足业务需求、并且可以有效减小服务器的压力,省去了那些握手建立双向连接的过程。
而websocket通常用于在线文档多人协同、在线聊天室等复杂场景。
二、简单实现流式渲染
那么我们从SSE拿到了服务器推送的回答文本流,怎么实现流式渲染呢?
其实,“流式”只要服务端对数据作好分块推送,本身就已经达到流式效果了
所以简单来说,我们只需将输出内容拼好,放入v-html中渲染即可
// html
<div v-html="context"></div>//script
const context = ref('')
sse.onmessage = (event) => {context.value += JSON.parse(event.data)
}
⚠️注意
注意后端返回的文本流格式
- 纯文本内容:需要自己组织dom结构
- html字符串:直接放入
- markdown:可以通过marked.js去解析markdwon,使其转换为html字符串
三、基于Vue的diff更新机制,实现增量渲染
Vue的diff算法大家一定不陌生,它是通过双端对比和就地复用策略高效更新虚拟DOM的算法。通俗来说,它会比较各个Vnode对象的信息,如果没有改变则不更新,只更新有改变的对象。减少不必要的重排重绘。
那么利用这个机制呢?
我们需要把握怎么去生成一个虚拟Vnode,通过底层的diff算法来控制dom的更新
在 Vue 中,模板(<template>
)其实只是语法糖,最终 Vue 都会把模板编译成 render
函数。
也就是说,render
是模板的“底层实现形式”,它定义了组件应该如何被“渲染成虚拟 DOM(VNode)”
抓住这个,我们就可以显示的用h函数去创建Vnode、调用render函数去渲染Vnode,底层就会自动依照diff执行增量渲染
import { defineComponent, h } from 'vue'
import { parseDocument } from 'htmlparser2'
import { marked } from '@/config/markdown'const mapper = (node: any) => {if (node.type === 'text') {return node.data} else {return h(node.tagName, node.attribs, node.children.map(mapper))}
}export default defineComponent({name: 'Markdown',props: {markdown: {type: String,required: true,},},render(props: { markdown: string }) {const htmlString = marked(props.markdown) as stringconst nodes = parseDocument(htmlString).childrenreturn h('section',{ class: 'markdown' }, nodes.map(mapper))},
})
上述代码中,mapper函数是一个递归解析dom节点的函数。
例如一个这样的html,
<h1>标题</h1>
<p>这是一段<strong>粗体</strong>文本。</p>
经过mapper的递归解析之后,会得到以下的h函数关系:
h('h1', {}, ['标题'])
h('p', {}, ['这是一段', h('strong', {}, ['粗体']), '文本。'])