Vue 项目中的前端 BFF:跨域代理与流式请求实战

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.jsonnode_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 启动服务)
  • [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: 格式)
  • [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 接口通常使用 POSTGETPUT/PATCHDELETE 表达这些操作。

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.jsonnode_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 也会更新输入框。文本输入框主要同步 valueinput 事件,复选框主要同步 checked 状态。

回答区域使用:

vue 复制代码
<div>{{ content }}</div>

这是单向的数据展示:数据流从 content 到页面。表单之所以需要 v-model,是因为用户还要把输入传回数据状态。

按钮使用:

vue 复制代码
<button @click="update">提交</button>

@clickv-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 控制主轴靠起点对齐。

盒模型由内容、paddingbordermargin 组成。按钮的 padding 扩大内部空间,margin-left 增加按钮与输入框的外部间距;.outputmargin-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.envpath 指定读取顺序,这里会查找 .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 和响应对象 resres.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.prompthello。解构赋值 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.bodyReadableStream。它不是已经生成好的完整字符串,而是可以逐批读取的响应体对象。打印它是为了先观察"流对象已经拿到"。

res.json({ prompt }) 把 BFF 接收到的 prompt 返回给 Vue,用来验证下面这条链路是否畅通:

text 复制代码
App.vue → Vite 代理 → Express /stream → JSON 响应 → App.vue

这里先聚焦 BFF 和跨域代理。真正读取 ReadableStream 时,还会涉及读取器、二进制块、TextDecoderdata: 和结束标记,下面会逐一解释这些概念。

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:5173localhost: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

排查顺序如下:

  1. 访问 http://localhost:3000/,确认能看到 Hello World!
  2. 确认前端请求写的是 /api/stream,不是 http://localhost:3000/stream
  3. 确认 target 端口与 app.listen 一致。
  4. 确认 rewrite 后的 /streamapp.get('/stream') 一致。
  5. 修改 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 再根据 targetrewrite 把请求转发为 http://localhost:3000/stream

App.vue 负责响应式状态和交互,server.mjs 负责 BFF 路由和上游请求,vite.config.js 负责开发代理。把这三个文件的执行顺序看懂,就能理解 BFF 为什么能保护密钥,也能准确判断跨域、502、404 和路径改写分别发生在哪一层。

相关推荐
小则又沐风a6 小时前
库的原理:目标文件,ELF格式,程序加载
java·linux·前端
程序员爱钓鱼6 小时前
Rust if let 与 while let 详解:简化模式匹配
前端·后端·rust
To_OC6 小时前
LC 79 单词搜索:都说这是回溯入门题,我却连错三回
javascript·算法·leetcode
胡萝卜术6 小时前
从暴力到滑动窗口的终极形态:力扣3「无重复字符的最长子串」的优化进化之路
前端·javascript·面试
钛态15 小时前
前端安全防线:CSRF 攻击链路与双重 Token 校验的工程实现
前端·vue·react·web
张拭心16 小时前
技术管理笔记:让我“燃尽”的一天
前端
不好听61317 小时前
BFF 架构实战:从前端直调 API 到加入中间层
前端·架构
不好听61317 小时前
前端跨域完全指南:从为什么报错到三种解决方案
前端
三84417 小时前
路由策略/控制 配置双点双向路由重发布
服务器·前端·javascript