我对 Node.js 框架的理解分几次补上的:先用 Express,后来看 Koa 的中间件,最后才注意到 NestJS 的模块化设计。
Q1: Node.js 框架的历史?为什么要有它?
1
1993 年: CGI 时代——每个请求一个进程
早期 Web 服务器处理请求的方式是 CGI(Common Gateway Interface):每来一个请求,就 fork 一个操作系统进程去处理。这种模型简单粗暴,但进程创建和销毁的开销极大,几百个并发就能把服务器拖垮。后来出现了 FastCGI 和各种持久化进程方案,但根本问题没变——每条请求独占一个进程/线程,资源浪费严重。
2
1995-2000 年: PHP/JSP 的线程池模型
PHP 搭配 Apache 的 prefork MPM,Java 搭配 Tomcat 的线程池,本质都是”预先创建一批工作单元,来了请求就分配一个”。并发量上去后,线程上下文切换和内存占用成为瓶颈。一台 4GB 内存的服务器,Tomcat 默认 200 个线程就要吃掉近 1GB。
3
2009 年: Ryan Dahl 发布 Node.js——事件驱动 + 非阻塞 I/O
Ryan Dahl 看穿了一个事实:Web 服务器的瓶颈不在 CPU,而在 I/O。 一次数据库查询要 50ms,这 50ms 里线程什么都不干只是等着。Node.js 用单线程 + 事件循环 + 非阻塞 I/O 解决了这个问题:一个请求在等数据库时,线程立刻去处理下一个请求。[1]
4
2010-2014 年: Express 一统江湖
TJ Holowaychuk 在 2010 年发布 Express,用极简的中间件模型和路由系统,迅速成为 Node.js 的事实标准。同一时期他还创造了 Koa(2013),引入了洋葱模型的概念。Express 的设计哲学是”少即是多”——只提供路由和中间件,其余靠社区生态。
5
2016-2020 年: 企业级框架崛起
阿里推出 Egg.js(2016),在 Express/Koa 之上加了”约定优于配置”和插件机制,解决企业级 Node.js 应用的工程化问题。NestJS(2017)则从 Angular 借鉴了依赖注入和装饰器模式,用 TypeScript 重新定义了 Node.js 框架的架构标准。[2]
Node.js 框架的演进本质是:从”手写一切”到”约定优于配置”,从”个人项目”到”企业级架构”,中间件模型是贯穿始终的核心设计模式。
没有框架
裸写
http.createServer(),路由用 if (req.url === '/api') 判断,中间件自己用回调嵌套实现。项目超过 10 个接口后代码就变成意大利面条,每个人写法不同,新人接手直接崩溃。✅ 引入框架
Express 提供
app.get() 路由 + app.use() 中间件,Koa 提供洋葱模型和 async/await,NestJS 提供完整的模块化架构。团队有了统一的代码规范,项目可以从 10 个接口扩展到 1000 个接口而不失控。Q2: Express 核心原理
Express 的本质是一个中间件调度器。它只做了三件事:注册路由、存储中间件、按顺序执行。next() 才会执行下一个。这种设计简单直接,但有两个致命缺陷:没有 async/await 支持(Express 4 基于回调),以及错误处理是同步的——异步错误必须手动 next(err) 传递。[3]
中间件的执行顺序
中间件的执行顺序完全取决于app.use() 的注册顺序。这是一个常见的踩坑点:
Express 的路由系统
Express 的路由是一个分层的路由器树。express.Router() 可以创建子路由器,通过 app.use('/api', router) 挂载到特定路径前缀。路由匹配是按注册顺序遍历的,第一个匹配的路由会处理请求。
为什么 Express 被”淘汰”?
Express 没有被淘汰——它依然是 npm 上下载量最大的 Node.js 框架。但它的设计确实落后了:- Express 4 不支持
async/await:错误处理基于回调,asynchandler 的 rejection 不会被自动捕获,必须手动next(err)。 - 没有内置的类型支持:纯 JavaScript,TypeScript 需要额外安装
@types/express。 - 中间件模型过于简单:没有洋葱模型,无法在响应阶段执行逻辑(如计算请求耗时)。
async handler 返回 Promise 的 rejection,但生态和中间件兼容性仍在迁移中,社区也大量转向 Koa 和 Fastify。[4]
Q3: Koa 核心原理
Koa 的核心贡献是洋葱模型——让中间件可以在请求和响应两个阶段都执行逻辑。async/await,把 Express 的回调地狱变成了线性的同步写法。每个中间件是一个 async 函数,await next() 就是”执行完后续中间件后再回来”。[5]
Koa vs Express 对比
洋葱模型的代码实现
洋葱模型的实际应用场景
洋葱模型最有价值的场景是在请求前后都执行逻辑:Q4: Egg 核心原理
Egg.js 的设计哲学是约定优于配置——通过统一的目录结构和插件机制,让团队不需要讨论”代码放哪里”这种问题。约定优于配置
Egg 规定了严格的目录结构,团队不需要讨论代码组织方式:插件机制
Egg 的插件是带生命周期的 npm 包。一个插件可以包含路由、中间件、配置、扩展、定时任务等。通过plugin.js 声明式地启用或禁用插件:
app.use(cors()) 更彻底——不只是挂一个中间件,而是把一整套能力(包括配置、启动钩子、定时任务)都集成进来。
Loader 加载器
Loader 是 Egg 的”心脏”。它负责在应用启动时,按照约定扫描目录结构,自动加载 controller、service、middleware、config 等模块,并把它们挂载到app 对象上。
require 每个文件——只要把文件放在约定的目录下,框架就会自动加载。这就是”约定优于配置”的实现方式。
为什么阿里选择 Egg?
阿里的 Node.js 应用面临三个核心挑战:[7]- 团队协作标准化:几千人的前端团队,每个人写 Node.js 的风格不同,Egg 的约定目录解决了这个问题。
- 企业级安全和稳定性:Egg 内置了 CSRF 防护、安全中间件、进程管理(egg-cluster 基于 master-agent-worker 模型),不需要额外配置。
- 渐进式框架:Egg 的插件机制让团队可以按需引入能力,而不是把所有东西都塞进来。
Q5: NestJS 核心原理
NestJS 的核心思想是用装饰器 + 依赖注入来组织代码,把 Node.js 框架从”中间件驱动”升级为”模块驱动”。模块化架构
NestJS 用@Module() 装饰器把代码组织成模块。每个模块声明自己的 controllers(处理请求)、providers(提供服务)、imports(依赖其他模块)、exports(对外暴露服务)。
装饰器 + 依赖注入
NestJS 的依赖注入(DI)是它的灵魂。你不需要手动new UserService(),而是通过构造函数声明依赖,框架自动注入:
控制器、服务、守卫
NestJS 把请求处理拆成多个层次:NestJS vs Egg 对比
Q6: BFF 架构详解
BFF(Backend For Frontend)是为前端定制的后端层——它不替代后端微服务,而是在前端和后端之间加一个”翻译层”。什么是 BFF
传统架构中,前端直接调用后端微服务。但前端需要的数据往往是多个微服务的聚合——比如商品详情页需要调用用户服务、商品服务、评论服务、推荐服务。如果前端直接调 4 个接口,页面加载会很慢。BFF 的作用就是在服务端把这些接口聚合成一个,返回前端需要的精确数据结构。为什么需要 BFF
- 接口聚合:一个页面的数据来自多个微服务,BFF 在服务端聚合,减少前端请求数。
- 数据裁剪:后端返回的数据可能包含前端不需要的字段,BFF 按前端需求裁剪。
- 协议转换:后端可能用 gRPC,前端只认 HTTP/JSON,BFF 做协议转换。
- 多端适配:Web、App、小程序需要的数据结构不同,每个端一个 BFF。
BFF 的典型场景
Node.js BFF 的优势
Node.js 天然适合做 BFF 层,原因有三:- 前端团队可以自己写:不需要等后端排期,前端自己搞定接口聚合。
- 异步 I/O 适合聚合场景:BFF 的核心是并发请求多个微服务然后聚合,Node.js 的非阻塞 I/O 正好匹配。
- 同构渲染:BFF 层可以用 Next.js / Nuxt.js 做 SSR,同一份代码既处理 API 聚合又做服务端渲染。
Q7: CommonJS 和 ESM 混用要注意什么?
框架代码最终都跑在 Node 的模块体系上。面试常追问三件事:双包怎么发、node: 前缀为什么要有、内置 test runner 能不能替代 Jest。
CommonJS 与 ESM 的本质差异
CommonJS 里
exports.x = 1 只是给 module.exports 挂属性,一旦重新赋值 module.exports = fn,之前的 exports 引用就断了。
双包(dual package)
同时支持require 和 import 的包,要在 package.json 里用 exports 条件导出:
types条件必须放在最前面,否则 TypeScript 可能解析不到声明文件。- 有
exports后,包的就近路径访问被封锁,只能走声明的子路径(要开放就加"./package.json": "./package.json")。 - 双包隐患(dual package hazard):同一个包被
import和require各加载一次,会得到两份模块实例,模块内的单例、instanceof判断、缓存全部失效。稳妥做法是状态放到globalThis,或只保留一份实现、另一份做薄包装。
__dirname、require,需要自己补:
node: 前缀
node: 前缀从 Node 14.18 / 16.0 起支持(require("node:fs") 与 import fs from "node:fs" 都可用),作用是强制解析到内置模块,避免被 node_modules 里的同名包(例如有人发布了一个叫 fs 的包)劫持。常见写法:node:path、node:url、node:fs/promises、node:assert/strict、node:test。
内置 test runner
node:test 模块在 Node 18.0 引入,Node 20 起转为 stable;node --test 命令从 Node 18.1 起可用。它足够覆盖单元测试,不需要额外依赖:
CJS 和 ESM 的差异在于加载时机和绑定方式;发双包要用
exports 条件导出并警惕双包隐患;node: 前缀防止内置模块被同名包劫持;node:test 从 Node 18 起可用、Node 20 稳定,足以承担基础单测。Q8: 高频面试题
Express 和 Koa 的核心区别是什么? Express 的中间件是线性链式调用,通过next() 回调传递控制权;Koa 是洋葱模型,通过 await next() 让每个中间件都能在请求和响应两个阶段执行逻辑。Express 4 不支持 async/await,异步错误处理困难;Koa 2 原生支持 async/await,可以用统一的 try/catch 捕获所有错误。
什么是洋葱模型?为什么叫”洋葱”?
洋葱模型是 Koa 的中间件执行机制。请求从外到内穿过每一层中间件,响应从内到外再穿过每一层。就像剥洋葱——一层层进去,再一层层出来。每一层都能在”进去”时执行前置逻辑,在”出来”时执行后置逻辑。
Egg 和 NestJS 怎么选?
看团队背景和项目规模。阿里系业务、纯前端团队、快速开发选 Egg——它的约定目录和插件机制让新人上手快。有 Java 背景的团队、需要微服务架构、追求强类型选 NestJS——它的依赖注入和装饰器模式更适合大型企业级应用。
Express 是”最小化”,Koa 是”现代化”,Egg 是”工程化”,NestJS 是”企业化”。选框架的本质是选团队的协作方式和项目的规模预期。
底层源码:Koa 洋葱模型是如何实现的?
koa-compose 的 compose 函数是怎么实现洋葱模型的?
Koa 的洋葱模型由 koa-compose 库实现(详见 koa-compose 源码 compose.js):
- 递归包装:
dispatch(0)启动第一个中间件,dispatch(i + 1)递归调用下一个。 - 防重入:
index变量确保next()只能被调用一次,防止循环调用。 - Promise 链:
Promise.resolve(fn(...))确保每个中间件的返回值都是 Promise,await next()等待后续所有中间件执行完毕。
Express 5 是怎么加 async 支持的?
Express 5 修改了路由匹配逻辑(详见 Express 5 源码 router/index.js),让路由处理函数可以返回 Promise。如果 handler 是 async 函数,Express 5 会自动 catch 其 rejection 并传递给错误处理中间件:
及格回答 vs 满分回答
问题:“请比较 Express、Koa、Egg、NestJS 的设计思想和适用场景”
及格回答(60-70分):“Express 最老最简单,Koa 支持 async/await,Egg 是阿里出的,NestJS 是 TypeScript 写的。Express 适合小项目,NestJS 适合大项目。”问题出在:知道有什么但不知道为什么。 满分回答(90-100分):
Node.js 框架的演进有一条清晰的主线:从”最小化”到”企业级”。Express 的设计哲学是”只提供路由和中间件,其余靠社区”——TJ Holowaychuk 用不到 200 行代码就实现了中间件调度器,核心就是next()递归调用链。这种极简设计让 Express 成为 npm 下载量最大的框架,但也导致了异步错误处理的缺陷——Express 4 的next(err)无法捕获async函数中的 rejection。[1]
Koa 的洋葱模型是对 Express 最重要的改进。koa-compose的compose函数用递归把中间件数组包装成嵌套的 Promise 链,每个中间件通过await next()实现”先执行后续逻辑,再回来执行自己的后置逻辑”。这让请求耗时统计、统一错误处理这些在 Express 中需要 hack 的功能变成了自然的写法。[5]
Egg 和 NestJS 解决的是不同层面的问题。Egg 的核心是”约定优于配置”——Loader 加载器在启动时扫描app/controller/、app/service/等目录,自动把模块挂载到app对象上,让几千人的前端团队不用讨论代码放哪里。NestJS 的核心是依赖注入——用@Module()装饰器声明模块依赖关系,DI 容器自动解析和注入,这让单元测试可以轻松 Mock 任何依赖。[6]
实际项目中,如果团队是纯前端背景且项目是快速迭代的业务,我会选 Egg 或直接用 Koa;如果项目是需要长期维护的企业级应用,我会选 NestJS——它的模块化和 DI 机制让代码的可维护性和可测试性远超 Express/Koa。[8]
常见追问
题目来源于真实面经(字节/腾讯/阿里前端面试、牛客网、haizlin/fe-interview),按难度递进。每个模块 8 个 Accordion,覆盖 4 个难度层级。
Express 的中间件和 Koa 的中间件有什么本质区别?
Express 的中间件和 Koa 的中间件有什么本质区别?
答题要点:
不只是 API 不同,要从执行模型的角度解释。Express 是线性链式,Koa 是洋葱模型。高分回答:
Express 的中间件通过
next() 回调传递控制权,执行顺序是 m1 → m2 → handler → res,中间件只能在请求阶段执行逻辑。Koa 的中间件通过 await next() 实现洋葱模型,执行顺序是 m1进 → m2进 → handler → m2出 → m1出,每个中间件都能在请求和响应两个阶段执行。一个实际差异是请求耗时统计:Express 需要在路由处理函数中手动记录结束时间并计算差值,而 Koa 可以在中间件中用 await next() 自然地捕获响应阶段。Express 的 next('route') 和 next() 有什么区别?
Express 的 next('route') 和 next() 有什么区别?
答题要点:
大部分人只知道
next(),不知道 next('route') 的存在。这是区分”用过 Express”和”理解 Express 路由机制”的关键。高分回答:
next() 是把控制权交给下一个中间件,next('route') 是跳过当前路由剩余的中间件,直接进入下一个匹配的路由。这在路由级中间件链中非常有用——比如你可以先做参数校验,校验失败就 next('route') 跳过后续处理,让 404 路由兜底。Koa 的 ctx 对象是怎么设计的?为什么不直接用 req/res?
Koa 的 ctx 对象是怎么设计的?为什么不直接用 req/res?
答题要点:
从 API 设计和开发者体验的角度解释。ctx 是对 req/res 的高层抽象。高分回答:
Koa 的
ctx(Context)是对 Node.js 原生 req/res 的高层封装,通过 getter/setter 代理了常用属性。ctx.body 实际上是设置 res.body,在响应阶段 Koa 会自动调用 res.end(ctx.body)。ctx.query 是对 url.parse(req.url).query 的解析结果。这种设计的好处是 API 更简洁——ctx.body = data 比 res.json(data) 更直观,而且 ctx 可以挂载任意属性(如 ctx.state.user)在中间件之间传递数据。Egg 的 Loader 加载器具体是怎么工作的?
Egg 的 Loader 加载器具体是怎么工作的?
答题要点:
要说出 Loader 的核心流程:扫描目录、加载文件、挂载到 app 对象。高分回答:
Egg 的 Loader 在应用启动时执行以下流程:首先扫描
config/plugin.js 声明的插件,按依赖顺序加载插件代码;然后扫描 config/config.default.js 和环境配置文件,合并配置;接着按约定扫描 app/extend/(扩展)、app/middleware/(中间件)、app/controller/(控制器)、app/service/(服务)等目录,把每个文件的导出挂载到 app 对应的属性上。比如 app/controller/user.js 的导出会挂载到 app.controller.user。这就是为什么在 router 中可以直接用 controller.user.list 而不需要 require。NestJS 的依赖注入是怎么实现的?为什么 TypeScript 装饰器能做 DI?
NestJS 的依赖注入是怎么实现的?为什么 TypeScript 装饰器能做 DI?
答题要点:
要解释
reflect-metadata 和 emitDecoratorMetadata 的作用。DI 容器通过反射获取构造函数参数类型。高分回答:
NestJS 的 DI 依赖 TypeScript 的 emitDecoratorMetadata 编译选项。当一个类被 @Injectable() 装饰时,TypeScript 编译器会在元数据中记录构造函数的参数类型。DI 容器启动时通过 Reflect.getMetadata('design:paramtypes', UserService) 获取到 [DatabaseService, CacheService],然后递归解析这些依赖的构造函数参数,最终构建出完整的依赖图。如果存在循环依赖,NestJS 会抛出错误并提示使用 forwardRef()。NestJS 的 Interceptor 和 Koa 的洋葱模型有什么关系?
NestJS 的 Interceptor 和 Koa 的洋葱模型有什么关系?
答题要点:
两者都实现了”在请求前后执行逻辑”的能力,但实现方式和抽象层次不同。高分回答:
NestJS 的 Interceptor 本质上也是洋葱模型——它用 RxJS 的
pipe 操作符包装了路由处理函数的返回值,可以在 handle() 前后执行逻辑。但 NestJS 的抽象层次更高:Koa 的洋葱模型是在中间件层面,所有请求都经过所有中间件;NestJS 的 Interceptor 可以通过装饰器精确控制作用范围——比如只对特定 Controller 或特定方法生效。此外 NestJS 还提供了 Guard(认证)、Pipe(校验)、Filter(错误处理)等独立的横切关注点,比 Koa 的”所有逻辑都是中间件”更清晰。如何设计一个支持热更新的 Node.js 框架插件系统?
如何设计一个支持热更新的 Node.js 框架插件系统?
答题要点:
考察对 Node.js 模块缓存机制和进程管理的理解。需要提到
require.cache 和 cluster 重启。高分回答:
Node.js 的 require 有模块缓存机制——同一个文件只加载一次,后续 require 返回缓存。要实现插件热更新,有两种方案。第一种是清除 require.cache 后重新 require,但这有风险——如果旧模块被其他模块引用,引用不会更新。第二种是用 cluster 模式:master 进程监控文件变化,通知 worker 进程优雅退出(等当前请求处理完),然后 fork 新的 worker 并加载新代码。Egg 的 egg-development 插件用的就是第二种方案。BFF 层如何处理微服务的级联故障?
BFF 层如何处理微服务的级联故障?
答题要点:
考察对服务治理的理解。需要提到熔断、降级、超时控制等机制。高分回答:
BFF 层调用多个微服务时,任何一个服务超时都会拖慢整个响应。需要三层防护:第一层是超时控制——每个
fetch 请求设置合理的超时时间(如 3 秒),用 AbortController 实现。第二层是降级策略——如果推荐服务超时,返回默认推荐列表而不是阻塞整个页面。第三层是熔断——用 circuit breaker 模式,当某个服务连续失败 N 次后,直接跳过该服务(返回降级数据),等一段时间后再尝试恢复。Node.js 中可以用 opossum 库实现熔断。延伸阅读与引用参考
- Ryan Dahl: Original Node.js Presentation (2009):Node.js 创造者首次公开演讲,阐述了事件驱动 + 非阻塞 I/O 的设计初衷。[1]
- 朴灵: 《深入浅出 Node.js》:国内 Node.js 布道先驱的代表作,对 Node.js 的事件循环、模块系统、内存管理有系统性的深度剖析。[2]
- TJ Holowaychuk: Express 源码:Express 作者的原始实现,理解中间件模型的第一手资料。[3]
- Fastify 官方文档:Fastify 的 schema 校验、插件体系与性能调优建议。[4]
- Koa 官方文档:Koa 2 的官方文档,洋葱模型和
ctx对象的权威解释。[5] - 阿里 Node 架构团队: Egg.js 设计理念:Egg 的”约定优于配置”和插件机制的官方设计文档。[6]
- 苏千 (fengmk2): Egg.js 核心作者:阿里 Egg.js 核心作者的 GitHub,长期参与企业级 Node 框架和 npm 镜像建设。[7]
- NestJS 官方文档:NestJS 的装饰器、依赖注入、模块系统的权威文档。[8]
