前言就一句话:这篇记录的是我个人的学习过程,不是什么保姆级教程,也不是最佳实践清单。
事情得从去年接手的一个 Node 项目说起。
先说一下我那个翻车的项目
项目不算大,两万行上下,Express + MongoDB。接手时打开 app.js ------ 八百多行。路由注册、数据库连接、静态资源配置、中间件注册、日志初始化,全在一个文件里。配置写在代码里,切换环境靠注释。package.json 的 dependencies 和 devDependencies 分不清,TypeScript 挂在 dependencies 里。README 写了三条命令,两条已经过时。
这就是典型的野生 Node 项目。能用,能跑,但谁都不敢动。
跟企业级工程化项目比,差距在哪?企业级项目不会把全部东西压在人脑记忆上------环境有人管、依赖有人分、目录有人分、上线有流程、回滚有方案。野生项目刚好反过来,所有负担都压在"上次那个谁记得"上面。
那时候我第一反应是:这项目缺工程化。第二反应是:那补上呗,装工具谁不会?
这时候我犯了个判断错:我以为工程化不是装插件,但我自己干的头一件事就是装插件。
我一开始以为工程化 = 装工具
那段时间的操作流程:ESLint、Prettier、Husky、lint-staged、Commitlint、TypeScript 定义、Dockerfile。装完觉得自己挺厉害。
工具到位了,该检查的能检查,该格式化的能格式化。但项目本质上没变化。不知道该往哪加新功能,配置切换还是靠注释,测试还是为零------代码耦合到没法写单测。工具都上齐了,什么都改不了。
卡住之后试过别的方向:强制代码评审,大家忙起来就跳流程。补工程化文档,写完没人翻第二次。拉专项会讲标准,下周照样各写各的。这些动作的共同问题,是全部压在人的自觉上------而人的自觉在工期面前最靠不住。
结果是三个状态一直没走出来:不可维护 ------谁都不敢改,改了不知道影响哪;不可协作 ------新人进来找不到门,看两个星期才能下手;不可上线------每次上线都像押注,出问题是大概率的。
这三个痛点,后文每个方案都在解决它们。
看上去,卡在哪了
这时候才意识到:问题不是工具没装全,是我从未搞明白工程化到底在解决什么。
改变是从一个动作开始的:顶着业务方的压力,把项目的目录结构重做了。
重新做了一遍目录结构
从原来全部平铺在根目录,改成按角色分目录:
bash
project/
config/ # 配置文件和配置读取逻辑
utils/ # 纯工具函数,不依赖项目上下文
middleware/ # 中间件独立,不混在路由文件里
modules/ # 业务模块,路由/控制器/服务在一个目录
core/ # 核心基础设施(数据库、缓存、日志)
constant/ # 常量集中管理
scripts/ # 工程脚本
test/ # 测试
app.js
这个动作跟"装工具"没有任何关系。但做完之后效果最明显。目录一分,职责边界就出来了。config/ 一出来,配置就有人管了。middleware/ 一出来,没人再把中间件往路由里塞。目录设计本身就是工程治理------这句话是亲手做完才理解的,不是从哪本书上看来的。
平铺目录最大的问题,是职责边界和协作边界会越来越模糊。你不知道一个文件到底属于哪个模块,所有人第一反应都是"再加一个文件",而不是"这个文件应该放哪"。新目录结构下,config 管配置,utils 管工具函数,middleware 管中间件,modules 管业务模块,core 管基础设施,constant 管常量。每层边界清晰,新人过来看路径就知道去哪找代码。
配置文件治理
之前配置写在代码里,开发和生产靠注释切换。后来改成了多环境 .env 隔离:.env.development、.env.production、.env.test。同时用 cross-env 处理跨平台环境变量差异------Windows 和 Linux 的环境变量设置语法不同,cross-env 统一了这层差异,这也是工程化链条里很容易被漏掉的一环。
然后我才重新理解了工程化在解决什么
目录结构理顺之后再回头想工程化这件事,才发现最开始的判断是反的。
工具解决的是"怎么做"的问题,但工程化首先解决的是"要做什么"的问题。装 ESLint 之前先得确定规范是什么,装 Husky 之前先得确定哪些检查应该在提交前执行,装 Docker 之前先得确定构建流程是什么。工具是最后的落地手段,不是决策本身。
Node 自由度太高,自由度越高越容易乱。工程化真正在做的是用四条线兜住这个乱:
规范化。命名、目录、依赖、提交日志。能规定的东西就规定,别靠商量,靠制度。
自动化。格式检查、测试、构建、部署。机器能做的事就别让人做。
分层解耦。配置、中间件、路由、业务、数据。每层有边界,改一层不影响另一层。
可观测可运维。日志、进程管理、异常捕获、优雅退出。上线不是终点,是运维的起点。
这四条线是全文主线,也是后面所有方案的核心------任何一个方案,服务不了其中至少一条,就不该上。
那条路走到后面,我补齐了什么
环境标准化
先用 nvm 锁定 Node 版本到 LTS,同时在 .nvmrc 和 package.json 的 engines 字段各约束一次。环境不统一会直接破坏协作基础------本地用 18、CI 用 16、线上用 14,三个环境三种表现,出了 bug 先排查"是不是版本问题",效率全耗在这上面。
包管理器与依赖边界
包管理器换成 pnpm。不是因为它新,而是 npm 扁平的 node_modules 在依赖冲突时排查成本太高,pnpm 的树形结构一致性更好。更重要的是划清 dependencies 和 devDependencies 的边界:运行时导入的放 deps,编译、测试、类型定义放 devDeps。边界不划清,部署时经常多装不需要的包,或少装运行时需要的包。
package.json 的工程角色
很多人只把 package.json 当依赖清单。事实上它有四个字段承担了明确的工程角色:engines 声明运行环境兼容性,scripts 统一研发命令入口,main 指定 CommonJS 入口,module 指定 ES Module 入口。这些字段和运行、构建、发布直接相关,不是可填可不填的摆设。
模块规范统一
项目里 import 和 require 混着用,CJS 和 ESM 混用的代价很具体:类型推导不一致、打包需要额外转换处理、重构时容易报 ERR_REQUIRE_ESM。统一走 ESM 路线之后这些问题自然消失。先统一模块体系再扩展项目,这个顺序不能倒。
代码风格防线
ESLint 和 Prettier 装上了,但光装不够。格式问题不应该出现在代码评审里------靠人自觉维持的格式标准,工期一紧就会被人跳过。后来加了 Husky + lint-staged,在 Git hooks 里做提交前自动校验,质量防线前移到 commit 之前,而不是等人来发现。
提交日志规范
Commitlint 统一了 commit message 格式。commit 日志属于规范体系的一部分------日志不统一,回溯变更时没法靠标题快速定位那次提交改了什么,生成 Changelog 也全靠手动。日志统一直接影响协作效率和回溯能力。
TypeScript 的工程价值
之前对 TS 一直犹豫,觉得写起来更麻烦。直到线上因类型传错出过几次线上 bug,排查成本远大于写代码时把类型补上。TS 不是"写起来更麻烦的 JS",它的价值在于把运行时才会暴露的问题前置到开发阶段,这本身就是工程治理的一部分。tsconfig 配了 paths 和 baseUrl,用别名代替 ../../../../utils/format 这种路径,重构时不用一个个改引用路径。
构建链路
构建工具选了 esbuild。构建速度比 Webpack 快一个数量级,产物直接就是 Node 能跑的 JS,不做无意义的拆包。构建速度影响开发体验,产物精简影响部署效率,这两个维度要同时兼顾------构建链路也是工程基座的一部分。
质量保障
之前的项目零测试,每次上线靠手工点几下接口。手工验证覆盖不了回归场景,每一次上线前的手工点验都不可重复、不可追溯。后来用 Jest 补了单元测试 + supertest 做接口自动化测试 + 覆盖率设了 80% 阈值,低于阈值 CI 不通。可测试性 = 可维护性------一个模块能不能写测试,直接反映它的耦合度。测试不是补充项,没有自动化的质量保障,工程化就不完整。
研发脚本统一
把常用操作统一成 dev、build、lint、test、start 五个命令。之前项目里"启动"这件事,不同人有不同记忆,有的用 npm start,有的用 node app.js,有的用 nodemon。统一之后不用再问"这项目怎么启动"。脚本工程化是上线稳定性的前提------部署脚本不会因为"那个人记得怎么配"而生效。
容器化与自动化交付
Docker 用了多阶段构建:构建阶段装依赖编译代码,运行阶段只拷贝产物和运行时依赖,镜像体积能缩 70% 以上。配上 CI/CD 自动化流程,从代码推送到构建、测试、部署走一条链,不需要人肉执行步骤。工程化必须覆盖从构建到交付的全链路。
环境配置
配置从注释切换改成了多环境 .env 隔离:.env.development、.env.production、.env.test。配上 cross-env 处理跨平台环境变量差异,Windows 和 Linux 之间不需要改脚本。多环境配置隔离和跨平台变量处理,看起来是小事,但都是工程化链条里容易被漏掉的环节。
进程守护与异常处理
Node 是单线程模型,一个未捕获异常能让整个进程退出。用 PM2 做了进程守护,写好了优雅退出的回调------收到 SIGTERM 时先停新请求,等正在处理的请求完成再退出。全局异常捕获也加上了,uncaughtException 和 unhandledRejection 都有日志记录。上线不是项目工程化的终点,而是运维期工程能力的起点。
后面这条路,我还在继续
上面这一套在一个项目里搭起来之后,新问题来了:下个项目要不要重新搭一遍?
方向有两个。一是自制脚手架,把目录结构、ESLint 配置、tsconfig 模板、测试框架这些基座做成工具,起新项目时一键生成。脚手架能保证所有项目有一致工程结构,差异只留到业务代码层面。
二是 Monorepo。当项目从一个变成多个------前端、后端、公共库------工程规范怎么共享、依赖怎么统一治理就成了新课题。还在试 pnpm workspace,没完全走通。
这条路的演进路径大概分三层:
- 项目级:单项目工程基座搭稳
- 团队级:脚手架保一致性,新人不用重新学一套
- 架构级:Monorepo 统一多项目治理
你的项目,现在最痛的是哪个?
整件事走下来,最大的变化是终于分清楚了两层东西:工具只是在落地,思维才是决策。
翻车的根本原因,是以为装齐工具等于工程化完成。但工程化真正在做的,是把项目里靠人记住、靠人自觉、靠人商量的部分,一个一个变成靠制度、靠工具、靠自动化来解决。
对我自己来说,判断一个工程化动作值不值得做,就看三件事------它能不能少让一个人来问你"这个怎么配"、能不能让半年后的自己少骂几句当时的代码、能不能让上线的时候少出几次冷汗。说白了就是协作成本、维护成本、出错成本。三样里一样都不占的,就先不动它。
这条路我自己也还在走,远没到"搭完了"的状态。上面列的这些方向------环境标准化、目录分层、代码规范、测试覆盖、CI/CD------也不是一次性全做完的,就是一个痛点一个痛点补过来的。
如果你也在补 Node 工程化这条线,我比较好奇的是:你项目现在最让你头疼的是哪个方向?目录乱了、依赖分不清、还是测试为零?欢迎在评论区聊聊。