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 和对应哪个 controller,controllers 只管业务逻辑和参数校验,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 必须做边界校验。如果用户传了page=0或者size=-5,offset 会变成负数,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 最小是 1,Math.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/list,Vite 会自动转发到http://localhost:3000/api/user/list。changeOrigin: true的作用是把请求头里的 host 改成目标地址,某些后端框架会校验这个,不加可能被拒。
注意:代理只在开发阶段生效,打包后的静态文件是没有代理的。生产环境要么让 Express 托管前端文件,要么用 Nginx 做反向代理,这个后面会讲。
4.3 组件里怎么优雅地调接口
有了封装好的 request,组件里调接口就很干净了。以用户列表页为例:
<template> <div> <table> <tr v-for="item in list" :key="item.id"> <td>{{ item.username }}</td> <td>{{ item.created_at }}</td> </tr> </table> <button @click="prevPage" :disabled="page === 1">上一页</button> <span>第 {{ page }} 页</span> <button @click="nextPage">下一页</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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;created_at和updated_at用数据库自动维护,比在代码里手动赋值可靠得多。status字段做软删除或状态标记,比直接物理删除安全。ENGINE=InnoDB是必须的,因为只有它支持事务和外键,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_HOST=localhost DB_PORT=3306 DB_USER=your_user DB_PASSWORD=your_password DB_NAME=your_db JWT_SECRET=your_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(); });把它放在所有中间件的最前面,每个请求的完整生命周期就都在你眼皮底下了。