浏览器流式文字输出

如今各种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 不存在或为初始值,服务器则从事件流的起始位置开始发送。

参考

Last modification:July 8, 2025
如果觉得我的文章对你有用,请随意赞赏