前端也能这么丝滑!Node + Vue3 实现 SSE 流式文本输出全流程

前言 ✨

​ 最近在论坛冲浪的时候,看到一个帖子是问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

    这是最常用的,后端每推送一条消息,这里就会触发一次。

    js 复制代码
    es.onmessage = event => {
      console.log("收到消息:", event.data);
    };

    你要的内容都在 event.data 里。

  • onopen

    连接刚建立好时会触发一次。可以用来做一些初始化提示。

    js 复制代码
    es.onopen = () => {
      console.log("SSE 连接已建立");
    };
  • onerror

    连接出错或者断开时会触发。比如后端挂了、网络断了啥的。

    js 复制代码
    es.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>

这里的EventSourceonmessage后面会介绍

点击连接之后,看到数据被推送过来了

打印响应内容可以看到我们的数据放到了 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效果,就像下面

这样的效果.但是这些库有个问题,他们都直接接收一段字符串然后直接渲染,没办法追加写入字符串,保持原有动画的情况下,继续向下渲染.

​ 遗憾最后只能是这样,后面有时间再找一下有没有可以追加写入的文本动画库

相关推荐
Ticnix11 小时前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人11 小时前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl11 小时前
OpenClaw 深度技术解析
前端
崔庆才丨静觅11 小时前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人11 小时前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼12 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端
布列瑟农的星空12 小时前
前端都能看懂的Rust入门教程(三)——控制流语句
前端·后端·rust
Mr Xu_12 小时前
Vue 3 中计算属性的最佳实践:提升可读性、可维护性与性能
前端·javascript
jerrywus12 小时前
我写了个 Claude Code Skill,再也不用手动切图传 COS 了
前端·agent·claude
玖月晴空12 小时前
探索关于Spec 和Skills 的一些实战运用-Kiro篇
前端·aigc·代码规范