大前端工程师必学:用 BFF 架构搭建安全的 AI 流式应用
摘要
做 AI 应用时,前端直接调 LLM API 会面临三大问题:API Key 暴露 、SSE 流式数据解析复杂 、跨域限制 。本文从零开始,用 Vue 3 + Vite + Express 搭建一套 BFF(Backend For Frontend)架构,将 API Key 安全藏在服务端,流式数据交给 Node 层处理,前端只需一行 fetch 就能拿到干净的数据。读完你将理解为什么需要 BFF、Vite 代理如何解决跨域、浏览器和 Node 进程的本质区别,以及如何一个人搞定从前端到 AI 接口的完整链路。
一、问题的起点:前端直连 LLM 的三个致命缺陷
做一个 AI 聊天应用,前端直接调 DeepSeek(或 OpenAI)的 API,会立刻撞上三道墙:
致命 1:API Key 暴露
php
// 前端代码 --- 任何人 F12 就能看到
fetch('https://api.deepseek.com/v1/chat/completions', {
headers: { 'Authorization': 'Bearer sk-xxxxxxxx' }
})
Key 写在 JS 里,用户右键查看源代码就能拿走。被人盗刷 API、账单爆炸只是时间问题。
致命 2:SSE 流式数据解析复杂
LLM 返回的是 SSE(Server-Sent Events)原始流:
css
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: [DONE]
前端需要 ReadableStream → getReader() → 手动解码 → 按行切分 → 解析 data: 前缀 → 提取 JSON → 拼接字符串。代码又臭又长,而且每个端(Web、iOS、Android)都要重写一遍。
致命 3:跨域拦截
前端跑在 localhost:5173(Vite),BFF 跑在 localhost:3000(Express)。端口不同即跨域,浏览器直接拦住,请求根本发不出去。
二、解决方案:BFF 架构
BFF(Backend For Frontend) 是在前端和后端之间加一层 Node 中间层,专门为前端服务。
vbnet
不做 BFF: 前端 ──→ LLM(Key暴露 / 流解析复杂)
做 BFF: 前端 ──→ Node BFF ──→ LLM(Key藏在BFF / 流在Node层处理)
这个 BFF 由前端工程师自己写,不等后端同事。有了 BFF:
| 问题 | 如何解决 |
|---|---|
| Key 安全 | Key 存 .env 文件,只在 Node 服务端,前端从未见过它 |
| SSE 解析 | Node 层处理脏活,前端只拿最终结果 |
| 跨域 | Vite proxy 代理转发,Node 之间通信不受浏览器同源策略限制 |
这就是「大前端工程师」的核心能力:一个人搞定从浏览器 UI 到 BFF 到 AI 接口的完整链路。
三、项目结构一览
css
stream-bff/
├── index.html ← 浏览器入口
├── package.json ← 项目依赖描述
├── vite.config.js ← Vite 编译配置 + 跨域代理
├── server.mjs ← BFF 核心!Express 服务器
├── .env ← API Key 藏身之处(不提交 Git)
├── src/
│ ├── main.js ← Vue 应用启动
│ └── App.vue ← 根组件(UI + 发请求)
三个进程分工:
| 进程 | 启动方式 | 端口 | 职责 |
|---|---|---|---|
| Vite | npm run dev |
5173 | 编译 Vue、提供页面文件、代理 API 请求 |
| Express | node server.mjs |
3000 | 藏 Key、调 LLM、处理流式数据、返回给前端 |
| 浏览器 | 用户打开 | --- | 渲染页面、执行 Vue 代码、展示 AI 回复 |
四、浏览器层:页面如何加载
文件接力链:
xml
index.html
│ <div id="app"></div> ← Vue 挂载点,初始空的
│ <script src="/src/main.js"> ← 加载 JS 入口
▼
main.js
│ import { createApp } from 'vue'
│ import App from './App.vue'
│ createApp(App).mount('#app') ← 把 App 组件塞进 div#app
▼
App.vue
│ <script setup> --- 发 fetch 请求
│ <template> --- UI 结构
│ <style> --- 样式
▼
页面渲染完成,用户看到界面
Vue 单文件组件(.vue)的优势: 一个文件里模板、逻辑、样式封装在一起,不用分散在多个文件。Vite 用 @vitejs/plugin-vue 插件把 .vue 编译成浏览器能执行的 JavaScript。
五、跨域解决:Vite Proxy 代理
什么是跨域? 浏览器的同源策略:协议、域名、端口任意一个不同,就拦截请求。
Vite :5173
Express :3000 → 端口不同 → 跨域!
解决方案------在 vite.config.js 配置代理:
javascript
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': { // 拦截所有 /api 开头的请求
target: 'http://localhost:3000', // 转发到 Express
secure: false, // 开发环境不验证 HTTPS
rewrite: (path) => path.replace(/^/api/, '') // /api/stream → /stream
}
}
}
})
一条请求的完整旅程:
bash
浏览器 fetch('/api/stream?prompt=你好')
│ 浏览器自动补全:http://localhost:5173/api/stream?prompt=你好
│ 同源!(5173→5173)不跨域!
▼
Vite :5173 收到请求
│ /api 前缀匹配 proxy 规则
│ rewrite: /api/stream → /stream
│ target: 转发到 http://localhost:3000/stream
│ 这一步是 Node↔Node,不走浏览器,无跨域审查!
▼
Express :3000 收到 GET /stream?prompt=你好
为什么代理不触发跨域? 跨域审查只发生在浏览器。Vite 和 Express 都是 Node 进程,进程之间直接通信,浏览器管不着。就像你出国要过海关(浏览器),但公司内部同事传文件不用。
/api 的作用是什么? 一个约定好的前缀标记。以 /api 开头 → 这是后端 API 请求,Vite 帮你转发。不是 /api → 前端静态资源,Vite 自己处理。
六、BFF 层:server.mjs 详解
javascript
// ① 加载环境变量
import * as dotenv from 'dotenv'
dotenv.config({ path: ['.env.local', '.env'] }) // 读取 .env,Key 进 process.env
// ② 引入 Express 框架
import express from 'express'
// ③ 实例化服务器
const app = express() // 创建 app 实例
const port = 3000 // 定义端口
// ④ 健康检查路由
app.get('/', (req, res) => {
res.send('Hello World!') // 测试服务是否启动
})
// ⑤ 核心业务路由 --- 前端调用这里
app.get('/stream', async (req, res) => {
const { prompt } = req.query // 从 URL 取出用户输入:?prompt=你好
const endpoint = 'https://api.deepseek.com/v1/chat/completions'
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.VITE_DEEPSEEK_API_KEY}` // Key 从 .env 安全读取
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
stream: true, // 开启流式返回
messages: [{ role: 'user', content: prompt }]
})
})
// response.body 是 ReadableStream --- 管道接口,还需要读取处理
} catch (err) {
// 错误处理
}
})
// ⑥ 启动服务器
app.listen(port, () => {
console.log(`服务器在${port}端口启动了`)
})
关键概念:
端口是什么? 端口是操作系统分配给进程的"门牌号"。app.listen(3000) 就是向操作系统申请 3000 号房间,之后所有发往 localhost:3000 的网络请求,操作系统自动转给这个 Express 进程。
路由是什么? 请求路径和函数的映射关系:
sql
GET / → 返回 "Hello World!"
GET /stream → 调 DeepSeek 并返回结果
GET /abc → 没找到,404
req 和 res 是什么? Express 自动传入的两个对象,每次有请求进来就帮你打包好:
| 对象 | 方向 | 作用 | 常用属性/方法 |
|---|---|---|---|
req(Request) |
前端 → 后端 | 请求的所有信息 | .query(URL参数)、.method(GET/POST)、.headers |
res(Response) |
后端 → 前端 | 回复工具包 | .send()、.json()、.status() |
类比:req 是听筒(听到对方说什么),res 是话筒(你通过它回复)。
七、前端层:App.vue 如何请求 BFF
xml
<script setup>
fetch('/api/stream?prompt=hello') // 相对路径,自动补全为 5173,不跨域
.then(res => res.json()) // 读取响应体,解析 JSON
.then(data => console.log(data)) // 拿到真正的数据
</script>
为什么有两个 .then()?
ini
fetch()
→ 返回 Response 对象(信封:状态码 + 头部 + body 流)
.then(res => res.json()) ← 拆信封,读 body
→ 返回解析后的 JS 对象
.then(data => console.log()) ← 拿到内容
res 是快递包裹,res.json() 拆包,data 才是里面的东西。
?prompt=hello 是什么? ? 是路径和参数的分隔符 ------前面是去哪,后面是带什么数据。Express 自动把 ?prompt=hello 解析成 req.query = { prompt: "hello" }。
八、完整数据流(用户点击"发送"后发生的一切)
bash
① 用户输入"你好",点击提交
│
② App.vue 执行 fetch('/api/stream?prompt=你好')
→ 浏览器补全为 http://localhost:5173/api/stream?prompt=你好
→ 同源!不跨域!
│
③ Vite :5173 收到请求
→ /api 前缀匹配 proxy 规则
→ rewrite: /api/stream → /stream
→ Node 内部转发到 Express :3000/stream?prompt=你好
│
④ Express :3000 收到 GET /stream
→ 路由匹配 app.get('/stream', fn)
→ req.query.prompt → "你好"
→ fetch DeepSeek,带上 .env 里的 API Key
│
⑤ DeepSeek 返回 SSE 流式数据
→ BFF 解析流,提取内容
→ res.json() 返回给 Vite
│
⑥ Vite 把响应原样返回给浏览器
│
⑦ App.vue 拿到数据 → 渲染到页面
→ 用户看到 AI 回复
九、核心知识点串联
| 概念 | 一句话解释 |
|---|---|
| BFF | 为前端定制的 Node 中间层,藏 Key、处理流、返回干净数据 |
| SSE | 服务器单向推送技术,LLM 用这个流式输出 AI 回复 |
| 端口 | 进程在操作系统里的"房间号" |
| 实例化 | express() = 把蓝图变成能干活的具体对象 |
| 路由 | URL 路径和函数之间的映射关系 |
| 跨域 | 浏览器安全策略,协议/域名/端口不同就拦 |
| Vite Proxy | 开发时绕过跨域,Node↔Node 通信不受审查 |
req |
请求对象,前端发来的所有信息(听筒) |
res |
响应对象,后端回复前端的工具(话筒) |
.env |
存放敏感配置(API Key),不提交 Git |
res.json() |
把 JS 对象变成 JSON 字符串返回给前端 |
| 单文件组件 | Vue 的 .vue 文件,模板+逻辑+样式封装在一起 |
十、总结
大前端工程师的核心竞争力是独立解决整条链路的问题。这套 BFF 架构的价值在于:
- 安全 ------Key 藏在
.env,用户永远看不到 - 简洁 ------前端只调
fetch,SSE 解析的脏活留给 Node - 独立------接口想改就改,不等后端同事排期
- 可复用------BFF 写一份,Web/iOS/Android 都能调
本文的代码目前完成了从浏览器到 BFF 再到 LLM 的请求链路搭建 ,下一步要做的是在 server.mjs 中读取 DeepSeek 返回的 ReadableStream,边读边转发给前端,实现真正的流式对话。这将在后续文章中详细展开。
关键词:BFF、SSE、Vite、Express、跨域、大前端、流式输出、AI 应用架构