← 返回首页
文章
2026-03-31
Fetch 流式获取数据:从基础到 AI 打字机效果实战
Fetch 流式获取数据:从基础到 AI 打字机效果实战 一篇文章掌握流式响应、ReadableStream API、AI 打字机效果的完整实现 一、核心概念 1.1 什么是流式获取?(大白话解释) 传统请求就像"等快递...
Fetch 流式获取数据:从基础到 AI 打字机效果实战 #
一篇文章掌握流式响应、ReadableStream API、AI 打字机效果的完整实现
一、核心概念 #
1.1 什么是流式获取?(大白话解释) #
传统请求就像"等快递"——包裹到了才能拆开,不知道里面装了什么。
流式获取就像"看电视直播"——内容一点点传输过来,边看边播放,不用等整个节目下载完。
对比:
| 方式 | 体验 | 适用场景 |
|---|---|---|
| 传统请求 | 等很久 → 突然全部显示 | 下载文件、图片 |
| 流式获取 | 边收边显示,实时反馈 | AI 对话、视频直播、大文件下载 |
1.2 为什么 AI 回答需要流式? #
AI 生成答案需要时间,一个完整回复可能要 10-30 秒:
// 传统方式 —— 用户盯着空白屏幕等 30 秒
const response = await fetch('/api/chat');
const data = await response.json();
console.log(data.answer); // 30 后才显示完整答案
// 流式方式 —— 用户立即看到 AI "正在思考"
const response = await fetch('/api/chat');
for await (const chunk of streamChunks(response)) {
console.log(chunk); // 每秒显示几个字,像真人打字
}1.3 流式获取的核心名词 #
| 名词 | 解释 |
|---|---|
| ReadableStream | 浏览器流式数据的 API,可以"一点一点"读取 |
| chunk | 数据块,每次读取的一小段数据 |
| TextDecoder | 把二进制数据转成文字的工具 |
| SSE | Server-Sent Events,服务器推送事件流 |
| 打字机效果 | 文字逐字/逐词显示,模拟打字动画 |
二、Fetch 流式基础 #
2.1 response.body 是什么? #
Fetch 返回的 response.body 是一个 ReadableStream 对象:
const response = await fetch('/api/data');
// response.body 就是 ReadableStream
console.log(response.body); // ReadableStream { ... }
// 传统方式:一次性读取全部
const text = await response.text();
// 流式方式:一点一点读取
const reader = response.body.getReader();2.2 最简单的流式读取 #
async function streamFetch(url) {
const response = await fetch(url);
const reader = response.body.getReader();
const decoder = new TextDecoder(); // 二进制 → 文字
let result = '';
while (true) {
const { done, value } = await reader.read();
if (done) break; // 读取完毕
// value 是 Uint8Array(二进制),需要解码成文字
const chunk = decoder.decode(value, { stream: true });
result += chunk;
console.log('收到:', chunk); // 每次收到数据就打印
}
console.log('完整结果:', result);
return result;
}
// 使用
streamFetch('/api/data');关键代码解析:
const { done, value } = await reader.read();
// done: boolean —— 是否读取完毕
// value: Uint8Array —— 本次读取的二进制数据块
// decoder.decode(value, { stream: true })
// { stream: true } 表示"还有后续数据",处理跨 chunk 的字符边界2.3 用 for await...of 简化(推荐) #
现代浏览器支持直接遍历 ReadableStream:
async function streamFetchSimple(url) {
const response = await fetch(url);
const decoder = new TextDecoder();
let result = '';
// 直接遍历 response.body
for await (const chunk of response.body) {
const text = decoder.decode(chunk);
result += text;
console.log('收到:', text);
}
return result;
}
// 注意:这需要浏览器支持 async iterators
// Chrome 85+, Firefox 90+, Safari 支持三、实战场景一:大文件下载进度显示 #
3.1 下载文件并显示进度 #
async function downloadWithProgress(url, onProgress) {
const response = await fetch(url);
// 获取文件总大小
const totalSize = parseInt(response.headers.get('content-length'), 10);
let downloadedSize = 0;
const reader = response.body.getReader();
const chunks = []; // 存储所有数据块
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value); // 存起来
downloadedSize += value.length;
// 回调进度
const percent = (downloadedSize / totalSize * 100).toFixed(1);
onProgress?.({
downloaded: downloadedSize,
total: totalSize,
percent: `${percent}%`
});
}
// 合并所有 chunk 成完整文件
const blob = new Blob(chunks);
return blob;
}
// 使用示例
downloadWithProgress('/api/video.mp4', (progress) => {
console.log(`下载进度: ${progress.percent}`);
document.querySelector('.progress-bar').textContent = progress.percent;
})
.then(blob => {
// 下载完成,可以保存或播放
const url = URL.createObjectURL(blob);
document.querySelector('video').src = url;
});3.2 带 UI 的下载进度条 #
<!DOCTYPE html>
<html>
<head>
<style>
.progress-container {
width: 300px;
height: 20px;
background: #eee;
border-radius: 10px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #4CAF50, #8BC34A);
width: 0%;
transition: width 0.1s;
}
.progress-text {
margin-top: 5px;
font-size: 14px;
color: #666;
}
</style>
</head>
<body>
<div class="progress-container">
<div class="progress-bar" id="progressBar"></div>
</div>
<div class="progress-text" id="progressText">准备下载...</div>
<button onclick="startDownload()">开始下载</button>
<script>
async function startDownload() {
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
try {
const blob = await downloadWithProgress(
'/api/large-file.zip',
(progress) => {
progressBar.style.width = progress.percent;
progressText.textContent = `已下载 ${progress.percent} (${progress.downloaded}/${progress.total} bytes)`;
}
);
progressText.textContent = '下载完成!';
// 自动触发保存
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'large-file.zip';
a.click();
} catch (error) {
progressText.textContent = `下载失败: ${error.message}`;
}
}
// downloadWithProgress 函数同上...
</script>
</body>
</html>四、实战场景二:AI 流式对话 #
4.1 处理 SSE 格式(Server-Sent Events) #
很多 AI API 返回的是 SSE 格式,每行是 data: {...}:
data: {"content": "你好"}
data: {"content": ",我是"}
data: {"content": "AI助手"}
data: [DONE]async function streamAIChat(message, onChunk) {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = ''; // 缓冲区,处理不完整的行
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解码本次数据
buffer += decoder.decode(value, { stream: true });
// 按行分割处理
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 最后一行可能不完整,留到下次
for (const line of lines) {
// SSE 格式:data: {...}
if (line.startsWith('data: ')) {
const data = line.slice(6); // 去掉 "data: "
if (data === '[DONE]') {
console.log('AI 回复完成');
return;
}
try {
const json = JSON.parse(data);
onChunk?.(json.content); // 回调每个片段
} catch (e) {
// JSON 解析失败,可能是换行等问题
console.warn('解析失败:', line);
}
}
}
}
}
// 使用
streamAIChat('介绍一下 React', (chunk) => {
console.log(chunk); // 逐字收到
});4.2 OpenAI 格式的流式处理 #
OpenAI API 返回的格式略有不同:
// OpenAI SSE 格式
// data: {"choices":[{"delta":{"content":"你"}}]}
// data: {"choices":[{"delta":{"content":"好"}}]}
// data: [DONE]
async function streamOpenAI(prompt, onChunk) {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`
},
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: prompt }],
stream: true // 关键:开启流式
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') return;
try {
const json = JSON.parse(data);
const content = json.choices?.[0]?.delta?.content;
if (content) {
onChunk(content);
}
} catch (e) {
// 忽略解析错误
}
}
}
}
}
// 使用
streamOpenAI('写一首关于代码的诗', (chunk) => {
document.getElementById('ai-response').textContent += chunk;
});五、实战场景三:AI 打字机效果 #
5.1 基础打字机效果 #
// 最简单的打字机效果
async function typewriterEffect(text, element, speed = 50) {
for (let i = 0; i < text.length; i++) {
element.textContent += text[i];
await sleep(speed); // 每个 character 延迟
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 使用
const el = document.getElementById('output');
typewriterEffect('你好,我是 AI 助手!', el, 100);5.2 流式 + 打字机效果(推荐) #
真正高级的效果是:流式接收 + 打字机显示,收到多少显示多少:
class AIChatStreamer {
constructor(element, options = {}) {
this.element = element;
this.speed = options.speed || 30; // 打字速度(ms/字符)
this.buffer = ''; // 接收到的全部内容
this.displayed = 0; // 已显示的字符数
this.isStreaming = false;
this.queue = []; // 收到但未显示的内容队列
}
// 开始流式对话
async startChat(message) {
this.element.textContent = '';
this.buffer = '';
this.displayed = 0;
this.isStreaming = true;
// 同时启动两个过程
const streamPromise = this._fetchStream(message);
const displayPromise = this._runTypewriter();
await Promise.all([streamPromise, displayPromise]);
}
// 流式获取数据
async _fetchStream(message) {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
this.isStreaming = false; // 标记结束
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
this.isStreaming = false;
return;
}
try {
const json = JSON.parse(data);
if (json.content) {
this.buffer += json.content; // 存入缓冲
}
} catch (e) {}
}
}
}
}
// 打字机效果循环
async _runTypewriter() {
while (this.isStreaming || this.displayed < this.buffer.length) {
if (this.displayed < this.buffer.length) {
// 还有内容要显示
this.element.textContent = this.buffer.slice(0, this.displayed + 1);
this.displayed++;
}
await sleep(this.speed);
}
// 完成后显示全部(确保没有遗漏)
this.element.textContent = this.buffer;
}
// 停止
stop() {
this.isStreaming = false;
}
}
// 使用
const streamer = new AIChatStreamer(document.getElementById('ai-response'), {
speed: 30 // 30ms 一个字符
});
streamer.startChat('介绍一下 JavaScript 的异步编程');5.3 带光标闪烁的打字机效果 #
<!DOCTYPE html>
<html>
<head>
<style>
.ai-response {
font-family: 'Courier New', monospace;
font-size: 16px;
line-height: 1.6;
padding: 20px;
background: #f5f5f5;
border-radius: 8px;
max-width: 600px;
}
/* 光标闪烁效果 */
.cursor {
display: inline-block;
width: 2px;
height: 18px;
background: #333;
margin-left: 2px;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
/* 光标隐藏(完成时) */
.cursor.done {
animation: none;
opacity: 0;
}
/* 用户输入框 */
.input-container {
margin-top: 20px;
display: flex;
gap: 10px;
}
.input-container input {
flex: 1;
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
.input-container button {
padding: 10px 20px;
background: #4CAF50;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
.input-container button:hover {
background: #45a049;
}
</style>
</head>
<body>
<h2>AI 流式对话演示</h2>
<div class="ai-response" id="aiResponse">
<span class="text"></span><span class="cursor"></span>
</div>
<div class="input-container">
<input type="text" id="userInput" placeholder="输入你的问题..." />
<button onclick="sendMessage()">发送</button>
</div>
<script>
class TypewriterStreamer {
constructor(container, options = {}) {
this.textEl = container.querySelector('.text');
this.cursorEl = container.querySelector('.cursor');
this.speed = options.speed || 25;
this.buffer = '';
this.displayed = 0;
this.isStreaming = false;
}
async chat(message) {
this.textEl.textContent = '';
this.buffer = '';
this.displayed = 0;
this.isStreaming = true;
this.cursorEl.classList.remove('done');
const [streamResult] = await Promise.all([
this._fetchStream(message),
this._typewriter()
]);
this.cursorEl.classList.add('done');
return this.buffer;
}
async _fetchStream(message) {
// 模拟 AI API(实际替换为真实 API)
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let lineBuffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
this.isStreaming = false;
break;
}
lineBuffer += decoder.decode(value, { stream: true });
const lines = lineBuffer.split('\n');
lineBuffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
this.isStreaming = false;
return;
}
try {
const json = JSON.parse(data);
if (json.content) {
this.buffer += json.content;
}
} catch (e) {}
}
}
}
}
async _typewriter() {
while (this.isStreaming || this.displayed < this.buffer.length) {
if (this.displayed < this.buffer.length) {
// 显示下一个字符
this.textEl.textContent += this.buffer[this.displayed];
this.displayed++;
}
await new Promise(r => setTimeout(r, this.speed));
}
}
stop() {
this.isStreaming = false;
this.cursorEl.classList.add('done');
}
}
// 初始化
const streamer = new TypewriterStreamer(
document.getElementById('aiResponse'),
{ speed: 20 }
);
function sendMessage() {
const input = document.getElementById('userInput');
const message = input.value.trim();
if (!message) return;
input.value = '';
streamer.chat(message);
}
// Enter 发送
document.getElementById('userInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') sendMessage();
});
</script>
</body>
</html>5.4 高级效果:按词显示 + Markdown 渲染 #
AI 回复通常是 Markdown 格式,需要边显示边渲染:
class MarkdownTypewriter {
constructor(element, options = {}) {
this.element = element;
this.speed = options.speed || 20;
this.buffer = '';
this.displayed = 0;
this.isStreaming = false;
// 需要 markdown 解析库,如 marked.js
this.mdParser = options.mdParser || marked.parse;
}
async chat(message) {
this.element.innerHTML = '';
this.buffer = '';
this.displayed = 0;
this.isStreaming = true;
await Promise.all([
this._fetchStream(message),
this._renderLoop()
]);
// 最终完整渲染
this.element.innerHTML = this.mdParser(this.buffer);
}
async _fetchStream(message) {
// ... 同上
}
async _renderLoop() {
const batchSize = 5; // 每次显示 5 个字符
while (this.isStreaming || this.displayed < this.buffer.length) {
if (this.displayed < this.buffer.length) {
// 每次多显示几个字符,减少渲染次数
const nextEnd = Math.min(this.displayed + batchSize, this.buffer.length);
const partialText = this.buffer.slice(0, nextEnd);
// 渲染部分 Markdown(可能不完整)
try {
this.element.innerHTML = this.mdParser(partialText);
} catch (e) {
// Markdown 不完整时可能报错,显示纯文本
this.element.textContent = partialText;
}
this.displayed = nextEnd;
}
await new Promise(r => setTimeout(r, this.speed));
}
}
}
// 使用(需要引入 marked.js)
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
const mdStreamer = new MarkdownTypewriter(
document.getElementById('ai-response'),
{ speed: 30, mdParser: marked.parse }
);
mdStreamer.chat('用 Markdown 格式介绍 React Hooks');六、实战场景四:AbortController 取消请求 #
用户可能中途想停止 AI 回复:
class CancellableAIChat {
constructor(element) {
this.element = element;
this.abortController = null;
}
async chat(message) {
// 取消之前的请求
if (this.abortController) {
this.abortController.abort();
}
// 创建新的 AbortController
this.abortController = new AbortController();
this.element.textContent = '';
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
signal: this.abortController.signal // 关键:传入 signal
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 处理 SSE 格式...
// 每收到数据就更新显示
this.element.textContent += extractContent(buffer);
}
} catch (error) {
if (error.name === 'AbortError') {
console.log('用户取消了请求');
this.element.textContent += ' [已停止]';
} else {
console.error('请求错误:', error);
}
}
}
// 取消当前请求
cancel() {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
}
// 使用
const chat = new CancellableAIChat(document.getElementById('response'));
// 发送
document.getElementById('send').onclick = () => {
chat.chat(input.value);
};
// 取消
document.getElementById('stop').onclick = () => {
chat.cancel();
};七、完整实战:React 组件封装 #
7.1 React AI Chat 组件 #
import React, { useState, useRef, useCallback } from 'react';
function AIChatBox() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const abortRef = useRef(null);
// 发送消息
const sendMessage = useCallback(async () => {
if (!input.trim() || isStreaming) return;
const userMessage = input.trim();
setInput('');
// 添加用户消息
setMessages(prev => [...prev, { role: 'user', content: userMessage }]);
// 添加空的 AI 消息(流式填充)
setMessages(prev => [...prev, { role: 'ai', content: '', isStreaming: true }]);
setIsStreaming(true);
// 创建 AbortController
abortRef.current = new AbortController();
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: userMessage }),
signal: abortRef.current.signal
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let lineBuffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
lineBuffer += decoder.decode(value, { stream: true });
const lines = lineBuffer.split('\n');
lineBuffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') break;
try {
const json = JSON.parse(data);
if (json.content) {
// 更新最后一条消息的内容
setMessages(prev => {
const last = prev[prev.length - 1];
return [
...prev.slice(0, -1),
{ ...last, content: last.content + json.content }
];
});
}
} catch (e) {}
}
}
}
// 标记完成
setMessages(prev => {
const last = prev[prev.length - 1];
return [
...prev.slice(0, -1),
{ ...last, isStreaming: false }
];
});
} catch (error) {
if (error.name === 'AbortError') {
setMessages(prev => {
const last = prev[prev.length - 1];
return [
...prev.slice(0, -1),
{ ...last, content: last.content + ' [已停止]', isStreaming: false }
];
});
} else {
console.error(error);
}
}
setIsStreaming(false);
abortRef.current = null;
}, [input, isStreaming]);
// 取消
const cancelStream = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort();
}
}, []);
return (
<div className="chat-container">
{/* 消息列表 */}
<div className="messages">
{messages.map((msg, i) => (
<div key={i} className={`message ${msg.role}`}>
{msg.content}
{msg.isStreaming && <span className="cursor">▊</span>}
</div>
))}
</div>
{/* 输入框 */}
<div className="input-box">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && sendMessage()}
placeholder="输入你的问题..."
disabled={isStreaming}
/>
{isStreaming ? (
<button onClick={cancelStream}>停止</button>
) : (
<button onClick={sendMessage}>发送</button>
)}
</div>
</div>
);
}
export default AIChatBox;
// CSS(可单独文件)
const styles = `
.message.user { background: #e3f2fd; margin-left: auto; }
.message.ai { background: #f5f5f5; }
.cursor { animation: blink 1s infinite; }
@keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
`;7.2 Vue 3 版本 #
<template>
<div class="ai-chat">
<!-- 消息列表 -->
<div class="messages">
<div
v-for="(msg, i) in messages"
:key="i"
:class="['message', msg.role]"
>
{{ msg.content }}
<span v-if="msg.isStreaming" class="cursor">▊</span>
</div>
</div>
<!-- 输入框 -->
<div class="input-box">
<input
v-model="input"
@keydown.enter="sendMessage"
placeholder="输入你的问题..."
:disabled="isStreaming"
/>
<button
@click="isStreaming ? cancelStream() : sendMessage()"
:class="{ stop: isStreaming }"
>
{{ isStreaming ? '停止' : '发送' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const messages = ref([]);
const input = ref('');
const isStreaming = ref(false);
let abortController = null;
async function sendMessage() {
if (!input.value.trim() || isStreaming.value) return;
const userMessage = input.value.trim();
input.value = '';
messages.value.push({ role: 'user', content: userMessage });
messages.value.push({ role: 'ai', content: '', isStreaming: true });
isStreaming.value = true;
abortController = new AbortController();
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: userMessage }),
signal: abortController.signal
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let lineBuffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
lineBuffer += decoder.decode(value, { stream: true });
const lines = lineBuffer.split('\n');
lineBuffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') break;
try {
const json = JSON.parse(data);
if (json.content) {
// 更新最后一条消息
messages.value[messages.value.length - 1].content += json.content;
}
} catch (e) {}
}
}
}
messages.value[messages.value.length - 1].isStreaming = false;
} catch (error) {
if (error.name === 'AbortError') {
messages.value[messages.value.length - 1].content += ' [已停止]';
}
messages.value[messages.value.length - 1].isStreaming = false;
}
isStreaming.value = false;
abortController = null;
}
function cancelStream() {
if (abortController) {
abortController.abort();
}
}
</script>
<style scoped>
.message.user {
background: #e3f2fd;
margin-left: auto;
}
.message.ai {
background: #f5f5f5;
}
.cursor {
animation: blink 1s infinite;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
</style>八、常见问题 #
Q1: ReadableStream 和 Blob 有什么区别? #
// Blob —— 一次性读取全部
const blob = await response.blob();
const text = await blob.text(); // 全部内容
// ReadableStream —— 流式读取
const reader = response.body.getReader();
const { value } = await reader.read(); // 只读取一小块
// 区别:
// Blob: 内存占用高,适合小文件
// Stream: 内存占用低,适合大文件、实时数据Q2: 为什么需要 TextDecoder? #
// response.body 返回的是 Uint8Array(二进制)
const { value } = await reader.read();
console.log(value); // Uint8Array(1024) [72, 101, 108, 108, 111, ...]
// 需要解码成文字
const decoder = new TextDecoder();
const text = decoder.decode(value); // "Hello..."Q3: SSE 格式为什么用 data: 前缀? #
SSE (Server-Sent Events) 是标准格式,浏览器原生支持:
// 原生 EventSource(只能 GET)
const source = new EventSource('/api/stream');
source.onmessage = (event) => {
console.log(event.data); // 自动去掉 "data: " 前缀
};
// fetch 可以 POST,更灵活
// 但需要手动解析 "data: " 格式Q4: 流式请求和 WebSocket 有什么区别? #
| 方式 | 特点 | 适用场景 |
|---|---|---|
| Fetch Stream | 单次请求 → 流式响应 | AI 对话、文件下载 |
| WebSocket | 双向实时通信 | 聊天室、游戏、股票 |
| SSE | 单向推送,自动重连 | 通知、新闻推送 |
Q5: 如何处理流式请求的错误? #
async function safeStream(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body.getReader();
// ... 流式读取
} catch (error) {
if (error.name === 'AbortError') {
console.log('用户取消');
} else if (error.name === 'TypeError') {
console.log('网络错误');
} else {
console.log('其他错误:', error);
}
}
}九、总结速记 #
Fetch 流式核心 API #
| API | 作用 | 返回值 |
|---|---|---|
response.body |
获取流对象 | ReadableStream |
reader.read() |
读取一块数据 | { done, value } |
TextDecoder.decode() |
二进制转文字 | string |
AbortController.signal |
取消请求 | AbortSignal |
流式处理流程 #
fetch(url) → response.body → getReader() → read() → decode() → 处理
↓ ↑
AbortController ←←←←← 用户取消 ←←←←←←←←←←←←←←←←←←←AI 打字机效果关键点 #
- 双线程并行:一个线程接收数据,一个线程显示数据
- 缓冲区设计:
buffer存全部内容,displayed记已显示位置 - 光标动画:CSS
@keyframes blink实现闪烁 - AbortController:支持中途取消
- Markdown 渲染:需要引入 marked.js 等库
附录:工具函数库 #
// stream-utils.js —— 可复用的流式工具函数
/**
* 流式获取文本
*/
export async function streamText(url, onChunk) {
const response = await fetch(url);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let result = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
result += chunk;
onChunk?.(chunk);
}
return result;
}
/**
* 解析 SSE 格式
*/
export async function parseSSE(response, onMessage) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') return;
try {
const json = JSON.parse(data);
onMessage?.(json);
} catch (e) {}
}
}
}
}
/**
* 带进度的下载
*/
export async function downloadProgress(url, onProgress) {
const response = await fetch(url);
const total = parseInt(response.headers.get('content-length'), 10);
const reader = response.body.getReader();
let downloaded = 0;
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
downloaded += value.length;
onProgress?.({
downloaded,
total,
percent: `${(downloaded / total * 100).toFixed(1)}%`
});
}
return new Blob(chunks);
}
/**
* 带取消的请求
*/
export function createCancellableFetch() {
let controller = null;
return {
async fetch(url, options) {
controller?.abort();
controller = new AbortController();
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
controller = null;
}
},
cancel() {
controller?.abort();
}
};
}最后更新:2026-03-31