前端也能这么丝滑!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效果,就像下面

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

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

相关推荐
G等你下课15 分钟前
告别刷新就丢数据!localStorage 全面指南
前端·javascript
该用户已不存在16 分钟前
不知道这些工具,难怪的你的Python开发那么慢丨Python 开发必备的6大工具
前端·后端·python
爱编程的喵18 分钟前
JavaScript闭包实战:从类封装到防抖函数的深度解析
前端·javascript
LovelyAqaurius18 分钟前
Unity URP管线着色器库攻略part1
前端
Xy91021 分钟前
开发者视角:App Trace 一键拉起(Deep Linking)技术详解
java·前端·后端
lalalalalalalala24 分钟前
开箱即用的 Vue3 无限平滑滚动组件
前端·vue.js
前端Hardy24 分钟前
8个你必须掌握的「Vue」实用技巧
前端·javascript·vue.js
snakeshe101026 分钟前
深入理解 React 中 useEffect 的 cleanUp 机制
前端
星月日28 分钟前
深拷贝还在用lodash吗?来试试原装的structuredClone()吧!
前端·javascript
爱学习的茄子29 分钟前
JavaScript闭包实战:解析节流函数的精妙实现 🚀
前端·javascript·面试