Vue 项目中的前端 BFF:跨域代理与流式请求实战
- 前言
- [1. 流式输出与 BFF 基础](#1. 流式输出与 BFF 基础)
-
- [1.1 为什么需要流式输出](#1.1 为什么需要流式输出)
- [1.2 BFF 是什么](#1.2 BFF 是什么)
- [1.3 为什么前端不能直接保存 API Key](#1.3 为什么前端不能直接保存 API Key)
- [2. 使用 Vite 创建 Vue 项目](#2. 使用 Vite 创建 Vue 项目)
-
- [2.1 初始化项目](#2.1 初始化项目)
- [2.2 `package.json` 与 `node_modules`](#2.2
package.json与node_modules) - [2.3 Vue 的入口文件](#2.3 Vue 的入口文件)
- [3. App.vue:页面、状态与请求](#3. App.vue:页面、状态与请求)
-
- [3.1 单文件组件的三个部分](#3.1 单文件组件的三个部分)
- [3.2 `App.vue` 完整代码](#3.2
App.vue完整代码) - [3.3 `ref` 为什么需要 `.value`](#3.3
ref为什么需要.value) - [3.4 `v-model`、插值表达式与事件绑定](#3.4
v-model、插值表达式与事件绑定) - [3.5 `fetch` 请求怎样执行](#3.5
fetch请求怎样执行) - [3.6 样式注释中的基础知识](#3.6 样式注释中的基础知识)
- [4. server.mjs:创建 BFF 服务](#4. server.mjs:创建 BFF 服务)
-
- [4.1 `server.mjs` 完整代码](#4.1
server.mjs完整代码) - [4.2 加载环境变量](#4.2 加载环境变量)
- [4.3 创建 Express 应用和根路由](#4.3 创建 Express 应用和根路由)
- [4.4 `/stream` 路由与查询参数](#4.4
/stream路由与查询参数) - [4.5 BFF 请求大模型](#4.5 BFF 请求大模型)
- [4.6 `response.body` 与测试响应](#4.6
response.body与测试响应) - [4.7 启动服务](#4.7 启动服务)
- [4.1 `server.mjs` 完整代码](#4.1
- [5. 跨域问题:为什么需要 Vite 代理](#5. 跨域问题:为什么需要 Vite 代理)
-
- [5.1 什么是同源](#5.1 什么是同源)
- [5.2 同源策略为什么存在](#5.2 同源策略为什么存在)
- [5.3 CORS 是什么](#5.3 CORS 是什么)
- [5.4 `vite.config.js` 完整代码](#5.4
vite.config.js完整代码) - [5.5 `defineConfig` 与 Vue 插件](#5.5
defineConfig与 Vue 插件) - [5.6 `server.proxy` 逐项解释](#5.6
server.proxy逐项解释) - [5.7 `/api/stream` 怎样变成 `/stream`](#5.7
/api/stream怎样变成/stream) - [5.8 为什么会出现 502 或 404](#5.8 为什么会出现 502 或 404)
- [5.9 开发代理与生产环境](#5.9 开发代理与生产环境)
- [6. `response.body` 与 SSE 基础](#6.
response.body与 SSE 基础) -
- [6.1 `ReadableStream` 是什么](#6.1
ReadableStream是什么) - [6.2 为什么需要解码](#6.2 为什么需要解码)
- [6.3 SSE 的 `data:` 格式](#6.3 SSE 的
data:格式)
- [6.1 `ReadableStream` 是什么](#6.1
- [7. 三个文件如何协同工作](#7. 三个文件如何协同工作)
-
- [7.1 一次提交的执行顺序](#7.1 一次提交的执行顺序)
- [7.2 三个文件的职责边界](#7.2 三个文件的职责边界)
- 总结
前言
大模型生成内容需要时间。如果浏览器必须等到完整答案生成后才能显示,用户会长时间看不到反馈;如果服务器生成一部分就发送一部分,页面再把收到的内容持续追加出来,就会形成聊天应用常见的"打字机"效果。
流式输出带来了新的问题:浏览器要面对二进制数据、文本解码和 data: 事件;API Key 不能安全地放在前端;Vue 页面运行在 5173 端口,Node 服务运行在 3000 端口,直接请求还会触发浏览器的跨域限制。
这个小项目使用一层 Node BFF 连接 Vue 和大模型服务:
text
Vue 页面 → Vite 开发代理 → Node BFF → 大模型服务
这个小项目只包含三个关键文件:App.vue 负责页面,server.mjs 负责 BFF,vite.config.js 负责开发环境代理。学习重点是理解 BFF 为什么出现,以及 /api/stream 怎样通过 Vite 转发到 3000 端口的 /stream。
1. 流式输出与 BFF 基础
1.1 为什么需要流式输出
大模型回答问题时,耗时主要来自模型推理和内容生成。问题越复杂、答案越长,等待完整结果的时间通常越久。
非流式响应的过程是:
text
发送问题 → 等待完整答案 → 一次性显示
流式响应的过程是:
text
发送问题 → 收到一部分 → 立即显示 → 继续接收和追加 → 完成
客户端发送 stream: true,表示希望服务器启用流式返回;服务器也必须支持这个约定,生成一部分就发送一部分。聊天页面收到增量内容后不断拼接,用户就不必面对长时间的空白等待。
流式输出不会减少模型本身的全部推理时间,它主要让用户更早看到第一部分结果,从而改善等待体验。
1.2 BFF 是什么
BFF 是 Backend For Frontend 的缩写,中文可以理解为"专门为前端服务的后端"。
传统后端常用 Java、Go 等语言开发,可能采用 MVC(Model、View、Controller)设计模式,负责数据库、CRUD、稳定性、并发和安全。CRUD 分别表示创建、读取、修改和删除;RESTful 接口通常使用 POST、GET、PUT/PATCH、DELETE 表达这些操作。
BFF 更靠近页面。它不代替完整的业务后端,而是按照某个前端应用的需要提供接口:
text
前端(Vue/React) → Node BFF → 传统后端或第三方服务
在大模型场景中,它变成:
text
Vue 页面 → Node BFF → LLM 服务器
BFF 在这里有三个直接作用:
- API Key 保留在 Node 服务中,不交给浏览器。
- Node 统一请求大模型,前端只访问自己的
/stream接口。 - 二进制流、文本解码和
data:解析可以逐步放到 BFF 中,降低页面复杂度。
1.3 为什么前端不能直接保存 API Key
Vue 项目最终会被构建成浏览器可以下载的 HTML、CSS 和 JavaScript。只要 API Key 出现在前端代码中,用户就可以通过开发者工具、Network 面板或构建文件看到它。
.env.local 只能帮助管理本地环境变量,不能让浏览器中的秘密真正不可见。Vite 会把以 VITE_ 开头并在前端使用的变量暴露给 import.meta.env,所以服务端密钥应使用不带 VITE_ 的名称:
dotenv
DEEPSEEK_API_KEY=自己的密钥
Node 通过 process.env.DEEPSEEK_API_KEY 读取,Vue 页面不读取它。这样浏览器只知道 BFF 地址,不知道上游密钥。
2. 使用 Vite 创建 Vue 项目
2.1 初始化项目
执行:
bash
npm init vite
交互选项依次选择:
text
Project name: stream-bff
Select a framework: Vue
Select a variant: JavaScript

进入项目并安装依赖:
bash
cd stream-bff
npm install
npm install express dotenv
vite 用于创建和运行前端工程,vue 提供组件与响应式能力,express 用于创建 BFF 服务,dotenv 用于加载 .env.local。
2.2 package.json 与 node_modules
package.json 记录项目名称、依赖和 npm 脚本。项目中的三个脚本分别是:
| 命令 | 实际执行 | 作用 |
|---|---|---|
npm run dev |
vite |
启动 Vue 开发服务器 |
npm run build |
vite build |
构建生产文件 |
npm run preview |
vite preview |
本地预览构建结果 |
node_modules 保存安装后的依赖代码,package-lock.json 锁定实际安装版本。它们都来自 npm install,不是手动编写的业务代码。
本项目包含两个独立的 HTTP 服务:
bash
# 终端一:启动 Vue 和 Vite
npm run dev
# 终端二:启动 Node BFF
node server.mjs
也可以使用nodemon持续观察
bash
nodemon server.mjs

Vite 通常监听 5173,BFF 监听 3000。正是因为端口不同,后面才需要讨论跨域和代理。
2.3 Vue 的入口文件
src/main.js 负责创建 Vue 应用:
javascript
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
createApp(App) 接收根组件 App,返回 Vue 应用实例;mount('#app') 把应用挂载到 index.html 中的 <div id="app"></div>。如果选择器与 HTML 中的 id 不一致,Vue 就找不到挂载位置。
执行顺序可以理解为:
text
加载 Vue → 加载全局样式 → 加载 App.vue → 挂载到 #app
3. App.vue:页面、状态与请求
3.1 单文件组件的三个部分
.vue 文件叫作 Vue 单文件组件,它把一个组件需要的内容放在一起:
| 区域 | 作用 |
|---|---|
<template> |
编写页面结构和 Vue 模板语法 |
<script setup> |
编写状态、事件和网络请求 |
<style> |
编写组件样式 |
普通 HTML 是静态结构,Vue 模板可以绑定数据并随着数据变化自动更新。<script setup> 是 Vue 3 的语法糖,脚本顶层声明的变量和函数可以直接在模板中使用。
3.2 App.vue 完整代码
页面只补充输入、开关、输出和提交函数需要的状态,不增加其他功能:
html
<script setup>
import { ref } from 'vue'
const question = ref('hello')
const stream = ref(true)
const content = ref('')
const update = () =>
fetch(`/api/stream?prompt=${encodeURIComponent(question.value)}`)
.then(res => res.json())
.then(data => {
content.value = data.prompt
})
</script>
<template>
<div class="container">
<div>
<label>输入:</label>
<input class="input" v-model="question" />
<button @click="update">提交</button>
</div>
<div class="output">
<div>
<label>Streaming</label>
<input type="checkbox" v-model="stream" />
</div>
<div>{{ content }}</div>
</div>
</div>
</template>
<style>
.container {
display: flex;
flex-direction: column;
align-items: start;
justify-content: start;
height: 100vh;
font-size: 0.85rem;
}
.input {
width: 200px;
}
.output {
margin-top: 10px;
min-height: 300px;
width: 100%;
text-align: left;
}
button {
padding: 0 10px;
margin-left: 6px;
}
</style>
这段代码保持了小项目的范围:点击提交后请求 BFF,BFF 暂时把收到的 prompt 作为 JSON 返回,页面再显示它。Streaming 复选框先负责保存是否开启流式模式的状态,后续真正处理流时再把这个状态传给 BFF。
3.3 ref 为什么需要 .value
ref(initialValue) 用于创建响应式状态。它接收一个初始值,返回一个带 value 属性的响应式引用:
javascript
const question = ref('hello')
在 JavaScript 中读取和修改它要使用:
javascript
question.value
content.value = data.prompt
JavaScript 的字符串和布尔值本身不能让 Vue 感知"变量被重新赋值",所以 ref 使用对象的 value 属性作为可追踪的读写入口。Vue 发现 content.value 变化后,会更新使用 content 的模板。
在模板中不用写 .value:
vue
<div>{{ content }}</div>
这是因为 Vue 会在模板表达式中自动解包 ref。自动解包只发生在模板中,普通 JavaScript 仍然要写 .value。
| 内容 | 参数或结果 | 使用场景 | 常见错误 |
|---|---|---|---|
ref('hello') |
参数是初始值,返回响应式引用 | 保存问题、开关、回答 | 在脚本中忘记 .value |
content.value = ... |
修改响应式引用中的值 | 接口返回后更新页面 | 写成 content = ... |
{``{ content }} |
显示并自动解包状态 | 将回答渲染到模板 | 在模板中多写 .value |
3.4 v-model、插值表达式与事件绑定
输入框使用:
vue
<input v-model="question" />
v-model 是双向绑定。用户输入会更新 question,代码修改 question.value 也会更新输入框。文本输入框主要同步 value 和 input 事件,复选框主要同步 checked 状态。
回答区域使用:
vue
<div>{{ content }}</div>
这是单向的数据展示:数据流从 content 到页面。表单之所以需要 v-model,是因为用户还要把输入传回数据状态。
按钮使用:
vue
<button @click="update">提交</button>
@click 是 v-on:click 的简写。参数是要执行的事件处理函数 update,点击时 Vue 调用它。模板中写 update 表示把函数交给事件系统,不是在页面渲染时立即执行。
3.5 fetch 请求怎样执行
update 中最关键的一行是:
javascript
fetch(`/api/stream?prompt=${encodeURIComponent(question.value)}`)
fetch(url) 接收请求地址,返回一个 Promise。这里没有写协议、域名和端口,所以 /api/stream 是相对地址,浏览器会把它补成:
text
http://localhost:5173/api/stream?prompt=hello
encodeURIComponent 会编码中文、空格、& 等特殊字符,避免它们破坏查询字符串。res.json() 将 BFF 返回的 JSON 响应解析成 JavaScript 对象,第二个 .then() 再把 data.prompt 写入 content.value。
执行顺序是:
text
点击按钮 → 调用 update → fetch 请求 BFF → 解析 JSON → 修改 content.value → Vue 更新页面
3.6 样式注释中的基础知识
浏览器默认的文档流通常让块级元素从上到下排列,让行内内容从左到右排列。每个元素都占据自己的位置和尺寸。
display: flex 会为容器创建 Flex 格式化上下文;
flex-direction: column 让子元素从上到下排列;
align-items: start 控制交叉轴靠起点对齐;
justify-content: start 控制主轴靠起点对齐。
盒模型由内容、padding、border 和 margin 组成。按钮的 padding 扩大内部空间,margin-left 增加按钮与输入框的外部间距;.output 的 margin-top 让输出区与输入区分开。
100vh 表示视口高度的 100%,rem 相对于根元素字体大小。0.85rem 会随着根字体大小按比例变化,适合配合全局媒体查询完成基础的移动端适配。
4. server.mjs:创建 BFF 服务
4.1 server.mjs 完整代码
javascript
import * as dotenv from 'dotenv'
import express from 'express'
dotenv.config({
path: ['.env.local', '.env']
})
const app = express()
const port = 3000
app.get('/', (req, res) => {
res.send('Hello World!')
})
app.get('/stream', async (req, res) => {
const { prompt } = req.query
const endpoint = 'https://api.deepseek.com/v1/chat/completions'
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}`
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
stream: true,
messages: [{ role: 'user', content: prompt }]
})
})
console.log(response.body)
res.json({ prompt })
} catch (err) {
console.log(err)
}
})
app.listen(port, () => {
console.log(`服务器在 ${port} 端口启动了`)
})
这里先让 BFF 返回收到的 prompt,这样可以用一个简单的 JSON 响应确认 BFF 和 Vite 代理链路已经连通。response.body 的流式读取放在后面的基础概念中说明。
4.2 加载环境变量
javascript
dotenv.config({
path: ['.env.local', '.env']
})
dotenv.config(options) 读取环境变量文件并写入 process.env。path 指定读取顺序,这里会查找 .env.local 和 .env。服务端随后通过 process.env.DEEPSEEK_API_KEY 获取密钥。
环境变量文件应放在项目根目录,与 package.json 同级,并加入 .gitignore。Node 进程启动时读取它,修改后要重新运行 node server.mjs。
4.3 创建 Express 应用和根路由
javascript
const app = express()
const port = 3000
express() 返回一个 Express 应用实例,后续路由都注册在 app 上。port 保存监听端口。
javascript
app.get('/', (req, res) => {
res.send('Hello World!')
})
app.get(path, handler) 注册 GET 路由。path 是 /,handler 接收请求对象 req 和响应对象 res。res.send() 一次性发送字符串并结束响应。访问 http://localhost:3000/ 看到 Hello World!,说明 BFF 已启动。
4.4 /stream 路由与查询参数
javascript
app.get('/stream', async (req, res) => {
const { prompt } = req.query
})
浏览器请求:
text
/stream?prompt=hello
Express 会把问号后面的查询字符串解析到 req.query,所以 req.query.prompt 是 hello。解构赋值 const { prompt } = req.query 把它取到同名变量中。
路由处理函数使用 async,因为里面要 await fetch()。async 函数会返回 Promise,允许使用同步书写顺序表达异步网络请求。
4.5 BFF 请求大模型
javascript
const response = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}`
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
stream: true,
messages: [{ role: 'user', content: prompt }]
})
})
各字段含义如下:
| 字段 | 作用 |
|---|---|
endpoint |
大模型接口地址 |
method: 'POST' |
使用请求体发送数据 |
Authorization |
使用 Bearer Token 完成身份认证 |
model |
指定要使用的模型,名称以账号实际支持为准 |
messages |
提交对话消息,user 表示用户角色 |
stream: true |
请求上游使用流式响应 |
JSON.stringify() 把 JavaScript 对象转换为 JSON 字符串,作为 HTTP 请求体发送。POST 并不等于自动加密,真正保护传输数据的是 HTTPS。
4.6 response.body 与测试响应
javascript
console.log(response.body)
res.json({ prompt })
当上游使用流式响应时,response.body 是 ReadableStream。它不是已经生成好的完整字符串,而是可以逐批读取的响应体对象。打印它是为了先观察"流对象已经拿到"。
res.json({ prompt }) 把 BFF 接收到的 prompt 返回给 Vue,用来验证下面这条链路是否畅通:
text
App.vue → Vite 代理 → Express /stream → JSON 响应 → App.vue
这里先聚焦 BFF 和跨域代理。真正读取 ReadableStream 时,还会涉及读取器、二进制块、TextDecoder、data: 和结束标记,下面会逐一解释这些概念。
4.7 启动服务
javascript
app.listen(port, () => {
console.log(`服务器在 ${port} 端口启动了`)
})
app.listen(port, callback) 让 HTTP 服务开始监听 3000 端口。回调在监听成功后执行,用于输出启动信息。只有 Node 进程正在运行,Vite 代理才能连接这个端口。
5. 跨域问题:为什么需要 Vite 代理
5.1 什么是同源
浏览器使用 协议、域名、端口 三项判断两个地址是否同源。三项必须全部相同。
页面地址是 http://localhost:5173:
| 请求地址 | 是否同源 | 原因 |
|---|---|---|
http://localhost:5173/api/stream |
是 | 协议、主机、端口都相同 |
http://localhost:3000/stream |
否 | 端口不同 |
https://localhost:5173/api/stream |
否 | 协议不同 |
http://127.0.0.1:5173/api/stream |
否 | 主机名不同 |
localhost:5173 和 localhost:3000 虽然都在本机,但端口不同,仍然属于不同的源。
5.2 同源策略为什么存在
浏览器可能同时打开银行、邮箱和陌生网站。如果任意网站的 JavaScript 都能读取其他网站的响应,恶意页面就可能窃取用户数据。因此浏览器使用同源策略限制跨源脚本读取响应。
Vue 如果直接请求:
javascript
fetch('http://localhost:3000/stream?prompt=hello')
页面源是 http://localhost:5173,目标源是 http://localhost:3000。浏览器会检查 BFF 响应是否允许 5173 这个来源。如果没有相应的 CORS 响应头,JavaScript 就不能正常读取响应。
需要注意:跨域不一定表示网络请求完全没有发出。简单请求可能已经到达服务器,但浏览器检查响应头后拒绝把响应内容交给页面脚本。
5.3 CORS 是什么
CORS 是 Cross-Origin Resource Sharing,中文是跨源资源共享。服务器通过响应头告诉浏览器哪些来源可以访问自己:
http
Access-Control-Allow-Origin: http://localhost:5173
如果页面使用 JSON POST、Authorization 等非简单请求头,浏览器还可能先发送 OPTIONS 预检,询问服务器是否允许请求方法和请求头。服务器允许后,浏览器才发送真正的请求。
本项目没有让浏览器直接请求 3000,而是使用 Vite 开发代理。浏览器只访问同源的 5173,再由 Vite 服务器访问 3000。同源策略约束的是浏览器脚本,不约束 Vite 与 Express 之间的服务器通信。
5.4 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',
secure: false,
rewrite: path => path.replace(/^\/api/, '')
}
}
}
})
这个文件只做两件事:注册 Vue 插件,配置开发服务器代理。
5.5 defineConfig 与 Vue 插件
javascript
export default defineConfig({
plugins: [vue()]
})
defineConfig(config) 接收 Vite 配置对象并返回该配置,主要作用是提供更清晰的类型提示和配置结构。vue() 创建 Vue 插件,使 Vite 能够编译 .vue 单文件组件。
5.6 server.proxy 逐项解释
javascript
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
secure: false,
rewrite: path => path.replace(/^\/api/, '')
}
}
}
| 配置项 | 作用 |
|---|---|
server |
配置 Vite 开发服务器 |
proxy |
声明代理规则 |
'/api' |
匹配以 /api 开头的请求 |
target |
把匹配请求转发到 http://localhost:3000 |
secure: false |
开发环境中不严格校验目标 HTTPS 证书;本项目目标是 HTTP |
rewrite |
重写请求路径,删除开头的 /api |
Vite 不会把所有请求都转给 BFF。只有路径匹配 /api 的请求才会进入这条代理规则,页面、CSS、图片仍然由 Vite 自己处理。
5.7 /api/stream 怎样变成 /stream
前端发起:
text
http://localhost:5173/api/stream?prompt=hello
target 将目标服务器改成:
text
http://localhost:3000/api/stream?prompt=hello
rewrite 执行:
javascript
path.replace(/^\/api/, '')
正则 ^\/api 表示匹配路径开头的 /api,替换为空字符串。最终 Express 收到:
text
http://localhost:3000/stream?prompt=hello
它正好对应:
javascript
app.get('/stream', async (req, res) => {})
完整链路是:
text
浏览器
│ 请求 5173/api/stream,同源
▼
Vite
│ 匹配 /api,目标改为 3000,删除 /api
▼
Express
│ 命中 /stream
▼
BFF 路由处理请求
5.8 为什么会出现 502 或 404
把浏览器请求改成 /api/stream 只解决了"浏览器先访问同源 Vite"这一部分。Vite 还必须成功连接 3000 端口。
| 现象 | 原因 |
|---|---|
502 Bad Gateway |
BFF 没有启动、端口不一致或 Vite 无法连接 target |
404 Not Found |
改写后的路径没有对应 Express 路由 |
| 返回 Vite 页面 | 前端请求了 /stream,没有匹配 /api 代理规则 |
| 代理配置不生效 | 修改 vite.config.js 后没有重启 Vite |
排查顺序如下:
- 访问
http://localhost:3000/,确认能看到Hello World!。 - 确认前端请求写的是
/api/stream,不是http://localhost:3000/stream。 - 确认
target端口与app.listen一致。 - 确认
rewrite后的/stream与app.get('/stream')一致。 - 修改 Vite 配置后重新执行
npm run dev。
5.9 开发代理与生产环境
server.proxy 只属于 Vite 开发服务器。执行 npm run build 后,生产静态文件不会自动携带这个代理。
生产环境通常由 Nginx 或网关把同域的 /api 转发到 BFF:
text
https://example.com → Vue 静态页面
https://example.com/api → Node BFF
这样浏览器仍然访问同一个域名。如果前端和 BFF 必须使用不同域名,就需要由 BFF 正确配置 CORS,而不是依赖 Vite 开发代理。
6. response.body 与 SSE 基础
6.1 ReadableStream 是什么
当 BFF 向上游发送 stream: true 后,response.body 是一个 ReadableStream。可以把它理解为一条仍在持续产生数据的通道,而不是已经装好完整内容的 JSON 对象。
流中的数据通常以二进制字节块到达。一次网络读取不一定等于一个 token:一个读取块可能包含多个 token,也可能只有一个事件的一部分。
6.2 为什么需要解码
网络字节不能直接当作 JavaScript 字符串使用。TextDecoder 用于把 UTF-8 字节转换为文本。中文字符可能被拆到两个数据块中,所以解码器还要保留前一个块没有完成的字节。
这也是流式前端比普通 response.json() 更复杂的原因:普通 JSON 等全部响应结束后一次解析,流式响应要边接收、边解码、边处理。
6.3 SSE 的 data: 格式
SSE 是 Server-Sent Events,中文是服务器发送事件。典型数据格式如下:
text
data: {"content":"你"}
data: {"content":"好"}
data: [DONE]
data: 表示事件数据,空行表示一个事件结束,[DONE] 常被大模型接口用作流结束标记。解析时还要考虑一个事件被拆成多个网络块,因此通常需要缓存未完成的文本。
本项目先通过 console.log(response.body) 认识流对象,并通过 res.json({ prompt }) 验证 BFF 和跨域代理。到这里已经完成 BFF 层和代理链路的学习目标,没有另外加入流解析代码。
7. 三个文件如何协同工作
7.1 一次提交的执行顺序
text
1. main.js 创建 Vue 应用并加载 App.vue
2. 用户输入内容,v-model 更新 question
3. 用户点击提交,@click 调用 update
4. App.vue 请求 /api/stream?prompt=...
5. 浏览器把请求发给同源的 Vite 5173
6. vite.config.js 匹配 /api
7. Vite 将目标改为 localhost:3000
8. rewrite 删除 /api,请求变成 /stream
9. server.mjs 的 app.get('/stream') 接收 prompt
10. BFF 携带服务端 API Key 请求大模型
11. BFF 观察 response.body,并返回 prompt 测试 JSON
12. App.vue 解析 JSON,修改 content.value
13. Vue 把 content 渲染到页面
7.2 三个文件的职责边界
| 文件 | 负责什么 | 不负责什么 |
|---|---|---|
App.vue |
输入、按钮、状态、发起同源请求、显示结果 | 保存 API Key、直接请求大模型 |
vite.config.js |
开发环境代理和路径改写 | 业务处理、大模型调用 |
server.mjs |
BFF 路由、环境变量、请求上游 | Vue 页面渲染 |
职责分开后,排错也更清晰:页面状态看 App.vue,代理路径看 vite.config.js,端口、路由和上游请求看 server.mjs。
总结
这个小项目使用 Vue 页面、Vite 开发代理和 Express BFF 组成了一条完整请求链路。BFF 的核心价值是把 API Key 和上游调用留在服务端,并为前端提供更简单的接口。
跨域问题的判断标准是协议、域名和端口。Vue 的 5173 与 Express 的 3000 不同源,所以前端请求 /api/stream,让浏览器先访问同源的 Vite;Vite 再根据 target 和 rewrite 把请求转发为 http://localhost:3000/stream。
App.vue 负责响应式状态和交互,server.mjs 负责 BFF 路由和上游请求,vite.config.js 负责开发代理。把这三个文件的执行顺序看懂,就能理解 BFF 为什么能保护密钥,也能准确判断跨域、502、404 和路径改写分别发生在哪一层。