
从零开始一个 Node.js 后端项目最烦的不是写业务代码而是把那些和业务无关的“地基”重新铺一遍init 项目、配 ts、搭路由、接数据库、处理跨域、加日志、写统一错误响应……一样都不能少每一样都大同小异。我这些年经历过好几轮这样的重复劳动从最早期的手工复制旧项目改改到后来用脚手架再到自己沉淀出一套模版核心感受是模版不是“一时偷懒”而是一个团队工程规范的具体落地。这篇文章就完整聊聊我怎么创建一个可复用的 Node.js 后端项目模版里面包含我在环境准备、技术选型、目录结构、中间件编排、数据层接入上的整套取舍和踩坑记录。如果你是正在准备前后端分离项目、不确定 Node.js 后端该怎么搭骨架这份记录可以直接参考。1. 准备阶段先把 Node.js 环境安顿好很多教程一上来就让你npm init -y但如果你连 Node.js 版本都还没理顺后面会有一堆莫名其妙的坑。这里说的“环境安顿好”不只是“装一个 Node.js”而是让本机和团队成员的 Node.js 版本保持一致性、可切换、可复现。这是模版能不能被团队顺畅使用的前提。1.1 为什么推荐用 nvm 而不是直接装一个固定版本我最早用 Node.js 时没这个意识直接在官网下载安装包一路下一步装完就是某个具体版本。后来项目多了就发现麻烦一个老项目可能停在 Node.js 16新项目想用 Node.js 20 的新特性直接升级会弄坏老项目。这时候nvmNode Version Manager就变得必不可少。项目模版里应当写清楚.nvmrc文件这样每个进来的人只看一眼就知道该项目锁定在什么 Node.js 版本上。.nvmrc的内容很简单就一行20.17.0团队成员在项目根目录执行nvm install nvm usenvm install会优先读取.nvmrc文件中的版本号并自动安装nvm use则切换到该版本。整个过程无需大家手工记忆版本号也从根上杜绝了“我本机跑得好好的但你那边跑不起来”的类问题。1.2 版本选择的逻辑LTS 优先别追新Node.js 版本号遵循奇偶规律偶数版本是稳定版LTS奇数版本是当前版Current。我之前看到有人直接用 Node.js v24 开发结果在安装某个依赖时报出类似 “error installing: Node.js v24.21.0 is not yet released or is not available” 的错其实就是因为本地锁定的一个补丁版本还没正式发布装了个“预告版”导致依赖包在编译时判断版本号失败。所以我的模版里默认使用Node.js 20 LTS现在也可以考虑 22 LTS视团队生态而定。原因很简单LTS 版本的范围更新可靠依赖生态兼容性验证充分绝大多数第三方包都优先保障 LTS 环境的稳定性。如果项目里有必须依赖 Node 22 新特性的需求再单独升级但模版的默认值一定图稳。环境准备还有一个容易忽视的点npm源。国内网络环境很多人会切换到淘宝镜像等自定义 registry这没问题但要注意在项目级.npmrc里不要把 registry 写死否则团队成员在公司内网或海外环境拉包时会卡死。模版里我建议.npmrc只放一些公共配置例如save-exacttrue fundfalse auditfalsesave-exacttrue会锁定安装依赖的具体版本号避免^前缀导致小版本漂移。团队工程的依赖可复现性就是这些细节堆出来的。2. 框架选型模版的根基要选稳所有后端模版绕不开的第一个决策是用哪个 web 框架。这个决定会直接影响到路由组织方式、中间件生态、TypeScript/JavaScript 的支持程度以及新成员的上手成本。2.1 主流 Node.js 框架的定位差异我实际用过、评估过的主流框架有三类Express生态最大、历史最久、入门门槛最低。几乎所有中间件都能找到 Express 版本网上资料也多。缺点是框架本身几乎不提供任何“结构约束”所有架构设计都得自己定项目一乱就乱得很彻底。Koa解决了 Express 回调地狱的问题采用洋葱模型的中间件机制。但它同样轻量也不提供项目结构约定很多装潢工作还得自己来。NestJS重量级选手自带依赖注入、模块化、装饰器、守卫、拦截器等全套抽象企业级应用的“确定性”很强。缺点是学习曲线陡对小型项目来说可能有点重。Fastify主打性能和低开销也有一定的插件化体系TypeScript 支持很好。它的中间件模型兼容 Express但要求开发者认可它的“schema 校验”和“封装作用域”理念。如果你只是想快速搭一个 API 服务、成员都是 JS/TS 全栈背景、没有特别复杂的企业架构需求Express 或 Fastify 都行。但如果你想做一个“适合多人协作、后续维护成本可控”的项目模版我会更倾向于带“约束感”的方案。2.2 我选型的标准与最终结论我做模版的核心标准是三条结构约束是否自然形成、TypeScript 支持和社区中间件丰富度、团队上手成本。最后我选择的是Fastify TypeScript。为什么不是 Express因为 Express 太“自由”。我自己经历过团队项目里路由文件散落各处、model 层和 controller 逻辑混淆的场景当项目到一定规模时“自由”就是灾难。Fastify 的好处在于它有明确的插件作用域和封装模型路由天然组织成树形结构同时允许我用类似 Express 的习惯去写中间件。如果你所在的团队已经普遍熟悉 NestJS 那套全家桶用 NestJS 也是完全可以的。但本文的模版以 Fastify 为例依然能代表一种典型的工程化思路。选型本身没有绝对对错难的是想清楚你到底需要什么。我最终在模版里锁定的技术栈为FastifyWeb 框架TypeScript类型安全PrismaORM下面会细讲Zod入参校验Fastify 本身支持 JSON Schema 校验但 Zod 在共享类型层面更方便Pino日志Fastify 默认日志框架3. 模版骨架落地目录结构与核心配置框架选完终于可以落地了。很多人一上来就写代码我反而建议先搭目录结构。一个结构清晰的空骨架比你盲目写 500 行业务代码有价值得多。它可以让你在动手前就意识到“哪些职责属于哪一层”后续也不会为了找某个文件翻遍整个仓库。3.1 目录结构设计我的模版目录大致如下src/ app.ts # 创建 Fastify 实例、注册插件和路由入口 server.ts # 独立启动文件区分 app 与启动逻辑 config/ # 环境变量读取与配置对象 routes/ # 路由定义虚路由负责 URL 映射 modules/ # 业务模块services, controllers plugins/ # Fastify 插件如 CORS、鉴权、日志包装 middlewares/ # 通用中间件如速率限制、请求 ID lib/ # 通用工具库logger、加密、各种 helper db/ # 数据库客户端和迁移脚本Prisma schema types/ # 全局类型声明 tests/ # 单元测试与集成测试这个结构里modules/是核心。每个业务模块内部再划分modules/ user/ user.controller.ts # 处理入参、调用 service、返回响应 user.service.ts # 业务逻辑 user.repository.ts # 访问数据库 user.schema.ts # 入参/返回类型的 Zod schema分层的目的很简单controller 不写 SQL、service 不出现 HTTP 状态码、repository 不关心谁在调用它。这样任意一层替换都不会波及其他层。如果你项目很小可能会觉得这种分层“过度设计”但模版面向的是团队协作和多业务迭代分层带来的维护收益远大于初期的结构成本。3.2 环境变量与配置管理配置管理的核心是“一个配置项只在一个地方被定义”。我习惯在src/config/index.ts里做一个集中配置对象import dotenv/config; const env { NODE_ENV: process.env.NODE_ENV ?? development, PORT: parseInt(process.env.PORT ?? 3000, 10), LOG_LEVEL: process.env.LOG_LEVEL ?? info, DATABASE_URL: process.env.DATABASE_URL ?? , CORS_ORIGIN: process.env.CORS_ORIGIN?.split(,) ?? [http://localhost:3001], };启动时我会校验必需字段例如DATABASE_URL。它在本地开发时来自.env文件在测试环境里来自流水线的注入在容器环境里来自配置映射。用dotenv加载.env只是一个本地开发的默认方案生产环境请直接注入环境变量不要把一个真实密钥提交到.env进仓库。.env.example是模版里必带的文件里面列出所有需要的键名但值是假的。这样新同事克隆代码后复制一份.env.example改为.env再填上自己的本地值就能启动。3.3 package.json 脚本与路径别名写模版时我会提前把所有常用命令沉淀到package.json的scripts里避免每个人用自己记忆中的命令运行项目{ scripts: { dev: tsx watch src/server.ts, build: tsc -p tsconfig.json, start: node dist/server.js, lint: eslint \src/**/*.ts\, test: vitest run, db:migrate: prisma migrate dev, db:generate: prisma generate } }注意dev用的tsx watch不是原始的ts-node --watch。tsx 在本地启动速度和热更新稳定性上都更省心尤其在 Node.js 20 环境下是我目前觉得最顺手的 TS 本地运行方案。路径别名也建议在模版里配好。你肯定不希望代码里写出一长串../../../modules/user/user.service。TypeScript 端用paths运行端让tsx直接用 TS 配置构建产物由tsc输出相对路径即可。tsconfig.json里大致这样配{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] }, target: ES2022, module: NodeNext, moduleResolution: NodeNext, esModuleInterop: true, strict: true } }踩过的坑说一下如果你同时用了tsc输出产物并且运行时用的是NodeNext模块解析要非常注意导入时是否写了.js后缀。TS 编译成 CommonJS 或 ESM 后对后缀的要求不同。模版里我干脆统一ESM 项目源代码导入带.js后缀这样tsc编译后不用改任何路径就能直接跑。这个细节很琐碎但不提前定好后面改起来非常痛苦。4. 中间件、CORS 与跨域前后端分离绕不开的实战环节前后端分离项目里后端模版如果不把跨域问题处理明白前端联调第一天就会炸。网上很多帖子直接把 CORS 归因为“后端加个响应头就行”其实里面有几个关键细节值得展开。4.1 跨域是怎么产生的以及为什么不是简单加个 Header浏览器同源策略限制了来自不同源协议、域名、端口任一不同的请求读取响应。前端跑在http://localhost:3001后端跑在http://localhost:3000端口不同即跨源。这时如果你用fetch直接调用后端浏览器会发送一个OPTIONS预检请求询问服务器是否允许该跨域请求。后端的 CORS 插件不仅能设置Access-Control-Allow-Origin还要正确响应预检请求允许特定的Access-Control-Allow-Methods和Access-Control-Allow-Headers组合。比如你的请求可能带Authorization头、Content-Type: application/json如果没在预检响应里声明允许这些请求头浏览器会直接拦截后续请求。Fastify 生态里用fastify/cors即可配置如下import cors from fastify/cors; await app.register(cors, { origin: config.CORS_ORIGIN, methods: [GET, POST, PUT, PATCH, DELETE, OPTIONS], allowedHeaders: [Content-Type, Authorization], });其中origin我建议显式配置成字符串数组而不是一个true。因为生产环境一旦放开所有跨域来源等于吸毒式开发当时爽之后前端页面被任意第三方站点调用接口时你就知道疼了。4.2 中间件的编排顺序Fastify 的中间件顺序和 Express 类似但更强调装饰器与 hook 的结合。我的模版在创建 app 实例时按如下顺序组织export async function buildApp() { const app Fastify({ loggerInstance: buildLogger(), // pino 日志实例 disableRequestLogging: false, }); app.register(cors, config); app.register(helmet); // 安全响应头 app.register(rateLimit); // 速率限制 app.addHook(preHandler, attachRequestId); // 请求 ID app.register(routes); app.setErrorHandler(errorHandler); return app; }顺序的思路是先做安全和基础防护CORS、helmet、rate limit再做通用 hookrequest ID最后挂业务路由。错误处理器放在最后作为一个兜底确保任何异常都会走到统一出口。4.3 统一响应体与错误处理很多前后端项目前后端联调时天天因为“返回格式不统一”吵架。后端一会儿返回{ data: ... }一会儿又返回{ message: ... }前端封装 axios 拦截器都不知道该解析哪个字段。模版必须从第一天起就统一响应结构。我习惯的响应结构是{ success: true, code: 200, message: ok, data: {} }错误结构{ success: false, code: 40001, message: 参数校验失败, errors: [] }这里的code不是 HTTP 状态码而是业务错误码。HTTP 状态码交给网络层业务错误码交给前端逻辑层。两者分离可以避免前端用status 200来判断业务成败而是用success字段。实现上所有业务错误都通过一个自定义AppError类抛出export class AppError extends Error { constructor( public statusCode: number, public code: number, public message: string, public errors?: unknown[] ) { super(message); } }然后在统一错误处理器里判断app.setErrorHandler((error, request, reply) { if (error instanceof AppError) { return reply.status(error.statusCode).send({ success: false, code: error.code, message: error.message, errors: error.errors, }); } // 未知错误记录日志并返回通用错误 request.log.error(error); return reply.status(500).send({ success: false, code: 50000, message: Internal Server Error, }); });这个统一出口让所有错误都“可见、可测、可定位”。我有一次把一个参数校验错误裸抛出去前端拿到的不是友好 JSON 而是 HTML 错误页排查了半天才发现是某个中间件把错误先吞掉了。统一错误处理器的钩子位置必须在所有路由和中间件之后注册否则可能会被其他错误处理逻辑拦截这点要注意。5. 数据层接入用相对省心的方式连数据库后端模版里数据库接入方式决定了业务开发的顺畅度。选 ORM 或 query builder 之前先想清楚一个问题你的团队更在意 SQL 的掌控力还是更在意开发效率与类型安全。没法两头都完美。5.1 ORM 选型Prisma 为什么适合模版Node.js 生态里常见选项包括Prisma类型安全最强、schema 建模直观、迁移工具完善但抽象层厚极端复杂查询写起来不如原生 SQL 顺手TypeORM功能全面接近传统 ORM 的实体映射方式但 TS 类型推导有时比较模糊Knexquery builderSQL 味更浓灵活度高但缺少类型安全直接 mysql2/pg 写 SQL性能最好掌控力最强但开发效率最低且没有自动的类型映射我在模版里默认用 Prisma。理由是它的schema 即代码工作流很适合多人协作所有表结构集中在prisma/schema.prisma改表结构走迁移生成出的 client 类型是强约束的。业务代码里如果访问了一个 schema 中不存在的字段编译期直接报错这比运行时报错舒服太多。prisma/schema.prisma示例model User { id String id default(uuid()) email String unique name String? createdAt DateTime default(now()) updatedAt DateTime updatedAt }然后执行npx prisma migrate dev --name init npx prisma generateprisma generate会生成PrismaClient到node_modules/.prisma。模版里我会封装一个db.ts单例import { PrismaClient } from prisma/client; const prisma new PrismaClient(); export default prisma;不建议在业务代码里到处 new PrismaClient它是重量级连接池管理对象。一个进程一个实例就够了。5.2 仓储层封装从裸 Prisma 调用到可测试的 Repository虽然 Prisma 的类型安全已经很高但在模版里我仍建议加一层repository。原因不是替 Prisma 遮羞而是统一数据访问的方式也为后续可能的缓存、审计、数据源切换留一个稳定接口。以用户模块为例repository 大概是export class UserRepository { async findByEmail(email: string) { return prisma.user.findUnique({ where: { email } }); } async create(userData: CreateUserInput) { return prisma.user.create({ data: userData }); } }有人会说是为了换数据源。说实话模版阶段说“数据源切换”有点虚但 repository 让 service 层的测试变得简单你可以 mock repository不依赖真实数据库就能验证业务逻辑。单测能不能跑得顺畅很大程度上取决于 data access 这一层有没有抽出来。5.3 连接池、超时与生产环境注意事项这里有几个生产环境经常翻车的地方我得在模版里提前埋好应对连接池大小Prisma 默认的连接池大小是num_cpus * 2 1并发高时默认值未必合适。你可以在连接串里带上参数或通过pool_size显式控制不同数据库驱动参数有差异以避免数据库被连接数打爆。连接超时有些默认驱动在数据库重启恢复期间会长时间等待。通常 SQLite/MySQL/PostgreSQL 连接串里可以配置connect_timeout建议设置一个合理阈值比如 10 秒让请求早点失败而不是傻等。该字段在 Prisma 不同数据库的配置方式不同模版 README 里可以直接写清楚。迁移与启动顺序生产环境发布时prisma migrate deploy必须在服务启动前执行而且建议做成流水线第一步。不要“启动服务后立刻在进程里跑 migration”容易在多个实例同时启动时产生竞争迁移锁。这些细节看起来和模版骨架没什么关系但项目上线第一天就会遇到。没有人希望把自己第一次上线变成 Prisma 文档的实践现场。6. 模版的进阶能力与常见坑模版搭到能跑通 CRUD并不等于可以交付给团队了。还有几件事必须值得在模版层面补齐安全加固、日志规范、以及一套从“不会跑”到“跑起来”的排错经验。6.1 安全加固不是上线前再做的事安全配置一旦错过事后补救成本极高。模版里我至少加入helmet设置各种安全相关响应头包括禁止 MIME 嗅探、X-Frame-Options、Strict-Transport-Security 等。Fastify 里可以用fastify/helmet。速率限制rate limit用fastify/rate-limit按 IP 维度限制单点访问频率。生产环境中登录接口、验证码接口的限流阈值要设置得比普通接口严格。输入体量限制Fastify 默认对 body 大小有限制自己也心里有数。把bodyLimit显式设置成业务允许的合理值比如1mb可以避免有人直接往你接口塞巨大的 JSON 导致内存压力。日志脱敏Pino 的序列化器可以对password、token、authorization等字段做脱敏处理。不要觉得“日志里谁看得到”——真出事时自己查日志先被自己的明文密码日志吓一跳。Pino 脱敏示例const logger pino({ serializers: { req(req) { return { method: req.method, url: req.url, headers: { authorization: req.headers.authorization ? [REDACTED] : undefined }, }; }, }, });6.2 部署启动脚本与进程管理模版只负责到“能启动”不算完还要考虑“在生产环境怎么跑起来”。现在的容器化环境下服务通常跑在 Docker 里进程管理交给容器平台就好。模版里我保留一个多阶段构建的Dockerfile示例大概是FROM node:20-alpine AS base WORKDIR /app FROM base AS deps COPY package*.json ./ RUN npm ci FROM base AS build COPY --fromdeps /app/node_modules ./node_modules COPY . . RUN npm run build RUN npx prisma generate FROM base AS runtime ENV NODE_ENVproduction COPY --frombuild /app/package*.json ./ COPY --frombuild /app/node_modules ./node_modules COPY --frombuild /app/dist ./dist COPY --frombuild /app/prisma ./prisma EXPOSE 3000 CMD [node, dist/server.js]这里有一个常见坑如果你不在构建阶段执行prisma generate到运行时才生成 client那容器里就得额外保留完整依赖和 schema 文件镜像体积大得多更容易出现运行时缺依赖的问题。构建阶段把 Prisma client 一并生成运行时只需要按构建产物走。6.3 Node.js 环境版本问题实战排查实际项目里“环境起不来”的坑往往比你想象的多。遇到过几个典型场景同事执行npm install时 Node.js 是 v18但依赖要求 Node.js 20 以上结果报错一片。解决办法不是大家口头约定而是在package.json的engines字段里硬性声明{ engines: { node: 20 21 } }如果再配合.npmrc里的engine-stricttrue不满足版本条件的安装会直接报错而不是等到代码运行时才炸。node-gyp编译类依赖在 Windows 上经常需要 Visual Studio Build Tools。模版无法替你解决本机问题但 README 里把常见前置条件列清楚是必要的Python 版本、C build tools、不同平台依赖包。还有一次调试了很久发现代码里用了fetch全局方法本地 Node.js 18 有但 CI 镜像用的是 Node.js 16跑单测直接提示 fetch is not defined。这个问题的根源是镜像版本没和.nvmrc对齐。所以版本声明不只在开发环境也要在 CI 镜像和 Dockerfile 里保持统一。模版仓库里我会加上所有声明版本的位置清单避免各处版本漂移。7. 模版如何持续迭代而不是变成死代码最后说一个大多数人忽略的问题模版写出来之后如果不持续使用和迭代一两年后基本就废了。我见过团队把项目模版当成“一次性脚手架”新项目还是老一套手工复制模版沦落为考古文物。我的做法是每隔一段时间就把当前跑过的最顺手的新实践合并回模版比如新引入一个验证库、优化一次构建流程、加一个更合适的 lint 规则。这样模版会持续吸收项目中的反馈也迫使我在新项目里真的去用模版而不是“好这个项目我手写也行”。具体做法上有几点经验不要追求大而全。模版要能解决 80% 的常规需求剩下 20% 的特殊玩法应该在具体项目里临时扩展不要试图模版化所有东西。把 README 当作文档资产来写作。我在模版 README 里写上目录结构说明、环境变量清单、常用命令、部署步骤、常见问题。别人第一次拿到模版最需要的不是看代码而是看 README 能不能让他 5 分钟内在本地跑起来。每次从模版创建了新项目如果发现需要改点什么才能跑通就回到模版里同步修正。让模版保持“可以随时公开创建项目”的状态。对我个人而言模版最大的价值不是说代码写得有多漂亮而是它把团队约定固化成了“开箱即得的基础设施”。一个后端模版真正够不够好不看命名多优雅、不看用了多新的框架而看你把它交给一个新人他能不能不翻文档就能照着目录开始写业务代码。能那就是好模版不能说明还要继续打磨。最后再说一下我自己的体会每次创建新项目的时候一定要逼着自己从模版走一遍完整流程一旦发现模版有断点立刻修别等。模版的品质就是在这一次次“用起来”的过程中熬出来的。