vue3实现流式输出

前端的“流式输出”本质是:后端不要等 AI 完整生成后再返回,而是边生成边写入 HTTP 响应;
Vue3 通过 ReadableStream 持续读取数据,并把新文本追加到响应变量中。

1
2
3
4
5
6
7
Vue3
│ fetch + ReadableStream

后端接口
│ 持续请求 AI 服务

AI 模型

后端必须设置接口响应头是:

1
2
3
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
响应头 作用
‌Content-Type‌ text/event-stream 声明数据格式为事件流,触发浏览器流式解析。
‌Cache-Control‌ no-cache ‌禁止缓存‌。防止浏览器或代理服务器缓存响应内容,确保每次收到的都是最新实时数据。
‌Connection‌ keep-alive ‌保持连接‌。明确告知客户端和中间网络设备不要关闭 TCP 连接,允许在同一连接上持续传输数据。

接口返回SSE格式:

1
2
3
4
5
6
7
data: {"content":"你好",format:"text"}

data: {"content":",这是",format:"markdown"}

data: {"content":"流式输出"}

data: [DONE]

vue3前端封装实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
// composables/useChatStream.ts
import {ref} from 'vue'

export function useChatStream() {
// ref 创建响应式数据;修改 .value 后,使用它的模板会自动更新
const content = ref('')
const loading = ref(false)
const error = ref('')

// 保存本次请求的控制器,点击“停止生成”时用它取消 fetch
let controller: AbortController | null = null

async function sendMessage(message: string) {
// 每次发送新问题前,清空上一次的内容和错误信息
content.value = ''
error.value = ''
loading.value = true
controller = new AbortController()

try {
// fetch 返回的是一个 Promise,需要用 await 等待服务器响应
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
// 把用户输入转换为 JSON 字符串放进请求体
body: JSON.stringify({message}),
// 把 AbortController 交给 fetch,之后才能主动取消请求
signal: controller.signal
})

// HTTP 4xx/5xx 不会自动进入 catch,需要手动判断响应是否成功
if (!response.ok) {
throw new Error(`请求失败:${response.status}`)
}

// response.body 是服务器返回的数据流;没有它就无法逐段读取
if (!response.body) {
throw new Error('浏览器不支持流式响应')
}

// reader.read() 每次读取服务器刚到达的一小段二进制数据
const reader = response.body.getReader()
// TextDecoder 负责把二进制 Uint8Array 解码成字符串
const decoder = new TextDecoder('utf-8')
// 一次 read 可能只拿到半条消息,所以先暂存在 buffer 中
let buffer = ''

while (true) {
const {value, done} = await reader.read()

// done 为 true 表示服务器已经关闭数据流
if (done) break

// stream: true 表示这次解码可能只是一个多字节字符的一部分
buffer += decoder.decode(value, {stream: true})

// SSE 事件以空行分隔
const events = buffer.split('\n\n')
// 最后一项可能是不完整事件,留到下一次 read 继续拼接
buffer = events.pop() || ''

for (const event of events) {
// 一个 SSE 事件可能有多行,这里只取 data: 开头的那一行
const line = event
.split('\n')
.find(line => line.startsWith('data:'))

if (!line) continue

// 去掉 data: 前缀,得到真正的 JSON 或文本内容
const data = line.slice(5).trim()

// [DONE] 是后端约定的结束标记,收到后结束本次处理
if (data === '[DONE]') {
return
}

try {
// 常见情况:data 是 JSON,例如 {"content":"你好"}
const parsed = JSON.parse(data)
// 追加新片段,而不是覆盖旧内容,才能呈现打字效果
content.value += parsed.content || ''
} catch {
// 如果后端直接返回纯文本,也可以直接追加
content.value += data
}
}
}
} catch (err: any) {
// 用户主动停止请求也会进入 catch,这种情况不应显示为错误
if (err.name !== 'AbortError') {
error.value = err.message || '请求失败'
}
} finally {
// 无论成功、失败还是取消,都要恢复空闲状态并释放控制器
loading.value = false
controller = null
}
}

function stop() {
// 可选链表示 controller 存在时才调用 abort,避免空指针报错
controller?.abort()
loading.value = false
}

// 暴露给组件使用:状态用于显示,方法用于发送和停止请求
return {
content,
loading,
error,
sendMessage,
stop
}
}

在组件中使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<script setup lang="ts">
import { ref } from 'vue'
import { useChatStream } from '@/composables/useChatStream'

// 输入框内容也使用 ref 保存,v-model 会负责双向同步
const input = ref('')

// 解构出组合式函数提供的响应式状态和操作方法
const {
content,
loading,
error,
sendMessage,
stop
} = useChatStream()

function submit() {
// trim() 过滤空白输入;加载中时禁止重复发送
if (!input.value.trim() || loading.value) return

// 发送当前输入内容;流式结果会不断写入 content
sendMessage(input.value)
}
</script>

<template>
<div class="chat">
<div class="response">
<!-- content 每次追加片段时,Vue 会自动重新渲染这里 -->
{{ content }}
<!-- 生成期间显示光标,让用户知道内容仍在输出 -->
<span v-if="loading">▌</span>
</div>

<!-- 只有发生错误时才显示错误信息 -->
<p v-if="error" class="error">
{{ error }}
</p>

<!-- v-model 将 textarea 的值同步到 input -->
<textarea v-model="input" />

<!-- 根据 loading 在“发送”和“停止生成”按钮之间切换 -->
<button v-if="!loading" @click="submit">
发送
</button>

<button v-else @click="stop">
停止生成
</button>
</div>
</template>

也可以用vue3的插件@ai-sdk/vue

npm i @ai-sdk/vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<script setup lang="ts">
import { useChat } from '@ai-sdk/vue'

const {
messages,
sendMessage,
status,
stop
} = useChat({
api: '/api/chat'
})

function submit() {
sendMessage({
text: '请介绍一下 Vue 3'
})
}
</script>

<template>
<div v-for="message in messages" :key="message.id">
<strong>{{ message.role }}:</strong>
<span>{{ message.content }}</span>
</div>

<button
v-if="status === 'streaming'"
@click="stop"
>
停止
</button>

<button
v-else
@click="submit"
>
发送
</button>
</template>

vue3实现流式输出
https://zouhualu.github.io/20251121/vue3实现流式输出/
作者
花鹿
发布于
2025年11月21日
许可协议