vue.js

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > javascript类库 > vue.js > Vue3实现AI流式打字机

Vue3实现AI流式打字机的完整解决方案

作者:英俊潇洒美少年

本文介绍了基于MessageChannel实现Vue AI流式对话的方案,通过SSE流式解析、时间切片、任务队列等技术,实现了非阻塞UI渲染和完美处理分包粘包问题,同时提供了一套可复用的Hook,并进行了详细的对比分析,需要的朋友可以参考下

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 — 时间切片调度器

/**
 * 时间切片队列,模拟 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 解析器

/**
 * 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 对话组件(业务层)

<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 方案 VS React useTransition

1. 两者体验差距

在 AI 流式场景下:Vue 方案 ≈ React 95% 体验
用户几乎感知不到区别。

2. 核心原理差异

Vue(本文方案)

React useTransition

3. React 到底如何实现“随时中断”?

靠三大底层设计:

(1)Fiber 链表

把渲染从递归改为迭代链表,每个节点一个工作单元。
每执行一个节点就判断:

(2)双缓存 WIP 树

所有 diff 都在内存进行,可随时扔掉,不影响界面。

(3)优先级调度(Lane 模型)

高优任务可以直接打断低优任务,丢弃现有进度,优先执行。

4. 那 5ms 到底是什么?

是 React 的协作式时间片上限,避免长时间霸占主线程。
它不是“随时中断”,只是主动让出

真正“随时中断”靠的是:
优先级插队 + 丢弃 WIP 树

5. 总结对比表

特性Vue 节流+时间切片React useTransition
不阻塞输入
可中断渲染❌(DOM 不可中断)✅(内存 Fiber 可中断)
优先级插队
自动丢弃过时更新
框架侵入强依赖 React
实现成本
流式体验极佳极致

八、最终结论

  1. Vue 没有并发渲染架构,无法真正中断 DOM 更新。
  2. 但通过节流 + 时间切片,已经可以实现接近 React 并发的流畅体验。
  3. React 可中断的核心是:Fiber + 双缓存 + 优先级调度,不是 5ms 时间片。
  4. 本文方案是 Vue AI 流式输出的生产级最佳实践,简单、稳定、可直接上线。

以上就是Vue3实现AI流式打字机的完整解决方案的详细内容,更多关于Vue3实现AI流式打字机的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文