从跨域到流式输出:理解 SSE、BFF 与 Vite 代理的请求链路
前言
在开发 AI 对话应用时,前端通常不能直接请求 DeepSeek、OpenAI 等大模型接口。
一方面,浏览器直接请求不同端口或不同域名的服务时,可能会遇到跨域问题;另一方面,API Key 也不能直接写进前端代码,否则任何人都可以通过浏览器开发者工具看到。
因此,真实项目中通常会在前端和大模型服务之间增加一层 Node 服务,也就是 BFF。
本次学习的核心不是单独写一个 Express 接口,而是理解下面这条完整请求链路:
Vue 前端
↓
Vite 开发服务器
↓
/api 代理
↓
Express BFF
↓
后端服务或 LLM API
这也是后续实现 SSE、LLM 流式输出、Agent 和 RAG 服务的重要基础。
一、什么是 BFF
BFF 的全称是:
Backend For Frontend
可以理解为:
专门为前端提供服务的一层后端。
传统情况下,前端可能直接请求业务后端:
浏览器前端 -> 业务后端
加入 BFF 后,请求链路变成:
浏览器前端 -> BFF -> 业务后端或第三方服务
BFF 并不只是简单地把请求转发一次,它通常还会负责:
- 保存 API Key 等敏感信息
- 调用第三方接口
- 整理复杂的响应数据
- 统一错误处理
- 聚合多个后端接口
- 处理流式响应
- 根据前端页面需求定制数据格式
例如,前端只需要请求:
/api/stream
至于真实的大模型地址、鉴权方式、请求参数和流式数据解析,都可以由 BFF 完成。
二、为什么前端不应该直接调用 LLM
假设直接在 Vue 前端中调用大模型接口:
fetch('https://api.example.com/chat', {
headers: {
Authorization: 'Bearer 真实的API_KEY',
},
});
这会产生几个明显的问题。
1. API Key 会暴露
前端代码最终会下载到用户浏览器中。
即使使用环境变量:
import.meta.env.VITE_API_KEY
只要这个变量被打包进前端代码,用户仍然可以在浏览器中查看。
因此:
VITE_API_KEY ≠ 安全的服务端环境变量
真正敏感的 Key 应该保存在 Node 服务中:
Vue 前端 -> Node BFF -> LLM API
2. 容易出现跨域问题
当前端运行在:
http://localhost:5173
BFF 运行在:
http://localhost:3000
虽然域名都是 localhost,但是端口不同,因此它们不属于同源。
3. 前端代码会过于复杂
LLM 流式返回通常涉及:
ReadableStream- 二进制数据块
TextDecoder- chunk 拼接
- SSE 数据格式解析
- 错误重试
- 中断请求
如果所有逻辑都直接写在 Vue 组件中,页面代码会变得非常臃肿。
BFF 可以承担一部分复杂处理,让前端只负责页面展示。
三、浏览器为什么会出现跨域
浏览器判断两个地址是否同源,主要看三个部分:
协议 + 域名 + 端口
只有三者都相同,才属于同源。
例如:
http://localhost:5173
http://localhost:3000
这两个地址的协议和域名相同,但是端口不同,因此属于跨域请求。
再例如:
http://localhost:5173
https://localhost:5173
虽然域名和端口相同,但协议不同,也不属于同源。
因此,只要下面三个条件中有一个不同,就可能产生跨域:
协议不同
域名不同
端口不同
需要注意的是:
跨域限制主要是浏览器的安全机制,不代表两个服务器之间不能通信。
Node 服务请求其他服务器时,通常不会受到浏览器同源策略的限制。
四、创建 Express BFF 服务
首先引入 dotenv 和 express:
import * as dotenv from 'dotenv';
import express from 'express';
dotenv.config({
path: ['.env.local', '.env'],
});
dotenv 用于读取环境变量。
真实项目中,可以将 API Key 放在 .env.local 文件中:
DEEPSEEK_API_KEY=你的密钥
然后通过:
process.env.DEEPSEEK_API_KEY
在服务端读取。
这样密钥只存在于 Node 服务中,不会暴露给浏览器。
接下来创建 Express 应用:
const app = express();
const port = 3000;
启动 HTTP 服务:
app.listen(port, () => {
console.log(`BFF 服务已启动:http://localhost:${port}`);
});
此时,Express 会监听本机的 3000 端口。
五、理解 Express 中的 req 和 res
在 Express 路由中,经常会看到:
app.get('/', (req, res) => {
res.send('Hello World!');
});
这里的 req 和 res 分别表示:
req = request,请求对象
res = response,响应对象
req 请求对象
req 中包含浏览器发送过来的请求信息,例如:
req.query
req.params
req.body
req.headers
req.method
req.url
例如请求:
/user?id=10
可以通过:
req.query.id
获得参数 10。
res 响应对象
res 用于向浏览器返回数据,例如:
res.send('Hello World!');
返回普通文本。
res.json({
message: 'Hello World!',
});
返回 JSON 数据。
res.status(404).json({
message: '资源不存在',
});
设置状态码并返回 JSON。
可以把一次请求简单理解为:
浏览器发送 req
服务器处理 req
服务器通过 res 返回结果
六、创建基础 BFF 路由
当前项目中提供了两个测试接口:
app.get('/', (req, res) => {
res.send('Hello World!');
});
访问:
http://localhost:3000
如果页面显示:
Hello World!
说明 Express 服务已经正常启动。
接着创建 /stream 路由:
app.get('/stream', async (req, res) => {
res.json({
message: 'Hello World!',
});
});
访问:
http://localhost:3000/stream
会得到:
{
"message": "Hello World!"
}
虽然路由名称叫 /stream,但目前它返回的仍然是一次性 JSON,而不是真正的流式响应。
它当前的主要作用是验证:
前端 -> Vite 代理 -> Express BFF
这条链路能否正常运行。
七、为什么需要 Vite 代理
Vue 项目通常由 Vite 启动:
http://localhost:5173
Express BFF 运行在:
http://localhost:3000
如果前端直接请求:
fetch('http://localhost:3000/stream');
浏览器会发现请求目标和当前页面端口不同,因此可能触发跨域限制。
开发环境中,可以使用 Vite proxy 解决这个问题。
配置如下:
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: path => path.replace(/^/api/, ''),
},
},
},
});
八、Vite proxy 每项配置的作用
1. /api
'/api': {
}
表示所有以 /api 开头的请求,都交给代理处理。
例如:
/api/stream
/api/chat
/api/user
都会进入这条代理规则。
这里的 /api 更像是前端和 Vite 之间约定的一个标记。
它不一定是后端真实存在的路由。
2. target
target: 'http://localhost:3000'
表示请求要被转发到哪个服务器。
也就是:
Vite -> Express BFF
3. changeOrigin
changeOrigin: true
它会修改代理请求中的 Host 等来源信息,让目标服务器认为请求是发给自己的。
在代理第三方接口或者对请求来源有检查的服务器时,这项配置比较常见。
4. rewrite
rewrite: path => path.replace(/^/api/, '')
它会删除请求地址开头的 /api。
前端请求:
/api/stream
经过重写后变成:
/stream
再与 target 拼接,最终请求地址为:
http://localhost:3000/stream
完整变化过程如下:
浏览器请求:
http://localhost:5173/api/stream
Vite 匹配:
/api
删除 /api:
/stream
转发到:
http://localhost:3000/stream
九、Vite 代理为什么可以解决跨域
前端代码中写的是:
fetch('/api/stream');
浏览器会认为自己访问的是:
http://localhost:5173/api/stream
当前页面本身也是:
http://localhost:5173
协议、域名和端口都相同,因此对浏览器来说这是同源请求。
接下来,Vite 开发服务器会在服务器内部把请求转发到:
http://localhost:3000/stream
这一步是服务器之间的转发,不是浏览器直接访问 3000 端口,因此绕开了浏览器的跨域限制。
可以理解成:
浏览器只认识 Vite
Vite 再去找 Express
需要特别注意:
Vite proxy 是 Vite 开发服务器提供的功能,不是浏览器本身具有的能力。
十、前端如何调用 BFF
Vue 前端只需要请求代理地址:
fetch('/api/stream')
.then(res => res.json())
.then(data => {
console.log(data);
});
这里没有直接写:
http://localhost:3000
前端也不需要知道 BFF 的真实端口。
使用 async/await 可以写成:
async function getData() {
try {
const response = await fetch('/api/stream');
if (!response.ok) {
throw new Error(`请求失败:${response.status}`);
}
const data = await response.json();
console.log(data);
} catch (error) {
console.error('请求发生错误:', error);
}
}
当前完整请求链路为:
App.vue
↓ fetch('/api/stream')
Vite dev server
↓ 删除 /api
http://localhost:3000/stream
↓
Express BFF
↓ res.json()
Vue 前端获得 JSON 数据
十一、BFF 不只是解决跨域
刚开始接触 BFF 时,很容易认为:
BFF 就是用来解决跨域的。
但解决跨域只是它在开发过程中带来的一个附加效果。
BFF 更核心的价值是:
根据前端需求,对后端能力进行适配和封装。
例如,一个页面可能需要:
用户信息
订单数据
优惠券信息
推荐商品
如果前端分别调用四个后端接口,页面代码会比较复杂。
BFF 可以在服务端聚合这些请求:
前端请求一次
↓
BFF 请求多个后端接口
↓
BFF 整理数据
↓
返回前端真正需要的格式
对于 AI 应用也是一样。
前端可能只提交:
{
"message": "什么是BFF?"
}
BFF 则负责:
- 读取 API Key
- 组装大模型 messages
- 调用 LLM 接口
- 设置模型参数
- 处理流式响应
- 统一错误信息
- 返回前端需要的数据
十二、SSE 和当前项目有什么关系
SSE 的全称是:
Server-Sent Events
它用于服务器持续向浏览器发送数据。
普通 JSON 接口通常是:
请求开始
↓
等待服务端全部处理完成
↓
一次性返回完整 JSON
而 SSE 或 LLM streaming 更像:
请求开始
↓
返回第一个文本片段
↓
返回第二个文本片段
↓
持续返回
↓
结束
例如大模型生成一句话:
BFF 是专门为前端设计的后端层。
普通请求可能会等整句话生成后一次性返回。
流式请求则可能分成:
BFF
是
专门
为前端
设计的
后端层。
前端每收到一小段,就立即追加到页面上,因此用户能看到类似打字机的效果。
十三、JSON 响应和流式响应的区别
当前 /stream 路由使用的是:
res.json({
message: 'Hello World!',
});
这仍然是一次性响应。
真正实现 SSE 时,通常需要设置响应头:
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
然后通过:
res.write();
持续发送数据。
例如:
app.get('/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
let count = 0;
const timer = setInterval(() => {
count++;
res.write(`data: 第 ${count} 条消息\n\n`);
if (count === 5) {
clearInterval(timer);
res.end();
}
}, 1000);
});
这里的关键区别是:
res.json();
一次性返回并结束响应。
res.write();
可以多次向客户端写入内容。
res.end();
表示流式响应结束。
十四、BFF 如何帮助处理 LLM 流
接入真实 LLM 后,BFF 可以成为流式数据的中转层:
Vue 前端
↓
Express BFF
↓
DeepSeek / OpenAI
大模型持续返回 chunk:
LLM chunk
↓
Node BFF 读取
↓
Node BFF 解析
↓
Node BFF 转发
↓
Vue 页面更新
BFF 可以负责处理:
- LLM 的鉴权信息
- 不同厂商的响应格式
- 文本增量提取
[DONE]等结束标记- 超时和异常
- 请求取消
- 内容审核
- Token 使用量统计
这样 Vue 页面不需要理解每一家大模型接口的底层协议。
前端只需要消费统一的数据格式。
十五、一个容易混淆的问题:SSE 和 fetch 流
SSE 通常可以使用浏览器的 EventSource:
const eventSource = new EventSource('/api/stream');
eventSource.onmessage = event => {
console.log(event.data);
};
但是 EventSource 主要用于 GET 请求,并且不方便自定义复杂请求体。
AI 对话通常需要发送:
{
"messages": [
{
"role": "user",
"content": "你好"
}
]
}
因此很多 LLM 项目会使用:
fetch()
配合:
response.body.getReader()
读取流。
例如:
const response = await fetch('/api/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: '你好',
}),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = decoder.decode(value, {
stream: true,
});
console.log(chunk);
}
因此,在 AI 场景中经常会同时遇到:
SSE 数据格式
fetch 流式读取
ReadableStream
TextDecoder
它们彼此有关联,但并不是完全相同的概念。
十六、开发环境和生产环境的区别
Vite proxy 主要用于开发环境。
开发时:
Vue:localhost:5173
BFF:localhost:3000
因此需要代理处理不同端口的问题。
生产环境中,通常会使用 Nginx、云平台路由或同一个域名部署。
例如:
https://example.com
https://example.com/api
可能会配置为:
/ -> Vue 静态页面
/api -> Node BFF
此时生产环境不再依赖 Vite 的开发代理。
因此可以理解为:
开发环境:Vite proxy
生产环境:Nginx 或云平台反向代理
十七、这几个文件分别负责什么
本次项目中的几个文件职责比较清晰。
server.mjs
负责启动 BFF 服务:
Express 创建服务
注册路由
读取环境变量
监听 3000 端口
后续调用 LLM API
vite.config.js
负责开发环境代理:
匹配 /api 请求
转发到 3000 端口
删除 /api 前缀
避免浏览器直接跨域
src/App.vue
负责前端页面:
发起请求
读取响应
更新页面状态
展示生成内容
它们之间的关系是:
App.vue 负责用户交互
vite.config.js 负责开发代理
server.mjs 负责服务端逻辑
每一层只处理自己应该负责的事情。
十八、本节课最重要的理解
通过本次学习,我对 BFF 和 Vite 代理有了几个更清晰的认识。
1. 跨域由协议、域名和端口共同决定
只要其中一项不同,就不属于同源。
2. Vite proxy 是开发服务器能力
不是浏览器主动绕过了跨域,而是浏览器只请求 Vite,再由 Vite 转发请求。
3. /api 是一个代理标记
前端请求:
/api/stream
真实 BFF 路由可以仍然是:
/stream
4. BFF 不只是转发请求
它还负责鉴权、数据适配、聚合接口、错误处理和流式数据解析。
5. API Key 必须保存在服务端
只要密钥进入前端打包结果,就不能被认为是安全的。
6. 当前代码还不是真正的 SSE
res.json() 返回的是普通 JSON。
真正的流式输出需要使用:
res.write()
或将上游 LLM 的 ReadableStream 持续转发给前端。
十九、后续学习路线
当前项目已经完成了最基础的请求链路:
Vue -> Vite proxy -> Express BFF
接下来可以按照下面的顺序继续完善。
第一步:支持 POST 请求
让前端把用户输入发送给 BFF:
POST /api/stream
第二步:接入真实 LLM API
由 BFF 读取环境变量中的 API Key,并调用 DeepSeek 或 OpenAI。
第三步:开启 streaming
让模型不是一次性返回完整答案,而是持续返回文本片段。
第四步:BFF 转发数据流
使用 Node 读取上游响应,并通过 res.write() 或 Web Stream 转发给前端。
第五步:前端解析 ReadableStream
使用:
response.body.getReader()
不断读取文本,并实时更新 Vue 页面。
最终链路会变成:
用户输入问题
↓
Vue 发起 POST 请求
↓
Vite 代理 /api
↓
Express BFF
↓
调用 LLM Streaming API
↓
持续读取模型输出
↓
持续返回给 Vue
↓
页面逐字显示回答
总结
本节课表面上是在学习 Express 路由和 Vite proxy,真正建立起来的却是一套 AI Web 应用的基础架构。
它解决了三个核心问题:
跨域问题
API Key 安全问题
前后端职责划分问题
当前 /stream 接口虽然还只是返回普通 JSON,但已经打通了:
Vue 前端 -> Vite 开发代理 -> Express BFF
这条链路。
在此基础上继续加入 SSE、ReadableStream 和 LLM streaming,就可以逐步实现一个真正支持流式输出的 AI 对话应用。
理解 BFF 后,再学习 Agent、RAG 或大模型工具调用时,也会更加清楚:
前端负责交互和展示,BFF 负责安全、编排和适配,大模型负责生成与推理。