流式响应是一个在现代 Web 应用尤其是在 AI 聊天、实时数据推送、大文件处理等场景中非常常见且重要的技术。
1. 什么是流式响应?
传统的 HTTP 请求-响应模型是“一次性”的:
- 客户端发送请求。
- 服务器处理完所有逻辑后,将完整的响应体打包,一次性发送给客户端。
- 客户端接收完整的响应后才开始处理。
流式响应则打破了这种模式。服务器在接收到请求后,可以分多次、持续不断地将数据块发送给客户端,而不必等待所有数据都准备好。客户端接收到一个数据块,就可以立即处理一个数据块。
这就像看视频:
- 传统模式:等整个视频文件都下载完才能播放。
- 流式模式:边下载边播放,体验更流畅。
2. 为什么需要 POST 请求的流式响应?
虽然 GET 请求也可以流式响应(例如大文件下载),但 POST 请求的流式响应在以下场景中尤为关键:
- AI 大语言模型 (LLM) 对话:这是最典型的应用。你发送一个 POST 请求(包含你的问题),模型不是一次性生成所有回答,而是逐字或逐词生成。通过流式响应,用户可以实时看到模型“正在思考”和“正在打字”的过程,体验极佳。
- 长时间运行的后台任务:客户端提交一个需要长时间处理的任务(如数据分析、视频转码、生成复杂报告)。服务器可以通过流式响应,实时向客户端推送任务的进度(例如:“10%... 50%... 完成!”)。
- 实时数据推送:客户端订阅某种数据源(如股票行情、日志监控),通过一个长连接的 POST 请求,服务器可以源源不断地将最新数据推送给客户端。
- 大数据量传输:当需要返回的数据量非常大,但又不是简单的文件下载时,流式传输可以减少服务器的内存压力(不需要在内存中拼装一个巨大的响应体),并让客户端更快地获得初始数据。
3. 工作原理:Transfer-Encoding: chunked
流式响应在 HTTP 协议层面主要通过 Transfer-Encoding: chunked(分块传输编码)头部来实现。
- 服务器端:
- 收到 POST 请求后,立即返回一个 HTTP 响应头,其中不包含
Content-Length,而是包含Transfer-Encoding: chunked。 - 这告诉客户端:“响应体不是一次性发完的,而是由一系列数据块组成,请准备好持续接收。”
- 然后,服务器每当有新数据产生时,就发送一个“块”。每个块都包含一个十六进制的长度值和数据本身。
- 当所有数据发送完毕后,服务器发送一个长度为 0 的特殊块,表示流结束。
- 收到 POST 请求后,立即返回一个 HTTP 响应头,其中不包含
- 客户端:1. 收到带有
Transfer-Encoding: chunked的响应头后,就不会等待连接关闭,而是进入“读取循环”。2. 持续从连接中读取数据块,并根据每个块的长度信息来解析出有效数据。3. 每收到一个数据块,就可以触发相应的处理逻辑。
现代浏览器和编程语言的 HTTP 库(如fetchAPI、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 通过 StreamingResponseBody 和 SseEmitter 提供了强大的流式响应支持,非常适合企业级应用。
使用 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 流式响应的关键点:
-
StreamingResponseBody:适用于任意类型的流式数据(文本、二进制等)- 通过
OutputStream直接写入数据 - 每次
write()后调用flush()确保立即发送 - 自动处理
Transfer-Encoding: chunked
- 通过
-
SseEmitter:更适合服务器向客户端推送事件(Server-Sent Events)- 基于 HTTP 协议,自动重连
- 支持命名事件(
event:字段) - 浏览器通过
EventSourceAPI 接收
-
线程管理:使用线程池异步处理长连接,避免阻塞主线程
-
错误处理:通过
onCompletion、onTimeout、onError回调管理连接生命周期
前端实现
<!-- 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>
前端代码核心点解析:
- 引入 Axios:通过 CDN 或 npm 安装引入
axios.post()发起请求,关键配置:responseType: 'stream'- 必须设置,告诉 Axios 以流模式处理响应- 请求参数与 fetch 类似,但更简洁
response.data是一个ReadableStream对象(fetch 中是response.body).getReader()获取流的读取器- 在
while(true)循环中,用reader.read()循环读取数据 done为true时,表示流结束value是原始的二进制数据(Uint8Array),需要用TextDecoder解码成字符串
Axios vs Fetch 对比:
| 特性 | Axios | Fetch |
|---|---|---|
| 流式响应 | 需设置 responseType: 'stream' | 原生支持 |
| 获取 Reader | response.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>
关键优化点:
- 防抖渲染:高频更新时,使用
requestAnimationFrame批量处理 - 代码块闪烁问题:流式渲染时,不完整的代码块会导致高亮闪烁,可延迟渲染代码块
- 性能优化:长文本时,使用虚拟滚动或分页加载
// 优化:防抖批量更新
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 请求的流式响应!