我记得刚接触 Express 那会儿项目入口就是一个 app.js里面堆满路由、中间件、静态资源托管的代码写到最后连自己都不想打开那个文件。后来一位前端同事扔给我一句你干吗不用 express-generator我才发现这个老牌脚手架能直接生成一套结构清晰、开箱即跑的 Express 项目骨架。如果你也在纠结 Node.js 项目环境怎么搭、目录怎么分这篇文章就把 express-generator 从安装到目录拆解到实操改造讲透适合刚入门的 Node.js 开发者也适合想规范项目结构的同学参考。1. 为什么我劝你放弃手搭 Express 项目1.1 手动搭建一次才知道脚手架省了多少事很多人觉得 Express 项目简单npm init 之后装个 express写两行代码就能跑。这确实没错但这个能跑和能维护之间隔着很长一段路。手动搭一个相对完整的 Express 项目你需要操心的事情包括初始化 package.json、安装 express 及相关中间件、创建入口文件、拆分路由模块、配置模板引擎、设计静态资源目录、加上 404 兜底和错误处理中间件、设置端口监听逻辑。这一整套流程走下来新手至少要折腾半天而且中间件顺序、视图引擎配置这些细节稍不留神就会踩坑。我第一次手动搭项目时就栽在中间件顺序上静态资源中间件写在了路由后面结果访问 public 下的 CSS 文件一直 404排查了很久才发现是顺序问题。express-generator 生成的骨架把这些默认配置全部帮你理好了而且顺序是经过大量项目验证的标准写法你可以直接在这个基础上加业务代码。1.2 express-generator 能帮你做什么不能帮你做什么express-generator 的价值一句话总结把项目长什么样这件事标准化。它解决的是从 0 到 1 的项目骨架搭建问题包括目录结构、基础中间件、路由划分、模板引擎集成、启动脚本、错误处理这些是它擅长的。但它不做业务不接数据库、不管登录鉴权、不负责单元测试、不替你部署上线。我见过不少新人以为脚手架生成完项目就能直接写业务了结果发现连数据库都连不上还得自己装驱动、配连接池——这不怪脚手架因为业务方案千差万别脚手架没办法替你预设。所以正确的预期是它给你一个干净、规范、可运行的骨架你在这个骨架上做二次开发。2. 开工前的环境准备这三件事最容易翻车2.1 Node.js 版本检查与建议express-generator 本身是个 Node.js 命令行工具对环境要求不算高但你本机 Node 版本会影响安装和生成的模板质量。先打开终端跑一句node -v npm -v建议使用 LTS 版本长期支持版别用太老的版本。我见过有人还在用 Node 8 跑新生成的模板结果 npm install 的时候一堆依赖版本对不上报错报得让人头大。也不要盲目追最新大版本部分原生模块可能还没跟上编译适配。LTS 版本是最稳的选择团队协作时大家统一 Node 大版本也能避免很多我本地能跑你本地不行的尴尬。nvmNode 版本管理器是个好东西可以随时切换 Node 版本。比如某项目需要 Node 16另一个项目要 Node 20用 nvm 切来切去比反复卸载安装省心得多。当然这只是个建议嫌麻烦的话装一个 LTS 版本就够用了。2.2 npm 源与全局安装权限的坑国内开发环境经常遇到 npm 下载慢的问题把 registry 切到国内镜像源是常见操作。但这里有两个坑第一镜像源的同步不是实时的有时候新版本包在官方源已经发布了镜像源还没同步过来。如果你发现 express-generator 生成的结果和官方文档描述不一致别急着怀疑是镜像源的锅可以等一两天再试或者临时用官方源装一次。第二全局安装包权限问题。在 Linux 或 macOS 上直接npm install -g express-generator可能会报 EACCES 权限错误。这时候千万别直接sudo chmod -R改 npm 全局目录权限也别随手sudo npm install -g——用 sudo 装全局包容易引发后续所有全局包权限混乱后患无穷。我推荐的方案是如果你用 nvm 管理 Node全局包的目录会在 nvm 的 node 安装目录下面不存在权限问题这是最干净的路径。如果你不想再折腾直接跳过全局安装用 npx 执行 express-generator 也行后面会细说。2.3 全局安装与 npx 临时执行怎么选express-generator 有两种用法全局安装后执行或者直接 npx 临时执行。全局安装npm install -g express-generator express --versionnpx 方式npx express-generator myapp两种方式的区别很实在全局安装之后每次生成项目都是秒级响应而且你可以在任意目录直接敲express命令适合经常需要起新项目的开发者。npx 方式的好处是不污染全局环境适合偶尔用一次、不想长期占用磁盘空间的场景代价是首次执行要等它下载包。就我的习惯来说建议全局安装因为 express-generator 这个工具很轻量全局装一次能用很长时间。而且后续配合 IDE 的终端、脚本自动化全局命令用起来更顺手。3. 生成一个项目骨架安装与核心参数3.1 最简单的生成命令先看一条最基本的命令express myapp这条命令会在当前目录下创建一个名为 myapp 的文件夹里面就是一套完整的 Express 项目骨架。默认情况下会带一套模板视图生成完了不用改任何配置就能启动。如果你只是想先跑通流程用这条就行。但实际项目中我更建议生成时就把参数带全这样后续省很多事。比如express myapp --viewejs --cssless --git这条命令的含义是生成 myapp 项目使用 ejs 模板引擎样式预处理器选 less并顺便生成一个 .gitignore 文件。参数选对了项目一落地就是按你的技术栈定制好的不用生成后再手动改配置。3.2 常用参数速查与用法express-generator 的参数不算多但有几个容易搞混我整理了一张速查表参数简写作用--viewengine-v指定模板引擎如 ejs、pug、hbs、hogan--ejs-e快速指定 ejs 模板引擎--pug-p快速指定 pug 模板引擎--hbs-h快速指定 handlebars 模板引擎--hogan-H快速指定 hogan 模板引擎--no-view无不生成视图模板适合纯 API 项目--cssengine-c指定 CSS 预处理器如 less、sass、stylus--git-g生成 .gitignore 文件--force-f目标目录已存在且非空时强制覆盖这里有个特别容易踩的坑-h在 express-generator 里是--hbs的简写不是 help。第一次用的人想查看帮助文档敲了express -h结果直接生成了一个使用 hbs 模板引擎的项目一脸懵。想看帮助直接敲express --help或者查官方文档就行。3.3 模板引擎怎么选模板引擎的选择没有绝对的对错但对项目开发体验影响不小。我这里说下实际感受ejsEmbedded JavaScript最接近 HTML 的语法在 HTML 里嵌% %标签写逻辑。如果你是从传统后端开发转过来的ejs 上手最快调试也简单因为模板本身长得很像最终输出的页面。适合大多数 Web 服务端渲染项目。pug原 jade语法非常简洁用缩进代替标签闭合写起来很省字符但缩进层级一多了也容易迷糊。pug 的哲学是让模板更像代码喜欢的人很喜欢不习惯的人会觉得它跟 HTML 长得不像心智负担略大。适合团队里大家统一技术栈、且愿意花一点时间适应缩进语法的场景。hbsHandlebars强调模板逻辑和业务逻辑的分离模板里尽量不写复杂逻辑数据由代码侧提前处理好。对前端转过来的人来说hbs 的{{ }}插值语法非常熟悉很适合后端渲染但又不希望模板里掺杂过多 JS 逻辑的项目。如果项目是前后端分离前端用 Vue 或 React 单独部署后端只提供接口那么直接用--no-view生成一个纯 API 项目模板引擎一个都不用装还能少一层依赖、少一些安全攻击面。这个选择在现在的开发模式下越来越常见。4. 生成的目录到底在讲什么每个文件逐个拆4.1 根目录结构一览执行express myapp --viewejs --git之后进到 myapp 目录看典型的骨架结构是这样的myapp/ ├── app.js ├── bin/ │ └── www ├── package.json ├── public/ │ ├── images/ │ ├── javascripts/ │ └── stylesheets/ │ └── style.css ├── routes/ │ ├── index.js │ └── users.js └── views/ ├── error.ejs └── index.ejs这个结构几乎就是 Express 项目的标准答案。根目录下所有代码文件加起来没几行但每个文件都承担明确职责。下面逐层拆开讲。4.2 app.js整个应用的装配车间app.js 是整个应用的核心装配文件别被它的名字骗了它不负责启动服务器只负责创建 Express 实例、装配各种中间件、挂载路由。核心代码大致长这样var createError require(http-errors); var express require(express); var path require(path); var cookieParser require(cookie-parser); var logger require(morgan); var indexRouter require(./routes/index); var usersRouter require(./routes/users); var app express(); // 视图引擎配置 app.set(views, path.join(__dirname, views)); app.set(view engine, ejs); // 中间件配置 app.use(logger(dev)); app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); app.use(express.static(path.join(__dirname, public))); // 路由挂载 app.use(/, indexRouter); app.use(/users, usersRouter); // 404 兜底 app.use(function(req, res, next) { next(createError(404)); }); // 错误处理中间件 app.use(function(err, req, res, next) { res.locals.message err.message; res.locals.error req.app.get(env) development ? err : {}; res.status(err.status || 500); res.render(error); }); module.exports app;这段代码值得一行一行看app.set(view engine, ejs)告诉 Express 使用 ejs 模板引擎。模板文件默认放在views目录下渲染时只要写res.render(index)Express 会自动去 views 目录找 index.ejs后缀名都不用写这个约定省了很多事。中间件这一块logger(dev)是 morgan 模块提供的开发日志中间件会在控制台打印每个请求的方法、路径、状态码和响应时间排查问题非常有帮助。express.json()和express.urlencoded({ extended: false })是 Express 4.16 之后内置的请求体解析中间件替代了早期的 body-parser 单独安装方式。前者解析 JSON 请求体后者解析表单格式application/x-www-form-urlencoded。注意extended: false的意思是使用 Node.js 内置的 querystring 库来解析表单数据不支持嵌套对象改成true则使用 qs 库支持富对象解析。具体选哪个取决于你的接口是否处理复杂嵌套的表单参数。cookieParser()负责解析请求里的 Cookie解析结果挂在req.cookies上。express.static(path.join(__dirname, public))是静态资源托管中间件public 目录下的所有文件都直接通过根路径访问比如public/images/logo.png可以通过http://localhost:3000/images/logo.png访问。路由挂载的逻辑也很好理解app.use(/, indexRouter)表示所有以/开头的请求都交给 indexRouter 处理app.use(/users, usersRouter)表示所有以/users开头的请求交给 usersRouter 处理。这种按路径前缀拆分路由的模式是 Express 官方的推荐做法能有效避免所有路由都堆在 app.js 里。最后那两个中间件是重点第一个是 404 兜底中间件它不处理任何正常请求只负责捕获前面所有中间件和路由都没匹配到的请求生成 404 错误对象后交给下一个错误处理中间件。第二个是错误处理中间件特点就是那个err参数——Express 会通过中间件函数的参数个数来识别错误处理中间件必须保留err, req, res, next四个参数少一个都不行。4.3 bin/www真正的启动入口很多新手会奇怪入口文件不是 app.js 吗为什么 package.json 里的 start 脚本写的是node ./bin/wwwbin/www 才真正承担了启动服务器的职责。app.js 只负责装配 Express 应用并把它导出bin/www 引入这个应用创建 HTTP 服务器指定端口处理启动过程中的各种事件。核心代码逻辑如下#!/usr/bin/env node var app require(../app); var debug require(debug)(myapp:server); var http require(http); var port normalizePort(process.env.PORT || 3000); app.set(port, port); var server http.createServer(app); server.listen(port); server.on(error, onError); server.on(listening, onListening);注意一个细节bin/www 里先读取环境变量process.env.PORT如果没有设置就用 3000。这意味着你想改端口不一定要改代码文件直接在启动时通过环境变量传PORT3001 npm start这样更灵活也更符合配置与代码分离的原则。normalizePort函数会把端口值转成数字如果转换失败或小于 0 就返回 false后面 listen 时再判断。server.on(error, onError)处理启动错误事件比如端口被占用EADDRINUSE时会打印提示server.on(listening, onListening)会在成功监听后打印一行日志告诉你应用已经跑起来了。为什么要把启动逻辑单独拆到 bin/www 而不是直接在 app.js 里app.listen(3000)最主要的原因是方便测试。app.js 导出的是纯 Express 应用实例测试框架可以直接加载这个实例并监听临时端口而不必真的启动一个固定端口的服务。这种装配和启动分离的思想在后端项目里是个非常经典的实践。4.4 routes 与 views 的协作关系routes 目录下默认两个文件index.js 和 users.js两者结构非常相似var express require(express); var router express.Router(); // 首页路由 router.get(/, function(req, res, next) { res.render(index, { title: Express }); }); module.exports router;这段代码定义了一个路由处理器当浏览器 GET 请求/时执行回调函数res.render(index, { title: Express })渲染 views/index.ejs 模板并传入一个 title 变量。这就是服务端渲染的基本流程路由接收请求控制器准备数据模板负责展示数据。users.js 则演示了返回 JSON 的方式router.get(/, function(req, res, next) { res.json([{ name: Alice }, { name: Bob }]); });比起res.send(JSON.stringify(...))res.json()会自动设置 Content-Type 为 application/json并做序列化更规范。这里顺手聊一下router.get(/, ...)的路径问题。在 routes/index.js 里路径写的是/但它在 app.js 里被挂载在app.use(/, indexRouter)所以完整路径是/在 routes/users.js 里路径写的是/但它被挂在app.use(/users, usersRouter)所以实际访问路径是/users。如果你在 routes/users.js 里写router.get(/list, ...)实际访问路径就是/users/list。搞清楚这个拼接规则写路由时就不会出现路径对不上、一直 404 的情况。views 目录下的 index.ejs 是首页模板error.ejs 是错误页模板。ejs 的语法很简单% title %输出转义后的变量值%- ... %输出不转义的 HTML 内容% %里写逻辑代码。配置文件里app.set(view engine, ejs)和app.set(views, ...)两行配合确立了模板放哪、引擎是谁、渲染时后缀名可省略三个约定。4.5 public 静态资源目录public 下有三个子目录images、javascripts、stylesheets。这些目录对应前端资源的三个基础分类。实际开发中服务端渲染的项目会把 CSS、JS、图片放这里通过express.static中间件直接对外开放前后端分离的话public 目录也可以放前端打包后的静态文件由一个统一的静态服务承载。这里有个细节express.static中间件也可以和缓存策略配合。默认情况下静态资源不带强缓存头如果你希望浏览器对 CSS、JS 做缓存优化可以在app.use(express.static(...))时配置maxAgeapp.use(express.static(path.join(__dirname, public), { maxAge: 7d }));这个配置意味着静态资源在浏览器端缓存 7 天减少重复请求。当然生产环境更精细的缓存策略通常配合文件名 hash 或 CDN 来做这里先不展开。5. 跑通第一个页面启动、验证与常见报错5.1 从生成到启动中间别漏了 npm install生成完项目骨架后先别急着启动这一步很容易被新手跳过。进到项目目录安装依赖cd myapp npm install因为 package.json 已经由脚手架生成好依赖列表都写在里面了npm install会一次性把 express、ejs、morgan、cookie-parser、http-errors、debug 等项目运行所需的包全部装进 node_modules。装完之后执行npm start正常情况下终端会打印监听提示浏览器打开http://localhost:3000就能看到 Express 默认欢迎页面。看到页面那一刻项目骨架就算真正跑通了。用 npm start 还是 node ./bin/www前者本质就是执行后者。npm start 会读取 package.json 里的 scripts 配置好处是团队统一入口、不会有人敲错命令。查看 package.json 你会发现 start 脚本明确写着node ./bin/www这也是为什么我之前强调 bin/www 才是启动入口的原因。5.2 端口冲突的完整排查链路端口被占用是最常见的启动报错错误信息大概长这样Error: listen EADDRINUSE: address already in use :::3000看到 EADDRINUSE 就说明 3000 端口已经被其他进程占用了。排查和解决思路分两步先找出是谁占用了端口再决定是杀掉进程还是换端口。Linux 和 macOS 下查看端口占用lsof -i :3000Windows 下用netstat -ano | findstr :3000如果确认是不需要的进程直接杀掉kill -9 进程PID如果不想杀进程或者那个进程是起着的数据库、中间件之类不能停的服务那就换端口。用环境变量是最干净的方式PORT3001 npm startWindows PowerShell 下写法略有不同$env:PORT3001; npm start你也可以直接改 bin/www 里process.env.PORT || 3000的默认值但我不建议这么做因为把默认端口写死在代码里后续换环境部署就要再改一次代码。用环境变量覆盖项目本身不需要动。还有一个容易忽略的坑如果你想监听 80 端口普通用户在 Linux/macOS 下会直接报 EACCES 权限不足。别一上来就 sudo 启动 Node 进程更稳妥的做法是在生产环境用 Nginx 这类反向代理监听 80 端口再把请求转发到 Node 应用运行的 3000 端口上。5.3 其他常见启动问题与验证手段启动成功之后除了看首页还可以做几个快速验证一是验证接口路由。浏览器访问http://localhost:3000/users应该看到一段 JSON 数组说明路由挂载正常。二是验证静态资源。直接访问http://localhost:3000/stylesheets/style.css能返回 CSS 文件内容就说明静态中间件工作正常同时也能确认 public 目录路径映射没问题。三是验证 404 兜底。访问一个不存在的路径比如http://localhost:3000/xxx浏览器会显示错误页面状态码是 404。这个不起眼的验证很重要它确认了兜底中间件没有被错误配置干扰。用命令行工具验证响应头会更直观curl -I http://localhost:3000返回的响应头里能看到 HTTP 状态码、X-Powered-By: Express 等字段。开发模式下响应速度慢一点、日志打得多一点都很正常不需要过度优化。等到了生产环境再考虑关闭 debug 日志、压缩响应体、配置 gzip 等调优手段。调试场景还有个技巧启动脚本默认没有监听调试端口如果你习惯断点调试直接把 node 的 inspect 模式打开node --inspect ./bin/www然后用 Chrome 的调试工具连接对应端口就能在代码里打断点逐步看请求处理流程比硬塞 console.log 高效得多。npm start 是跑正常服务的调试时记得单独用这个命令。6. 从骨架到真实项目目录改造与进阶思路6.1 前后端分离时怎么裁剪生成结果现在很多项目的前后端是分离的后端只需要提供 API不需要渲染 HTML 页面。这种情况下骨架里的 views 其实成了多余的包袱。产出这种项目时我会建议直接用--no-viewexpress api-server --no-view --git生成结果里不会有 views 目录routes 下的接口代码用res.json()返回数据模板引擎相关的依赖也不会装。骨架会精简到 app.js、bin/www、routes、public、package.json 几个核心部分干净利落。如果你的项目已经是带视图的骨架想改造成纯 API需要动的地方也不多删掉 views 目录在 app.js 里删除两行视图配置把 routes 里所有res.render(...)改成res.json(...)最后删掉 package.json 里的模板引擎依赖。这套改造几分钟就能完成但执行前一定确认没有其他页面依赖模板解析否则删完页面直接炸。6.2 业务代码多起来之后的分层调整默认骨架的路由结构比较扁平routes 目录下每个路由文件直接写响应逻辑。这种写法在项目初期很舒服接口一多立刻变味——路由文件越来越长业务逻辑和请求处理混在一起改一个接口要上下翻几百行代码。我现在的做法是引入经典的 controller-service 分层routes 目录只做路由分发controller 负责请求参数处理和响应组装service 专注业务逻辑。一个例子说明routes/user.jsvar express require(express); var router express.Router(); var userController require(../controllers/userController); router.get(/profile, userController.getProfile); router.post(/update, userController.updateProfile); module.exports router;controllers/userController.jsexports.getProfile function(req, res, next) { // 参数校验、调用 service、组装响应 userService.getProfile(req.userId) .then(function(profile) { res.json({ code: 0, data: profile }); }) .catch(next); };services/userService.jsexports.getProfile function(userId) { // 和数据库打交道的逻辑都放这里 return db.query(SELECT ..., [userId]); };再加一层 models 目录放数据库模型这就是一个比较标准的后端项目分层了。骨架本身不给你预设这套结构但它保留了清晰的扩展边界——新增 controllers、services、models 目录不会破坏原有逻辑。项目规模到了需要分层的程度直接按这个模式往里加就行不用推翻重来。6.3 脚手架不会替你做的几件事坦白讲express-generator 生成的骨架比较传统它停留在 2019 年那个时代标准CommonJS 模块规范、views 目录约定、较少数量的内置中间件。你需要知道它的边界在哪里。数据库接入不包含。无论你打算用 MySQL、PostgreSQL 还是 MongoDB都需要自己安装对应的驱动或者第三方 ORM自己建连接池。骨架给了你一个干净的入口但数据存储方案得自己选。登录鉴权不包含。session、jwt、OAuth 这些统统没有。常见做法是引入 jsonwebtoken 或 express-session然后再封装一层认证中间件挂在需要保护的接口前面。这属于业务基础设施需要自己动手。环境变量管理不包含。虽然 bin/www 里读取process.env.PORT但项目整体的配置管理没有预设。我习惯装一个 dotenv项目根目录建 .env 文件把数据库连接串、密钥之类的放进去然后在 app.js 顶部require(dotenv).config()。这个习惯强烈建议从项目一开始就建立别等上线部署时才想起来。ESM 模块规范也不包含。骨架生成的是 CommonJS用 require 和 module.exports。如果你想让项目变成 import/export 语法可以在 package.json 里加type: module但要注意加完之后所有 .js 文件里的 require 都要改成 import这不是一个开关就能解决的切换。新项目可以考虑直接手搭一个 ESM 版本老项目建议保持 CommonJS 不动除非你有非常明确的理由要迁移。6.4 一条重要的经验别被生成的骨架框住脚手架生成的结构是一个好的起点但它不应该是终点。它给你的是大多数 Express 项目长这样的默认式答案而不是你的项目必须长这样的规范。实际业务里该删的演示路由就删该加的目录就加该改的中间件配置就改项目结构最终服务于团队协作和业务复杂度。比如骨架默认的 morgan 日志、cookie-parser 这些中间件如果你的纯 API 服务根本不用 Cookie完全可以去掉 cookie-parser 和它对应的依赖减少攻击面。反之如果接口需要跨域支持就得自己加 cors 中间件。等你对每个中间件的作用都门儿清的时候就已经吃透这个骨架了。我个人的习惯是生成完项目先做三件事——删掉演示用的 routes/users.js把 bin/www 里的默认端口改成从环境变量读取的方式然后补一个/health健康检查接口返回服务状态。这三件事做完项目才算从脚手架模板变成了我能直接开工的地基。以后不管项目怎么长大至少入口是干净的目录是明确的新人进来也能快速找到该改的地方。