BFF 架构实战:从前端直调 API 到加入中间层

适合读者:前端开发者,写过 Vue/React 项目,对 Node.js 有基本了解。

你将收获:理解 BFF 是什么、为什么需要它、以及如何在一个 Vue 项目中从零搭建 BFF 层。


从一个能跑的项目开始

我写了一个 AI 流式对话的前端页面------用户在输入框里打字,点击提交,页面上逐字显示 DeepSeek 的回复。核心代码长这样:

js 复制代码
// App.vue ------ 前端直接调用 DeepSeek API
const endpoint = 'https://api.deepseek.com/chat/completions';

const response = await fetch(endpoint, {
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
  },
  body: JSON.stringify({
    model: 'deepseek-v4-flash',
    stream: true,
    messages: [{ role: 'user', content: question.value }]
  })
})

// 拿到流,解析 SSE 格式,逐字显示......
const reader = response.body?.getReader();
const decoder = new TextDecoder();
// ......省略几十行流式解析代码

功能跑通了,效果不错。但多看两眼就会发现几个问题:

  1. API Key 在浏览器 Network 面板里裸奔 ------ 按 F12 就能看到 Authorization: Bearer sk-xxx
  2. 前端扛了太多跟页面无关的活 ------ 解析 SSE 协议、处理不完整数据块、切分 data: 前缀、判断 [DONE]......这些跟"显示文字"有什么关系?
  3. DeepSeek 哪天改了返回格式,前端要跟着改 ------ 没有缓冲层

于是我加了一层中间服务,架构变成了这样:

scss 复制代码
之前:浏览器 ──────────────────→ DeepSeek API

之后:浏览器 ──→ BFF (Express) ──→ DeepSeek API

这篇文章就聊聊这个"中间层"------BFF(Backend For Frontend)


BFF 是什么

BFF 全称 Backend For Frontend,直译就是"为前端服务的后端"。

它和前端的区别是:前端跑在浏览器里,BFF 跑在服务器上。它和后端(Java/Go)的区别是:后端负责业务逻辑和数据库,BFF 只服务前端的需求。

本质上是前端和后端之间的"转换器"------转换协议、清洗数据、聚合接口,让前端只需要关心"拿什么数据显示"。


为什么需要 BFF

第一个原因:前端太重了

没有 BFF 的时候,前端需要懂的东西:

  • SSE 协议的数据格式(data: xxx\n\n
  • DeepSeek 返回的 JSON 结构(choices[0].delta.content
  • ReadableStream 怎么读、TextDecoder 怎么用
  • 网络分包导致 JSON 被截断怎么拼接

但这些知识跟"做一个聊天界面"有一毛钱关系吗?没有。

理想的状况是:前端只做一件事------拿到干净文本,显示在页面上:

js 复制代码
// 前端理想的状态
const response = await fetch('/api/stream?prompt=你好');
// 直接拿到: "从前有座山,山里有座庙......"

所有脏活------SSE 解析、数据清洗、格式转换------全部扔给 BFF 层。前端回归它本该做的事:管理 UI 状态、渲染页面。

第二个原因:安全

即使你用 Vite 的 import.meta.env,API Key 在构建时也会被直接替换成明文写进 JS 文件。任何人打开浏览器 DevTools → Sources 就能看到。

BFF 从 process.env 读 Key------这个值永远留在服务器内存里,绝对不会离开服务器。

js 复制代码
// server.mjs ------ 跑在服务器上,Key 永远不到浏览器
dotenv.config({ path: ['.env', '.env.local'] })

const response = await fetch('https://api.deepseek.com/...', {
  headers: {
    Authorization: `Bearer ${process.env.VITE_DEEPSEEK_API_KEY}` // ← 服务器端读取
  }
})

第三个原因:前后端解耦

假设 DeepSeek 把返回字段从 choices[0].delta.content 改成了 selections[0].delta.text。没有 BFF,你要改前端代码、重新构建、重新部署。有 BFF,你只改 BFF 那一行映射逻辑,前端一行代码不动。

其他原因还包括:一个页面需要调多个后端时 BFF 可以聚合请求、可以做限流和日志、可以做鉴权。但这些不是本文重点。


动手实现

项目结构

css 复制代码
stream-bff/
├── server.mjs          ← BFF 层(本文主角)
├── vite.config.js      ← Vite 配置(含跨域代理)
├── src/
│   ├── App.vue         ← 前端页面
│   └── main.js         ← 前端入口
└── package.json        ← vue + vite + express + dotenv

一个仓库,两个进程:

bash 复制代码
# 终端1:启动 BFF
node server.mjs          # Express 监听 :3000

# 终端2:启动前端
pnpm run dev             # Vite 监听 :5173

BFF 核心代码

js 复制代码
// server.mjs
import express from 'express';
import * as dotenv from 'dotenv';

dotenv.config({ path: ['.env', '.env.local'] })

const app = express();
const port = 3000;

app.get('/stream', async (req, res) => {
  const { prompt } = req.query;

  const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.VITE_DEEPSEEK_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      model: 'deepseek-v4-flash',
      stream: true,
      messages: [{ role: 'user', content: prompt }]
    })
  })

  // response.body 是 ReadableStream
  // 这里逐块读取 SSE 数据,清洗后 res.write() 给前端
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  res.setHeader('Content-Type', 'text/event-stream');

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    const chunk = buffer + decoder.decode(value);
    buffer = "";
    // 解析 SSE、提取 delta.content、res.write(cleanText)
    // ......具体逻辑省略,文末有完整代码链接
  }

  res.end();
})

app.listen(port, () => {
  console.log(`BFF 已启动: http://localhost:${port}`);
})

前端变得多薄

js 复制代码
// App.vue ------ 前端只需要这样
fetch('/api/stream?prompt=你好')
  .then(res => {
    // 拿到的就是干净的文本流,不需要解析 SSE
    const reader = res.body.getReader();
    // ......简单读取、逐字追加到 content.value
  })

跨域问题怎么解决

浏览器页面在 localhost:5173(Vite),BFF 在 localhost:3000(Express)。端口不同,触发跨域。

这里用 Vite Proxy------让浏览器请求同源的 /api/xxx,Vite 开发服务器在背后转发给 BFF:

js 复制代码
// vite.config.js
export default defineConfig({
  plugins: [vue()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        rewrite: path => path.replace(/^\/api/, '')  // /api/stream → /stream
      }
    }
  }
})

浏览器发 fetch('/api/stream') → Vite(:5173) 转发 → BFF(:3000)。浏览器从头到尾只跟 :5173 通信,不触发跨域。


BFF 代码放在哪

三种实践,核心问题是"谁来写、谁维护":

方式 谁维护 适用场景
放在前端项目里(本文的做法) 前端团队 小团队、前端主导、快速迭代
独立 BFF 仓库 前端团队 多前端共享、BFF 逻辑复杂
放在后端项目里 后端团队 大公司、后端主导架构

学习和独立开发时,放在前端项目里最实用------一个仓库搞定整条链路。


BFF 不是银弹

如果你的场景是:只有一个简单的后端 API、它允许跨域、返回格式已经很友好、不涉及敏感 Key------那直接在前端调用就行,不需要加 BFF。

加层的原则永远是:两端不匹配时才需要中间层做转换。没有不匹配,层就是多余的。 BFF 解决的是实际问题,不是无脑套的"最佳实践"。


总结

  • BFF 是前端的后端 ------ 帮前端扛脏活,让前端回归 UI 和状态管理
  • 核心价值:前端变薄 > 安全 > 解耦 > 聚合 > 跨域
  • 实现方式:一个 Express 服务(可以跟前端项目放在同一个仓库),跑在独立端口
  • 判断标准:两端不匹配的时候才加,不匹配不存在的时候别加
相关推荐
不好听6131 小时前
前端跨域完全指南:从为什么报错到三种解决方案
前端
三8441 小时前
路由策略/控制 配置双点双向路由重发布
服务器·前端·javascript
Qimooidea2 小时前
祁木 CAD Translator 工程图纸出海实战指南
服务器·前端·安全
小林ixn2 小时前
从BFF到SSE:我在Vue项目里藏了个“AI翻译官”
前端·vue.js·vite
元气少女小圆丶3 小时前
unity发布web嵌入到前端页面的接受参数
前端·unity·webgl
工业HMI实战笔记3 小时前
【拯救HMI】:低碳制造:自动化技术如何助力企业节能降耗
运维·前端·自动化·交互·制造
Georgeviewer4 小时前
数字化工程落地复盘:通用SaaS架构的本地化缺陷与自研系统最优解|皖禾数智实战
大数据·人工智能·架构
柒和远方4 小时前
V043:BFF 层流式输出:从前端直连到生产级流式网关的架构演进
javascript·架构·node.js
不简说5 小时前
JS 代码技巧 vol.4 — 10 个异步并发控制,Promise.all 这帮兄弟的踩坑实录
前端·javascript·面试