CodeL
以前端为翼,以 AI 为脑,向全栈而行
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 打字机效果关键点 #

  1. 双线程并行:一个线程接收数据,一个线程显示数据
  2. 缓冲区设计buffer 存全部内容,displayed 记已显示位置
  3. 光标动画:CSS @keyframes blink 实现闪烁
  4. AbortController:支持中途取消
  5. 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