🔥 保姆级教程|SSE + BFF + 跨域三件套,从零实现 ChatGPT 流式输出(附完整代码)
摘要 :ChatGPT 为什么能一个字一个字地"蹦"出来?这背后是 SSE 技术。而要让前端顺利接上 SSE,还绕不开 BFF 架构 和 跨域问题。本文从零开始,掰开了揉碎了讲透这三个知识点,附完整可运行代码。
📑 目录
- 前言:为什么你一定要学这个?
- [第一部分:SSE 是什么?](#第一部分:SSE 是什么? "#-%E7%AC%AC%E4%B8%80%E9%83%A8%E5%88%86sse-%E6%98%AF%E4%BB%80%E4%B9%88")
- [第二部分:BFF 是什么?为什么需要它?](#第二部分:BFF 是什么?为什么需要它? "#-%E7%AC%AC%E4%BA%8C%E9%83%A8%E5%88%86bff-%E6%98%AF%E4%BB%80%E4%B9%88%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81%E5%AE%83")
- 第三部分:跨域问题深度解析
- 第四部分:完整代码实战
- [第五部分:SSE 流式输出完整实现](#第五部分:SSE 流式输出完整实现 "#-%E7%AC%AC%E4%BA%94%E9%83%A8%E5%88%86sse-%E6%B5%81%E5%BC%8F%E8%BE%93%E5%87%BA%E5%AE%8C%E6%95%B4%E5%AE%9E%E7%8E%B0")
- [第六部分:常见踩坑 & 解决方案](#第六部分:常见踩坑 & 解决方案 "#-%E7%AC%AC%E5%85%AD%E9%83%A8%E5%88%86%E5%B8%B8%E8%A7%81%E8%B8%A9%E5%9D%91--%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88")
- 第七部分:最终完整代码汇总
- 重点总结
📌 前言:为什么你一定要学这个?
2026 年了,AI 应用遍地开花。你打开 ChatGPT、Kimi、豆包,输入一个问题,答案像打字机一样一个字一个字地出现。
但你有没有想过:这到底是怎么实现的? 🤔
如果你用过 fetch 请求数据,你知道它的工作方式是:发请求 → 等待 → 一次性拿到全部数据。
但 AI 的回答可能有几千字,让用户等 10 秒才看到结果?体验太差了!
所以需要一种技术,让服务器一点一点地 把数据推给前端------这就是 SSE。
而当你自己动手写的时候,你会发现:
- 前端直接请求 AI 接口?跨域了! 浏览器不让你发!
- API Key 写在前端代码里?不安全! 用户右键就能看到!
于是你需要一个中间层------BFF,来帮你解决这些问题。
今天这篇文章,一次性把这三个知识点全讲透。 👇
🎯 本文适合谁
- ✅ 有基础的 HTML/CSS/JS 知识,想了解 SSE 的前端初学者
- ✅ 用过 Vue/React,但没接触过后端的前端同学
- ✅ 被跨域问题折磨过,想彻底搞懂的开发者
- ✅ 想接入 AI 流式输出,但不知道怎么做的同学
🛠️ 环境准备
开始之前,请确保你的电脑上已经安装了:
| 工具 | 版本要求 | 检查命令 | 说明 |
|---|---|---|---|
| Node.js | >= 18.0 | node -v |
运行 BFF 后端需要 |
| npm | >= 9.0 | npm -v |
安装依赖需要 |
| 编辑器 | VS Code 推荐 | --- | 写代码用 |
💡 如果
node -v提示找不到命令,请先去 Node.js 官网 下载安装。
📚 第一部分:SSE 是什么?
🍕 用外卖来理解 SSE
想象你点了一份外卖,有两种送餐方式:
| 方式 | 描述 | 对应技术 |
|---|---|---|
| 方式 A | 外卖小哥把所有菜做好了,一次性给你送来 | 普通 HTTP 请求(fetch) |
| 方式 B | 厨师做好一道,就让骑手送一道过来 | SSE(Server-Sent Events) |
方式 A:你得等所有菜都做好才能开吃,可能要等 30 分钟。 方式 B:第一道菜 5 分钟就到了,你可以先吃着,后面的菜陆续送到。
SSE 就是方式 B ------服务器做好一部分数据,就立刻推给前端,前端可以边接收边展示。
📖 SSE 的正式定义
SSE(Server-Sent Events,服务器发送事件) 是一种 HTTP 协议允许服务器向客户端单向、持续地推送数据的技术。
关键特点:
- 🔗 基于 HTTP:不需要 WebSocket 那样的新协议,就是普通 HTTP
- 📡 单向通信 :只有服务器能推数据给客户端(客户端发请求用普通
fetch) - 📝 文本格式 :传输的是纯文本,格式简单(每行以
data:开头) - 🔄 自动重连 :连接断了,浏览器的
EventSourceAPI 会自动尝试重连 - 🚀 实时推送:服务器有新数据就立刻推,不用客户端反复轮询
📡 SSE 的数据长什么样?
SSE 的格式非常简单,就是纯文本,每条消息以 data: 开头,以 \n\n(两个换行)结尾:
css
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: {"choices":[{"delta":{"content":"啊"}}]}
data: [DONE]
就这么简单!没有复杂的二进制协议,就是一行一行的文本。
💡 补充 :浏览器原生提供了
EventSourceAPI 来接收 SSE,自带自动重连。但在 AI 流式输出场景中,我们通常用fetch+ReadableStream手动解析,因为EventSource只支持 GET 请求,无法发送 POST body。
🔍 SSE vs WebSocket vs 轮询
| 特性 | 轮询(Polling) | SSE | WebSocket |
|---|---|---|---|
| 方向 | 客户端 → 服务器 | 服务器 → 客户端 | 双向 |
| 协议 | HTTP | HTTP | WS/WSS |
| 复杂度 | 低 | 低 | 中等 |
| 实时性 | 差(有延迟) | 好 | 最好 |
| 自动重连 | ❌ | ✅ | ❌(需手动) |
| 适合场景 | 低频更新 | AI 流式输出、通知推送 | 聊天室、游戏 |
💡 一句话总结:如果你只需要服务器推数据给客户端(比如 AI 对话),用 SSE 就够了;如果需要双向实时通信(比如聊天室),用 WebSocket。
📚 第二部分:BFF 是什么?为什么需要它?
🏪 用餐厅来理解 BFF
想象你去一家高级餐厅吃饭:
你(前端)→ 服务员(BFF)→ 厨房(后端)
你不会直接冲进厨房对厨师说:"给我来一份牛排,七分熟,少放盐!"
而是告诉服务员,服务员帮你翻译成厨房能理解的语言,下单、等菜、摆盘,最后端到你面前。
BFF 就是这个"服务员"。
📖 BFF 的正式定义
BFF = Backend For Frontend(为前端服务的后端),是一个轻量级的中间层服务,专门为前端应用量身定制接口。
🤔 为什么前端需要 BFF?
你可能会问:前端直接请求后端 API 不就行了?为什么还要加一层?
来看三个痛点 👇
痛点 1:API Key 安全问题 🔐
javascript
// ❌ 错误做法:API Key 写在前端
const response = await fetch('https://api.openai.com/v1/chat/completions', {
headers: {
'Authorization': 'Bearer sk-xxxxx' // 用户右键查看源码就能看到!
}
});
后果:任何人都能看到你的 API Key,拿去滥用,你的账户被刷爆 💸
javascript
// ✅ 正确做法:通过 BFF 代理,Key 放在服务端
// 前端只需要请求自己的 BFF
const response = await fetch('/api/stream?prompt=你好');
// BFF 层(server.mjs)负责带上 Key 去请求真正的 API
// 用户看不到 Key,安全!
痛点 2:跨域问题 🚫
前端运行在 http://localhost:5173,AI API 在 https://api.xiaomimimo.com。
域名不同 = 跨域 = 浏览器直接拦截你的请求!
但如果请求发给同域名下的 BFF(localhost:3000),BFF 再去请求 AI API(服务端没有跨域限制),问题就解决了。
痛点 3:数据格式转换 🔧
AI 返回的数据格式可能很复杂(SSE 流、二进制数据),前端直接处理很麻烦。
BFF 可以帮前端预处理数据,让前端只关心展示。
🏗️ 完整架构图
bash
┌──────────────┐ ①前端请求 ┌──────────────┐ ②带上API Key ┌──────────────┐
│ │ /api/stream? │ │ 请求AI接口 │ │
│ 前端 │ ────────────→ │ BFF 层 │ ────────────→ │ AI 服务器 │
│ Vue/React │ localhost:5173 │ Express │ api.xiaomim │ (LLM API) │
│ │ ←──────────── │ :3000 │ ←──────────── │ │
│ │ ③流式数据回来 │ │ ④流式响应 │ │
└──────────────┘ └──────────────┘ └──────────────┘
📚 第三部分:跨域问题深度解析
🚨 什么是跨域?
同源策略 :浏览器的安全机制,要求网页只能请求同源的资源。
同源 = 协议 + 域名 + 端口,三者完全一致
举个例子:
| 当前页面 | 请求地址 | 是否同源 | 结果 |
|---|---|---|---|
http://localhost:5173 |
http://localhost:5173/api |
✅ 同源 | 正常请求 |
http://localhost:5173 |
http://localhost:3000/api |
❌ 端口不同 | 跨域被拦截! |
http://localhost:5173 |
https://api.xiaomimimo.com |
❌ 域名+协议不同 | 跨域被拦截! |
💥 为什么会这样?
这是浏览器的安全保护机制。想象一下,如果没有同源策略:
- 你打开了一个恶意网站
evil.com - 这个网站的 JS 偷偷请求
bank.com/api/transfer?to=黑客&amount=10000 - 因为你的浏览器里有
bank.com的登录 Cookie,请求自动带上了认证信息 - 你的钱就被转走了 💀
同源策略就是为了防止这种攻击!
🔧 用 Vite Proxy 解决跨域
在我们的项目中,情况是这样的:
arduino
前端(Vite): http://localhost:5173
BFF 后端: http://localhost:3000
两个端口不同 → 跨域!
解决方案:让 Vite 充当"中间人",帮我们转发请求。
javascript
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
// 当前端请求的 URL 以 /api 开头时
'/api': {
// 把请求转发到这个地址
target: 'http://localhost:3000',
// 修改请求头中的 Host 为目标地址(虚拟主机场景需要)
changeOrigin: true,
secure: false,
// 把 /api 去掉,比如 /api/stream → /stream
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
})
🔍 逐步拆解这个配置
第 1 步:前端发起请求
javascript
// App.vue 中
fetch('/api/stream?prompt=hello')
// 浏览器实际请求:http://localhost:5173/api/stream?prompt=hello
第 2 步:Vite 拦截请求
Vite 发现 URL 以 /api 开头,命中了 proxy 规则,于是:
- 不让这个请求去
localhost:5173(那里没有这个接口) - 而是转发 到
http://localhost:3000
第 3 步:路径重写
javascript
rewrite: (path) => path.replace(/^\/api/, '')
// /api/stream?prompt=hello → /stream?prompt=hello
第 4 步:BFF 收到请求
Express 服务器在 localhost:3000 收到了 /stream?prompt=hello,正常处理!
📊 完整请求流程图
bash
前端代码: fetch('/api/stream?prompt=hello')
│
▼
┌───────────────────────────────────────────┐
│ 浏览器: 这个请求是发给 localhost:5173 的 │
│ Vite: /api 命中了我的 proxy 规则! │
│ Vite: 我帮你转发到 localhost:3000 │
│ 路径重写: /api/stream → /stream │
└───────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────┐
│ Express 服务器 (localhost:3000) │
│ 收到请求: GET /stream?prompt=hello │
│ 处理逻辑: 请求 AI API,返回流式数据 │
└───────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────┐
│ 浏览器: 收到 SSE 流式响应 │
│ 前端代码: 逐块读取 → 解析 → 渲染到页面 │
└───────────────────────────────────────────┘
💡 核心原理 :Vite 的 dev server 和前端页面在同一个端口(5173),所以 Vite 代理的请求不算跨域。而 Vite(Node.js 环境)去请求
localhost:3000,服务端之间没有跨域限制!
📚 第四部分:完整代码实战(从零搭建)
🛠️ 项目结构
bash
stream-bff/
├── .env.local # API Key 等敏感配置(不提交到 git!)
├── .env # 默认环境变量
├── .gitignore # Git 忽略文件
├── server.mjs # BFF 后端(Express)
├── vite.config.js # Vite 配置(含 proxy)
├── package.json # 项目依赖
└── src/
├── App.vue # 前端页面
└── main.js # 入口文件
Step 1:初始化项目
bash
# 创建 Vue + Vite 项目
npm create vue@latest stream-bff
cd stream-bff
# 安装依赖
npm install express dotenv
package.json 中的关键依赖:
json
{
"dependencies": {
"dotenv": "^16.4.7",
"express": "^5.2.1",
"vue": "^3.5.34"
}
}
Step 2:配置环境变量
bash
# .env.local(存放你的 API Key,不要提交到 git!)
MIMO_API_KEY=sk-你的密钥
MIMO_API_BASE_URL=https://api.xiaomimimo.com/v1
MIMO_MODEL=mimo-v2.5-pro
⚠️ 重要安全提示 :这些环境变量不以
VITE_开头!这是故意的。
VITE_前缀是 Vite 的约定:以它开头的变量会被注入到前端代码中,前端通过import.meta.env.VITE_XXX就能读到- 我们的 API Key 只在 BFF 服务端(
server.mjs)使用,绝不能暴露给浏览器- 所以不用
VITE_前缀,确保 Key 只存在于 Node.js 进程环境中server.mjs中通过process.env.MIMO_API_KEY读取,这是 Node.js 的方式,安全!
Step 3:编写 BFF 层(核心!)
javascript
// server.mjs
import 'dotenv/config'; // dotenv v16+ ESM 推荐写法,一行搞定
import express from 'express';
// ========== 1. 加载环境变量 ==========
// dotenv/config 会自动读取项目根目录的 .env 文件
// 如果同时有 .env 和 .env.local,可以用以下写法指定优先级:
// import { config } from 'dotenv';
// config({ path: ['.env.local', '.env'] });
// ========== 2. 创建 Express 应用 ==========
const app = express();
const port = 3000;
// ========== 3. 定义路由 ==========
// 测试路由:访问 http://localhost:3000/ 返回 Hello World
app.get('/', (req, res) => {
res.send('Hello World! BFF is running 🚀');
})
// 核心路由:SSE 流式接口
// 前端会请求: /stream?prompt=你好
app.get('/stream', async(req, res) => {
// 从 URL 参数中获取用户的 prompt
// 例如: /stream?prompt=你好 → prompt = "你好"
const { prompt } = req.query;
// AI API 的地址
const endpoint = 'https://api.xiaomimimo.com/v1/chat/completions';
try {
// ========== 4. 向 AI 服务器发起流式请求 ==========
const response = await fetch(endpoint, {
method: 'POST',
headers: {
// ⭐ 使用环境变量中的 API Key(安全!用户看不到)
// 注意:这里读的是 process.env,不是 import.meta.env
'Authorization': `Bearer ${process.env.MIMO_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
// 使用环境变量中的模型名称
model: process.env.MIMO_MODEL,
// ⭐ 关键:stream: true 表示开启流式输出
stream: true,
messages: [
{
role: 'user',
content: prompt // 用户的问题
}
]
})
});
// response.body 是一个 ReadableStream(可读流)
// 数据会一点一点地从 AI 服务器流过来
// 具体怎么转发给前端,见第五部分的完整实现
console.log('AI 响应流已建立:', response.status);
}
catch (error) {
console.log('❌ 请求出错:', error);
res.status(500).json({ error: '请求失败' });
}
});
// ========== 5. 启动服务器 ==========
// 注意:先定义路由,再 listen,这是 Express 的最佳实践
app.listen(port, () => {
console.log(`✅ BFF 服务器已启动: http://localhost:${port}`);
})
console.log('我是一个在前端项目中藏着的 BFF 程序 🤫')
🔑 代码关键点解析
为什么要用 dotenv?
ini
.env.local 文件:
MIMO_API_KEY=sk-c0t3rgnbasfltud7efv5pojt...
server.mjs 中:
process.env.MIMO_API_KEY → 读取到密钥
好处:
1. 密钥和代码分离,代码提交到 git 不会泄露密钥
2. 不同环境(开发/测试/生产)可以用不同的密钥
为什么要 stream: true?
javascript
// stream: false(默认)→ AI 生成完所有内容,一次性返回
// 用户要等很久才能看到结果 😫
// stream: true → AI 生成一部分就返回一部分
// 用户立刻就能看到内容,体验好! 😊
为什么 app.listen 放在路由定义之后?
javascript
// ✅ 推荐:先定义路由,再启动监听
app.get('/stream', handler); // 先定义
app.listen(port); // 再启动
// ⚠️ 虽然放前面也能工作(Express 会异步处理),
// 但先定义后启动更符合直觉,也更容易理解代码执行顺序
Step 4:配置 Vite 代理(解决跨域)
javascript
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
// 匹配规则:所有以 /api 开头的请求
'/api': {
// 转发目标:BFF 服务器
target: 'http://localhost:3000',
// 修改 Host 头为目标地址(虚拟主机场景需要)
changeOrigin: true,
secure: false,
// 路径重写:去掉 /api 前缀
// /api/stream → /stream
// /api/user → /user
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
})
🧠 为什么要 rewrite?
ini
前端请求: /api/stream?prompt=hello
如果不 rewrite:
BFF 收到: /api/stream?prompt=hello
BFF 定义的是 /stream 路由,匹配不上!404 ❌
rewrite 之后:
BFF 收到: /stream?prompt=hello
匹配到了 app.get('/stream', ...),正常处理! ✅
Step 5:启动项目
bash
# 终端 1:启动 BFF 后端
node server.mjs
# 输出: ✅ BFF 服务器已启动: http://localhost:3000
# 终端 2:启动前端开发服务器
npm run dev
# 输出: Local: http://localhost:5173/
💡 为什么需要两个终端? 因为 BFF(Express)和前端(Vite)是两个独立的服务,需要分别启动。VS Code 可以按 ``Ctrl+``` 打开多个终端。
📚 第五部分:SSE 流式输出完整实现
前面的代码搭建好了项目骨架,但 /stream 路由还没有真正把 AI 的流式数据转发给前端。下面我们来实现完整的 SSE 流式输出。
🔄 BFF 层:转发 SSE 流
javascript
// server.mjs - /stream 路由完善版
app.get('/stream', async(req, res) => {
const { prompt } = req.query;
const endpoint = 'https://api.xiaomimimo.com/v1/chat/completions';
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MIMO_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: process.env.MIMO_MODEL,
stream: true,
messages: [{ role: 'user', content: prompt }]
})
});
// ⭐ 设置 SSE 响应头
// 告诉前端:这是一个事件流,请持续接收
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache'); // 不缓存,实时推送
res.setHeader('Connection', 'keep-alive'); // 保持 TCP 连接
res.setHeader('X-Accel-Buffering', 'no'); // 禁用 nginx 缓冲(生产环境必须)
// ⭐ 获取 AI 返回的可读流
const reader = response.body.getReader();
// TextDecoder:把二进制数据(Uint8Array)解码成人类可读的文本
const decoder = new TextDecoder('utf-8');
// ⭐ 监听客户端断开连接
// 当用户关闭页面或取消请求时,中断读取,释放资源
let aborted = false;
req.on('close', () => {
aborted = true;
reader.cancel();
});
// ⭐ 循环读取流中的数据
while (true) {
// 如果客户端已断开,停止读取
if (aborted) break;
const { done, value } = await reader.read();
// done = true 表示流结束了
if (done) {
res.write('data: [DONE]\n\n'); // SSE 结束标志
res.end();
break;
}
// value 是二进制数据(Uint8Array),需要解码成文本
const chunk = decoder.decode(value, { stream: true });
// 将数据块发送给前端
// SSE 格式:每条消息以 "data: " 开头,以 "\n\n" 结尾
res.write(`data: ${chunk}\n\n`);
}
}
catch (error) {
console.log('❌ 请求出错:', error);
// 确保在出错时也正确结束响应
if (!res.headersSent) {
res.status(500).json({ error: '请求失败' });
} else {
res.end();
}
}
});
🔑 关键概念解释
ReadableStream 是什么?
diff
想象一根水管 🚰:
- 水(数据)从一端(AI 服务器)流过来
- 你从另一端(reader.read())一节一节地接水
- 每次 read() 可能接到一小节水(一个 chunk)
- done = true 表示水流完了
TextDecoder 是什么?
csharp
网络传输的是二进制数据(0 和 1),人类看不懂
TextDecoder 把二进制翻译成文字:
[0xE4, 0xBD, 0xA0] → "你"
[0xE5, 0xA5, 0xBD] → "好"
X-Accel-Buffering: no 是什么?
arduino
如果你的项目部署在 nginx 反向代理后面,
nginx 默认会"攒"一批数据再一起发给浏览器,
这会导致 SSE 流不能实时推送!
加上这个头,告诉 nginx:"别攒了,直接转发!"
🎨 前端:接收 SSE 流并实时展示
vue
<!-- App.vue - 完整版 -->
<script setup>
import { ref } from 'vue'
// 响应式数据
const question = ref('') // 用户输入的问题
const content = ref('') // AI 返回的内容
const loading = ref(false) // 加载状态
// 提交问题
const update = async () => {
if (!question.value.trim()) return
if (loading.value) return // 防止重复提交
content.value = '' // 清空之前的内容
loading.value = true // 显示加载状态
try {
// 发起请求:通过 Vite proxy 转发到 BFF
// /api/stream?prompt=xxx → Vite 代理 → localhost:3000/stream?prompt=xxx
const response = await fetch(
`/api/stream?prompt=${encodeURIComponent(question.value)}`
)
// 检查 HTTP 状态码
if (!response.ok) {
throw new Error(`请求失败: ${response.status}`)
}
// ⭐ 使用 ReadableStream 读取 SSE 数据
// ReadableStream 就像一根水管,数据一块一块地流过来
const reader = response.body.getReader()
// TextDecoder 把二进制数据翻译成文字
const decoder = new TextDecoder('utf-8')
// ⭐ 缓冲区:处理 chunk 边界截断问题
// reader.read() 返回的数据块边界是任意的
// 一个 chunk 可能只包含半条 SSE 消息
// 所以我们需要用 buffer 来暂存不完整的数据
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
// 把二进制数据解码成文本,追加到缓冲区
buffer += decoder.decode(value, { stream: true })
// 按换行符分割,逐行解析
const lines = buffer.split('\n')
// ⭐ 最后一行可能不完整(被 chunk 边界截断了)
// 留到下次 read() 再处理
buffer = lines.pop() || ''
for (const line of lines) {
// 跳过空行和结束标志
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
try {
// 去掉 "data: " 前缀,解析 JSON
const json = JSON.parse(line.slice(6))
// 提取 AI 回复的文字内容
const text = json.choices?.[0]?.delta?.content || ''
// ⭐ 逐字追加到页面,实现打字机效果
content.value += text
} catch (e) {
// 忽略非 JSON 行(如空行、注释等)
}
}
}
}
} catch (error) {
// 网络错误、API 错误等
content.value = `❌ 请求出错: ${error.message}`
} finally {
// 无论成功还是失败,都要关闭加载状态
loading.value = false
}
}
</script>
<template>
<div class="container">
<!-- 输入区域 -->
<div>
<label>输入:</label>
<input
class="input"
v-model="question"
placeholder="请输入你的问题..."
@keyup.enter="update"
/>
<button @click="update" :disabled="loading">
{{ loading ? '生成中...' : '提交' }}
</button>
</div>
<!-- 输出区域 -->
<div class="output">
<!-- AI 回复内容 -->
<div class="content">{{ content }}</div>
</div>
</div>
</template>
<style>
.container {
display: flex;
flex-direction: column;
align-items: start;
justify-content: start;
height: 100vh;
font-size: 0.85rem;
padding: 20px;
}
.input {
margin-top: 10px;
min-height: 30px;
width: 300px;
text-align: left;
}
button {
padding: 0 10px;
margin-left: 6px;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.output {
margin-top: 20px;
}
.content {
white-space: pre-wrap; /* 保留换行符,自动换行 */
line-height: 1.8;
}
</style>
📊 数据流动过程
swift
AI 服务器返回(OpenAI 兼容格式):
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: {"choices":[{"delta":{"content":"啊"}}]}
data: [DONE]
↓ BFF 转发(原样转发)
前端逐条解析:
buffer += "data: {\"choices\":[{\"delta\":{\"content\":\"你\"}}]}\n\n"
→ 解析出 "你" → content.value = "你"
buffer += "data: {\"choices\":[{\"delta\":{\"content\":\"好\"}}]}\n\n"
→ 解析出 "好" → content.value = "你好"
buffer += "data: {\"choices\":[{\"delta\":{\"content\":\"啊\"}}]}\n\n"
→ 解析出 "啊" → content.value = "你好啊"
"data: [DONE]" → 流结束,停止读取
💡 为什么需要 buffer? 因为
reader.read()返回的数据块边界是任意的。AI 服务器可能一次发 10 条消息,也可能一条消息被分成 3 个 chunk 发过来。没有 buffer,半条消息就会 JSON 解析失败,导致数据丢失。
📚 第六部分:常见踩坑 & 解决方案
🐛 坑 1:502 Bad Gateway
现象 :前端请求 /api/stream 返回 502
原因:BFF 服务器没有启动!
bash
# 解决:确保 BFF 在运行
node server.mjs
🐛 坑 2:CORS 跨域错误
现象 :浏览器控制台报 Access-Control-Allow-Origin 错误
原因:直接从前端请求了 BFF,没有走 Vite proxy
javascript
// ❌ 错误:直接请求 BFF 地址(跨域!)
fetch('http://localhost:3000/stream?prompt=hello')
// ✅ 正确:通过 Vite proxy(不跨域)
fetch('/api/stream?prompt=hello')
🐛 坑 3:API Key 泄露
现象:用户在浏览器 DevTools 的 Network 面板中看到了 API Key
原因 :前端代码中硬编码了 Key,或者环境变量用了 VITE_ 前缀
javascript
// ❌ 危险!Key 暴露在前端
headers: { 'Authorization': 'Bearer sk-xxxxx' }
// ❌ 危险!VITE_ 前缀会被 Vite 注入前端
// .env: VITE_API_KEY=sk-xxxxx
// 前端代码: import.meta.env.VITE_API_KEY ← 可以读到!
// ✅ 安全!Key 只在 BFF 层使用,不用 VITE_ 前缀
// .env: MIMO_API_KEY=sk-xxxxx
// server.mjs: process.env.MIMO_API_KEY ← 只有 Node.js 能读
🐛 坑 4:SSE 流数据丢失 / 中文乱码
现象:流式输出的文字有缺失,或者中文显示为乱码
原因:没有使用 buffer 处理 chunk 边界截断
javascript
// ❌ 错误:直接 split,半条消息会丢失
const lines = chunk.split('\n')
// ✅ 正确:用 buffer 暂存不完整数据
let buffer = ''
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || '' // 最后一行可能不完整,留到下次
🐛 坑 5:生产环境 SSE 流不实时(nginx 缓冲)
现象:开发环境正常,部署到服务器后 SSE 数据不是逐条到达,而是一次性返回
原因:nginx 反向代理默认会缓冲响应
nginx
# 解决方案 1:nginx 配置中关闭缓冲
location /api/stream {
proxy_buffering off;
proxy_cache off;
}
# 解决方案 2:在 BFF 中设置响应头(已在代码中添加)
res.setHeader('X-Accel-Buffering', 'no');
🐛 坑 6:用户中途关闭页面导致资源泄漏
现象:用户刷新页面或关闭标签页,BFF 还在继续读取 AI API 的响应
原因:没有监听客户端断开事件
javascript
// ✅ 解决:监听 req 的 close 事件
let aborted = false;
req.on('close', () => {
aborted = true;
reader.cancel(); // 取消读取,释放资源
});
while (true) {
if (aborted) break; // 客户端已断开,停止循环
const { done, value } = await reader.read();
// ...
}
📚 第七部分:最终完整代码汇总(可直接复制)
📄 .env.local
bash
# ⚠️ 不要提交到 git!加入 .gitignore
# 不用 VITE_ 前缀,确保只在 BFF 服务端可用
MIMO_API_KEY=sk-你的密钥
MIMO_API_BASE_URL=https://api.xiaomimimo.com/v1
MIMO_MODEL=mimo-v2.5-pro
📄 .gitignore(追加)
bash
# 环境变量文件(包含密钥,不能提交)
.env.local
.env
📄 vite.config.js(完整)
javascript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
})
📄 server.mjs(完整)
javascript
import 'dotenv/config';
import express from 'express';
const app = express();
const port = 3000;
// 测试路由
app.get('/', (req, res) => {
res.send('Hello World! BFF is running 🚀');
});
// SSE 流式接口
app.get('/stream', async (req, res) => {
const { prompt } = req.query;
const endpoint = 'https://api.xiaomimimo.com/v1/chat/completions';
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MIMO_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: process.env.MIMO_MODEL,
stream: true,
messages: [{ role: 'user', content: prompt }]
})
});
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
// 读取 AI 响应流
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
// 监听客户端断开
let aborted = false;
req.on('close', () => {
aborted = true;
reader.cancel();
});
// 循环转发数据
while (true) {
if (aborted) break;
const { done, value } = await reader.read();
if (done) {
res.write('data: [DONE]\n\n');
res.end();
break;
}
const chunk = decoder.decode(value, { stream: true });
res.write(`data: ${chunk}\n\n`);
}
} catch (error) {
console.log('❌ 请求出错:', error);
if (!res.headersSent) {
res.status(500).json({ error: '请求失败' });
} else {
res.end();
}
}
});
app.listen(port, () => {
console.log(`✅ BFF 服务器已启动: http://localhost:${port}`);
});
📄 src/App.vue(完整)
vue
<script setup>
import { ref } from 'vue'
const question = ref('')
const content = ref('')
const loading = ref(false)
const update = async () => {
if (!question.value.trim()) return
if (loading.value) return
content.value = ''
loading.value = true
try {
const response = await fetch(
`/api/stream?prompt=${encodeURIComponent(question.value)}`
)
if (!response.ok) {
throw new Error(`请求失败: ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
try {
const json = JSON.parse(line.slice(6))
const text = json.choices?.[0]?.delta?.content || ''
content.value += text
} catch (e) {}
}
}
}
} catch (error) {
content.value = `❌ 请求出错: ${error.message}`
} finally {
loading.value = false
}
}
</script>
<template>
<div class="container">
<div>
<label>输入:</label>
<input
class="input"
v-model="question"
placeholder="请输入你的问题..."
@keyup.enter="update"
/>
<button @click="update" :disabled="loading">
{{ loading ? '生成中...' : '提交' }}
</button>
</div>
<div class="output">
<div class="content">{{ content }}</div>
</div>
</div>
</template>
<style>
.container {
display: flex;
flex-direction: column;
align-items: start;
justify-content: start;
height: 100vh;
font-size: 0.85rem;
padding: 20px;
}
.input {
margin-top: 10px;
min-height: 30px;
width: 300px;
}
button {
padding: 0 10px;
margin-left: 6px;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.output {
margin-top: 20px;
}
.content {
white-space: pre-wrap;
line-height: 1.8;
}
</style>
💡 重点总结
🎯 三个核心概念
| 概念 | 一句话解释 | 解决什么问题 |
|---|---|---|
| SSE | 服务器持续推送数据给前端 | AI 流式输出、实时通知 |
| BFF | 前端和后端之间的中间层 | 安全(隐藏 API Key)、数据转换 |
| Vite Proxy | 开发时的跨域解决方案 | 前端请求转发到不同端口的服务 |
🔗 请求链路总结
javascript
前端 fetch('/api/stream?prompt=你好')
↓ Vite Proxy 拦截,转发到 localhost:3000
BFF 收到 /stream?prompt=你好
↓ 带上 API Key,请求 AI 服务器
AI 返回 SSE 流(data: ...)
↓ BFF 逐块转发给前端
前端用 buffer + JSON.parse 解析,逐字显示
🔐 环境变量安全速查
| 场景 | 变量命名 | 读取方式 | 是否暴露给浏览器 |
|---|---|---|---|
| BFF 服务端专用 | MIMO_API_KEY |
process.env.MIMO_API_KEY |
❌ 安全 |
| 前端可用 | VITE_XXX |
import.meta.env.VITE_XXX |
⚠️ 会暴露 |
🔗 参考资料
💬 交流讨论
你在做 AI 流式输出的时候遇到过哪些坑?或者对 SSE、BFF、跨域有什么疑问?欢迎在评论区讨论!👇
**觉得有用?点个赞👍收藏⭐关注👆
掘金推荐标签 :
前端SSEBFF跨域Node.jsExpressViteAI流式输出