POST请求的流式响应

4 阅读13分钟

流式响应是一个在现代 Web 应用尤其是在 AI 聊天、实时数据推送、大文件处理等场景中非常常见且重要的技术。


1. 什么是流式响应?

传统的 HTTP 请求-响应模型是“一次性”的:

  1. 客户端发送请求。
  2. 服务器处理完所有逻辑后,将完整的响应体打包,一次性发送给客户端。
  3. 客户端接收完整的响应后才开始处理。
    流式响应则打破了这种模式。服务器在接收到请求后,可以分多次、持续不断地将数据块发送给客户端,而不必等待所有数据都准备好。客户端接收到一个数据块,就可以立即处理一个数据块。
    这就像看视频:
  • 传统模式:等整个视频文件都下载完才能播放。
  • 流式模式:边下载边播放,体验更流畅。

2. 为什么需要 POST 请求的流式响应?

虽然 GET 请求也可以流式响应(例如大文件下载),但 POST 请求的流式响应在以下场景中尤为关键:

  • AI 大语言模型 (LLM) 对话:这是最典型的应用。你发送一个 POST 请求(包含你的问题),模型不是一次性生成所有回答,而是逐字或逐词生成。通过流式响应,用户可以实时看到模型“正在思考”和“正在打字”的过程,体验极佳。
  • 长时间运行的后台任务:客户端提交一个需要长时间处理的任务(如数据分析、视频转码、生成复杂报告)。服务器可以通过流式响应,实时向客户端推送任务的进度(例如:“10%... 50%... 完成!”)。
  • 实时数据推送:客户端订阅某种数据源(如股票行情、日志监控),通过一个长连接的 POST 请求,服务器可以源源不断地将最新数据推送给客户端。
  • 大数据量传输:当需要返回的数据量非常大,但又不是简单的文件下载时,流式传输可以减少服务器的内存压力(不需要在内存中拼装一个巨大的响应体),并让客户端更快地获得初始数据。

3. 工作原理:Transfer-Encoding: chunked

流式响应在 HTTP 协议层面主要通过 Transfer-Encoding: chunked(分块传输编码)头部来实现。

  • 服务器端
    1. 收到 POST 请求后,立即返回一个 HTTP 响应头,其中不包含 Content-Length,而是包含 Transfer-Encoding: chunked
    2. 这告诉客户端:“响应体不是一次性发完的,而是由一系列数据块组成,请准备好持续接收。”
    3. 然后,服务器每当有新数据产生时,就发送一个“块”。每个块都包含一个十六进制的长度值和数据本身。
    4. 当所有数据发送完毕后,服务器发送一个长度为 0 的特殊块,表示流结束。
  • 客户端:1. 收到带有 Transfer-Encoding: chunked 的响应头后,就不会等待连接关闭,而是进入“读取循环”。2. 持续从连接中读取数据块,并根据每个块的长度信息来解析出有效数据。3. 每收到一个数据块,就可以触发相应的处理逻辑。
    现代浏览器和编程语言的 HTTP 库(如 fetch API、axios、Python 的 requests/aiohttp)都很好地封装了这一底层细节,让开发者可以方便地使用流。

4. 代码实现示例

下面我们用两个最流行的技术栈来演示如何实现:后端前端

后端实现

示例 1: Node.js (Express)
// server.js
const express = require("express");
const app = express();
const port = 3000;
// 使用 express.json() 中间件来解析 POST 请求的 body
app.use(express.json());
app.post("/stream", (req, res) => {
  console.log("收到请求体:", req.body);
  // 设置响应头,告诉客户端这是一个流式响应,内容是纯文本
  // Express 会在你使用 res.write() 时自动设置 Transfer-Encoding: chunked
  res.writeHead(200, {
    "Content-Type": "text/plain; charset=utf-8",
    "Transfer-Encoding": "chunked", // 这一行可以省略,Express 会自动处理
  });
  let count = 0;
  const maxCount = 10;
  const intervalId = setInterval(() => {
    count++;
    const chunk = `这是第 ${count} 条数据...\n`;

    // 发送一个数据块
    res.write(chunk);
    if (count >= maxCount) {
      // 所有数据发送完毕,结束响应
      clearInterval(intervalId);
      res.end(); // 发送结束标记
      console.log("流式响应发送完毕");
    }
  }, 500); // 每 500ms 发送一次
});
app.listen(port, () => {
  console.log(`服务器运行在 http://localhost:${port}`);
});
示例 2: Python (FastAPI)

FastAPI 对流式响应的支持非常优雅,特别适合 AI 应用。

# main.py
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio
app = FastAPI()
async def generate_data(prompt: str):
    """一个异步生成器函数,用于产生数据块"""
    yield f"收到你的问题: '{prompt}'\n"
    yield "正在思考中...\n"

    for i in range(5):
        await asyncio.sleep(1) # 模拟耗时操作
        yield f"这是第 {i+1} 步的思考结果...\n"

    yield "思考完毕,回答结束。"
@app.post("/stream")
async def stream_response(request: dict):
    prompt = request.get("prompt", "默认问题")

    # StreamingResponse 会自动处理 Transfer-Encoding: chunked
    return StreamingResponse(
        generate_data(prompt),
        media_type="text/plain; charset=utf-8"
    )
# 运行命令: uvicorn main:app --reload
示例 3: Java (Spring Boot)

Spring Boot 通过 StreamingResponseBodySseEmitter 提供了强大的流式响应支持,非常适合企业级应用。

使用 StreamingResponseBody(通用流式响应):

// StreamController.java
package com.example.streamingdemo.controller;

import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;

import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Map;

@RestController
@RequestMapping("/api")
@CrossOrigin(origins = "*") // 允许跨域,生产环境请配置具体域名
public class StreamController {

    @PostMapping(value = "/stream", produces = MediaType.TEXT_PLAIN_VALUE)
    public ResponseEntity<StreamingResponseBody> streamResponse(@RequestBody Map<String, String> request) {
        String prompt = request.getOrDefault("prompt", "默认问题");
        
        StreamingResponseBody stream = (OutputStream outputStream) -> {
            // 发送初始消息
            String initialMsg = "收到你的问题: '" + prompt + "'\n";
            outputStream.write(initialMsg.getBytes(StandardCharsets.UTF_8));
            outputStream.flush();
            
            outputStream.write("正在思考中...\n".getBytes(StandardCharsets.UTF_8));
            outputStream.flush();
            
            // 模拟分块发送数据
            for (int i = 1; i <= 5; i++) {
                Thread.sleep(1000); // 模拟耗时操作
                String chunk = "这是第 " + i + " 步的思考结果...\n";
                outputStream.write(chunk.getBytes(StandardCharsets.UTF_8));
                outputStream.flush(); // 立即刷新,确保数据发送
            }
            
            outputStream.write("思考完毕,回答结束。".getBytes(StandardCharsets.UTF_8));
            outputStream.flush();
        };
        
        return ResponseEntity.ok()
                .contentType(MediaType.TEXT_PLAIN)
                .body(stream);
    }
}

使用 SseEmitter(服务器发送事件,更适合实时推送):

// SseController.java
package com.example.streamingdemo.controller;

import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@RestController
@RequestMapping("/api")
@CrossOrigin(origins = "*")
public class SseController {
    
    private final ExecutorService executor = Executors.newCachedThreadPool();

    @PostMapping("/sse-stream")
    public SseEmitter sseStream(@RequestBody Map<String, String> request) {
        String prompt = request.getOrDefault("prompt", "默认问题");
        
        // 创建 SseEmitter,设置超时时间(0 表示不超时)
        SseEmitter emitter = new SseEmitter(0L);
        
        executor.execute(() -> {
            try {
                // 发送初始事件
                emitter.send(SseEmitter.event()
                        .name("message")
                        .data("收到你的问题: '" + prompt + "'"));
                
                emitter.send(SseEmitter.event()
                        .name("message")
                        .data("正在思考中..."));
                
                // 模拟流式生成内容
                for (int i = 1; i <= 5; i++) {
                    Thread.sleep(1000);
                    emitter.send(SseEmitter.event()
                            .name("message")
                            .data("这是第 " + i + " 步的思考结果..."));
                }
                
                emitter.send(SseEmitter.event()
                        .name("message")
                        .data("思考完毕,回答结束。"));
                
                // 发送完成事件
                emitter.send(SseEmitter.event().name("complete").data(""));
                emitter.complete();
                
            } catch (IOException | InterruptedException e) {
                emitter.completeWithError(e);
            }
        });
        
        // 处理连接断开
        emitter.onCompletion(() -> System.out.println("SSE 连接已关闭"));
        emitter.onTimeout(() -> System.out.println("SSE 连接超时"));
        emitter.onError((e) -> System.out.println("SSE 连接错误: " + e.getMessage()));
        
        return emitter;
    }
}

Maven 依赖(pom.xml):

<dependencies>
    <!-- Spring Boot Web Starter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- 可选:用于处理 JSON -->
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>
</dependencies>

Spring Boot 流式响应的关键点:

  1. StreamingResponseBody:适用于任意类型的流式数据(文本、二进制等)

    • 通过 OutputStream 直接写入数据
    • 每次 write() 后调用 flush() 确保立即发送
    • 自动处理 Transfer-Encoding: chunked
  2. SseEmitter:更适合服务器向客户端推送事件(Server-Sent Events)

    • 基于 HTTP 协议,自动重连
    • 支持命名事件(event: 字段)
    • 浏览器通过 EventSource API 接收
  3. 线程管理:使用线程池异步处理长连接,避免阻塞主线程

  4. 错误处理:通过 onCompletiononTimeoutonError 回调管理连接生命周期

前端实现

<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>POST 流式响应示例</title>
    <style>
      body {
        font-family: sans-serif;
        padding: 20px;
      }
      #output {
        border: 1px solid #ccc;
        padding: 10px;
        height: 300px;
        overflow-y: scroll;
        white-space: pre-wrap; /* 保留换行和空格 */
        background-color: #f9f9f9;
      }
      button {
        padding: 10px 15px;
        font-size: 16px;
      }
    </style>
  </head>
  <body>
    <h1>POST 流式响应演示</h1>
    <button id="fetchBtn">开始请求流式数据</button>
    <div id="output"></div>
    <!-- 引入 Axios -->
    <script src="https://cdn.jsdelivr.net/npm/axios@1.6.0/dist/axios.min.js"></script>
    <script>
      const fetchBtn = document.getElementById("fetchBtn");
      const outputDiv = document.getElementById("output");
      fetchBtn.addEventListener("click", async () => {
        outputDiv.textContent = ""; // 清空上次的内容
        fetchBtn.disabled = true;
        fetchBtn.textContent = "请求中...";
        try {
          // 使用 Axios 发送流式 POST 请求
          const response = await axios.post(
            "http://localhost:3000/stream", // 或 FastAPI 的地址
            { prompt: "请解释什么是流式响应?" }, // 请求体
            {
              responseType: "stream", // 关键:设置响应类型为流
              headers: { "Content-Type": "application/json" }
            }
          );
          
          // response.data 是一个 ReadableStream 对象
          const reader = response.data.getReader();
          const decoder = new TextDecoder("utf-8");
          
          while (true) {
            // reader.read() 返回一个 Promise,解析为 { done, value }
            const { done, value } = await reader.read();

            if (done) {
              console.log("流接收完毕");
              break;
            }

            // value 是一个 Uint8Array,需要解码成字符串
            const chunk = decoder.decode(value, { stream: true });
            console.log("收到数据块:", chunk);

            // 将数据块追加到页面上
            outputDiv.textContent += chunk;
          }
        } catch (error) {
          console.error("请求失败:", error);
          if (error.code === "ERR_NETWORK") {
            outputDiv.textContent = "网络错误!请检查后端服务是否已启动,并检查 CORS 配置。";
          } else {
            outputDiv.textContent = `错误: ${error.message}`;
          }
        } finally {
          fetchBtn.disabled = false;
          fetchBtn.textContent = "开始请求流式数据";
        }
      });
    </script>
  </body>
</html>

前端代码核心点解析

  1. 引入 Axios:通过 CDN 或 npm 安装引入
  2. axios.post() 发起请求,关键配置:
    • responseType: 'stream' - 必须设置,告诉 Axios 以流模式处理响应
    • 请求参数与 fetch 类似,但更简洁
  3. response.data 是一个 ReadableStream 对象(fetch 中是 response.body
  4. .getReader() 获取流的读取器
  5. while(true) 循环中,用 reader.read() 循环读取数据
  6. donetrue 时,表示流结束
  7. value 是原始的二进制数据(Uint8Array),需要用 TextDecoder 解码成字符串

Axios vs Fetch 对比

特性AxiosFetch
流式响应需设置 responseType: 'stream'原生支持
获取 Readerresponse.data.getReader()response.body.getReader()
错误处理自动拦截 HTTP 错误码需手动检查 response.ok
浏览器兼容需引入库(~30KB)原生 API
请求取消支持 CancelToken需使用 AbortController
<script setup>
  import { ref } from "vue";
  import axios from "axios";

  // 响应式状态
  const output = ref(""); // 用于显示流式数据
  const isLoading = ref(false); // 用于控制按钮状态
  const buttonText = ref("开始请求流式数据");

  // 发起流式请求的函数
  const fetchData = async () => {
    output.value = ""; // 清空上次的结果
    isLoading.value = true;
    buttonText.value = "请求中...";

    try {
      // 关键点:设置 responseType: 'stream'
      // 这告诉 Axios 不要等待整个响应完成,而是直接返回一个可读流
      const response = await axios.post(
        "http://localhost:3000/stream",
        {
          prompt: "这是来自 Vue + Axios 的请求!",
        },
        {
          responseType: "stream",
        },
      );

      // response.data 就是一个 ReadableStream 对象
      const reader = response.data.getReader();
      const decoder = new TextDecoder("utf-8");

      // 循环读取流中的数据块
      while (true) {
        const { done, value } = await reader.read();

        if (done) {
          console.log("流接收完毕");
          break;
        }

        // 将 Uint8Array 解码为字符串
        const chunk = decoder.decode(value, { stream: true });
        console.log("收到数据块:", chunk);

        // 将数据块追加到 output,Vue 的响应性会自动更新 DOM
        output.value += chunk;
      }
    } catch (error) {
      console.error("请求失败:", error);
      // 如果是 CORS 错误,会在这里捕获到
      if (error.code === "ERR_NETWORK") {
        output.value = "网络错误!请检查后端服务是否已启动,并检查 CORS 配置。";
      } else {
        output.value = `请求失败: ${error.message}`;
      }
    } finally {
      isLoading.value = false;
      buttonText.value = "开始请求流式数据";
    }
  };
</script>

<template>
  <div id="app-container">
    <h1>Vue 3 + Axios 流式响应演示</h1>
    <button @click="fetchData" :disabled="isLoading">{{ buttonText }}</button>
    <!-- 
      使用 <pre> 标签可以很好地保留换行和空格,比 <div> 更适合显示流式文本
      v-html 而不是 {{ }} 是为了能正确渲染换行符
    -->
    <pre><code>{{ output }}</code></pre>
  </div>
</template>

<style scoped>
  #app-container {
    font-family: Avenir, Helvetica, Arial, sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-align: center;
    color: #2c3e50;
    margin-top: 60px;
    padding: 20px;
  }

  button {
    padding: 10px 20px;
    font-size: 16px;
    cursor: pointer;
    margin-bottom: 20px;
  }

  button:disabled {
    cursor: not-allowed;
    opacity: 0.6;
  }

  pre {
    text-align: left;
    background-color: #f5f5f5;
    border: 1px solid #ddd;
    padding: 15px;
    border-radius: 5px;
    white-space: pre-wrap; /* 允许长单词或 URL 地址换行 */
    word-wrap: break-word; /* 允许在单词内换行 */
    max-width: 600px;
    margin: 0 auto;
  }
</style>

5. Vue 主流 Markdown 插件推荐

在 Vue 项目中渲染 Markdown 内容(如文档、博客、AI 聊天消息等),以下是主流的 Markdown 插件:

1. marked + DOMPurify(最轻量)

npm install marked dompurify
<template>
  <div class="markdown-body" v-html="sanitizedHtml"></div>
</template>

<script setup>
import { computed } from 'vue';
import { marked } from 'marked';
import DOMPurify from 'dompurify';

const props = defineProps({
  content: String
});

// 配置 marked 选项
marked.setOptions({
  breaks: true,      // 支持换行
  gfm: true,         // GitHub Flavored Markdown
  headerIds: true,   // 为标题生成 ID
  mangle: false
});

const sanitizedHtml = computed(() => {
  const rawHtml = marked.parse(props.content || '');
  return DOMPurify.sanitize(rawHtml);
});
</script>

<style scoped>
.markdown-body {
  line-height: 1.6;
}
.markdown-body :deep(h1) { font-size: 2em; border-bottom: 1px solid #eaecef; }
.markdown-body :deep(pre) {
  background: #f6f8fa;
  padding: 16px;
  border-radius: 6px;
  overflow-x: auto;
}
.markdown-body :deep(code) {
  font-family: 'Consolas', monospace;
  background: rgba(175, 184, 193, 0.2);
  padding: 2px 6px;
  border-radius: 3px;
}
</style>

优点:体积小、性能好、可定制性强
缺点:需要手动配置语法高亮和 XSS 防护


2. @mdit-vue

npm install markdown-it @mdit-vue/plugin-component
<template>
  <div class="markdown-body" v-html="renderedContent"></div>
</template>

<script setup>
import { computed } from 'vue';
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';

const props = defineProps({ content: String });

const md = new MarkdownIt({
  html: true,        // 允许 HTML 标签
  breaks: true,      // 转换换行符为 <br>
  linkify: true,     // 自动转换 URL 为链接
  typographer: true  // 启用排版优化
});

const renderedContent = computed(() => {
  return DOMPurify.sanitize(md.render(props.content || ''));
});
</script>

优点:插件生态丰富、支持 Vue SFC 组件嵌入
缺点:体积比 marked 稍大


3. vue-markdown-render(最简单)

npm install vue-markdown-render
<template>
  <VueMarkdown :source="content" />
</template>

<script setup>
import VueMarkdown from 'vue-markdown-render';

defineProps({ content: String });
</script>

优点:零配置、开箱即用
缺点:自定义能力有限


4. mavon-editor(富文本编辑器 + 预览)

npm install mavon-editor
<template>
  <!-- 编辑模式 -->
  <mavon-editor v-model="content" />
  
  <!-- 仅预览模式 -->
  <mavon-editor
    v-model="content"
    :subfield="false"
    :defaultOpen="'preview'"
    :toolbarsFlag="false"
  />
</template>

<script setup>
import { ref } from 'vue';
import { mavonEditor } from 'mavon-editor';
import 'mavon-editor/dist/css/index.css';

const content = ref('# Hello World');
</script>

优点:功能完整、支持编辑和预览、内置代码高亮
缺点:体积较大、UI 风格固定


5. v-md-editor(推荐用于文档站)

npm install @kangc/v-md-editor
<template>
  <!-- 预览模式 -->
  <v-md-preview :text="content"></v-md-preview>
  
  <!-- 编辑模式 -->
  <v-md-editor v-model="content" height="400px"></v-md-editor>
</template>

<script setup>
import { ref } from 'vue';
import VMdPreview from '@kangc/v-md-editor/lib/preview';
import '@kangc/v-md-editor/lib/style/preview.css';
import githubTheme from '@kangc/v-md-editor/lib/theme/github.js';
import '@kangc/v-md-editor/lib/theme/style/github.css';
import hljs from 'highlight.js';

VMdPreview.use(githubTheme, { Hljs: hljs });

const content = ref('# Hello World\n\n```js\nconsole.log("hi")\n```');
</script>

优点:主题丰富、支持目录导航、代码复制、图片预览
缺点:配置较复杂


选型建议

场景推荐方案
轻量级展示(AI 聊天消息)marked
需要插件扩展markdown-it
快速开发、零配置vue-markdown-render
后台管理系统mavon-editor
文档站点v-md-editor

流式渲染 Markdown(AI 打字机效果)

在 AI 聊天应用中,需要实现 Markdown 内容的逐字/逐块渲染效果。以下是 Vue 3 + marked 的完整实现:

<template>
  <div class="chat-container">
    <div
      v-for="(msg, index) in messages"
      :key="index"
      :class="['message', msg.role]"
    >
      <!-- 用户消息直接显示 -->
      <div v-if="msg.role === 'user'" class="content">{{ msg.content }}</div>
      
      <!-- AI 消息:流式渲染 -->
      <div v-else class="content markdown-body" v-html="renderMarkdown(msg.content)"></div>
    </div>
    
    <!-- 正在输入的指示器 -->
    <div v-if="isStreaming" class="typing-indicator">
      <span></span><span></span><span></span>
    </div>
  </div>
</template>

<script setup>
import { ref, nextTick } from 'vue';
import axios from 'axios';
import { marked } from 'marked';
import DOMPurify from 'dompurify';

// 消息列表
const messages = ref([
  { role: 'user', content: '请解释什么是流式响应?' },
  { role: 'assistant', content: '' } // AI 回复,初始为空
]);

const isStreaming = ref(false);

// 配置 marked
marked.setOptions({
  breaks: true,
  gfm: true,
  headerIds: false  // 流式渲染时禁用标题 ID 生成
});

// 渲染 Markdown(带 XSS 防护)
const renderMarkdown = (content) => {
  if (!content) return '';
  const rawHtml = marked.parse(content);
  return DOMPurify.sanitize(rawHtml);
};

// 使用 Axios 实现流式接收数据
const streamResponse = async () => {
  isStreaming.value = true;
  const currentMsg = messages.value[messages.value.length - 1];
  
  try {
    // 关键点:设置 responseType: 'stream' 获取原始流
    const response = await axios.post(
      'http://localhost:8080/api/stream',
      { prompt: '解释流式响应' },
      {
        // 设置响应类型为流
        responseType: 'stream', // 必须设置,否则 Axios 会等待完整响应
        headers: { 'Content-Type': 'application/json' }
      }
    );
    
    // response.data 是一个 ReadableStream
    const reader = response.data.getReader();
    const decoder = new TextDecoder('utf-8');
    
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      
      const chunk = decoder.decode(value, { stream: true });
      currentMsg.content += chunk; // 追加内容,Vue 自动更新渲染
      
      // 可选:滚动到底部
      await nextTick();
      scrollToBottom();
    }
  } catch (error) {
    console.error('流式请求失败:', error);
    if (error.code === 'ERR_NETWORK') {
      currentMsg.content += '\n\n[网络错误:请检查后端服务是否启动]';
    } else {
      currentMsg.content += '\n\n[请求失败: ' + error.message + ']';
    }
  } finally {
    isStreaming.value = false;
  }
};

// 滚动到底部
const scrollToBottom = () => {
  const container = document.querySelector('.chat-container');
  if (container) {
    container.scrollTop = container.scrollHeight;
  }
};

// 启动流式请求
streamResponse();
</script>

<style scoped>
.chat-container {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
  height: 600px;
  overflow-y: auto;
}

.message {
  margin-bottom: 20px;
  padding: 15px;
  border-radius: 12px;
}

.message.user {
  background: #e3f2fd;
  margin-left: 20%;
}

.message.assistant {
  background: #f5f5f5;
  margin-right: 20%;
}

/* Markdown 样式 */
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {
  margin-top: 16px;
  margin-bottom: 12px;
}

.markdown-body :deep(pre) {
  background: #f6f8fa;
  padding: 16px;
  border-radius: 8px;
  overflow-x: auto;
}

.markdown-body :deep(code) {
  font-family: 'Consolas', monospace;
  font-size: 0.9em;
}

.markdown-body :deep(p) {
  margin: 8px 0;
}

/* 打字动画指示器 */
.typing-indicator {
  display: flex;
  gap: 4px;
  padding: 15px;
}

.typing-indicator span {
  width: 8px;
  height: 8px;
  background: #999;
  border-radius: 50%;
  animation: typing 1.4s infinite;
}

.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }

@keyframes typing {
  0%, 60%, 100% { transform: translateY(0); }
  30% { transform: translateY(-10px); }
}
</style>

关键优化点:

  1. 防抖渲染:高频更新时,使用 requestAnimationFrame 批量处理
  2. 代码块闪烁问题:流式渲染时,不完整的代码块会导致高亮闪烁,可延迟渲染代码块
  3. 性能优化:长文本时,使用虚拟滚动或分页加载
// 优化:防抖批量更新
let pendingContent = '';
let updateTimer = null;

const appendChunk = (chunk) => {
  pendingContent += chunk;
  
  if (!updateTimer) {
    updateTimer = requestAnimationFrame(() => {
      currentMsg.value.content += pendingContent;
      pendingContent = '';
      updateTimer = null;
    });
  }
};

6. 注意事项和最佳实践

  • 错误处理:如果流中途断开(如网络问题、服务器崩溃),客户端的 reader.read() 会抛出异常。务必用 try...catch 包裹。
  • 背压:如果客户端处理数据的速度远慢于服务器发送的速度,数据会在客户端内存中堆积。现代浏览器和流 API 会自动管理背压,但在自己实现流时需要注意。
  • 超时:长连接容易受服务器、代理(如 Nginx)或客户端的超时设置影响。需要适当调大超时时间。
  • 连接关闭:如果用户关闭了页面,客户端的连接会断开。好的后端实现应该能检测到连接断开(例如在 Node.js 中监听 res.on('close')),并停止相应的后台任务,避免资源浪费。
  • 替代方案:对于纯服务器到客户端的单向数据流,服务器发送事件 (SSE) 是一个更标准化的选择,它基于流式响应,但提供了更简单的事件格式和自动重连机制。如果需要双向实时通信,则应该考虑 WebSocket

总结

POST 请求的流式响应是一种强大的技术,它通过 Transfer-Encoding: chunked 实现了服务器向客户端的持续、低延迟数据推送。它极大地提升了用户在实时交互和长任务场景下的体验,并且能有效管理服务器资源。现代前端和后端框架都提供了成熟的 API 来简化其实现,是全栈开发者应该掌握的重要技能。
希望这个解释和示例能帮助你理解并实现 POST 请求的流式响应!