做前端做到第五个年头,我越来越觉得,页面上真正难的不是某个组件怎么写,而是整条链路怎么串。前阵子接了一个内容型门户的项目,需求特别典型:首屏要快、SEO 必须能爬到正文、同时小程序和 H5 还想要共用一套接口逻辑。那个场景下,技术选型几乎是明牌——Vue3 负责视图层,SSR 解决首屏渲染和 SEO,Node BFF 层解决多端接口聚合和裁剪。这篇文章就是我从零到一搭一个 Node BFF + Vue3 SSR 项目的完整复盘,涉及环境准备、目录设计、BFF 接口怎么拆、SSR 双入口怎么写、生产构建怎么做,以及最后上线时踩到的一堆坑。手撸这套东西不是为了折腾,而是想真正搞清楚每一层在干什么。如果你正好要接手 SSR 项目,或者面试被问到 BFF 层的职责边界,这篇应该能给你一些可落地的参考。
整篇不依赖 Nuxt 这类全家桶框架,核心思路是用 Vite 做构建、Node 写中间层、Vue3 的 createSSRApp 手写服务端渲染。下面按我的实操顺序展开,从为什么这么选型,到每一步的具体代码和命令,最后是问题排查速查表。
1. 为什么是“Node BFF + Vue3 SSR”这一套组合
先聊选型。很多人一听到 SSR 就想到 Next.js 或者 Nuxt,但实际业务里你很可能没有选择全家桶的自由:老旧的后端接口格式不统一、多端需要不同字段裁剪、页面里有一部分是纯静态展示、有一部分是强实时交互。这种情况下,手撸一套分层清晰的 SSR 架构,反而比引入全家桶更可控。
1.1 不迷信框架:先搞清楚你的业务痛点是什么
给内容型门户做首屏优化,核心痛点其实有三个。
第一个是 SEO。搜索引擎爬虫不执行 JavaScript,或者说执行成本很高,如果你的页面是纯 SPA,爬虫拿到的就是一个空 div 和一堆 script 标签,正文内容根本索引不到。第二个是首屏白屏时间。SPA 首屏要等 JS 下载、解析、执行、再发接口请求渲染,中低端手机上的体验就是白屏两三秒。第三个问题是接口管理混乱。同一个详情页,H5 要标题、作者、发布时间、点赞数,小程序还要收藏状态、是否可评论,PC 端又要多一些推荐位数据,三个端的字段要求不完全一样,如果让每个前端都去直接请求后端十几个接口,维护成本会失控。
SSR 解决前两个问题:服务端直接渲染出完整 HTML,爬虫能看到内容,用户也能更快看到页面骨架。BFF 层解决第三个问题:在 Node 层做一次接口聚合和字段裁剪,前端拿到的就是刚刚好够用的数据。
所以这个项目的整体架构是这样的:浏览器请求到 Node 服务,Node 里同时跑着 BFF 接口层和 SSR 渲染层。SSR 渲染层在服务端请求 BFF 接口拿数据,渲染成完整 HTML 返回给浏览器;浏览器拿到 HTML 后,再由 Vue3 的客户端逻辑接管页面交互。
1.2 BFF 不是银弹:职责边界一定要清楚
这里必须泼一盆冷水。BFF 层最容易犯的错误就是越做越厚,最后变成一个没人敢动的巨型中间层。我的原则是 BFF 只做四件事:接口聚合、字段裁剪、协议转换、简单的鉴权透传。它不碰业务规则,不写复杂的状态机,也不该在 BFF 层做需要事务保证的数据操作。你可以把 BFF 理解成餐厅里的传菜间:厨房做好各种菜,传菜间根据每桌客人的需求拼盘、去葱、调辣度,再端出去。传菜间不会自己炒菜,炒菜逻辑在后端厨房里。
这个职责边界在代码里要有体现:BFF 模块只依赖上游接口定义,不依赖具体业务数据库;所有对上游接口的请求都封装在一个 service 目录里,路由层只做编排。
2. 环境准备与工程基建:先把地基打对
选型想清楚之后,就开始动手。这一步很重要,我见过太多人一上来就写业务代码,结果 Node 版本不对、npm 源没配好、Vite 插件版本冲突,折腾半天还没进主题。环境问题不要小看,它占用了新手一半的踩坑时间。
2.1 先管好 Node 版本:nvm 是必须的
很多报错,比如某个依赖不支持当前 Node 版本、某个特性只在新版本里才有,根子都在 Node 版本管理上。我强烈建议用 nvm 来管理 Node 版本,而不是直接去官网下载一个安装包一直用下去。
Windows 用户可以去 nvm-windows 的 release 页面下载 nvm-setup.exe,macOS 用户直接 brew install nvm。安装完之后常用的命令就这几个:
# 查看已安装和可用的 Node 版本 nvm list nvm list available # 安装指定版本 nvm install 20.11.1 # 切换版本 nvm use 20.11.1 # 设置默认版本 nvm alias default 20.11.1Vue3 SSR 项目我建议用 Node 20 LTS 或者 22 LTS。Node 18 也能用,但如果你用到一些新特性,比如原生 fetch 的某些行为,Node 20 会更稳。这个项目里 BFF 层要发 HTTP 请求,我直接用 Node 20 自带的全新 fetch API,可以少引一个 axios。
还有镜像源的问题。安装依赖慢的话,设置一下 npm 国内镜像:
npm config set registry https://registry.npmmirror.com注意不要全局设置到公司项目的私有 registry,这个后面会坑到自己。
2.2 Vite + Vue3 初始化与 SSR 改造思路
现在脚手架很成熟了,直接用 Vite 创建一个 Vue3 项目作为基础:
npm create vite@latest vue3-ssr-bff -- --template vue cd vue3-ssr-bff npm install这一步出来的还是一个标准 SPA 项目,接下来要把它改造成 SSR 工程。为了说明白这里的差异,先看一张改造后的目录结构:
vue3-ssr-bff/ ├── index.html # 客户端入口 HTML 模板 ├── package.json ├── vite.config.js ├── server/ │ ├── index.js # Node 服务入口(Express) │ ├── bff/ │ │ ├── routes.js # BFF 路由定义 │ │ └── services/ # 上游接口封装 │ │ ├── user-service.js │ │ └── content-service.js │ └── ssr/ │ ├── render.js # renderToString 封装 │ └── template.js # 模板拼接 ├── src/ │ ├── entry-client.js # 客户端入口 │ ├── entry-server.js # 服务端入口 │ ├── App.vue │ ├── router/ │ │ └── index.js │ └── views/ │ ├── HomeView.vue │ └── DetailView.vue └── dist/ # 构建产物之所以不像普通 SPA 那样只有一个 main.js,是因为 SSR 工程必须区分“服务端入口”和“客户端入口”。服务端入口负责每次请求时创建一个全新的 Vue 应用实例,渲染成字符串;客户端入口负责在浏览器里接管 DOM,做事件绑定和交互。这两个入口做的事情完全不同,后面第 4 节详细展开。
2.3 依赖清单:哪些包是必须的
这是这个项目 package.json 里的核心依赖,我加了注释说明用途:
{ "dependencies": { "vue": "^3.4.21", "vue-router": "^4.3.0", "express": "^4.19.2" }, "devDependencies": { "@vitejs/plugin-vue": "^5.0.4", "vite": "^5.2.0", "cross-env": "^7.0.3" } }注意,这个项目没有用 @vue/server-renderer 这个包?其实不需要单独装,Vue 3 里服务端渲染的能力是内置的,直接从 vue/server-renderer 引入 createRenderer 或 renderToString 就行。有很多人不知道这一点,跑起来报找不到模块就懵了。
3. 手写 BFF 中间层:从接口代理到数据聚合
环境好了,先写 BFF 层,因为 SSR 渲染时要调它拿数据。BFF 层我选择了 Express,没选 Fastify。理由很简单:Express 生态成熟、资料多、团队新手上手快。这一个项目的体量远没到 Fastify 的性能优势能体现出来的程度。
3.1 一个真实的 BFF 接口:三次上游请求聚合成一次
假设详情页需要三块数据:用户基本信息(作者头像和昵称)、文章正文内容、推荐列表。上游有三个接口分别提供这些数据:
- GET /api/user/:id
- GET /api/article/:id
- GET /api/article/:id/recommend
如果前端直接调这三个接口,就要等三次网络往返,而且每个端拿到的字段可能完全不同。BFF 层把这三个接口聚合一次,返回前端刚刚好需要的结构:
// server/bff/routes.js const express = require('express'); const router = express.Router(); const { getUserInfo } = require('./services/user-service'); const { getArticleDetail, getRecommendList } = require('./services/content-service'); router.get('/detail/:id', async (req, res) => { const { id } = req.params; const start = Date.now(); try { // 并发请求三个上游接口 const [user, article, recommend] = await Promise.allSettled([ getUserInfo(id), getArticleDetail(id), getRecommendList(id) ]); // 聚合裁剪,返回前端真正需要的字段 const data = { author: user.status === 'fulfilled' ? { name: user.value.name, avatar: user.value.avatar, bio: user.value.bio } : null, article: article.status === 'fulfilled' ? { title: article.value.title, content: article.value.content, publishTime: article.value.publishTime } : null, recommend: recommend.status === 'fulfilled' ? recommend.value.list.slice(0, 6) : [] }; res.json({ code: 0, data, cost: Date.now() - start }); } catch (err) { res.status(500).json({ code: 500, message: 'bff error' }); } }); module.exports = router;这里有几个细节值得展开说。
第一,用 Promise.allSettled 而不是 Promise.all。详情页里用户信息挂了,不应该让整篇文章都打不开。容错降级是 BFF 层的基本素养,一个上游故障不应该让整页雪崩。第二,BFF 返回的数据结构要稳定,建议统一用 { code, data, message } 包裹,方便 SSR 层处理。第三,超时控制必须有,否则上游接口卡住了,BFF 请求也一直挂着,SSR 的 TTFB 就会非常难看。我习惯在 fetch 封装里加 AbortController。
3.2 给上层数据加上超时、缓存和降级策略
一个只做转发和聚合的 BFF 是不合格的,超时控制、简单缓存和降级策略才是它的价值所在。这是我的 fetch 封装:
// server/bff/services/http.js const BASE_TIMEOUT = 3000; async function request(url, options = {}) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), options.timeout || BASE_TIMEOUT); try { const res = await fetch(url, { ...options, signal: controller.signal, headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } }); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } return await res.json(); } finally { clearTimeout(timer); } } module.exports = { request };对于不常变的数据,比如文章详情侧的推荐列表,可以在 BFF 层加一个简单的内存缓存,TTL 设置为 60 秒。这里不推荐用什么 Redis,一个 Node 进程的内存 Map 足够了。注意缓存不要滥用,带登录态的接口千万不要缓存,否则就是严重的越权事故。我在项目里只在无状态的推荐接口上做了缓存。
降级策略方面,除了上面用 Promise.allSettled 让单个接口失败不影响整体之外,还要在 BFF 层输出一个简单的健康检查接口 /bff/health,这样后续接入运维监控会省很多事。
3.3 面试必问视角:BFF 层如何设计接口粒度
这个问题几乎每次面试都会被问到。
接口粒度的设计原则其实是“按场景设计,不要按后端资源设计”。BFF 层的接口应该对应一个“用户场景”,比如“获取文章详情页所需全部数据”是一个场景,而不是“获取文章”“获取用户”“获取推荐”三个资源接口。前者是面向场景的 API,后者是面向资源的 API。BFF 层存在的最大意义,就是把多个面向资源的后端接口,编排成一个或多个面向场景的接口。
这个视角在面试里非常好用,因为大多数人只会说“BFF 做接口聚合”,但能讲清楚“聚合的粒度是场景而非资源”的人不多。实际项目里,一个场景端到端的数据聚合做得好,前端的代码会非常干净:调一个接口,拿到的数据直接就能渲染,不需要在前端做一堆 data 转换。
4. Vue3 SSR 的核心实现:不进框架也要懂原理
BFF 层写完,现在到了整个项目的重头戏,Vue3 SSR 服务端渲染实现。很多人一说到 Vue3 SSR 就想着 Nuxt,但自己手写一遍之后,你会对 createSSRApp、renderToString、hydrate 这些概念有完全不同的理解。面试被问 Vue3 SSR 原理时也能说得更具体。
4.1 双入口设计:服务端和客户端各干各的
Vue3 SSR 的第一步,是把应用入口拆成两个文件:src/entry-server.js 和 src/entry-client.js。
服务端入口的核心要求是“每次请求都要创建一个全新的应用实例”。记住这句话,后面所有坑都从这里来。如果复用同一个应用实例,不同用户的请求就会共享同一个响应式状态,第一个用户的登录态会泄漏给第二个用户。
来看服务端入口代码:
// src/entry-server.js import { createSSRApp } from 'vue'; import { createRouter } from './router'; import App from './App.vue'; export async function createServerApp(url) { const app = createSSRApp(App); const router = createRouter(); // 服务端路由必须使用 history 模式下的 url router.push(url); await router.isReady(); app.use(router); return { app, router }; }客户端入口这里用的 createSSRApp 是 Vue3 专门为 SSR 设计的工厂方法,它和 createApp 有一点关键区别:createSSRApp 在客户端激活时会复用服务端渲染出来的 DOM,而不是重新创建一堆 DOM 节点,性能更好,也能避免闪烁。
// src/entry-client.js import { createSSRApp } from 'vue'; import { createRouter } from './router'; import App from './App.vue'; const app = createSSRApp(App); const router = createRouter(); app.use(router); router.isReady().then(() => { app.mount('#app'); });注意这里用了 router.isReady()。SSR 场景下,如果路由有异步组件或者路由守卫,客户端首次激活时必须等 router 就绪再 mount,否则可能激活失败。
4.2 用 renderToString 生成最终 HTML
服务端入口定义好了,接下来要在 Express 里调用它并渲染成 HTML。核心方法是 vue/server-renderer 里的 renderToString,它接收一个 Vue 应用实例,返回一段 HTML 字符串。
// server/ssr/render.js import { renderToString } from 'vue/server-renderer'; import { createServerApp } from '../../src/entry-server'; import { fetchBffData } from './data-fetch'; export async function render(url) { // 在 SSR 渲染之前,先通过 BFF 层拿数据 const initialState = await fetchBffData(url); const { app, router } = await createServerApp(url); // 把数据提供给组件使用 app.provide('initialState', initialState); const appHtml = await renderToString(app); return { appHtml, initialState }; }数据获取在渲染之前做,这是 SSR 的一个核心原则:先把该拿的数据拿齐,然后一次性渲染,再把数据和 HTML 一起返回。千万不要在服务端渲染过程中去请求接口,那会导致渲染被阻塞,而且 Vue 组件的 onMounted 在服务端根本不会执行。
4.3 模板拼接:把数据安全地注入到 HTML
拿到 appHtml 之后,需要拼到一个完整的 HTML 模板里。模板通常长这样:
<!DOCTYPE html> <html> <head> <title>文章详情</title> </head> <body> <div id="app"><!-- app-html --></div> <script>window.__INITIAL_STATE__ = <!-- state-data --></script> <script type="module" src="/src/entry-client.js"></script> </body> </html>注意,把数据注入 HTML 时,千万别直接用 JSON.stringify 然后塞进 script 标签。文章内容里如果有一个</script>字符串,直接就给你把页面干破了,甚至可能产生 XSS 漏洞。正确做法是把<转义成\u003c:
function serializeState(state) { return JSON.stringify(state).replace(/</g, '\\u003c'); }这一步看着小,但价值很大。很多新手自己写 SSR 时在这块踩坑,页面内容包含特殊字符时就渲染错乱。转义之后服务端就能安全地把数据塞给浏览器端了。
4.4 客户端激活:hydrate 时最常见的警告
服务端渲染返回的 HTML 在浏览器里展示出来了,但此时页面还是“死”的,没有事件绑定。客户端入口 mount 时,Vue3 会做一次激活(hydration):在已有的 DOM 上绑定事件、初始化响应式数据,而不是重新创建 DOM。
激活阶段最常见的告警是:
[Vue warn]: Hydration completed but contains mismatches.这个警告的本质是服务端渲染出来的 HTML 结构和客户端首次渲染出来的结构不一致。常见原因有:组件里用了时间戳、random、window.innerWidth 这类只在客户端才有的值;数据在服务端和客户端不一样。解决办法是遵循“服务端渲染什么,客户端就激活什么”原则,差异数据放到 onMounted 里再更新:
// 错误示例:SSR 时 Date.now() 和客户端不同 // const time = Date.now(); // 正确示例 const time = ref(''); onMounted(() => { time.value = Date.now(); });这一小节做个总结:服务端把完整的 HTML 和数据吐出来,客户端拿到后原地激活,两边尽量保持一致,直到 onMounted 之后才允许出现客户端特有的数据变化。
5. 实操记录:跑通从“npm run dev”到完整 SSR 页面
代码写差不多了,进入实操阶段。这里记录一下我实际跑这个项目的完整过程、看到的结果,以及中途踩到的问题。
5.1 开发链路:一个 Node 服务同时做 BFF 和 SSR
在开发阶段,我不想开两个服务,所以直接把 Express 服务作为唯一入口,BFF 路由和 SSR 渲染都挂在同一个端口上。Express 服务的主要代码是这样:
// server/index.js const express = require('express'); const bffRoutes = require('./bff/routes'); const app = express(); const PORT = process.env.PORT || 3000; // BFF 接口路由 app.use('/bff', bffRoutes); // SSR 渲染路由(开发模式) app.get('*', async (req, res) => { const { appHtml, initialState } = await render(req.url); const html = template(appHtml, initialState); res.send(html); }); app.listen(PORT, () => { console.log(`Server running at http://localhost:${PORT}`); });在开发模式下,Vite 的 transform 能力要接入进来,否则浏览器不认识 .vue 文件。这里我在开发环境用 Vite 的 middleware 模式,生产环境直接用构建后的产物,这样开发和部署都不折腾。
// 开发模式下接入 Vite const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' }); app.use(vite.middlewares);这就是“不依赖 Nuxt”的代价:环境切换的处理要自己写明白。但写完这层之后,你对 SSR 构建流程的理解会上升一个台阶。
5.2 我实测时看到的 HTML 长什么样
启动 npm run dev 之后,打开浏览器访问 http://localhost:3000/detail/123,查看网页源代码,能看到类似这样的内容:
<!DOCTYPE html> <html> <head> <title>文章详情 - 示例站点</title> </head> <body> <div id="app"> <div class="detail-page"> <div class="author"> <img src="https://cdn.example.com/avatar/1.png" alt="作者头像"> <span>张三</span> </div> <h1>Node BFF 架构实践</h1> <div class="content"> <p>这是文章正文的第一段内容……</p> </div> <div class="recommend-list"> <a href="/detail/456">相关文章标题一</a> <a href="/detail/789">相关文章标题二</a> </div> </div> </div> <script> window.__INITIAL_STATE__ = {"article":{"title":"Node BFF 架构实践","content":"..."},"author":{...},"recommend":[...]} </script> <script type="module" src="/src/entry-client.js"></script> </body> </html>看到这段纯 HTML 就能确认,爬虫能直接拿到正文内容了,用户不再需要等 JS 执行完才看到页面。这就是 SSR 最基本的价值,也是我搭这套东西的核心目标。
5.3 生产构建:双包输出与启动
开发链路通了,生产构建就要把服务端代码和客户端代码分开构建。vite.config.js 里的核心配置长这样:
// vite.config.js import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; export default defineConfig({ plugins: [vue()], build: { rollupOptions: { input: { client: 'index.html', server: 'src/entry-server.js' }, output: { format: 'esm' } } } });构建脚本在 package.json 里这样定义:
{ "scripts": { "dev": "node server/index.js", "build": "vite build", "start": "cross-env NODE_ENV=production node server/index.js" } }我实测时先 npm run build,再 npm run start,然后用 curl 访问页面:
curl http://localhost:3000/detail/123返回的就是完整 HTML。再把 NODE_ENV 设置成 production 后,服务端会读取 dist 目录下的构建产物,不再走 Vite 的 dev middleware。
生产部署这块,我个人习惯用 PM2 管理 Node 进程:
pm2 start server/index.js --name vue3-ssr-bff -i 2-i 2 表示启动两个实例,PM2 自带了负载均衡。不过要注意,如果开了多实例,Node 进程内的内存缓存就不是全局唯一的,每个实例各有一份,对于上面说的 60 秒 TTL 推荐列表缓存来说问题不大,但如果缓存数据量大了或者要求强一致,还是得引入 Redis 之类的独立缓存服务。
6. 常见问题与排查速查表:这些坑我替你踩过了
最后一部分,也是我每次写技术文章最看重的内容:问题排查。这些坑不是看文档能学到的,每一个我都花了真金白银的调式时间。
6.1 环境类问题:从装 Node 到跑 project 的第一道坎
这一类问题在技术社区里被问得最多,几乎每个前端新手都遇到过。
| 问题现象 | 根本原因 | 解决方法 |
|---|---|---|
| npm 安装依赖报错 Hostname/IP doesn't match certificate's altnames | 镜像源证书不匹配,或公司内网代理拦截 | 清理代理配置,或切换回官方源改用镜像源里更稳定的 registry |
| npm.ps1 无法加载,因为在此系统上禁止运行脚本 | Windows PowerShell 执行策略限制 | 以管理员身份运行 Set-ExecutionPolicy RemoteSigned |
| ERROR: Cannot find module 'node:path' | Node 版本过低,不支持 node: 前缀导入 | 升级到 Node 18+,推荐 20 LTS |
| SyntaxError: The requested module 'node:util' does not provide an export named | 使用了 Node 内置模块但格式不兼容 | 检查 import 语法,改用 CommonJS 或升级 Node 版本 |
| nvm 切换 Node 版本后 node -v 还是旧的 | 当前 shell 没有重新加载 PATH | 执行 nvm use 后再重开一个终端;Windows 下确认以管理员身份操作 |
这里我单独补充一下 Node 版本问题。做 SSR 项目,Node 18 是底线,Node 20 LTS 是推荐项。为什么?Node 18 开始原生支持 fetch,但有些行为在 20 里才稳定。BFF 层要发 HTTP 请求,与其引 axios 不如直接用原生 fetch,少一个依赖就少一份维护成本。Node 22 我也试过,稳定性没问题,但有些旧的 C 模块可能没编译好,建议在 CI 里锁定版本。还有,npm 换源时要注意,公司项目如果有私有包,通常需要同时配置 @scope:registry 指向私有仓库,只设置一个 registry=https://registry.npmmirror.com 会导致私有包拉不下来,报 404 时别一脸懵。
6.2 SSR 运行时报错:渲染时最容易翻车的地方
SSR 项目特有的问题主要集中在“服务端没有 DOM”和“数据不一致”两个方面。
第一个高频报错是:
ReferenceError: document is not defined这个报错的本质是你在服务端渲染时执行了只有浏览器才有的 API。比如直接访问了 window、document、localStorage,或者某个第三方库在 import 的时候就引用了 document。解决办法是动态导入:
// 错误示例:服务端执行到这里会崩 // import { setupTracking } from './tracking'; // 正确示例:仅在客户端加载 if (typeof window !== 'undefined') { import('./tracking').then(mod => mod.setupTracking()); }第二个高频问题是 hydrate 不匹配警告,前面 4.4 节已经讲过了,核心就是服务端和客户端首次渲染要保持一致,所有浏览器环境相关的值都要放到 onMounted 里更新。
第三个很容易忽略的是 fetch 在服务端的使用。SSR 的服务端渲染阶段是在 Node 环境里执行的,如果你在组件里直接写 fetch,Node 18 以下会报 fetch is not defined,Node 20 之后能用但要注意请求时的超时和取消。我的建议是,服务端渲染阶段的数据请求不要散落在组件里,统一在 SSR 入口那层(比如第 4.2 节的 fetchBffData)完成,组件只负责消费数据。这样既好排查问题,也能更好地控制超时和并发。
6.3 BFF 层典型问题:超时、取消与并发控制
BFF 层最常见的报错就是热词里提到的 request aborted。这个报错通常是上游接口响应太慢,客户端或中间层主动取消了请求。我排查这类问题时,第一步看 BFF 是否设置了超时,第二步看上游接口是不是有慢 SQL,第三步看网关层有没有默认超时。这个项目里的 fetch 封装用 AbortController 设置 3 秒超时,有效避免了请求一直挂着。
还有一个并发问题值得提:BFF 聚合接口里,如果某个上游接口非常慢,会拖慢整个聚合接口。我通常会给每个上游请求设置独立的超时,而不是共用 BFF 接口的总超时。比如用户接口 2 秒超时,推荐接口 1 秒超时,内容接口 3 秒超时,这样就不会因为一个慢接口拖垮整个页面。
最后,Node 里发 HTTP 请求的并发数也要控制。BFF 层如果同时进来大量请求,每个请求都并发 3 个上游请求,上游服务很容易被打爆。我在这里加了一个简单的并发限制,用 p-limit 控制每个上游服务的最大并发连接数,比如限制在 50。这个在上线后高流量时非常重要。
我对这套架构的实际体会是,项目能不能跑起来是第一步,能不能在流量冲击下稳定跑才是真正的考验。BFF 层的超时、降级、并发控制,不是锦上添花的优化,而是必须有的兜底机制。最后再分享一个小技巧:所有 BFF 接口都建议在响应头里加上 X-BFF-Cache: HIT/MISS 这样的标记,排查线上问题时能直接看出来数据是从缓存取的还是实时请求的,省去一大半猜谜时间。