🔥 前端也能玩转 AI 流式输出!从二进制流到打字机效果,一篇讲透
摘要:ChatGPT 那种"一个字一个字蹦出来"的效果到底是怎么实现的?本文用一个 Vue 3 小项目,从最底层的二进制流开始,一步步带你搞懂 AI 流式输出的全部原理。初学者友好,全程大白话 + 生活比喻,看完你也能自己写一个!
TL;DR :fetch请求 AI 接口(stream: true)→response.body.getReader()拿到读取器 →while循环reader.read()逐块读二进制 →TextDecoder({ stream: true })解码 → 解析 SSE 格式提取 token →content.value += token逐字追加 → Vue 响应式自动更新页面。核心就一个 while 循环。
📌 前言
最近在学 AI 应用开发,发现不管是 ChatGPT、Claude 还是各种 AI 聊天框,它们都有一个共同特点:回答不是一次性全给你的,而是一个字一个字"蹦"出来的。
这个效果叫流式输出(Streaming Output)。
一开始觉得很高深,后来自己动手写了一个 demo 才发现------原理其实不复杂,就像拧开水龙头接水一样简单。
今天就用这个 demo,从最基础的二进制流开始,一步步讲清楚:
- 什么是二进制流?(水是怎么流动的)
- 什么是 ReadableStream?(水管是什么)
- 什么是 SSE 协议?(水里加了什么调料)
- 怎么实现打字机效果?(怎么把水接到杯子里)
🎯 本文适合谁
- ✅ 了解 Vue 3 基础(ref、响应式)的前端初学者
- ✅ 想接入 AI API 但不知道流式输出怎么处理的同学
- ✅ 听过 Stream、SSE 但不知道是啥的同学
- ❌ 已经很熟悉 ReadableStream 的大佬(可以跳过前两节)
📚 第一站:二进制流------水是怎么流动的
在讲流式输出之前,我们先搞懂一个最基础的概念:二进制流。
为什么数据是"流"?
你在浏览器里看到的文字、图片、视频,在底层都是0和1组成的二进制数据 。这些数据不是一次性全部到达的,而是像水流一样,一小块一小块地流过来。
🌊 生活比喻:想象你在用吸管喝奶茶。奶茶(数据)不是一口全到你嘴里的,而是一小口一小口通过吸管(网络)流过来的。
TextEncoder 和 TextDecoder
浏览器提供了两个很实用的 API,帮我们在字符串 和二进制数据之间转换:
javascript
// 🔤 编码器:把字符串 → 二进制数组(Uint8Array)
const encoder = new TextEncoder();
const bytes = encoder.encode('你好');
console.log(bytes);
// Uint8Array [228, 189, 160, 229, 165, 189]
// 每个数字都在 0-255 之间
// 这些数字是二进制字节的"十进制表示"------本质还是二进制,只是用十进制数字展示方便人看
// 比如 228 = 11100100(二进制),它就是"你"这个字的第一个字节
// 🔓 解码器:把二进制数组 → 字符串
const decoder = new TextDecoder('utf-8');
const str = decoder.decode(bytes);
console.log(str); // "你好"
Uint8Array 是什么?
Uint8Array 是 JavaScript 里的一种类型化数组 ,专门用来存放 0~255 之间的整数。
🧊 生活比喻 :普通数组就像一个万能收纳盒,什么都能放(数字、字符串、对象...)。而 Uint8Array 是一个专门放鸡蛋的蛋托,每个格子只能放 0~255 大小的"蛋"。
为什么是 0~255?因为 8 位二进制(1 字节)能表示的最大数就是 11111111 = 255。这就是"Uint8Array"名字的由来。
为什么要了解这个?
因为后面我们从 AI 服务器接收到的数据,就是这种 Uint8Array 二进制流。如果不认识它,后面的内容就看不懂了。
📚 第二站:ReadableStream------认识这根"水管"
什么是 ReadableStream?
ReadableStream 是浏览器原生提供的流式读取 API ,它代表一个可读的数据流。
🚰 生活比喻:ReadableStream 就是一根水管。水管的一头连着服务器(水厂),另一头连着你的浏览器(水龙头)。数据(水)从服务器流过来,你可以在浏览器这边一点一点地接。
怎么用?
当你用 fetch 请求一个返回流式数据的接口时,response.body 就是一个 ReadableStream 对象:
javascript
const response = await fetch(url, options);
// response.body 就是那根"水管"
console.log(response.body); // ReadableStream { locked: false, ... }
// 拿到一个"读取器"------就像在水管口装了一个水龙头
const reader = response.body.getReader();
reader.read()------拧开水龙头接水
reader.read() 每调用一次,就从水管里**"嘬"一口水**,返回一个 Promise:
javascript
// 每次 read() 返回 { done: boolean, value: Uint8Array }
const { done, value } = await reader.read();
// done = false → 还有水,继续接
// done = true → 水接完了,水管空了
// value = 本次接到的水(二进制数据)
💡 关键理解 :
await reader.read()会"等着"------如果服务器还没发数据过来,它就暂停在这里 ,直到有新数据到达才 resolve。这就是"流"的精髓:有数据就处理,没数据就等着。
📚 第三站:SSE 协议------水里加了什么"调料"
什么是 SSE?
SSE(Server-Sent Events)是一种服务器向浏览器推送数据的协议。AI 接口的流式输出,用的就是这种协议。
🍵 生活比喻:SSE 就像泡茶。服务器把茶叶(数据)放在滤网里,热水(网络连接)流过茶叶,每一口水都带着茶味流到你的杯子里。而且每一口的浓度可能不一样------这就是为什么 AI 的回答是一个词一个词出来的。
SSE 的数据格式长什么样?
服务器发过来的数据不是纯文本,而是有固定格式的:
css
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"你"}}]}
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"好"}}]}
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"呀"}}]}
data: [DONE]
每一条数据的规则:
- 每条消息以
data:开头(注意data:后面有个空格) - 每条消息之间用换行符
\n分隔 - 最后一条是
data: [DONE],表示流结束了
🔑 为什么要了解这个? 因为后面解析数据的时候,我们需要按换行符分割 ,然后过滤出以
data:开头的行,再解析里面的 JSON。
📚 第四站:Vue 3 实战------逐行拆解执行流程
理论讲完了,现在开始写代码!这一站是全文最重要的部分,我会把每一行代码的执行过程、每一轮循环内存里发生了什么,全部拆给你看。
项目初始化
bash
npm create vite@latest stream-demo -- --template vue
cd stream-demo
npm install
💡 这个项目没有引入任何额外依赖,完全用原生的
fetch+ReadableStream实现流式输出。
一、响应式数据准备
vue
<script setup>
import { ref } from 'vue'
const question = ref('讲一个中国龙的故事') // 用户输入框
const content = ref('') // AI 回答显示区
const stream = ref(true) // 流式/普通模式开关
</script>
三个 ref 就是全部的状态。重点理解 content :它是页面显示和流式数据的桥梁。后面所有 token 都会往它身上追加,它一变,页面就自动更新------这就是 Vue 响应式的威力。
二、调用 AI 接口
javascript
const update = async () => {
if (!question.value) return
content.value = '思考中...' // 先给用户一个反馈
const endpoint = `${import.meta.env.VITE_MIMO_API_BASE_URL}/chat/completions`
const headers = {
'Authorization': `Bearer ${import.meta.env.VITE_MIMO_API_KEY}`,
'Content-Type': 'application/json'
}
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: import.meta.env.VITE_MIMO_MODEL,
messages: [{ role: 'user', content: question.value }],
stream: stream.value // 👈 关键参数!
})
})
🔑
stream: true这个参数告诉 AI 服务器:"我要流式返回,你生成一个 token 就发一个给我,别等全部生成完。"
三、流式模式:逐轮拆解执行流程
这是整个 demo 的灵魂。我不打算给你贴一段代码然后"读者自明"------我要带你模拟执行每一轮循环,让你看到内存里数据是怎么变化的。
先看完整代码,后面逐行拆
javascript
if (stream.value) {
content.value = ''
// ===== 阶段 A:准备工具 =====
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8') // 指定 UTF-8 编码
let done = false
let buffer = ''
// ===== 阶段 B:循环读取 =====
while (!done) {
const { done: doneReading, value } = await reader.read()
done = doneReading
const chunkValue = buffer + decoder.decode(value, { stream: true })
buffer = ''
const lines = chunkValue
.split('\n')
.filter(line => line.startsWith('data: '))
// ===== 阶段 C:逐行解析 =====
for (const line of lines) {
const data = line.replace('data: ', '')
if (data === '[DONE]') {
done = true
break
}
try {
const parsed = JSON.parse(data)
const token = parsed.choices[0].delta.content
if (token) {
content.value += token
}
} catch (e) {
buffer = line
}
}
}
}
阶段 A:准备工具(只执行一次)
javascript
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let done = false
let buffer = ''
逐行解释:
| 代码 | 做了什么 | 生活比喻 |
|---|---|---|
response.body |
拿到服务器返回的 ReadableStream 对象 | 拿到一根连着水厂的水管 |
.getReader() |
从水管上"安装"一个读取器(Reader) | 在水管口装一个水龙头 |
new TextDecoder('utf-8') |
创建 UTF-8 编码的翻译器 | 准备一个翻译官 |
done = false |
开关变量,控制 while 循环 | 水流是否结束的信号灯 |
buffer = '' |
缓冲区,暂存不完整的数据 | 接水的临时容器 |
⚠️
getReader()调用后,这个 ReadableStream 就被"锁定"了 。你不能再对同一个 Stream 调用第二次getReader(),就像一根水管只能装一个水龙头。这是浏览器的安全机制。
此刻内存状态:
ini
reader = ReadableStreamReader(已锁定流,等待数据)
decoder = TextDecoder(UTF-8,待命)
done = false
buffer = ""
content = "思考中..."(页面显示"思考中...")
阶段 B:while 循环------逐轮模拟执行
这是最关键的部分。我假设 AI 服务器陆续发来了 3 个数据包(chunk),我们来看每一轮循环发生了什么。
🔵 第 1 轮循环:while (!done) → !false = true,进入!
Step 1:等待数据到达
javascript
const { done: doneReading, value } = await reader.read()
reader.read() 返回一个 Promise。此时服务器还没发数据,代码就"暂停"在这里(不是阻塞,是让出 JS 主线程,等网络数据到了再继续)。
过了大约 200ms,服务器发来了第一批数据。await 拿到结果:
ini
doneReading = false(还有更多数据)
value = Uint8Array [100, 97, 116, 97, 58, 32, 123, ...]
(这就是 "data: {" 的二进制形式)
Step 2:解码二进制 → 字符串
javascript
const chunkValue = buffer + decoder.decode(value, { stream: true })
// buffer 是 ""(空的),所以 chunkValue = decoder.decode(value, { stream: true })
⚠️
{ stream: true }是必须的! 它告诉翻译官:"这是一段流,后面还有数据,如果最后一个字符的字节被截断了,先别翻译它,留着等下一轮拼起来再翻译。"如果不传这个参数,中文等多字节字符在 chunk 边界被截断时会产生乱码(�替换字符)。
decoder.decode(value, { stream: true }) 把二进制翻译成字符串:
css
chunkValue = 'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"从"}}]}\n
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"前"}}]}\n'
💡 这个 chunk 里包含了两个 SSE 消息!服务器可能一次发了多个 token,因为 LLM 的计算速度很快,多个 token 打包成一个 chunk 发送,效率更高。
Step 3:按换行符分割 + 过滤
javascript
const lines = chunkValue
.split('\n')
.filter(line => line.startsWith('data: '))
split('\n') 把字符串按换行符切成数组:
rust
split 后: [
'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"从"}}]}',
'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"前"}}]}',
'' ← 最后一个换行符产生了一个空字符串
]
filter(line => line.startsWith('data: ')) 过滤掉空行和非数据行:
rust
lines = [
'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"从"}}]}',
'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"前"}}]}'
]
Step 4:进入 for 循环,逐行解析
第 1 行:
javascript
const data = line.replace('data: ', '')
// data = '{"id":"chatcmpl-xxx","choices":[{"delta":{"content":"从"}}]}'
if (data === '[DONE]') // 不是 [DONE],跳过
const parsed = JSON.parse(data)
// parsed = { id: "chatcmpl-xxx", choices: [{ delta: { content: "从" } }] }
const token = parsed.choices[0].delta.content
// token = "从"
if (token) { // 👈 这个判断很重要!
content.value += token
// content: "" + "从" = "从"
// ⚡ Vue 响应式触发!页面上 {{ content }} 立刻更新为 "从"
}
💡 为什么要
if (token)判断? 因为 AI 接口有时候会发送不含content的消息(比如finish_reason: "stop"的结束信号),此时parsed.choices[0].delta.content是undefined。不加判断就会拼接一个"undefined"字符串到页面上------翻车现场。
第 2 行:
javascript
// 同样的流程
token = "前"
content.value += "前"
// content: "从" + "前" = "从前"
// ⚡ 页面更新为 "从前"
第 1 轮结束时内存状态:
ini
done = false(流还没结束)
buffer = ""(没有残余数据)
content = "从前"(页面显示"从前")
🟢 第 2 轮循环:while (!done) → !false = true,继续!
Step 1:再次等待数据
javascript
const { done: doneReading, value } = await reader.read()
又暂停了,等服务器发下一个 chunk...
过了 300ms,数据到了。但这次来了一个特殊情况 ------服务器发的数据被网络截断了!一个 JSON 对象只来了一半:
ini
doneReading = false
value = Uint8Array [100, 97, 116, 97, 58, 32, 123, 34, 105, 100, ...]
解码后:
css
chunkValue = 'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"在"}}]}\n
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"' ← 被截断了!
Step 2-3:解码 + 分割 + 过滤
rust
lines = [
'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"在"}}]}',
'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"'
]
Step 4:逐行解析
第 1 行: 正常解析,token = "在"
javascript
content.value += "在"
// content = "从前" + "在" = "从前在"
// ⚡ 页面更新为 "从前在"
第 2 行: JSON 被截断,JSON.parse() 报错!
javascript
try {
const parsed = JSON.parse('data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"')
// ❌ SyntaxError: Unexpected end of JSON input
} catch (e) {
// 进入 catch 分支
buffer = line // 把这行不完整的数据存进 buffer!
}
🎯 buffer 的精髓就在这里! 不完整的数据不丢弃,存进 buffer,下一轮和新到的数据拼在一起处理。
第 2 轮结束时内存状态:
ini
done = false
buffer = 'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"' ← 不完整的数据!
content = "从前在"
🟡 第 3 轮循环:buffer 拼接------残余数据的处理
Step 1:等待数据
服务器发来了被截断的那部分数据的剩余部分:
ini
doneReading = false
value = Uint8Array [...]
解码后:
arduino
decoder.decode(value) = '山"}]}\n'
Step 2:buffer 拼接------关键一步!
javascript
const chunkValue = buffer + decoder.decode(value, { stream: true })
// buffer = 'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"'
// 新数据 = '山"}]}\n'
// 拼接后 = 'data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"山"}}]}\n'
💡 看到了吗!上一轮被截断的 JSON,和这一轮新到的数据拼在一起,恢复成了一个完整的 JSON!
Step 3-4:正常解析
makefile
lines = ['data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"山"}}]}']
javascript
token = "山"
content.value += "山"
// content = "从前在" + "山" = "从前在山"
// ⚡ 页面更新为 "从前在山"
第 3 轮结束时内存状态:
ini
done = false
buffer = ""(清空了,因为数据都处理完了)
content = "从前在山"
🔴 第 4 轮循环:收到 data: [DONE],流结束!
Step 1:等待数据
ini
doneReading = true ← 服务器说:我发完了!
value = Uint8Array [100, 97, 116, 97, 58, 32, 91, 68, 79, 78, 69, 93]
解码后:
arduino
decoder.decode(value) = 'data: [DONE]\n'
Step 2-3:解码 + 分割
ini
lines = ['data: [DONE]']
Step 4:解析
javascript
const data = line.replace('data: ', '')
// data = '[DONE]'
if (data === '[DONE]') {
done = true // ✅ 标记流结束
break // 跳出 for 循环
}
回到 while 判断: while (!done) → !true = false → 退出循环!
最终内存状态:
ini
done = true
buffer = ""
content = "从前在山"(页面最终显示"从前在山")
📊 完整执行流程图
把上面 4 轮循环画成一张图:
javascript
时间线 →→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→
服务器: [chunk1: "从前"] [chunk2: "在" + 截断] [剩余: "山"] [DONE]
↓ ↓ ↓ ↓
浏览器: 第1轮 第2轮 第3轮 第4轮
第1轮: reader.read() → Uint8Array
decoder.decode() → 'data: {..."从"}...\ndata: {..."前"}...\n'
split + filter → ["data:{...}", "data:{...}"]
JSON.parse → "从" → content = "从前" ✅
JSON.parse → "前" → content = "从前" ✅
第2轮: reader.read() → Uint8Array
decoder.decode() → 'data: {..."在"}...\ndata: {"...(截断)'
split + filter → ["data:{...}", "data:{"]
JSON.parse → "在" → content = "从前在" ✅
JSON.parse → 报错!→ buffer = 'data:{"...(截断)' ⚠️
第3轮: reader.read() → Uint8Array
decoder.decode() → '山"}]}\n'
buffer + 新数据 → 'data: {..."山"}...}\n' ← 拼接修复!
JSON.parse → "山" → content = "从前在山" ✅
第4轮: reader.read() → done = true
decoder.decode() → 'data: [DONE]\n'
data === '[DONE]' → done = true → 退出循环 🏁
页面最终显示: "从前在山"
💡 关于 { stream: true } 的补充
你可能注意到上面所有 decoder.decode(value, { stream: true }) 都传了 { stream: true }。这不是可选的------在流式场景下它是必须的。
原因:中文字符在 UTF-8 中占 3 个字节。如果 chunk 恰好在中文字符的中间被截断(比如只来了 2 个字节),decoder.decode() 默认会把这 2 个字节当成"无法识别的字符",替换成 �(U+FFFD 替换字符)。传入 { stream: true } 后,翻译官会说:"这个字符还没完,先留着,等下一轮新数据来了再一起翻译。"
javascript
// ❌ 不传 stream: true → 中文截断时产生乱码 "�"
decoder.decode(value)
// ✅ 传了 stream: true → 截断的字节会保留到下一轮
decoder.decode(value, { stream: true })
🔄 关于 [DONE] 和 done 的关系
第 4 轮循环中,你可能注意到 done 被设为 true 有两个来源:
reader.read()返回的doneReading = true(网络层说:流关闭了)- 解析到
data: [DONE](业务层说:AI 生成完了)
在实际场景中,这两个信号不一定同时到达:
- 可能
doneReading = false但数据里包含[DONE]------大部分情况是这样 - 可能
doneReading = true且数据里包含[DONE]------最后一包恰好是结束标记 - 可能
doneReading = true但没有[DONE]------网络异常断开
当前代码用 while (!done) 同时监听两个信号,无论哪种情况都能正确退出。
⚠️ buffer 的一个边界场景
当前的 buffer 机制有一个极端情况 没覆盖到:如果整个 chunk 恰好是某个 JSON 消息的前半部分(没有换行符 \n),split('\n') 不会切出它,filter 也会把它过滤掉------数据就丢了。
当前 demo 中这种情况概率极低(AI 接口一般会在每条消息末尾加 \n),但如果你要做生产级的流式解析,可以这样改进:
javascript
// 在 while 循环末尾,检查是否有未被 filter 捕获的残余数据
const lines = chunkValue
.split('\n')
.filter(line => line.startsWith('data: '))
// 如果 chunkValue 不以 '\n' 结尾,最后一段可能是不完整的消息
if (!chunkValue.endsWith('\n')) {
// 从 chunkValue 中找到最后一个 'data: ' 的位置
const lastDataIndex = chunkValue.lastIndexOf('data: ')
if (lastDataIndex !== -1) {
buffer = chunkValue.slice(lastDataIndex) // 存进 buffer
}
}
初学阶段不需要掌握这个,知道有这么个边界就行。等你真正做生产项目时再处理。
5. 页面模板
vue
<template>
<div class="container">
<!-- 输入区域 -->
<div>
<label>输入:</label>
<input class="input" v-model="question" />
<button @click="update">提交</button>
</div>
<!-- 输出区域 -->
<div class="output">
<!-- 流式/普通模式切换 -->
<div>
<label>Streaming</label>
<input type="checkbox" v-model="stream" />
</div>
<!-- AI 回答显示 -->
<div>{{ content }}</div>
</div>
</div>
</template>
四、完整数据流图解
把整个过程串起来,从用户点击按钮到页面显示文字,数据经历了这些阶段:
javascript
用户点击"提交"
│
▼
┌─────────────────────────────────────────────┐
│ ① fetch 发送 POST 请求 │
│ body: { messages: [...], stream: true } │
└─────────────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ ② AI 服务器开始生成回答 │
│ 每生成一个 token,就通过 SSE 推送一条消息 │
│ 消息格式: data: {"choices":[{"delta":...}]}
└─────────────────┬───────────────────────────┘
│
▼ (数据以 Uint8Array 二进制流的形式,通过网络到达浏览器)
┌─────────────────────────────────────────────┐
│ ③ response.body (ReadableStream) 接收数据 │
│ 数据在浏览器的"水管"里等待被读取 │
└─────────────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ ④ while 循环:reader.read() │
│ ┌───────────────────────────────────┐ │
│ │ 嘬一口 → Uint8Array │ │
│ │ TextDecoder 解码 → 字符串 │ │
│ │ 和 buffer 拼接 │ │
│ │ split('\n') + filter('data: ') │ │
│ │ for 循环逐行: │ │
│ │ JSON.parse → 提取 token │ │
│ │ content.value += token │ │
│ │ ↓ │ │
│ │ Vue 响应式触发 → DOM 局部更新 │ │
│ │ ↓ │ │
│ │ 页面显示新文字 ✨ │ │
│ └───────────────────────────────────┘ │
│ 重复,直到 done = true │
└─────────────────────────────────────────────┘
🐛 踩坑记录
坑 1:done 变量名冲突
javascript
// ❌ 错误写法
const { done, value } = await reader.read()
done = done // 自己赋值给自己???
// ✅ 正确写法:用解构重命名
const { done: doneReading, value } = await reader.read()
done = doneReading
while (!done) 里的 done 和 reader.read() 返回的 done 撞名了!用解构重命名就能解决。
坑 2:不完整的 JSON 数据包
有时候服务器发来的数据被截断 了------一个 JSON 对象可能被分成两个包发送。直接 JSON.parse 会报错。
javascript
try {
const parsed = JSON.parse(data)
// 正常处理...
} catch (e) {
// 解析失败,把这行数据存到 buffer,下一轮和新数据拼起来再处理
buffer = line
}
用 buffer 缓冲区 + try/catch 就能优雅地处理这个问题。
坑 3:API Key 安全问题
千万不要把 API Key 写死在代码里! 用 .env.local 文件管理环境变量:
bash
# .env.local(不要提交到 git)
VITE_MIMO_API_BASE_URL=https://api.xxx.com/v1
VITE_MIMO_API_KEY=sk-xxxxxxxxxxxxxxxx
VITE_MIMO_MODEL=xxx
Vite 项目中,以 VITE_ 开头的环境变量才会暴露给前端代码。
💡 重点总结
-
二进制流(Uint8Array) 是网络数据传输的基本形式,
TextEncoder/TextDecoder是字符串和二进制之间的桥梁 -
ReadableStream 是浏览器原生的流式读取 API,
response.body.getReader()获取读取器,reader.read()逐块读取 -
SSE(Server-Sent Events) 是 AI 接口流式输出的标准协议,数据以
data:前缀 + JSON 格式发送,data: [DONE]表示结束 -
Vue 响应式 + 流式读取 = 打字机效果 :每次追加一个 token 到
ref变量,页面自动局部更新 -
核心就三行代码:
javascriptconst reader = response.body.getReader() // 拿到读取器 const { value } = await reader.read() // 读一块数据 content.value += decoder.decode(value, { stream: true }) // 解码并追加
🔗 参考资料
💬 交流讨论
流式输出其实是一个很小但很核心的知识点,搞懂了之后你会发现:
- 文件上传的进度条(ReadableStream)
- 视频播放的缓冲(MediaSource)
- 大文件下载(ReadableStream + WritableStream)
它们的底层原理都是相通的------流式处理。
你在项目中用过流式输出吗?遇到了什么坑?欢迎评论区交流!
觉得有用?点个赞 👍 收藏 ⭐ 关注 👆,下篇准备写「大文件分片上传」,同样是流式处理的实战应用!