Vue3 实现 AI 流式打字机(SSE+时间切片模拟 React 并发)工程化完整版

Vue3 实现 AI 流式打字机(SSE+时间切片模拟 React 并发)工程化完整版

Vue 实现 AI 流式对话时,高频更新易造成页面卡顿、输入阻塞,且没有 React 内置的并发渲染能力。

本文基于 MessageChannel 实现时间切片,模拟 React 低优先级更新调度,并对 SSE 流式解析、分包粘包、任务队列、内存安全做完整工程化抽离


一、核心原理

  1. SSE 流式解析:buffer 拼接解决 TCP 分包/粘包
  2. 时间切片(Time Slicing):模拟 React 并发,非阻塞 UI 渲染
  3. MessageChannel:宏任务调度,优先级低于交互、高于定时器
  4. 任务队列:避免任务覆盖、丢失,保证打字机不跳字不漏字
  5. 安全兜底:异常捕获、取消流、组件销毁清理,无内存泄漏

二、目录结构

复制代码
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>

五、核心亮点

  1. 纯 Vue3 实现,无第三方依赖
  2. 时间切片模拟 React 并发,输入框永不卡顿
  3. SSE 分包粘包完美处理,不丢字、不乱码
  4. 任务队列安全机制,不覆盖、不丢失、不漏更
  5. 工程化抽离 Hook,可复用、易维护、易扩展
  6. 完整异常处理 + 内存安全,支持生产环境
  7. 支持 停止生成、自动滚动、回车发送

六、面试/问答亮点

  • 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
实现成本
流式体验 极佳 极致

八、最终结论

  1. Vue 没有并发渲染架构,无法真正中断 DOM 更新。
  2. 但通过节流 + 时间切片 ,已经可以实现接近 React 并发的流畅体验。
  3. React 可中断的核心是:Fiber + 双缓存 + 优先级调度,不是 5ms 时间片。
  4. 本文方案是 Vue AI 流式输出的生产级最佳实践,简单、稳定、可直接上线。
相关推荐
帮我吧智能服务平台2 小时前
装备制造服务数字化痛点破解:大模型+协同工具的实战应用
大数据·人工智能·制造
胡单纯2 小时前
AI 直接解析 PDF 文档!OpenClaw 2026.3.3 新功能实测太强了
数据库·人工智能·pdf
盟接之桥2 小时前
盟接之桥®说制造:从“制造”到“智造”,以品类品牌重塑制造业的生态未来
大数据·网络·人工智能·学习·制造
码码哈哈0.02 小时前
Spring AI 1.0.0 + ChromaDB 最新版踩坑:Collection does not exist 404 报错全记录
java·人工智能·spring
User_芊芊君子2 小时前
Python+Agent入门实战:0基础搭建可复用AI智能体
开发语言·人工智能·python
迷你可可小生2 小时前
图像视觉面经学习(一)
图像处理·人工智能·python·学习
炼金士2 小时前
大模型、运筹优化、概率论与控制论在港口物流智能调度中的融合应用(挑战与未来研究报告)
人工智能·概率论·集装箱码头
铁蛋AI编程实战2 小时前
旧电脑秒变 AI 员工:OpenClaw 本地部署教程(含环境配置 + 插件开发 + 常见坑)
人工智能
平安的平安2 小时前
用 Python 玩转 AI 绘图:Stable Diffusion 本地部署指南
人工智能·python·stable diffusion