Vue3 实现 AI 流式打字机(SSE+时间切片模拟 React 并发)工程化完整版
Vue 实现 AI 流式对话时,高频更新易造成页面卡顿、输入阻塞,且没有 React 内置的并发渲染能力。
本文基于 MessageChannel 实现时间切片,模拟 React 低优先级更新调度,并对 SSE 流式解析、分包粘包、任务队列、内存安全做完整工程化抽离
一、核心原理
- SSE 流式解析:buffer 拼接解决 TCP 分包/粘包
- 时间切片(Time Slicing):模拟 React 并发,非阻塞 UI 渲染
- MessageChannel:宏任务调度,优先级低于交互、高于定时器
- 任务队列:避免任务覆盖、丢失,保证打字机不跳字不漏字
- 安全兜底:异常捕获、取消流、组件销毁清理,无内存泄漏
二、目录结构
src/
├─ hooks/
│ ├─ useTimeSlicedQueue.js // 时间切片调度(模拟并发)
│ └─ useSseParser.js // SSE 流式解析(分包处理)
└─ views/
└─ ChatStream.vue // AI 对话组件
三、工具 Hook 抽离(可复用)
1. useTimeSlicedQueue.js --- 时间切片调度器
javascript
/**
* 时间切片队列,模拟 React 并发更新
* @param sliceTime 每片执行时间,默认 8ms
*/
export function useTimeSlicedQueue(sliceTime = 8) {
const taskQueue = []
let isScheduling = false
const channel = new MessageChannel()
const { port1, port2 } = channel
port2.onmessage = () => {
const start = performance.now()
// 时间切片:避免长时间占用主线程
while (taskQueue.length > 0) {
const task = taskQueue.shift()
task()
if (performance.now() - start > sliceTime) break
}
isScheduling = false
// 剩余任务继续调度
if (taskQueue.length > 0) schedule()
}
function schedule() {
if (!isScheduling) {
isScheduling = true
port1.postMessage('')
}
}
// 添加低优先级更新任务
function addTask(task) {
taskQueue.push(task)
schedule()
}
// 清空队列(组件销毁用)
function clearQueue() {
taskQueue.length = 0
}
return {
addTask,
clearQueue
}
}
2. useSseParser.js --- SSE 解析器
javascript
/**
* SSE 流式解析,处理分包/粘包
* @param onChunk 解析完成回调
*/
export function useSseParser(onChunk) {
let buffer = ''
// 推入 chunk 并按换行拆分完整行
function feed(chunk) {
buffer += chunk
const lines = buffer.split('\n')
buffer = lines.pop() || ''
lines.forEach(line => parseLine(line))
}
// 解析单行 SSE
function parseLine(line) {
const trimLine = line.trim()
if (!trimLine.startsWith('data: ')) return
const dataStr = trimLine.replace('data: ', '').trim()
if (dataStr === '[DONE]') return onChunk?.({ done: true })
try {
const data = JSON.parse(dataStr)
onChunk?.({ data })
} catch (e) {
// 分包导致不完整 JSON,忽略
}
}
// 结束时冲刷剩余数据
function flush() {
if (buffer.trim()) parseLine(buffer)
buffer = ''
}
// 清空缓存
function clearParser() {
buffer = ''
}
return {
feed,
flush,
clearParser
}
}
四、Vue3 对话组件(业务层)
js
<template>
<div class="chat-container">
<div class="message-list" ref="messageListRef">
<div v-for="(msg, idx) in msgList" :key="idx" :class="['msg', msg.role]">
<div class="bubble">{{ msg.content }}</div>
</div>
</div>
<div class="input-bar">
<textarea
v-model="inputText"
@keydown.enter.exact="sendMessage"
placeholder="输入问题..."
/>
<button @click="sendMessage" :disabled="loading">发送</button>
<button v-if="loading" @click="stopGenerate">停止生成</button>
</div>
</div>
</template>
<script setup>
import { ref, onUnmounted, nextTick } from 'vue'
import { useTimeSlicedQueue } from '@/hooks/useTimeSlicedQueue'
import { useSseParser } from '@/hooks/useSseParser'
const inputText = ref('')
const msgList = ref([])
const loading = ref(false)
const messageListRef = ref(null)
// 时间切片(低优先级更新)
const { addTask, clearQueue } = useTimeSlicedQueue(8)
// SSE 解析
const { feed, flush, clearParser } = useSseParser(onChunkResult)
// 流控制
let controller = null
let reader = null
let fullText = ''
let aiMsgIndex = -1
// 发送消息
async function sendMessage() {
if (!inputText.value.trim() || loading.value) return
const text = inputText.value.trim()
inputText.value = ''
// 插入对话
msgList.value = [
...msgList.value,
{ role: 'user', content: text },
{ role: 'ai', content: '' }
]
aiMsgIndex = msgList.value.length - 1
fullText = ''
loading.value = true
controller = new AbortController()
try {
const res = await fetch('/api/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: text }),
signal: controller.signal
})
if (!res.ok) throw new Error(`请求错误 ${res.status}`)
if (!res.body) throw new Error('当前环境不支持流式')
reader = res.body.getReader()
const decoder = new TextDecoder('utf-8')
while (true) {
const { done, value } = await reader.read()
if (done) {
flush()
break
}
feed(decoder.decode(value))
}
} catch (err) {
const tip = err.name === 'AbortError' ? '\n[已停止]' : '\n[加载失败]'
updateContentView(fullText + tip)
} finally {
loading.value = false
reader = null
controller = null
}
}
// SSE 解析回调
function onChunkResult({ data, done }) {
if (done) return
const content = data?.content || data?.delta?.content || ''
if (!content) return
fullText += content
updateContentView(fullText)
}
// 时间切片更新视图(不阻塞输入)
function updateContentView(text) {
addTask(() => {
if (aiMsgIndex >= 0) {
msgList.value[aiMsgIndex].content = text
}
nextTick(scrollToBottom)
})
}
// 停止生成
function stopGenerate() {
controller?.abort()
reader?.cancel().catch(() => {})
}
// 自动滚动到底部
function scrollToBottom() {
const el = messageListRef.value
if (el) el.scrollTop = el.scrollHeight
}
// 组件销毁清理
onUnmounted(() => {
stopGenerate()
clearQueue()
clearParser()
})
</script>
<style scoped>
.chat-container {
max-width: 800px;
margin: 0 auto;
height: 100vh;
display: flex;
flex-direction: column;
}
.message-list {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.msg {
margin-bottom: 12px;
}
.msg.ai {
text-align: left;
}
.msg.user {
text-align: right;
}
.bubble {
display: inline-block;
padding: 8px 14px;
border-radius: 12px;
background: #f1f3f4;
max-width: 75%;
white-space: pre-wrap;
}
.msg.user .bubble {
background: #007bff;
color: #fff;
}
.input-bar {
padding: 12px;
border-top: 1px solid #eee;
}
textarea {
width: 100%;
height: 60px;
margin-bottom: 8px;
padding: 8px;
border-radius: 6px;
border: 1px solid #ddd;
resize: none;
}
button {
margin-right: 8px;
padding: 6px 12px;
}
</style>
五、核心亮点
- 纯 Vue3 实现,无第三方依赖
- 时间切片模拟 React 并发,输入框永不卡顿
- SSE 分包粘包完美处理,不丢字、不乱码
- 任务队列安全机制,不覆盖、不丢失、不漏更
- 工程化抽离 Hook,可复用、易维护、易扩展
- 完整异常处理 + 内存安全,支持生产环境
- 支持 停止生成、自动滚动、回车发送
六、面试/问答亮点
- Vue 没有原生并发,如何实现非阻塞流式渲染?
→ 使用 MessageChannel + 时间切片 + 任务队列 模拟低优先级更新。 - 流式为什么会卡顿?
→ 高频更新阻塞主线程,必须把 UI 更新降级为低优先级任务。 - SSE 为什么需要 buffer?
→ TCP 分包/粘包会导致 JSON 不完整,必须按行拼接解析。
七、重点对比:Vue 方案 VS React useTransition
1. 两者体验差距
在 AI 流式场景下:Vue 方案 ≈ React 95% 体验
用户几乎感知不到区别。
2. 核心原理差异
Vue(本文方案)
- 把 DOM 更新任务切小
- 执行 8ms → 暂停 → 继续
- DOM 更新一旦开始,不能中断
- 属于:事后优化、工程手段
React useTransition
- 不直接操作 DOM,在内存中构建 Fiber 树
- render 阶段可中断、可丢弃、可重启
- commit 阶段才同步更新 DOM
- 属于:框架级并发架构
3. React 到底如何实现"随时中断"?
靠三大底层设计:
(1)Fiber 链表
把渲染从递归改为迭代链表 ,每个节点一个工作单元。
每执行一个节点就判断:
- 时间到 5ms 了吗?
- 有更高优先级任务吗?
(2)双缓存 WIP 树
- Current Tree:页面真实 DOM 树
- WorkInProgress Tree:内存中计算的新树
所有 diff 都在内存进行,可随时扔掉,不影响界面。
(3)优先级调度(Lane 模型)
- 用户输入、点击 = 高优先级
- AI 流式、列表渲染 = 低优先级
高优任务可以直接打断低优任务,丢弃现有进度,优先执行。
4. 那 5ms 到底是什么?
是 React 的协作式时间片上限 ,避免长时间霸占主线程。
它不是"随时中断",只是主动让出。
真正"随时中断"靠的是:
优先级插队 + 丢弃 WIP 树。
5. 总结对比表
| 特性 | Vue 节流+时间切片 | React useTransition |
|---|---|---|
| 不阻塞输入 | ✅ | ✅ |
| 可中断渲染 | ❌(DOM 不可中断) | ✅(内存 Fiber 可中断) |
| 优先级插队 | ❌ | ✅ |
| 自动丢弃过时更新 | ❌ | ✅ |
| 框架侵入 | 无 | 强依赖 React |
| 实现成本 | 低 | 高 |
| 流式体验 | 极佳 | 极致 |
八、最终结论
- Vue 没有并发渲染架构,无法真正中断 DOM 更新。
- 但通过节流 + 时间切片 ,已经可以实现接近 React 并发的流畅体验。
- React 可中断的核心是:Fiber + 双缓存 + 优先级调度,不是 5ms 时间片。
- 本文方案是 Vue AI 流式输出的生产级最佳实践,简单、稳定、可直接上线。