SSE Practice

Record the use of SSE to achieve the GPT typewriter effect


ChatGPT 的出现给我们的工作生活带来了很大影响。出于对ChatGPT 是如何实现让文字一个字符一个字符地出现在屏幕上的疑问?在网络上进行了一番折腾。最后发现这种实时流式显示的实现依靠着 SSE(Server-Sent Events) 技术。
让我们一起来实现一个类似的打字机效果吧。


SSE

什么是SSE ?

SSE(Server-Sent Events) 是一种 Web 标准,允许服务器向客户端推送实时数据。与 WebSocket 不同,SSE 是单向通信(服务器到客户端),专门用于实时数据流传输。

SSE 的核心概念

1. 连接建立

SSE 连接通过 HTTP 协议建立,使用特殊的 MIME 类型:text/event-stream

2. 数据格式

SSE 使用特定的文本格式:

复制代码
event: message
id: 123
data: {"content": "Hello World"}
data: It is a message

SSE vs WebSocket

特性 SSE WebSocket
通信方向 单向(服务器 → 客户端) 双向
实时性
实现复杂度 简单 中等
适用场景 通知、股票、仪表盘等 聊天、游戏帧同步等

实现一个 SSE 应用

后端实现(Koa)

首先安装依赖

json 复制代码
  "dependencies": {
    "koa": "^2.14.2",
    "koa-router": "^12.0.0",
    "koa-cors": "^0.0.16",
    "koa-bodyparser": "^4.4.1"
  },
  "devDependencies": {
    "nodemon": "^3.0.1"
  }

我们安装了结果模块,nodemon实现热重载, koa-cors解决跨域问题, 一路由和参数解析模块。

完整代码实现

js 复制代码
const Koa = require('koa');
const Router = require('koa-router');
const cors = require('koa-cors');
const bodyParser = require('koa-bodyparser');

const app = new Koa();
const router = new Router();

app.use(cors());
app.use(bodyParser());
app.use(router.routes());
app.use(router.allowedMethods());

router.get('/chat', async (ctx) => {
    const { message } = ctx.query;
    // set header
    ctx.set({
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive',
        'Access-Control-Allow-Origin': '*'
    });

    ctx.respond = false;
    ctx.status = 200;
    const res = ctx.res;
    const response = `我收到了你的消息:"${message}"。这是一个很有趣的问题,让我想想...

根据我的理解,这是一个需要深入思考的话题。我建议你可以从多个角度来分析这个问题,这样会得到更全面的答案。

如果你有更具体的问题,我很乐意为你提供更详细的解答!`;

    let charIndex = 0;
    const streamInterval = setInterval(() => {
        if (charIndex < response.length) {
            res.write(`data: {"type": "char", "char": "${response[charIndex]}", "progress": ${charIndex + 1}, "total": ${response.length}}

`);
            charIndex++;
        } else {
            res.write('data: {"type": "finished", "message": "finished"}

');
            clearInterval(streamInterval);
            res.end();
        }
    }, 100);

    ctx.req.on('close', () => {
        clearInterval(streamInterval);
        console.log('Client disconnected');
    });
});

app.listen(4200, () => {
    console.log(`Server is running`);
});

1.其中我们首先将响应头这设置为允许跨域和保持长连接。
2.然后接口的实现代码中用定时器不断地发送数据模拟流式数据,当数据传输完后关闭连接。
3.需要注意的是 ctx.respond = false 很重要,如果没有这个配置,Koa 会在函数结束时自动发送响应,导致SSE连接关闭。

javascript 复制代码
ctx.respond = false;

4.启动服务 执行命令 npm run dev

json 复制代码
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  },

5.我们的SSE服务就准备启动完成了。

前端实现

完整代码

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>SSE Practice</title>
    <style>
      body {
        background: #373232;
        font-size: 14px;
      }

      .chat-container {
        max-width: 600px;
        margin: 0 auto;
        background: white;
        border-radius: 8px;
        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
      }

      .chat-content {
        padding: 20px;
        min-height: 200px;
        max-height: 300px;
        overflow-y: auto;
        border-bottom: 1px solid #eee;
      }

      .message {
        background: #ffffff;
        padding: 10px 15px;
        border-radius: 8px;
        color: #333;
        border: 1px solid #e9ecef;
      }

      .chat-input-content {
        display: flex;
        gap: 10px;
        padding: 20px;
      }

      #chatInput {
        flex: 1;
        padding: 10px;
        border: 1px solid #ddd;
        border-radius: 4px;
      }

      #chatButton {
        cursor: pointer;
        padding: 10px 20px;
        background: #007bff;
        color: white;
        border: none;
        border-radius: 4px;
      }
    </style>
  </head>
  <body>
    <div class="chat-container">
      <div class="chat-content" id="chatContent">
        <div class="message">Hello, It's GPT</div>
      </div>
      <div class="chat-input-content">
        <input type="text" id="chatInput" placeholder="please input..." />
        <button onclick="sendMessage()" id="chatButton">Send</button>
      </div>
    </div>

    <script>
      let chatEventSource = null;

      function sendMessage() {
        const input = document.getElementById("chatInput");
        const button = document.getElementById("chatButton");
        const message = input.value.trim();
        button.textContent = "Stop";
        button.onclick = stopMessage;

        if (chatEventSource) {
          chatEventSource.close();
        }
        chatEventSource = new EventSource(
          `http://localhost:4200/chat?message=${encodeURIComponent(message)}`
        );

        let aiResponse = "";
        chatEventSource.onmessage = (event) => {
          const data = JSON.parse(event.data);
          if (data.type === "char") {
            aiResponse += data.char;
            //update message
            const aiMessage = chatContent.querySelector(".message");
            aiMessage.textContent = aiResponse;
          } else if (data.type === "finished") {
            button.textContent = "Send";
            button.onclick = sendMessage; // 恢复发送功能
            chatEventSource.close();
          }
        };

        chatEventSource.onerror = (error) => {};
      }

      function stopMessage() {
        const button = document.getElementById("chatButton");
        if (chatEventSource) {
          chatEventSource.close();
        }
        button.textContent = "Send";
        button.onclick = sendMessage;
      }
    </script>
  </body>
</html>

核心代码:

javascript 复制代码
chatEventSource = new EventSource(
  `http://localhost:4200/chat?message=${encodeURIComponent(message)}`
);

chatEventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  if (data.type === "char") {
    aiResponse += data.char;
    //update message
    const aiMessage = chatContent.querySelector(".message");
    aiMessage.textContent = aiResponse;
  } else if (data.type === "finished") {
    button.textContent = "Send";
    button.onclick = sendMessage; // 恢复发送功能
    chatEventSource.close();
  }
};

chatEventSource.onerror = (error) => {};

1.首先调用 New EventSource 创建一个SSE连接。

2.chatEventSource.onmessage 用于监听响应,这样当服务端有数据流返回时就可以实时的就受到数据,同时更改dom的数据。
3.chatEventSource.onerror 用于注册发生异常时的回调。

可以看到SSE 的实现非常的简单,相比于WebSocket 简单的多。因为它本身就基于http, 还内置了连接丢失后的重连机制。

最终实现效果: