Skip to content

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
全局对象windowglobal
DOM✅ 有❌ 没有
模块ESM 为主CJS(传统)/ ESM(现代)并存
微任务优先级Promise.thenprocess.nextTick 优先于 Promise
宏任务单一队列6 个阶段:timers → pending → idle → poll → check → close
特有 APIfetch、localStoragefs、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"团队没有运维能力时,它是负担
"前端校验过了,服务端不用校验"前端校验只是体验,服务端必须校验(安全边界)

动手练习 ​

  1. 写一个最小 BFF:把"用户信息 + 文章列表"两个下游接口聚合成一个 /api/home,并加上 10 秒缓存。
  2. 在 Node 里跑 setTimeout vs setImmediate,分别在顶层和 fs.readFile 回调里调用,观察顺序差异并解释。
  3. 用 Vite proxy 解决本地跨域,再用 Nginx 反代配置一次,体会"同源代理"为什么能绕开 CORS。

相关文档 ​