4. 企业项目脚手架搭建
脚手架不是“把空文件夹填满”,而是把团队在项目初期最容易反复争论、最容易出错、后期最难补救的决策一次性固化下来。一个可用的企业级 Node.js 脚手架,至少要解决四件事:代码放在哪里、不同环境如何加载配置、TypeScript 如何编译与运行、提交前如何自动拦截低级错误。本节以一个基于 Express + TypeScript 的订单服务为例,给出可直接落地的目录结构、配置加载策略、tsconfig 关键项与提交钩子链路。
一、目录结构:按“职责边界”而非“文件类型”分层
很多脚手架习惯用 controllers、services、models 三个平铺目录,项目一大就会出现“一个业务改动要横跨五个目录”的问题。企业项目更推荐按业务域(feature/module)聚合,再在域内分层。下面是一个可直接使用的结构:
order-service/
├── src/
│ ├── config/ # 环境变量解析与校验
│ │ ├── index.ts
│ │ └── schema.ts
│ ├── modules/ # 业务域,按功能聚合
│ │ └── order/
│ │ ├── order.controller.ts
│ │ ├── order.service.ts
│ │ ├── order.repository.ts
│ │ ├── order.dto.ts
│ │ └── order.route.ts
│ ├── shared/ # 跨域复用:日志、错误、中间件
│ │ ├── logger.ts
│ │ ├── errors.ts
│ │ └── middleware/
│ ├── app.ts # 组装 Express 实例
│ └── server.ts # 启动入口,只负责监听
├── tests/
│ ├── unit/
│ └── integration/
├── .env.example
├── .eslintrc.cjs
├── .prettierrc
├── tsconfig.json
├── package.json
└── .husky/
关键约定:server.ts 只做进程启动与优雅退出,app.ts 负责中间件与路由装配,这样集成测试可以直接 import app 而不真正监听端口。业务域内部 controller 只做参数校验与响应封装,service 承载业务规则,repository 负责数据访问,三层之间通过接口或 DTO 传递,避免 service 直接依赖 Express 的 req/res。
二、环境配置:集中解析、启动即校验
环境配置最常见的坑是“散落各处读 process.env”,导致缺变量时运行到某条分支才报错。正确做法是在 src/config/index.ts 集中读取,并用 zod 在启动阶段校验,缺失或类型错误直接让进程退出。
import { z } from 'zod';
import dotenv from 'dotenv';
dotenv.config();
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
PORT: z.coerce.number().default(3000),
DB_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error('环境变量校验失败:', parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const config = parsed.data;
注意 z.coerce.number():环境变量永远是字符串,PORT 必须显式转换,否则会得到 "3000" 这种字符串参与比较。同时提交 .env.example 作为字段清单,真实 .env 写入 .gitignore。生产环境建议由容器编排或密钥管理注入,不落盘。
三、TypeScript 集成:编译与运行分离
开发期用 tsx 或 ts-node 直接运行 TS,生产期用 tsc 编译成 JS 再运行,二者职责不同。tsconfig 中几个企业项目必须显式设置的项:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"]
}
strict 与 noUncheckedIndexedAccess 是拦截空值错误的核心开关,后者会让 arr[0] 的类型带上 undefined,逼迫你处理越界。路径别名 @/* 需要在运行时也生效,开发期由 tsx 读取 tsconfig paths,生产期可用 tsc-alias 在编译后重写引用,否则 dist 里的 @/config 会解析失败。package.json 脚本建议:
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc && tsc-alias",
"start": "node dist/server.js",
"typecheck": "tsc --noEmit",
"lint": "eslint . --ext .ts",
"test": "vitest run"
}
四、代码规范与提交钩子:把检查前移到本地
规范的价值在于“自动执行”,而不是写在文档里。ESLint 负责逻辑与类型相关规则,Prettier 负责格式,两者通过 eslint-config-prettier 关闭冲突规则。提交钩子用 husky + lint-staged,只检查本次改动的文件,避免全量扫描拖慢提交。
// package.json
"lint-staged": {
"*.ts": ["eslint --fix", "prettier --write"],
"*.{json,md}": ["prettier --write"]
}
// 初始化
npx husky init
echo "npx lint-staged" > .husky/pre-commit
echo "npm run typecheck" > .husky/pre-push
pre-commit 做快速修复与格式统一,pre-push 做全量类型检查,因为类型检查较慢,放在推送前更合适。若团队使用 commitlint,可在 commit-msg 钩子校验提交信息是否符合 feat: / fix: / chore: 约定。
五、脚手架初始化后的自检
搭好之后不要急着写业务,先跑一遍自检:执行 npm run typecheck 应零错误;故意删掉 .env 中的 DB_URL,启动应打印字段错误并退出;提交一个含未使用变量的 TS 文件,pre-commit 应自动修复或拦截;执行 npm run build && npm start,确认 dist 中别名已被重写、服务能正常监听。这四步通过,脚手架才算真正可用。
scripts/verify-scaffold.sh,新成员克隆仓库后先跑一次,能在写第一行业务代码前暴露环境问题。