第10章 SSE魔改
SSE(Server-Sent Events,服务器推送事件) 是一种基于标准HTTP协议的服务器到客户端的单向数据流技术。它允许服务器在建立初始连接后,通过一个持久的HTTP连接主动、连续地向客户端推送数据更新,而无需客户端重复发起请求。其核心机制是客户端使用 EventSource API 连接到指定端点后,服务器以 text/event-stream 格式持续发送事件流,每个事件由标识类型(event:)、数据(data:)和可选ID组成,客户端通过监听事件类型来实时处理数据,连接中断时还会借助最后接收的ID自动尝试重连。
与需要双向通信的WebSocket相比,SSE的典型优势在于协议轻量、天然支持自动重连与断点续传,且无需额外协议升级。它非常适合服务器主导的实时数据分发场景,如股市行情推送、新闻直播、社交媒体动态、任务进度通知等,浏览器兼容性广泛。但需要注意的是,SSE是单向通道(服务器→客户端),且主流实现中传输格式限于文本(二进制数据需编码),若需双向实时交互则仍需选择WebSocket。
通过以上对SSE的解释,我们可以想到现如今非常经典的例子,AI网站中输出文字的打字机效果(例如DeepSeek),一个字一个字的往外输出,这也是一种SSE。前端给后端发送一次消息,而后端可以给前端一直发消息。
10.1 初始化项目
我们采用Express来模拟SSE,因此需要如下3个步骤来初始化项目:
(1)创建index.html和index.ts文件。
(2)安装express和对应的声明文件,并引入index.ts文件中。
(3)安装cors和对应的声明文件,并引入index.ts文件中。
两个index文件用于展示效果以及编写SSE逻辑。
// 安装express
npm i express
// 安装 CORS(跨域资源共享中间件)
npm install cors
// 安装 Express 的 TypeScript 类型定义
npm install --save-dev @types/express
// 安装 CORS 的 TypeScript 类型定义
npm install --save-dev @types/cors
// 一次性安装所有依赖
npm install express cors @types/express @types/cors
对应的package.json文件中,将type字段设置为module,从而可以使用import引入写法。
// package.json
{
"type": "module",
"dependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"cors": "^2.8.5",
"express": "^5.2.1"
}
}
在index.ts文件使用ES模块语法引入依赖模块express和cors后,创建Express应用实例的app对象,然后在app对象中,通过use()方法挂载必要的全局中间件cors()和express.json(),用于处理跨域资源共享以及解析请求体中格式为JSON的数据。
// index.ts
import express from "express";
import cors from 'cors'
const app = express()
// 处理跨域
app.use(cors())
// 解析请求体中格式为JSON的数据
app.use(express.json())
最后,启动Express服务器,监听3000端口,完成SSE的项目初始化。
app.listen(3000, () => {
console.log("Server is running on port 3000");
});
10.2 SSE逻辑实现
SSE要求接口必须是一个get请求,因此我们来定义一个get请求。
SSE的核心代码只有一行,将Content-Type设置为text/event-stream。通过将HTTP响应的内容类型明确声明为事件流格式,通知客户端(通常是浏览器)本次连接并非普通的请求-响应交互,而是一个需要保持开启、持续接收服务器推送事件的长连接通道。浏览器接收到这个特定头部后,会启动其内建的SSE处理机制(EventSource API),自动保持连接活性并准备以流式方式解析后续传入的数据。
返回数据的格式一定要遵循data: {实际数据}\n\n的形式。
// index.ts
app.get('/chat', (req, res) => {
res.setHeader("Content-Type", "text/event-stream"); // 返回SSE
// 不缓存
res.setHeader("Cache-Control", "no-cache");
// 持久化连接
res.setHeader("Connection", "keep-alive");
// 定时器,每秒返回一次时间,模拟后端连续地向客户端推送数据更新
setInterval(() => {
res.write(`data: ${new Date().toISOString()}\n\n`);
}, 1000);
});
完成后端SSE的逻辑之后,前端需要如何接受后端传递过来的数据?通过浏览器内置的 EventSource API 来建立连接并接收后端SSE事件流数据就可以。
// index.html
const sse = new EventSource("http://localhost:3000/chat");
sse.onmessage = (event) => {
console.log(event.data);
};
此时启动后端服务器,打开index.html页面的控制台看流式输出时间,即前端可以实时接收后端返回的数据,如图10-1所示。
![]()
图10-1 流式输出时间
此时打开网络选项卡,输出效果如图10-2所示。chat接口的EventStream会不断的接收message类型的消息,并展现对应的数据。
![]()
图10-2 网络选项卡展示输出效果
10.3 SSE设置post请求
但一般在实际的项目中,是不会使用EventSource的,因为它必须是一个get请求,而在工作中经常使用的是post请求。那面对这种冲突的情况,应该如何去做?
如果我们只是在后端简单的将get请求直接改成post请求,然后重启服务去看效果的话,是无法生效的。
// index.ts
app.post('/chat', (req, res) => {
// 省略...
});
chat接口修改成post请求如图10-3所示。请求方法依然为GET,网络状态则是404。
![]()
图10-3 chat接口修改成post请求
面对后端chat接口修改为post请求不起效果的情况,我们只能在前端去魔改方案,不使用EventSource API来建立连接并接收后端SSE事件流数据。
我们在前端使用fetch()去接收chat接口返回的数据,此时从浏览器的的网络选项卡可以看到接通了,并且从响应选项中会不断打印出时间数据。
fetch("http://localhost:3000/chat", {
headers: {
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify({ message: "Hello, world!" }),
})
.then(async response => {
const reader = response.body.getReader(); // 获取流
const decoder = new TextDecoder(); // 解码ASCII码值
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(value) // value是ASCII码
const text = decoder.decode(value, { stream: true });
console.log(text);
}
})
let a = [1]
let b = a[Symbol.iterator]();
console.log(b.next());
console.log(b.next());
现如今基本上都是通过fetch去魔改实现,将get请求修改成post请求也能传输数据。目前为止,没有更好的解决方法了。
在这段魔改的代码中,我们做了什么?
首先是设置请求头接收的内容类型以及请求方式。当接收到数据时,通过getReader()方法获取流,获取流得到的是一个Promise,因此需要通过await操作符去等待Promise兑现并获取它兑现之后值,通过read()方法去读取数据中的每一个流。
此时每一个流返回的是一个迭代器,迭代器是一个对象,内部有一个next()方法,该方法返回具有两个属性的对象:
(1)value:迭代序列的下一个值。
(2)done:如果已经迭代到序列中的最后一个值,则它为 true。如果 value 和 done 一起出现,则它就是迭代器的返回值。
所以迭代器对象可以通过重复调用next()方法显式地迭代。在while循环中持续调用reader.read()方法,这个方法返回的Promise在每次兑现时都提供一个类似迭代器next()方法的对象——包含value(当前数据块)和done(流是否结束)两个属性。通过循环判断done是否为false,我们可以持续读取Uint8Array格式的数据块,然后使用TextDecoder将其解码为可读文本,实现了对服务器推送数据流的实时逐块处理。
这种显式迭代的核心优势在于按需、增量地处理数据,避免了等待整个响应体完全到达才能开始处理。每次调用reader.read()都明确请求下一个数据块,直到done变为true表示流已结束。这与传统的一次性接收完整响应形成对比,特别适合处理SSE这种持续、长时间的数据流连接,确保了在处理服务器实时推送时内存使用的高效性和响应的即时性。