做这类电商练手项目的人我见过太多了基本都是同一个路子找到一个商城系统模板把数据库导进去改个名就以为自己有了一个能上线的东西。结果别人一问你项目里购物车是怎么设计的库存扣减怎么保证不超卖当场卡壳。我这次复盘的这个基于 Node.js Vue 的小零食超市购物商城销售系统就是从零开始自己梳理清楚的一个完整项目前后端加起来不到三千行业务代码但该有的东西一样没少商品分类、SKU 规格、购物车、订单、会员登录、库存扣减。这篇文章我会把当时的技术选型、环境配置、前后端核心模块的落地思路和踩坑记录完整讲一遍希望能帮正在做同类系统的朋友少走点弯路。这套系统适合谁参考第一类是拿它当毕业设计或者课程项目的学生你需要的是一个能让老师追问下去的完整闭环而不是一个花架子。第二类是刚工作一两年、想用全栈项目证明自己能力的初级工程师你正好缺一个能用 Node.js 打通前后端所有环节的实战案例。我下面讲的都基于 Node.js 18 LTS Express Vue 3 Vite MySQL如果你用的是其他版本核心逻辑不变细节上自己微调就行。1. 为什么偏偏是 Node.js Vue而不是其他组合先说清楚一个事商城系统并不是电商公司专用的大型分布式项目。小零食超市这种业态有它的特点——SKU 数量大、单品价格低、用户决策路径短、复购率极高。这意味着你不需要去考虑秒杀、分布式事务、消息队列这些东西真正需要下功夫的是如何让用户快速选品、快速下单、结算不卡顿。在这个量级下Node.js Vue 的组合有天然优势。1.1 这类商城项目真正考验人的地方很多人做商城第一反应是先画库存表、商品表、订单表然后写 CRUD。但这类项目真正难的不是 CRUD是下面这几个点一是商品规格模型。零食超市的商品往往有多个维度口味原味、番茄味、烧烤味、净含量50g、100g、200g、包装形式袋装、盒装。一个乐事薯片在页面上是一张卡片在数据库里却是好几个 SKU 的聚合。这个模型做不好后面的购物车、订单、库存全都会跟着乱。二是购物车与会话的关系。用户是登录了才能加购物车还是不登录也可以加登录后购物车要不要和本地购物车合并这些都是真实业务中每天都要处理的问题。你这个项目只做浏览器本地版本购物车就能用 localStorage如果需要支持多设备同步购物车就必须放到后端。三是库存扣减的准确性。两个用户同时下单最后一个库存怎么办在前端管库存是肯定不行的必须在后端下单接口里保证原子性。我见过很多毕设级商城商品表字段琳琅满目但上述三个问题一个都没说清楚。理解了这个前提我们再来谈技术选型你才能明白我不是随便选了个热门组合而是这套组合真的能让这些问题以更低成本被解决掉。1.2 技术栈选型的底层逻辑Node.js 后端在解决上述问题时最大的好处是前后端语言同构。前端写 Vue 用的是 JavaScript后端的路由、控制器、数据校验也全用 JavaScript代码里类型结构是天然互通的。比如后端定义一个商品 SKU 的对象结构前端拿到 JSON 后不需要做二次转换直接把字段映射到页面组件里就行。对单人开发或者小团队开发来说这省掉的不只是切换上下文的时间出问题时的定位链路也短得多。有人会问那 Spring Boot Vue 不是更正规吗这个热搜词我看到了确实有不少项目这么用。但如果你的目标是快速把一个商城系统做出来并且讲清楚每一步Spring Boot 的重型框架反而会带来大量无意义的模板代码实体类、Mapper、Service 层、Controller 层代码量轻松翻倍。Node.js 这边用 Express 或者 Koa路由直接写在 controller 文件里一个中间件搞定鉴权几十行代码就能把接口全部串起来。当然不是无脑站队。如果你的项目未来要面对千人以上团队协作、重度并发事务、长期权益系统这些复杂场景Java 家族肯定更稳。但一个小零食超市购物商城销售系统——注意标题里的小字——用它来描述的场景Node.js 是足够且更高效的。1.3 商城分层架构拆解整个系统我分成三层前端展示层Vue 3 SPA负责商品列表、商品详情、购物车、下单结算、个人中心这些页面交互。后端 API 层Express 服务负责暴露 REST 接口包括用户注册登录、商品查询、购物车增删改查、创建订单、订单详情以及后台管理端的商品上下架和库存修改。数据存储层MySQL保存用户、商品、SKU、购物车、订单、订单明细这六类核心数据。开发时前端跑 5173 端口后端跑 3000 端口通过 Vite 的 proxy 代理把接口请求转发到后端这样就不存在跨域问题。生产环境我直接把前端 build 出来的 dist 目录挂到 Express 的静态目录下用同一个进程对外服务省一层 Nginx 的配置成本。对小项目来说这种部署方式最省心。2. 环境搭建阶段最容易让人劝退的三个坑我本来想直接从业务代码开始讲但看到热搜词里npm : 无法加载文件 d:\program files (x86)\nodejs\npm.ps1因为在此系统上禁止运行脚本出现了好几次就知道环境这关卡住了太多人。这里单独拿出来详细说因为这确实是最容易让人以为自己电脑有问题、其实几分钟就能解决的坎。2.1 npm.ps1 无法加载PowerShell 执行策略问题这个问题的症状很典型你在 VS Code 的终端里敲npm -v结果报了一长串红字大致意思是 npm.ps1 不是有效命令或者被禁止运行。为什么会有这个报错原因在于 Node.js 安装后npm 命令实际上被封装成了一个npm.ps1文件PowerShell 脚本版本而 Windows 默认的 PowerShell 执行策略是 Restricted啥脚本都不让跑。你明明把 Node.js 装对了环境变量也配了就因为这个策略挡着所有 npm 命令全部失灵。解决办法有三个按推荐程度排方法一管理员权限打开 PowerShell运行Set-ExecutionPolicy RemoteSigned这个命令会把执行策略改成本地脚本可运行远程脚本需要签名。npm.ps1是本地文件所以会被放行。执行完敲node -v和npm -v验证一下。这是最推荐的办法能一次性根治不用每次开终端都折腾。方法二如果不想动全局策略就避开 PowerShell 终端。在 VS Code 里把终端切换成 Command Prompt 或者 Git Bash这两个终端不需要走 PowerShell 执行策略。npm命令一样能跑。这个方法治标不治本但应急很有效。方法三还有一类情况是你电脑上装的 npm 路径带空格的文件夹比如d:\program files (x86)\nodejs\。如果前面两个方法都试了还报错排除一下路径问题。但以我见过的大量反馈来看90% 以上都是执行策略引起的路径只是背锅。2.2 Node.js 装了却找不到命令环境变量问题另一个热门搜索是nodejs环境变量配置。很多人下载了 Node.js 安装包双击安装一切顺利但关掉安装向导后打开命令行输入node -v提示不是内部或外部命令。这是因为他安装时没有把 Node.js 自动加入系统 PATH。新版 Node.js 安装器一路默认安装其实会自动配好 PATH出现找不到命令的情况多半是没走默认路径或者手动指定了目录却没有勾选 Add to PATH 选项。我的建议是安装的时候不要改路径直接 C 盘默认装实在要改务必在步骤里把 Add to PATH 勾上装完再重启一次终端。如果你已经装好了但就是找不到命令那就自己去配环境变量右键此电脑→ 属性 → 高级系统设置 → 环境变量。在系统变量里找到 Path把 Node.js 的安装目录比如C:\Program Files\nodejs\加进去。确定保存后新开一个终端再试。顺便提一下热搜词里还有windows如何升级nodejs版本。别再手动去官网下载新安装包覆盖了直接装一个 nvm-windows用nvm install 20、nvm use 20搞定版本切换不仅是升级方便遇到项目需要不同 Node 版本时也很实用。2.3 依赖安装慢或报错注册表源与版本匹配问题环境变量解决了第下一步就是安装依赖。国内直接跑npm install那个下载速度谁试谁知道而且经常因为网络问题导致依赖装了一半就红字报错。解决办法是换镜像源npm config set registry https://registry.npmmirror.com换完可以验证一下npm config get registry看到淘宝镜像地址就说明生效了。依赖装完过后创建 Vue 3 项目这一步我建议直接用官方脚手架npm create vuelatest这里要注意npm create vue创建出来的是 Vite Vue 3 的项目不是旧的 Vue CLI 项目。老教程里用vue create的教程基本是 Vue 2 时代的产物了结构已经不再推荐。脚手架会问你要不要集成 Vue Router、Pinia、ESLint 等统一回答 Yes 就好省得后期手动加。创建完npm install再跑npm run dev能打开页面你的开发环境就算彻底通了。这一步做完后面才是真正的业务开发。3. 后端订单和购物车才是这个系统的灵魂商城系统的后端说穿了是给前端提供数据接口但哪些接口之间是有依赖关系的、哪些操作必须保证要么全成功要么全失败这才是设计的关键。我按照数据模型、登录鉴权、购物车与下单三个环节来讲。3.1 数据表设计把 SKU 的概念单独拎出来小零食超市的商品有两个层级概念这里必须分清楚商品Product和 SKUStock Keeping Unit库存量单位。商品是用户看到的一件东西比如三只松鼠每日坚果 30 袋装SKU 是具体能卖的规格比如30 袋装/原味/750g对应一个价格和一堆库存。一张商品卡背后往往挂好几个 SKU。我在 MySQL 里建了六张核心表用户表userid、用户名、密码哈希、昵称、创建时间。分类表categoryid、分类名、父级分类 id、排序值。商品表productid、名称、主图、分类 id、描述、状态上架/下架、创建时间。商品规格表skuid、商品 id、规格名称比如原味50g、价格、库存、销量。购物车表cart_itemid、用户 id、SKU id、数量、选中状态、加入时间。订单表orderid、订单号、用户 id、总金额、状态待支付/已支付/已发货/已完成/已取消、收货信息、创建时间。订单明细表order_itemid、订单 id、SKU id、商品快照、单价、数量。order_item里为什么要存商品快照因为商品价格和名称是会变的如果订单明细细表直接关联product表半年后再看历史订单商品名可能已经改了价格也对不上账。把当时的名称、单价、图片 url 存进快照字段才能保证历史订单的数据是凝固的。这个细节很基础但很多项目都忽略了。3.2 登录鉴权JWT 的完整闭环用户系统不用复杂一个 JWT 就够了。注册接口接收用户名和密码密码用 bcryptjs 加盐哈希再入库绝不能明文存。登录接口校验密码通过后签发一个 JWT里面带上用户 id 和用户名设置 7 天过期时间返回给前端。后面所有需要身份的接口前端在请求头里带Authorization: Bearer token。后端写一个鉴权中间件const jwt require(jsonwebtoken); function authMiddleware(req, res, next) { const header req.headers[authorization] || ; const token header.startsWith(Bearer ) ? header.slice(7) : null; if (!token) { return res.status(401).json({ code: 401, msg: 未登录 }); } try { const decoded jwt.verify(token, process.env.JWT_SECRET); req.user decoded; next(); } catch { return res.status(401).json({ code: 401, msg: 登录已过期 }); } }中间件挂在购物车、下单、订单列表这些接口前面。访问/api/user/info时从req.user里取用户 id不用前端把用户 id 传过来。这个设计很关键否则任何人都可以伪造请求拿到别人的购物车和订单。3.3 购物车和下单逻辑事务和并发缺一不可先说购物车。登录用户的购物车我放在后端cart_item表里每次加购都是直接调 API。未登录用户我也允许加购这时前端把购物车数据放在 localStorage 里等用户登录后再发起一次合并请求把本地购物车的条目全部写入后端。为什么要这么设计零食电商的用户决策很轻如果强制登录才能加购转化率会掉一大截。但这套系统的核心场景是用户登录后操作购物车所以 localStorage 方案只是体验上的一个补充。下单接口是整个后端逻辑最核心的部分因为它涉及多表更新和并发问题。流程是接收请求带上收货人、电话、地址、购物车条目列表。后端根据 SKU id 列表查出当前库存。逐个判断请求数量是否超过库存任何一个超了就返回错误。生成订单号、订单记录、订单明细记录。更新 SKU 的库存和销量。清空购物车中已下单的条目。步骤 3 到 5 必须放在同一个数据库事务里。Express 本身不管事务我需要用 mysql2 的connection.beginTransaction()然后结合SELECT ... FOR UPDATE对 SKU 行加锁才能避免超卖const conn await db.getConnection(); try { await conn.beginTransaction(); const [rows] await conn.query( SELECT id, stock FROM sku WHERE id IN (?) FOR UPDATE, [skuIds] ); // 检查库存扣减库存插入订单 await conn.commit(); } catch (error) { await conn.rollback(); throw error; } finally { conn.release(); }FOR UPDATE在 InnoDB 引擎下会对命中的行加排他锁同一时间第二个请求就得等第一个事务提交后才能读到最新库存。这个机制用对了超卖至少在数据库层是被拦住的。很多人做商城把前端传数量后端直接UPDATE一下最高并发一压就出问题没人教过他们事务和行锁在这里是必须的。4. 前端把逛零食的感觉做出来后端能把数据存起来、接口调通项目的骨架就有了。但商城好不好用、展示顺不顺眼全看前端。Vue 3 这个部分我用的是组合式 API 加script setup项目结构上按views / components / router / stores / api来组织。4.1 路由组织页面划分和懒加载一个零食商城的用户路径其实非常短首页逛 → 点进商品详情 → 加购物车 → 结算。管理端则相对独立。所以路由我分成两块client路由和管理端路由。客户端路由{ path: /, component: () import(/views/Home.vue) }, { path: /category/:id, component: () import(/views/Category.vue) }, { path: /product/:id, component: () import(/views/ProductDetail.vue) }, { path: /cart, component: () import(/views/Cart.vue) }, { path: /checkout, component: () import(/views/Checkout.vue), meta: { requiresAuth: true } }, { path: /orders, component: () import(/views/OrderList.vue), meta: { requiresAuth: true } }这里用了动态路由。/category/:id和/product/:id是典型的路由参数组件内通过route.params.id读取当前分类或商品 id。meta.requiresAuth字段用来做路由守卫在router.beforeEach里判断有没有登录 token没登录就重定向到登录页。你们做商城系统这个守卫应该是标配千万不能只依赖按钮级判断。所有页面组件都用() import()懒加载。零食商城页面多、图片多如果把所有页面都打进一个 chunk首屏体积会很大懒加载按路由拆分 chunk首屏只加载首页资源体验会好不少。4.2 商品规格选中与 SKU 联动商品详情页的核心组件是规格选择器。零食商品最常见的是两个维度比如口味和重量每个维度有几个选项用户必须把所有维度选齐才能确定一个 SKU。我在ProductDetail.vue里维护两个数据结构const specs ref([]); // [{ name: 口味, values: [原味, 辣味, 番茄] }] const selectedSpecs ref({}); // { 口味: 原味, 重量: 50g }当用户点击某个规格值时先判断这个组合有没有对应 SKU。最简单的方式是前端拿到商品详情时把后端返回的skuList转成一个 mapkey 是各规格值的组合串value 是 sku 对象const skuMap {}; skuList.forEach(sku { const key sku.specText; // 后端拼接好的比如 原味|50g skuMap[key] sku; });用户选完后拼接当前selectedSpecs的 values去skuMap里查查到了就把价格、库存、skuId 展示出来查不到就是此组合暂时缺货。这个做法的好处是复杂度很低没有复杂的排列组合计算适合多规格但规模有限的小零食商城。等你做服装类那种几十个规格属性的项目再考虑专门的 SKU 矩阵算法。4.3 购物车交互与状态管理前端购物车我用 Pinia 管理因为购物车数据要同时被导航栏角标、购物车页面、结算页这三个地方用到如果再在各组件之间用 props 和事件传来传去那真是自找麻烦。store/cart.js的核心结构export const useCartStore defineStore(cart, { state: () ({ items: [] }), getters: { totalCount: (state) state.items.reduce((sum, item) sum item.count, 0), selectedItems: (state) state.items.filter(item item.checked), totalPrice: (state) state.items .filter(item item.checked) .reduce((sum, item) sum item.price * item.count, 0) }, actions: { addItem(sku) { const exist this.items.find(i i.skuId sku.id); if (exist) { exist.count; } else { this.items.push({ skuId: sku.id, name: sku.name, price: sku.price, count: 1, checked: true }); } } } });注意几个细节。数量加减要设下限 1上限是 SKU 剩余库存提交订单前再让后端做最终校验。零食按小件走数量和一个一个加挺合理但如果你以后卖的是整箱饮料单品加购数量限制就要考虑以箱为单位之类的问题。这些都要根据商品类型定。加购之后我除了 toast 提示还会做一个小动画商品卡片上的加入购物车按钮点击后一个缩略的图标飞向导航栏购物车角标位置同时导航栏角标数字做一次弹跳动画。这个不算难一个绝对定位元素加 CSS 过渡就行但对逛零食这种场景来说这个小细节会让整个页面显得活泼很多。热搜词里有人搜vue 组合式和选项式混合开发我自己的建议是购物车这种共享状态用组合式 Pinia组件内部如果逻辑简单就用选项式混着用没有原则问题关键是要让别人能看懂。5. 前后端联调、打包部署与桌面端扩展前后端都写完了最后一步是把它变成一个真正能访问、能演示、能部署的东西。这个环节的技术含量不高但是细节多一步没配好就是一个红屏报错。而且我观察到electron打包vue项目、springboot vue前后端分离这些热词一直有人搜说明大家做到这步多少都有卡壳。5.1 跨域与开发代理开发环境的连通方案开发时前端在 5173 端口后端在 3000 端口如果前端直接 fetchhttp://localhost:3000/api/...浏览器就会因为跨域拦截导致请求失败即使后端开了 CORS 也有大量额外配置要做。最简单的方案是 Vite 的 dev server 代理。在vite.config.js里配export default defineConfig({ server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } });配置完成后前端请求/api/product/list就会在本地 dev server 层面转发到http://localhost:3000/api/product/list。前端代码里只写相对路径/api/xxx不要写http://localhost:3000这样就算是换到生产环境代码也不用改第二遍。另外后端 Express 里别忘了挂cors中间件做兜底因为你不知道会不会有别的调试工具直接请求后端接口。5.2 打包与生产部署一个进程搞定一切生产部署我选择了最省事的一种Express 直接托管前端静态资源。前端执行npm run build后生成dist目录把它整个放进后端项目的根目录下然后 Express 加三行代码const path require(path); app.use(express.static(path.join(__dirname, ../dist))); app.get(*, (req, res) { res.sendFile(path.join(__dirname, ../dist/index.html)); });app.get(*)这一条很关键它保证用户在浏览器里直接刷新/product/10这样的前端路由时后端会把index.html返回给他由前端路由接管页面渲染。如果不加这个 fallback直接刷新详情页就是一个 404。这是前后端分离项目最容易忽略的坑。后端进程管理我用 PM2。首先生态文件ecosystem.config.jsmodule.exports { apps: [{ name: snack-mall, script: ./server/index.js, instances: 1, autorestart: true, env: { NODE_ENV: production, PORT: 3000 } }] };然后pm2 start ecosystem.config.js就行了。instances为什么只开 1因为小零食商城当前的并发量级单实例远远够用而 Node.js 的单线程模型处理这种 I/O 密集型 API 已经游刃有余。多开实例反而要处理数据同步问题没必要。等哪天真有几千并发再加小负载均衡也不晚。数据库配置、JWT 密钥这些敏感信息一定要放在.env文件里用process.env.PORT这种形式读取不要硬编码在源码里。我见过太多人把数据库密码直接写进 index.js 然后整个项目传 GitHub 的这个习惯很危险。5.3 用 Electron 把商城变成收银台一个有意思的扩展热搜词里electron打包vue项目出现率不低。我后来确实给这套商城套了一个 Electron 壳做成一个桌面版收银端在超市前台跑收银。这里简单说下落地思路毕竟很多人卡在Vue 打包后怎么和 Electron 整合这个问题上。我的做法是用 electron-builder入口 HTML 文件直接指向 Vue 打包出来的dist/index.html。关键点是主进程和渲染进程的通信渲染进程Vue 页面负责界面交互比如展示购物车、计算总价、扫码录入商品。主进程负责和系统层打交道比如读取 USB 扫码枪数据、调用本地小票打印机、保存对账单。两者之间用ipcRenderer和ipcMain通信。// preload.js 中暴露安全的桥接方法 const { contextBridge, ipcRenderer } require(electron); contextBridge.exposeInMainWorld(printer, { printReceipt: (orderData) ipcRenderer.invoke(print-receipt, orderData) });这里有个安全红线不要直接在渲染进程里用 Node.js 原生的 require 和 Electron APIElectron 新版本默认contextIsolation是开启的渲染进程拥有独立上下文必须通过 preload 暴露白名单 API。这样即使前端页面被注入了恶意脚本它也拿不到本地系统的文件操作权限。用 Electron 壳封装这套商城系统能获得的最大价值是它不需要浏览器双击就开而且能直接操作本地硬件。缺点是打包体积大、内存占用偏高所以它更适合收银台这种专用设备场景不适合做 C 端在线商城的常规形态。6. 联调阶段的高频报错与定位思路最后这部分是我在实际做这个项目时反复遇到、也帮别人排查过的一批报错列成清单。现象根本原因排查思路前端请求/api报 404Vite 代理没生效或后端路由没有挂到/api前缀先直接请求后端 3000 端口看有没有返回再看 dev 控制台有没有代理转发日志请求成功但登录接口报 500数据库表字段和后端代码字段不一致或密码哈希函数出错看后端 console 堆栈用日志中间件打印 SQL 语句部署后刷新详情页 404Express 缺少 SPA fallback检查静态目录托管和app.get(*)是否已配置方案导出订单时商品名是乱码MySQL 连接字符集没设为 utf8mb4在 mysql2 连接配置里加charset: utf8mb4登录后购物车数据丢失localStorage 购物车没有在登录后合并后端购物车在登录接口成功后调用mergeCart接口更新库存后商品详情价格没变SKU 价格改到商品主表而不是 sku 表检查后端更新逻辑到底更新了哪张表价格查询走 sku 表定位问题有个最基本的顺序先看浏览器 Network 面板里请求到底出去没有、响应到底是什么再看后端 console 有没有输出最后才考虑是不是代码逻辑问题。我见过很多朋友一报错就打开源文件反复看其实 80% 的情况下报错信息已经把你引到根因了你只需要把日志看全。上面这一步里有一个高频坑我多说一句下载vue、vue安装依赖等热词背后还有一个隐藏问题很多人在前端环境完全没搭好的情况下就去写业务代码结果就是分不清自己的语法错误和环境错误。所以如果报错很怪先npm run dev看能否正常起服务起不来的话基本是依赖缺失node_modules删了重装一遍就好了这个方法听着粗暴但真的能治好大部分玄学问题。我个人在实际操作中还有一个体会做这种全栈项目一定要先定好数据库字段再动手写前端页面。我最早是边写前端边改后端字段结果就是前端联调时一堆字段对不上来回改接口。后来学乖了先画出一张接口文档表把每个接口的路径、入参、出参字段列清楚前端照着写后端照着实现联调效率一下子翻倍。如果你也要上手这类系统这个建议值得你花十分钟照做。