Node.js + Express + Vue + MySQL 全栈开发实战:从架构设计到部署的完整指南
发布时间:2026/10/21 16:06:15 锦皓数字建站

1. 这套技术栈到底适合做什么为什么我最终选了它先把结论摆在前面Node.js Express Vue MySQL axios 这套组合是我在中小型全栈项目里用得最顺手、踩坑最少的一套方案。它不追求极致性能也不搞花哨的架构但它有一个特别实在的优点——前后端语言统一心智负担低一个人就能把整个项目从数据库一路写到页面交互。我最早接触这套栈是因为一个内部管理系统的需求。当时团队只有两个人要求两周内出一个能跑的后台包含登录、权限、列表增删改查、数据导出。如果用 Java 那一套光是 Spring 的配置和 Maven 依赖就能耗掉两三天换成这套 Node 全栈第一天就把数据库和接口跑通了第三天前端页面就能联调。这不是说 Java 不好而是在快速交付、人手有限、需求还会变的场景下这套栈的性价比极高。这套组合里每个角色分工很明确。Node.js 是运行时让 JavaScript 能跑在服务端Express 是 Web 框架负责路由、中间件、请求响应Vue 管前端视图和交互MySQL 存数据axios 则是前端发请求的桥梁。你可以把它理解成一家餐厅Node 是厨房的场地Express 是厨师长负责接单派活MySQL 是仓库Vue 是前厅的装修和菜单axios 就是服务员把客人的需求端到厨房、再把菜端回来。适合谁来参考如果你是会一点 JavaScript、想独立做一个完整项目的前端或后端新手这套栈是最平滑的入门路径。如果你是有经验但没做过 Node 全栈的开发者这篇文章里的目录结构、参数配置和踩坑记录能帮你省掉大量试错时间。如果你只是想知道为什么大家都这么搭我也会把每个选型背后的取舍讲清楚。需要提前说明的是下面涉及的所有目录名、项目名、配置值都是我在实际项目里用过的通用写法你可以直接替换成自己的。文中不会出现任何真实公司或项目信息全部用代称处理。2. 项目整体架构设计与目录规划2.1 前后端分离的边界到底怎么划很多人一上来就问前后端分离是不是必须的我的答案是这套栈天然就是分离的但分离的粒度要你自己定。所谓分离核心就一句话——前端只负责展示和交互后端只负责数据和逻辑两边通过 HTTP 接口通信谁也不碰谁的代码。具体到这套栈前端 Vue 项目跑在一个端口比如 5173后端 Express 跑在另一个端口比如 3000。开发阶段前端通过 axios 直接请求后端地址生产阶段则把 Vue 打包成静态文件交给 Express 或者 Nginx 托管。这个边界划清楚之后好处非常明显前端改样式不用重启后端后端改接口不用重新打包前端两个人可以并行开发。但边界划得太死也有代价。我见过有团队把分离理解成前端绝对不能碰后端逻辑结果一个简单的字段格式化都要后端专门加接口效率反而低。我的经验是展示层的格式化比如日期显示、金额千分位放前端业务规则和数据校验比如权限判断、库存扣减放后端。这条线守住基本不会出问题。2.2 目录结构怎么设计才不混乱目录结构这件事新手最容易犯的错是想到哪写到哪最后所有文件堆在一个文件夹里。我推荐下面这种按职责分层的结构后端和前端分开管理project-root/ ├── server/ # 后端 │ ├── app.js # Express 入口 │ ├── config/ │ │ └── db.js # 数据库连接池配置 │ ├── routes/ # 路由层 │ │ ├── user.js │ │ └── product.js │ ├── controllers/ # 控制器层处理业务逻辑 │ │ ├── userController.js │ │ └── productController.js │ ├── models/ # 数据访问层写 SQL │ │ ├── userModel.js │ │ └── productModel.js │ ├── middlewares/ # 中间件如鉴权、日志 │ │ └── auth.js │ └── utils/ # 工具函数 │ └── response.js ├── client/ # 前端 │ ├── src/ │ │ ├── main.js │ │ ├── App.vue │ │ ├── api/ # axios 封装 │ │ │ └── request.js │ │ ├── views/ # 页面 │ │ ├── components/ # 组件 │ │ └── router/ # 路由 │ └── vite.config.js └── package.json这里要重点说下后端的分层。routes 只管定义 URL 和对应哪个 controllercontrollers 只管业务逻辑和参数校验models 只管拼 SQL 和操作数据库。为什么要分这么细因为一旦业务变复杂比如创建订单要同时扣库存、写日志、发通知如果全塞在路由里代码会变成一坨。分层之后每层职责单一改起来心里有数。提示新手常把 SQL 直接写在 controller 里短期看省事但一旦要换数据库或者复用查询逻辑就会非常痛苦。哪怕项目再小也建议把 SQL 抽到 models 层。2.3 为什么用连接池而不是单连接数据库连接这块我强烈建议一开始就用连接池别用单连接。原因很简单单连接意味着同一时刻只能处理一个数据库请求一旦并发上来后面的请求全在排队页面就会卡。连接池则是预先建好一批连接谁需要谁拿去用用完还回来。在 MySQL 的 Node 驱动里连接池配置大概是这样// server/config/db.js const mysql require(mysql2); const pool mysql.createPool({ host: localhost, port: 3306, user: your_user, password: your_password, database: your_db, waitForConnections: true, connectionLimit: 10, // 连接池最大连接数 queueLimit: 0, // 排队上限0 表示不限制 charset: utf8mb4 // 支持 emoji 和完整中文 }); module.exports pool.promise();connectionLimit设多少合适我的经验是小型项目 10 到 20 就够中型项目可以到 50。设太大反而会拖垮数据库因为每个连接都占内存。charset一定要用utf8mb4用utf8的话用户输入 emoji 或者某些生僻字会直接报错这个坑我踩过不止一次。3. 后端核心环节Express 接口从零到能用3.1 入口文件该写哪些东西Express 的入口文件是整个后端的门面写得好不好直接影响后续维护。我的习惯是入口文件只做四件事加载中间件、挂载路由、统一错误处理、启动服务。业务逻辑一行都不写。// server/app.js const express require(express); const cors require(cors); const userRoutes require(./routes/user); const productRoutes require(./routes/product); const app express(); // 解析 JSON 请求体 app.use(express.json()); // 解析表单请求体 app.use(express.urlencoded({ extended: true })); // 跨域开发阶段用 app.use(cors()); // 挂载路由 app.use(/api/user, userRoutes); app.use(/api/product, productRoutes); // 统一错误处理必须放在所有路由之后 app.use((err, req, res, next) { console.error(err.stack); res.status(500).json({ code: 500, msg: 服务器内部错误 }); }); app.listen(3000, () { console.log(服务已启动监听 3000 端口); });这里有个细节很多人忽略错误处理中间件必须放在所有路由之后而且必须是四个参数err, req, res, next。少一个参数 Express 就不认会当成普通中间件处理错误就捕获不到。我第一次写的时候就是因为只写了三个参数结果接口报错直接让进程崩了排查了半天。3.2 路由、控制器、模型三层怎么配合拿一个获取用户列表的接口举例看看三层是怎么串起来的。路由层只负责把 URL 和控制器方法对应起来// server/routes/user.js const express require(express); const router express.Router(); const userController require(../controllers/userController); router.get(/list, userController.getUserList); router.post(/add, userController.addUser); module.exports router;控制器层负责接收参数、调用模型、返回结果// server/controllers/userController.js const userModel require(../models/userModel); exports.getUserList async (req, res, next) { try { const page parseInt(req.query.page) || 1; const size parseInt(req.query.size) || 10; const offset (page - 1) * size; const list await userModel.findList(offset, size); const total await userModel.countAll(); res.json({ code: 0, data: { list, total } }); } catch (err) { next(err); // 交给统一错误处理 } };模型层只负责拼 SQL 和查询// server/models/userModel.js const pool require(../config/db); exports.findList async (offset, size) { const [rows] await pool.query( SELECT id, username, created_at FROM users LIMIT ? OFFSET ?, [size, offset] ); return rows; }; exports.countAll async () { const [rows] await pool.query(SELECT COUNT(*) AS total FROM users); return rows[0].total; };注意LIMIT ? OFFSET ?这里用的是占位符不是字符串拼接。所有涉及用户输入的 SQL 都必须用占位符这是防注入的底线。我见过有人图省事直接拼字符串结果被一个带引号的参数把整张表删了这种事故一次就够记一辈子。3.3 分页参数怎么算才不出错分页是列表接口的标配但参数算错会导致数据重复或丢失。核心公式就一个offset (page - 1) * size。比如每页 10 条第 1 页 offset 是 0第 2 页是 10第 3 页是 20以此类推。这里有个容易忽略的点page 和 size 必须做边界校验。如果用户传了page0或者size-5offset 会变成负数MySQL 直接报错。我的做法是在控制器里加一层保护const page Math.max(parseInt(req.query.page) || 1, 1); const size Math.min(Math.max(parseInt(req.query.size) || 10, 1), 100);Math.max(..., 1)保证 page 最小是 1Math.min(..., 100)保证 size 最大不超过 100防止有人一次拉一万条把数据库拖垮。这种防御性写法看着啰嗦但线上出问题时能救命。3.4 统一响应格式的价值后端返回的数据格式一定要统一否则前端处理起来会疯掉。我用的格式是{ code, msg, data }code 为 0 表示成功非 0 表示各种错误。这样前端只需要判断 code 就能决定是渲染数据还是弹错误提示。// server/utils/response.js exports.success (res, data null, msg ok) { res.json({ code: 0, msg, data }); }; exports.fail (res, msg 操作失败, code 1) { res.json({ code, msg, data: null }); };为什么要统一因为一旦格式不统一前端每个接口都要写不同的解析逻辑维护成本极高。统一之后axios 的响应拦截器里写一次判断全项目通用。这个设计我在每个项目里都会保留属于前期花十分钟、后期省十天的投入。4. 前端核心环节Vue 与 axios 的配合4.1 axios 封装一次配置全局受益axios 直接用也能跑但每个请求都写一遍 baseURL、超时、token 携带纯属重复劳动。正确做法是封装一个 request 实例把公共配置和拦截器都放进去。// client/src/api/request.js import axios from axios; const request axios.create({ baseURL: /api, // 开发阶段配合代理生产阶段配合 Nginx timeout: 10000 // 10 秒超时 }); // 请求拦截器统一带上 token request.interceptors.request.use( config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }, error Promise.reject(error) ); // 响应拦截器统一处理业务错误 request.interceptors.response.use( response { const res response.data; if (res.code ! 0) { // 业务错误弹提示 console.error(res.msg); return Promise.reject(new Error(res.msg)); } return res.data; // 直接返回 data页面里少写一层 }, error { if (error.response error.response.status 401) { // 未登录跳登录页 localStorage.removeItem(token); window.location.href /login; } return Promise.reject(error); } ); export default request;这里有个设计选择值得说响应拦截器里我直接返回了res.data而不是整个res。这样页面里调用时就是const list await getUserList()不用再写res.data.data。少一层嵌套代码清爽很多。代价是如果某个接口需要拿响应头信息就得单独处理但这种情况很少见。4.2 开发阶段的跨域代理怎么配开发阶段前端跑在 5173后端跑在 3000浏览器会因为端口不同触发跨域限制。解决办法有两种后端开 CORS或者前端配代理。我推荐开发阶段用代理生产阶段用 Nginx因为代理能让前端请求看起来是同源的省去很多麻烦。Vite 的代理配置// client/vite.config.js import { defineConfig } from vite; import vue from vitejs/plugin-vue; export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } });配好之后前端请求/api/user/listVite 会自动转发到http://localhost:3000/api/user/list。changeOrigin: true的作用是把请求头里的 host 改成目标地址某些后端框架会校验这个不加可能被拒。注意代理只在开发阶段生效打包后的静态文件是没有代理的。生产环境要么让 Express 托管前端文件要么用 Nginx 做反向代理这个后面会讲。4.3 组件里怎么优雅地调接口有了封装好的 request组件里调接口就很干净了。以用户列表页为例template div table tr v-foritem in list :keyitem.id td{{ item.username }}/td td{{ item.created_at }}/td /tr /table button clickprevPage :disabledpage 1上一页/button span第 {{ page }} 页/span button clicknextPage下一页/button /div /template script setup import { ref, onMounted } from vue; import request from /api/request; const list ref([]); const page ref(1); const size 10; const fetchList async () { const data await request.get(/user/list, { params: { page: page.value, size } }); list.value data.list; }; const prevPage () { if (page.value 1) { page.value--; fetchList(); } }; const nextPage () { page.value; fetchList(); }; onMounted(fetchList); /script用ref而不是reactive存列表是因为列表整体替换时ref更直观。onMounted里首次拉数据翻页时重新拉。这套写法在中小型列表页里够用了不需要引入 Pinia 之类的状态管理除非多个页面共享同一份数据。5. 数据库设计与联调中的关键细节5.1 建表时容易忽略的几个字段建表这件事新手往往只关注业务字段忽略了几个通用字段后期补起来很麻烦。我的习惯是每张表都带上这几个CREATE TABLE users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, status TINYINT DEFAULT 1 COMMENT 1正常 0禁用, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;created_at和updated_at用数据库自动维护比在代码里手动赋值可靠得多。status字段做软删除或状态标记比直接物理删除安全。ENGINEInnoDB是必须的因为只有它支持事务和外键MyISAM 早就不推荐了。密码字段长度给到 255是因为现在推荐用 bcrypt 之类的算法加密加密后的字符串很长给 50 或 100 会截断。这个坑我见过有人踩注册时好好的登录时怎么都对不上最后发现是字段长度不够把哈希截断了。5.2 联调时接口对不上的排查顺序前后端联调最烦的就是我这边明明没问题。我的排查顺序固定是四步先看浏览器 Network 面板的请求 URL 对不对再看请求参数格式对不对然后看后端有没有收到请求最后看返回的数据结构对不对。第一步看 URL常见问题是代理没生效或者 baseURL 配错请求打到了 5173 而不是 3000。第二步看参数常见问题是 GET 请求参数没放在params里或者 POST 请求没设Content-Type。第三步看后端日志如果后端压根没打印请求日志说明请求根本没到后端问题在前端或代理。第四步看返回结构常见问题是后端返回{ code, data }前端却按data.list取实际应该是data.data.list——这也是为什么我在拦截器里直接返回res.data。把这四步走一遍九成的联调问题都能定位。剩下那一成多半是环境问题比如数据库没启动、端口被占用这种看报错信息就能判断。5.3 环境变量怎么管理才安全数据库密码、密钥这类敏感信息绝对不能硬编码在代码里更不能提交到代码仓库。正确做法是用环境变量配合.env文件管理。# server/.env DB_HOSTlocalhost DB_PORT3306 DB_USERyour_user DB_PASSWORDyour_password DB_NAMEyour_db JWT_SECRETyour_secret_key然后在代码里用dotenv加载require(dotenv).config(); const pool mysql.createPool({ host: process.env.DB_HOST, // ... });.env文件必须加到.gitignore里同时提供一个.env.example模板给协作者参考。我见过有人把.env提交上去结果数据库密码泄露虽然是小项目没造成大损失但这个习惯必须从第一天就养成。6. 常见问题与排查技巧实录6.1 高频问题速查表下面这张表是我这些年攒下来的高频问题清单基本覆盖了这套栈八成的坑问题现象可能原因排查方向接口 404路由路径拼错或代理未生效看 Network 请求 URL对比后端路由定义跨域报错开发未配代理或后端未开 CORS检查 vite proxy 或 cors 中间件数据库连接失败账号密码错或服务未启动用命令行先连一次数据库验证中文乱码字符集不是 utf8mb4检查建表语句和连接配置请求体为空未加 express.json()检查入口中间件顺序token 失效不跳转响应拦截器未处理 401检查拦截器逻辑分页数据重复offset 计算错误核对 (page-1)*size 公式打包后接口 404生产环境未配代理检查 Nginx 或 Express 静态托管这张表建议存下来出问题时先对号入座能省不少时间。6.2 几个只有踩过才知道的坑第一个坑是中间件顺序。express.json()必须放在路由之前否则req.body永远是空对象。我有次调了半天接口参数怎么都传不进去最后发现是中间件写在了路由后面。Express 的中间件是自上而下执行的顺序错了功能就废了。第二个坑是异步错误捕获。Express 4 不会自动捕获 async 函数里抛出的错误必须手动 try/catch 然后next(err)。如果忘了写错误会变成未处理的 Promise rejection进程可能直接挂掉。Express 5 据说会改善这点但用 4 的时候必须自己兜住。第三个坑是Vue 的响应式丢失。用reactive定义的对象如果整个替换掉响应式会失效。比如state.list newArray在reactive里是可以的但如果解构出来用就会丢响应式。我的建议是列表数据一律用ref省心。第四个坑是生产环境的前端路由刷新 404。Vue 用的是 history 模式用户刷新/user/list这种路径时服务器会去找这个文件找不到就 404。解决办法是在 Nginx 里配try_files $uri $uri/ /index.html把所有找不到的路径都指回 index.html交给前端路由处理。6.3 生产部署的简化方案小项目不一定非要上 Nginx用 Express 直接托管前端打包文件也行const path require(path); app.use(express.static(path.join(__dirname, ../client/dist))); // 所有非 API 请求都返回 index.html app.get(/^\/(?!api).*/, (req, res) { res.sendFile(path.join(__dirname, ../client/dist/index.html)); });这样部署时只需要启动一个 Node 进程前端后端一起跑特别适合个人项目或者内部系统。等访问量上来了再换成 Nginx 做静态托管和负载均衡也不迟。架构要跟着规模走别一上来就搞复杂这是我做了这么多项目最深的体会。7. 我在实际项目里的一些体会这套栈我用了好几年最大的感受是它的价值不在于每个组件有多强而在于组合起来的心智成本足够低。前后端都是 JavaScript数据格式都是 JSON调试时不用在两种语言和两套思维之间来回切换。对于独立开发者和小团队来说这种顺手比任何性能指标都重要。如果让我给刚上手的人一句建议那就是先把一条完整的链路跑通再考虑优化。从数据库建一张表到 Express 出一个接口到 Vue 页面调通显示数据这条链路走通了剩下的都是在这条链路上加东西。很多人卡住不是因为技术难而是想一次性把所有东西都搞完美结果哪一步都没走完。最后分享一个我常用的调试技巧在 Express 里加一个全局请求日志中间件把每个请求的方法、路径、参数、耗时都打出来。联调时前端说我发了请求后端说我没收到有这个日志一眼就能看出请求到底到没到、参数对不对。这个中间件不到十行代码但能省掉大量扯皮时间。app.use((req, res, next) { const start Date.now(); res.on(finish, () { console.log(${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - start}ms); }); next(); });把它放在所有中间件的最前面每个请求的完整生命周期就都在你眼皮底下了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。