浏览器流式文字输出
如今各种AI工具和智能体的兴起,让我们能够享受AI工具带来的便捷。这些AI工具在回复给我们消息时,不是一瞬间输出所有内容,而是像打字一样连续的输出。当然这是为了提升用户体验,也给我们带来ai真的在思考的错觉。那么我们来探讨下在浏览器如何实现这种效果吧
方案探讨
定时器
优点:
- 兼容性强:浏览器都支持
缺点:
- 延迟:轮询的间隔时间会导致数据更新的延迟。
- 资源消耗:频繁的轮询请求会消耗更多的服务器资源和网络带宽。
- 实现复杂性:需要手动管理客户端的状态和定时器。
SSE
优点:
- 轻量级,使用简单
- 单工,只能服务端单向发送消息 安全性更好
- 内置断线重连和消息追踪的功能
缺点:
- 连接数存在限制:HTTP/1.x 6个,HTTP/2.x 协商数量(默认100个)
- 无法添加自定义请求头。
- 只能使用 GET 方法,不能指定其他 HTTP 方法。
- 连接中断时,浏览器的重试策略有限,可能不足以支持健壮的应用需求。
需要考虑浏览器兼容性(IE)
WebSocket
优点:
- 事件类型广泛
- 连接数无限制
缺点:
- 使用较为复杂
- 全双工,客户端可以向服务端发送内容,增加服务端的安全成本
- 重试机制需自行实现
看起来基于当前场景下,SSE是最为合适的。
SSE 代码示例
服务端
使用express+http实现的简易服务器
const express = require("express");
const http = require("http");
const app = express();
const port = 3001;
// 存储所有客户端连接
const clients = new Set();
// 要发送的文本内容
const sampleText = `这是一个基于Server-Sent Events (SSE) 的流式文本输出示例。SSE是一种允许服务器主动向客户端发送数据的Web API,非常适合实现实时文本流、日志输出、聊天系统等场景。与WebSocket相比,SSE是单向通信,更轻量级,且内置于浏览器中,无需额外的协议支持。`;
app.use((req, res, next) => {
req.httpVersion = "";
next();
});
// SSE流接口
app.get("/stream", (req, res) => {
// 设置SSE响应头
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.flushHeaders();
// 添加客户端到连接集合
console.log("clients: ", clients);
clients.add(res);
// 发送初始数据
const textStream = startTextStream();
// 客户端断开连接时清理
req.on("close", () => {
clients.delete(res);
textStream.interrupt();
res.end();
});
});
function resWriteHandle(options) {
// \n\n 结束标识
if (options.data) {
this.write(`data: ${JSON.stringify(options.data)}\n\n`);
}
if (options.id) {
this.write(`id: ${options.id}\n\n`);
}
if (options.event) {
this.write(`event: ${options.event}\n\n`);
}
if (options.retry) {
this.write(`retry: ${options.retry}\n\n`);
}
}
// 启动文本流发送
function startTextStream() {
let index = 0;
let gap = 5;
let timeFlag = null;
let isInterrupted = false; // 新增中断标志
function sendChar() {
if (isInterrupted) {
// 如果已中断,则清除定时器并返回
clearTimeout(timeFlag);
return;
}
if (index < sampleText.length) {
const char = sampleText.slice(index, index + gap);
// 向所有客户端发送字符
clients.forEach((client) => {
resWriteHandle.call(client, { data: { char: escapeSse(char) } });
// client.write(`data: {"char": "${escapeSse(char)}"}\n\n`);
});
index += gap;
timeFlag = setTimeout(sendChar, 50); // 控制发送速度
} else {
// 文本发送完毕,等待3秒后重新开始
timeFlag = setTimeout(() => {
index = 0;
sendChar();
}, 3000);
}
}
sendChar();
// 返回一个对象,包含中断函数
return {
timeFlag,
interrupt: function () {
isInterrupted = true;
},
};
}
// 转义SSE特殊字符
function escapeSse(data) {
return data.replace(/\n/g, "\\n").replace(/:/g, "\\:");
}
// 使用 http 模块创建服务器,并设置 allowHTTP1 为 true 如果客户端报错426可以尝试设置
const server = http.createServer(
{
allowHTTP1: true,
},
app
);
// 启动服务器
server.listen(port, () => {
console.log(`Server running on port ${port}`);
// startTextStream(); // 开始发送文本流
});
// 优雅关闭服务器
process.on("SIGINT", () => {
server.close(() => {
console.log("Server closed");
process.exit(0);
});
});
客户端
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>SSE流式文本示例</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 2rem;
}
.output-box {
border: 1px solid #ddd;
padding: 1rem;
min-height: 100px;
margin: 1rem 0;
font-size: 18px;
}
button {
padding: 0.5rem 1rem;
font-size: 16px;
}
</style>
</head>
<body>
<h1>SSE流式文本输出示例</h1>
<div class="output-box" id="output"></div>
<button id="startBtn">开始接收</button>
<button id="stopBtn" disabled>停止接收</button>
<script>
let eventSource = null;
const outputElement = document.getElementById('output');
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
// 开始接收流式文本
startBtn.addEventListener('click', () => {
outputElement.textContent = ''; // 清空输出区域
startBtn.disabled = true;
stopBtn.disabled = false;
// 创建EventSource连接到服务端的/stream接口
eventSource = new EventSource('http://localhost:3001/stream');
eventSource.onopen = function (e) {
console.log(e, "连接刚打开时触发");
};
// 监听message事件,接收服务端发送的数据
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
outputElement.textContent += data.char;
} catch (error) {
console.error('解析SSE数据失败:', error);
}
};
// 监听错误事件
eventSource.onerror = (error) => {
console.error('SSE连接错误:', error);
outputElement.textContent += '\n[连接中断,请刷新页面重试]';
eventSource.close();
startBtn.disabled = false;
stopBtn.disabled = true;
};
});
// 停止接收
stopBtn.addEventListener('click', () => {
if (eventSource) {
eventSource.close();
eventSource = null;
startBtn.disabled = false;
stopBtn.disabled = true;
outputElement.textContent += '\n[已停止接收]';
}
});
</script>
</body>
</html>
拓展
文本输出暂停和继续
参考思路
- 设置事件流的 ID 字段:服务器在发送每个事件时,可以为该事件设置一个唯一的 ID。当浏览器重新连接时,会将上次接收到的最后一个事件的 ID 通过
Last-Event-ID
请求头发送给服务器。 - 记录客户端的接收状态:服务器需要记录每个客户端的接收状态,包括客户端已经接收到的最后一个事件的 ID。这可以通过在服务器端维护一个对象或数据库来实现,对象的键可以是客户端的唯一标识(如 IP 地址、用户 ID 等),值是该客户端已经接收到的最后一个事件的 ID。
- 根据客户端状态发送事件:当服务器接收到浏览器的重新连接请求时,首先检查请求头中的
Last-Event-ID
。如果存在,服务器根据该 ID 从记录的状态中查找该客户端的接收进度,然后从该 ID 对应的事件之后开始发送事件流。如果Last-Event-ID
不存在或为初始值,服务器则从事件流的起始位置开始发送。
参考
- SEE.JS https://www.npmjs.com/package/sse.js/v/0.5.0
fetch-event-source https://www.npmjs.com/package/@microsoft/fetch-event-source
相比传统的SSE,功能更加丰富,更加强大