前言 ✨
最近在论坛冲浪的时候,看到一个帖子是问chat gpt
这些工具中对话界面那种文本输出方式,是怎么实现的,其中就有提到SSE
.研究一番之后,做一个项目DEMO
来介绍一下
什么是 SSE? 💡
SSE(Server-Sent Events,服务端推送事件)其实就是一种让服务端可以主动、持续地把数据推送给前端页面的技术。你可以把它理解成"后端一直在给前端发消息,前端随时都能收到",而不是前端隔一会儿就去问一次"有新消息吗?"。
它的原理是什么?
-
前端用
EventSource
对象发起一个 HTTP 请求,和服务端建立一条"专线"。 -
这条专线不会像普通请求那样很快断开,而是一直保持连接。
-
服务端可以随时往这条专线上写数据,前端就能第一时间收到。
有什么优点?
-
实时性强:服务端一有新数据就能立刻推送给前端。
-
省资源:不用前端一直轮询,节省带宽和服务器压力。
-
实现简单:浏览器原生支持,API 简单易用。
适合什么场景?
-
实时通知(比如消息提醒、进度条、弹幕)
-
实时数据大屏
-
只需要后端推送,前端不用回传的场景
和 WebSocket 有啥区别?
- SSE 是单向的,WebSocket 是双向的
- SSE 用 HTTP 协议,WebSocket 是独立协议
- SSE 支持自动重连,断了会自己连上
- SSE 用起来简单,浏览器原生支持
EventSource 对象介绍 🔌
EventSource
就是前端用来和后端"实时聊天"的一个工具。只不过这个聊天是单向的------后端说,前端听。它专门用来接收服务端推送(SSE,Server-Sent Events),不用你前端一直傻傻地轮询,省心又高效。
怎么用?
用法超级简单,直接 new 一个就行:
js
const es = new EventSource(后端服务的请求路由);
只要这行代码一跑,浏览器就会帮你和后端搭好一条"专线",后端想发啥你都能第一时间收到。
常用事件和方法
-
onmessage
这是最常用的,后端每推送一条消息,这里就会触发一次。
jses.onmessage = event => { console.log("收到消息:", event.data); };
你要的内容都在
event.data
里。 -
onopen
连接刚建立好时会触发一次。可以用来做一些初始化提示。
jses.onopen = () => { console.log("SSE 连接已建立"); };
-
onerror
连接出错或者断开时会触发。比如后端挂了、网络断了啥的。
jses.onerror = err => { console.log("SSE 连接出错", err); };
-
close()
注意:EventSource 没有 close 方法!如果你想手动断开,只能 es = null,然后让浏览器自动断开(或者刷新页面)。
数据格式
后端推送的数据格式有点讲究,必须长这样:
kotlin
data: 你要发的内容
每条消息都要以两个换行结尾(\n\n
),否则前端收不到。
比如后端这样写:
js
res.write("data:hello world\n\n");
前端就能在 event.data
里拿到 hello world
。
除了 data:
,其实还可以有这些字段:
id:
消息编号,前端可以拿来做断点续传event:
自定义事件类型,前端可以用addEventListener
监听retry:
告诉前端断线重连的时间(毫秒)
但一般用得最多的还是 data:
。
node 服务 🛠️
我们先创建一个 node 项目,作为我们的后端服务.
这里我创建了一个文件夹叫sseServer
,并执行pmpm init
,初始化成功之后,会出现package.json
文件

package.json
内容如下

安装 express
初始化完成之后,再安装一个express
.
shell
pnpm add express
express
是一个轻量级的 web 框架,方便我们等下搭建服务
编写 get 请求
安装完相关依赖之后,在sseServer
文件夹下面创建一个index.js
文件
index.js
代码如下
js
const express = require("express");
const app = express();
const port = 3000;
app.listen(port, () => {
console.log(`express 服务启动,端口为 ${port}`);
});
app.get("/getReqText", (req, res) => {
res.send("hello world");
});
这里我监听了3000
端口,并且创建了一个get
请求.
修改package.json
找到前面的package.json
,在里面的scripts
添加一段脚本
package.json
json
{
"name": "sseServer",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "node index.js" // 添加的脚本
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.5.0",
"dependencies": {
"express": "^5.1.0"
}
}
这是为了能够直接用pnpm run dev
去运行服务,这样比较方便

这里服务就启动成功了
测试请求
用apifox
测试一下请求能否正常访问,你也可以用别的

不出意外,你的请求就成功了.
修改为 sse 请求
接下来就要将这个请求修改为一个sse
请求.
还是前面那个index.js
文件,里面的代码修改为如下
js
const express = require("express");
const app = express();
const port = 3000;
app.listen(port, () => {
console.log(`express 服务启动,端口为 ${port}`);
});
app.get("/getReqText", (req, res) => {
// 设置响应头
res.set({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive"
});
// 发送请求头
res.flushHeaders();
setInterval(() => {
res.write("data:hello world\n\n");
}, 1000);
});
用apifox
测试效果如下
这样就成功的创建了一个 sse 请求,并且建立连接
参数和方法介绍
首先是三个响应头的作用
Content-Type": text/event-stream
- 告诉浏览器这是一个 SSE(Server-Sent Events)流,而不是普通的 HTTP 响应。浏览器会用特殊方式处理它,自动接收服务端不断推送的数据。
"Cache-Control": "no-cache"
- 禁止缓存,确保每次都能拿到最新的数据。
Connection: keep-alive
- 保持连接不断开,允许服务端持续推送数据。
js
res.flushHeaders();
这个方法的作用是立即把响应头发送给客户端,建立 SSE 通道。这样客户端就能马上开始接收数据,而不是等到服务端响应结束。
最后推送数据的时候用的是write()
,而不是send
因为res.send()
是一次发送响应数据,并且会自动调用end()
这会导致请求直接断掉,而我们sse
服务的目的是不断的推送数据到客户端,所以用write
这里还有一个地方
js
res.write("data:hello world\n\n");
我传送的数据是 data:[内容]\\n\n
,这是为什么呢?
这是 SSE(Server-Sent Events)协议规定的格式,要求服务端推送的数据必须遵循特定的文本格式,每条消息以两次换行(\n\n)结尾。
至于这个data
是规定的字段,浏览器端的 EventSource
会自动把 data: 后面的内容当作消息体。
除了data
还有,下面这三种,后面会介绍是干嘛用的
-
id: 消息编号
-
event: 事件类型
-
retry: 重连时间
前端使用 🖥️
前面后端的服务搭建成功了,这里新建一个vue3
项目,来演示效果
shell
npm create vite
用vite
创建一个新的项目

设置代理
添加一下后端服务的代理
vite.config.ts
ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, "")
}
}
}
});
简单做一下界面

chat.css
css
.chat-container {
width: 100vw;
height: 100vh;
max-width: none;
margin: 0;
background: linear-gradient(135deg, #23272f 60%, #2e335a 100%);
border-radius: 0;
box-shadow: none;
display: flex;
flex-direction: column;
overflow: hidden;
border: none;
* {
outline: none;
}
}
.chat-header {
padding: 24px 32px 16px 32px;
border-bottom: 1.5px solid #2e335a;
background: rgba(30, 34, 54, 0.92);
border-radius: 0;
text-align: center;
}
.chat-header h5 {
margin: 0;
font-size: 1.25em;
font-weight: 700;
letter-spacing: 1px;
color: #7ec4fa;
text-shadow: 0 2px 8px #1a1a2a44;
}
.chat-messages {
flex: 1;
overflow: hidden auto;
padding: 24px 32px;
background: transparent;
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
scrollbar-width: thin;
scrollbar-color: #7ec4fa #23272f;
align-items: flex-start;
}
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-thumb {
background: #7ec4fa88;
border-radius: 4px;
}
.btns {
display: flex;
gap: 18px;
justify-content: center;
align-items: center;
padding: 24px 0 32px 0;
background: rgba(30, 34, 54, 0.92);
border-top: 1.5px solid #2e335a;
border-radius: 0 0 0 0;
position: sticky;
bottom: 0;
width: 100%;
}
.btns button {
flex: 1 1 0;
min-width: 0;
max-width: 320px;
padding: 16px 0;
border-radius: 24px;
border: 1.5px solid #d1d5db;
background: #282c42;
color: #fff;
font-weight: 500;
font-size: 1.12em;
cursor: pointer;
box-shadow: none;
transition: background 0.18s, border 0.18s, color 0.18s;
}
.btns button:hover {
background: #ededed;
border: 1.5px solid #bdbdbd;
color: #222;
box-shadow: none;
}
.message-content {
position: relative;
}
App.vue
vue
<template>
<div class="chat-container">
<div class="chat-header">
<h5>流式文本输出</h5>
</div>
<div class="chat-messages">
<div class="message-content"></div>
</div>
<div class="btns">
<button @click="connectEvent">连接</button>
<button @click="stopEvent">停止</button>
<button @click="clearEvent">清空</button>
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
const contentList = ref([]);
let eventSource = null;
// 创建连接
const connectEvent = () => {};
// 清空
const clearEvent = () => {};
// 停止连接
const stopEvent = () => {};
</script>
<style scoped>
@import "./style/chat.css";
</style>
建立连接
js
<template>
<div class="chat-container">
<div class="chat-header">
<h5>流式文本输出</h5>
</div>
<div class="chat-messages">
<div class="message-content">
{{ message }}
</div>
</div>
<div class="btns">
<button @click="connectEvent">连接</button>
<button @click="stopEvent">停止</button>
<button @click="clearEvent">清空</button>
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
const contentList = ref([]);
const message = ref("");
let eventSource = null;
// 创建连接
const connectEvent = () => {
eventSource = new EventSource("/api/getReqText");
eventSource.onmessage = event => {
message.value += event.data;
console.log("👉 ~ connectEvent ~ event:", event);
};
};
// 清空
const clearEvent = () => {};
// 停止连接
const stopEvent = () => {};
</script>
<style scoped>
@import "./style/chat.css";
</style>
这里的EventSource
和onmessage
后面会介绍
点击连接之后,看到数据被推送过来了

打印响应内容可以看到我们的数据放到了 data 里面

打开 f12 查看请求

可以发现多了一个eventstream
,这代表sse
建立成功了,只要连接不断开,这个请求就会一直保持pending
状态.
总结
如果你只是想让后端"喂"你数据,EventSource
简直不要太香。代码简单,浏览器支持好,出了问题还能自动重连。唯一的缺点就是不能反向发消息给后端(但大多数场景其实用不到)。
最终效果 🚀
前面了解了,eventSource
是什么东西, 现在修改一下前面的代码,将我们的index.js
改造一下,让它更加丝滑的输出流式文本
index.js
js
const express = require("express");
const fs = require("fs");
const app = express();
const port = 3000;
const text = fs.readFileSync("./docs/背影.txt", "utf-8");
app.listen(port, () => {
console.log(`express 服务启动,端口为 ${port}`);
});
app.get("/sseText", (req, res) => {
// 设置响应头
res.set({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive"
});
let index = 0;
const textArray = [...text];
// 发送事件流的头部
res.flushHeaders();
let timer = setInterval(() => {
res.write(`data:${textArray[index]}\n\n`);
index++;
if (index >= textArray.length) {
clearInterval(timer);
timer = null;
res.write("data:文章输出结束!\n\n");
}
}, 20);
});
并且放了一篇朱自清的背影进去


最终效果如下

这样就通过sse
实现一个流式的文本输出效果了.
结尾 🏁
虽然流式文本实现了,但是我一开始是想要再做一个文本渐变显示出来的动画的,并不是那种打字机效果,而是类motion
中的text-effect
效果,就像下面

这样的效果.但是这些库有个问题,他们都直接接收一段字符串然后直接渲染,没办法追加写入字符串,保持原有动画的情况下,继续向下渲染.
遗憾最后只能是这样,后面有时间再找一下有没有可以追加写入的文本动画库