23 · Node.js 与 BFF
本篇解决什么问题: 前端要不要碰服务端?BFF(Backend For Frontend) 是前端最自然的"服务端切入点"—— 它解决的是接口不合用、多端数据不同、跨域与鉴权麻烦这些前端天天遇到的真实问题。 本篇讲清 Node 运行时、BFF 职责边界,以及什么时候不该建 BFF。
📌 核心原则:BFF 是"为前端服务的适配层",不是"另一个后端"——越界就会变成维护灾难。
开篇速览
| 主题 | 关键认知 |
|---|---|
| Node 运行时 | 单线程 + libuv;与浏览器事件循环不同(有阶段划分) |
| BFF 是什么 | 面向特定前端的后端适配层 |
| 职责 | 聚合、裁剪、适配、鉴权转发、缓存 |
| 不该做 | 业务逻辑、持久化、事务 |
| SSR 服务端 | BFF 最常见的落地场景之一 |
| 边界 | 前端到哪为止,后端从哪开始 |
一、Node 运行时:与浏览器的异同
1.1 相同点
- 都是 V8 引擎、都是 JS 语法
- 都是单线程 + 事件循环 + 非阻塞 I/O
- 微任务/宏任务的基本模型一致(见 A09)
1.2 关键差异 ⭐
| 维度 | 浏览器 | Node.js |
|---|---|---|
| 全局对象 | window | global |
| DOM | ✅ 有 | ❌ 没有 |
| 模块 | ESM 为主 | CJS(传统)/ ESM(现代)并存 |
| 微任务优先级 | Promise.then | process.nextTick 优先于 Promise |
| 宏任务 | 单一队列 | 6 个阶段:timers → pending → idle → poll → check → close |
| 特有 API | fetch、localStorage | fs、net、stream、Buffer、child_process |
1.3 Node 事件循环的 6 个阶段(面试高频)
┌─────────────────────────────────────────┐
│ timers setTimeout / setInterval │
│ pending I/O 回调(上一轮遗留) │
│ idle/prepare 内部使用 │
│ poll **大部分 I/O 回调在这里** │
│ check setImmediate │
│ close socket.on('close') │
└─────────────────────────────────────────┘
每两个阶段之间:清空 process.nextTick → 清空 Promise 微任务js
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
// 顺序**不总是确定**(受进程启动耗时影响)
// 但在 I/O 回调里调用时,setImmediate **一定先**于 setTimeout
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate')) // ✅ 总是先打印 immediate
})⚠️ Node 是单线程的,CPU 密集任务会阻塞所有请求—— 计算密集要用
worker_threads或拆成独立服务,别在 API 里跑大循环。
二、BFF 是什么
BFF(Backend For Frontend):为特定前端(Web / App / 小程序)定制的后端适配层。
移动端 ──► BFF-Mobile ──┐
Web端 ──► BFF-Web ──┼──► 微服务群(用户/订单/商品…)
小程序 ──► BFF-Mini ──┘2.1 BFF 该做的五件事
| 职责 | 说明 | 例子 |
|---|---|---|
| ① 聚合 | 一个页面要 5 个接口 → BFF 合成 1 个 | 首页 = 用户信息 + 轮播 + 推荐 |
| ② 裁剪 | 后端返回 50 个字段,前端只用 5 个 | 减少传输、避免字段泄漏 |
| ③ 适配 | 脏数据 → 前端模型(见 A19 适配器) | snake_case → camelCase |
| ④ 聚合鉴权 | 统一校验 Token、注入用户态 | 各微服务不用各自鉴权 |
| ⑤ 缓存 | 对下游结果做短缓存,削峰 | 首页数据缓存 10s |
ts
// BFF 示例(Express / Fastify / NestJS)
app.get('/api/home', async (req, res) => {
const userId = await verifyToken(req.headers.authorization) // ④ 鉴权
const cacheKey = `home:${userId}`
const cached = await redis.get(cacheKey)
if (cached) return res.json(JSON.parse(cached)) // ⑤ 缓存
// ① 聚合:并行请求下游
const [user, banners, feeds] = await Promise.all([
userService.get(userId),
contentService.banners(),
contentService.feeds({ limit: 10 }),
])
// ②③ 裁剪 + 适配
const data = {
user: { id: user.id, name: user.nick_name, avatar: user.avatar_url },
banners: banners.filter(b => b.online).map(b => ({ id: b.id, img: b.image_url })),
feeds: feeds.map(adaptFeed),
}
await redis.setex(cacheKey, 10, JSON.stringify(data))
res.json(data)
})2.2 BFF 不该做的三件事
| ❌ 不该做 | 原因 |
|---|---|
| 业务逻辑 | 打折规则、库存扣减属于领域服务;放 BFF 会多处重复、规则不一致 |
| 直接操作数据库 / 事务 | BFF 不是数据拥有者;应通过下游服务 |
| 成为"第二个后端" | 团队会不知不觉把所有逻辑堆进来 → 巨石 BFF |
🎯 判断标准(一句话): 这个逻辑换一个端(App)还成立吗? 成立 → 应该在后端服务; 不成立(只为 Web 的首屏聚合服务)→ 才属于 BFF。
三、什么时候该建 / 不该建
| 场景 | 建议 |
|---|---|
| 一个页面要调 5+ 个接口、串行依赖 | ✅ 建(聚合收益明显) |
| 多端需要不同的数据形状 | ✅ 建(BFF 的本职) |
| 后端是稳定微服务、不前端适配 | ✅ 建 |
| 小项目 / 后端愿意配合改接口 | ❌ 不建(直接让后端给合适的接口更简单) |
| 团队没有 Node 运维能力 | ❌ 慎建(要监控、扩容、值班) |
| 只是想"前端也能写接口" | ❌ 不建(这是技术欲望,不是问题驱动) |
⚠️ BFF 的真实成本:多一个服务 = 多一套部署、监控、告警、值班、扩容。 小团队贸然上 BFF,往往换来一个"没人愿意维护的中间层"。
四、SSR 服务端(BFF 最常见的落地)
选了 SSR 就必须有 Node 服务(见 A13 · 渲染模式)。
ts
// Next.js / Nuxt 之外的最小 SSR(Express + React)
app.get('*', async (req, res) => {
const html = renderToString(<App url={req.url} />)
res.send(`<!doctype html>
<html><body>
<div id="root">${html}</div>
<script>window.__DATA__=${JSON.stringify(data)}</script> <!-- 注水,避免重复请求 -->
<script src="/client.js"></script>
</body></html>`)
})SSR 服务端要注意:
| 要点 | 说明 |
|---|---|
| 数据注水/脱水 | 服务端取的数据序列化到 HTML,客户端直接用,避免二次请求 |
| 错误边界 | 渲染抛错 → 返回 500;必须兜底降级到 CSR |
| 超时与降级 | 下游慢时要能降级为 CSR,不能把页面拖挂 |
| CPU 密集 | 渲染是 CPU 密集 → 需要缓存 HTML、限流、扩容 |
| 内存泄漏 | 长驻进程,注意全局缓存与监听器 |
五、鉴权与代理
5.1 BFF 里的鉴权转发
ts
// BFF 校验 Token → 换成内部凭证转发给下游(不要让前端 Token 直达微服务)
app.use(async (req, res, next) => {
const token = req.cookies?.token || req.headers.authorization
if (!token) return res.status(401).end()
try {
req.user = await verifyJwt(token) // ✅ 在边界校验一次
next()
} catch { res.status(401).end() }
})
// 转发时带上内部身份
fetch(`${USER_SERVICE}/me`, { headers: { 'X-User-Id': req.user.id } })5.2 反向代理(解决跨域)
ts
// 开发环境:Vite proxy
export default defineConfig({
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } },
})nginx
# 生产:Nginx 反代(同源,天然无跨域)
location /api/ { proxy_pass http://backend:8080/; }💡 跨域(CORS)本质是浏览器的限制,服务端之间不存在。 用同源反向代理可以彻底绕开它,而不用在每个接口配 CORS。 CORS 的排查见 A04 · 浏览器与网络。
六、前后端边界怎么划
| 问题 | 归属 |
|---|---|
| 数据校验(能不能为空、格式) | 两端都要:前端做体验,服务端做安全(见 A08) |
| 业务规则(折扣、库存、权限判定) | 服务端 |
| 展示格式(日期展示、金额千分位) | 前端(但金额计算必须在服务端,避免浮点误差) |
| 接口聚合、字段裁剪 | BFF |
| 会话与 Token 签发 | 服务端 |
| 页面状态、交互 | 前端 |
🎯 一句话边界:"计算和决策在服务端,展示和交互在前端"。
自检清单
- [ ] 能说出 Node 与浏览器在运行时的 3 个关键差异(无 DOM、nextTick 优先、阶段循环)
- [ ] 能说出 Node 事件循环的 6 个阶段,以及 I/O 回调里
setImmediate与setTimeout的顺序 - [ ] 能列出 BFF 该做的 5 件事与不该做的 3 件事
- [ ] 能用"换个端还成立吗"判断逻辑归属
- [ ] 知道 BFF 的真实成本(运维、监控、值班)
常见误区
| ❌ 误区 | ✅ 事实 |
|---|---|
| "BFF 就是前端写的后端" | 它是适配层;放业务逻辑会变成巨石 |
| "Node 单线程所以性能差" | I/O 密集场景很好;CPU 密集才是短板 |
| "有了 BFF 后端就不用改了" | 后端接口仍是事实来源;BFF 只是缓冲 |
| "小项目也要有 BFF" | 团队没有运维能力时,它是负担 |
| "前端校验过了,服务端不用校验" | 前端校验只是体验,服务端必须校验(安全边界) |
动手练习
- 写一个最小 BFF:把"用户信息 + 文章列表"两个下游接口聚合成一个
/api/home,并加上 10 秒缓存。 - 在 Node 里跑
setTimeoutvssetImmediate,分别在顶层和fs.readFile回调里调用,观察顺序差异并解释。 - 用 Vite proxy 解决本地跨域,再用 Nginx 反代配置一次,体会"同源代理"为什么能绕开 CORS。
相关文档
- 浏览器端事件循环 → A09 · JavaScript 运行时
- 渲染模式与 SSR → A13 · 渲染模式
- 适配器模式 → A19 · 前端设计模式
- 安全边界与鉴权 → A08 · 前端安全
- CORS 与网络 → A04 · 浏览器与网络
- 服务端的体系知识 → C 域 · 后端计划