从跨域到流式输出:理解 SSE、BFF 与 Vite 代理的请求链路

2 阅读10分钟

从跨域到流式输出:理解 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 服务

首先引入 dotenvexpress

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!');
});

这里的 reqres 分别表示:

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 负责安全、编排和适配,大模型负责生成与推理。