Node.js+Vue3自研电子产品商城系统实战指南
最近好几个朋友问我手头有个电子产品销售商城的需求到底该直接用开源的商城系统还是干脆自己从头搭一套。我的建议很明确如果只是做个简单展示站开源商城够用但如果你卖的是电子产品尤其是手机、笔记本这类SKU非常复杂的品类那自研一套基于Nodejs Vue3的商城系统反而是一条更省心、更能掌控全局的路线。我自己前后折腾过两条路先用过现成的商城框架后来因为电子产品的规格属性太特殊——同一款手机有不同颜色、内存、存储版本、渠道价格标准商城系统的商品模型根本接不住最后被迫重写了一套属于自己的商城系统。这篇文章就是我基于当时的真实落地过程整理的适合有一定Vue基础、想补齐Nodejs后端能力、或者准备从零做一个可上线商城项目的人阅读。里面不会只给代码片段更会讲清楚每一步为什么要这么设计以及在联调、部署阶段那些文档里查不到的坑。1. 为什么是Nodejs Vue3商城技术栈选型的真实考量很多人一上来就问用什么框架其实最该先想清楚的是需求边界。我在之前的文章里聊过一个观点技术选型的本质是用合适的复杂度解决合适的问题。对于一个电子产品销售商城来说它跟卖衣服、卖食品的商城有本质区别这个区别直接决定了后端和前端的技术形态。1.1 开箱即用商城系统与自研的边界在哪市面上的开源商城系统不少Java系的CRMEB、PHP系的微商城系统都挺成熟。我最初也评估过直接用这些系统来改但在拆解需求后发现三个非常现实的问题。第一个是商品模型僵化。电子产品的核心属性是规格参数比如一台手机的颜色、内存、存储空间、是否支持5G、处理器型号、摄像头像素这些参数不仅数量多而且层级关系复杂。标准商城系统的商品表普遍是SPU 通用属性结构可以加参数但加出来的参数是扁平的无法像电子产品那样做到多级规格联动——比如你选了一个颜色库存和价格都要跟着变。第二个是价格体系僵化。电子产品的价格不是单一售价而是有官网价、渠道价、活动价、会员价等多层价格而且价格和供应链强相关需要频繁调整。现成系统的价格逻辑通常是写死的改起来牵一发动全身。第三个是二次开发的隐性成本。很多开源商城系统为了保证通用性会在上层堆一大堆扩展表和配置项你每改一个需求都得先去读它的源码、理解它的设计意图这个成本往往比从零开始写一套还高。所以我的判断是如果你的商城要走功能定制 垂直品类深耕的路线自研是更可控的选择。Nodejs Vue3这个组合在这个场景下性价比非常高。1.2 Nodejs后端与Vue3前端的配合逻辑选这套技术栈我并不是因为它新或者热门而是因为它在商城这个场景下有几个实打实的优势。首先前后端同用一种语言。商城系统的核心流程是用户选商品 → 加购物车 → 下单 → 结算这个流程里前端要管理大量订单状态、购物车状态后端要管理商品数据、库存数据和订单数据。如果前后端都用JavaScript那么定义在接口层的DTO结构、枚举值、状态码就可以直接复用不用在两套语言之间来回翻译。我之前用Java写后端、用Vue写前端的时候最痛苦的就是前端定义一个订单状态枚举后端也要同步维护一份两边一旦不同步就出现莫名其妙的状态错乱。其次Vue3的组合式API非常适合商城这种多状态联动的场景。购物车有选中态、数量、金额计算、优惠计算订单有草稿态、待支付、已支付、已发货、已完成等状态。如果用Vue2的Options API这些逻辑会散落在data、methods、computed各个地方代码一长就很难理清。而Vue3的setup语法和组合式函数Composables可以把购物车相关的所有状态和操作收敛到一个模块里后续维护起来非常顺手。再者Nodejs的生态让后端开发效率非常高。商城后端需要处理RESTful API、文件上传、支付回调、邮件通知、定时任务这些在npm生态里都有非常成熟的库。Express框架本身虽然小而轻但配合各种中间件已经完全够用。2. 环境搭建阶段最容易翻车的三个地方很多教程会把环境搭建一笔带过但我在实际帮人排查问题时发现商城项目的前期卡壳几乎都集中在环境这一关。而且电商项目对依赖版本敏感Nodejs版本、npm版本、Vue脚手架版本任何一个对不上都会引发诡异报错。这里我把实测中最关键的三点拎出来单独讲。2.1 Nodejs的版本选择与Windows安装要点是的第一步就有人踩坑。Nodejs官网提供两个大版本线Current当前版本通常是单号版本号和LTS长期支持版双号版本号。很多新手下载的时候一看Current版本号更高就默认选它结果装完发现一堆老项目跑不起来或者某个npm包编译报错。做商城这类生产项目务必装LTS版本。原因很简单LTS版本已经被大量生产环境验证过配套的node-gyp、node-sass等原生模块基本都有预编译二进制你不需要本地装Visual Studio Build Tools去编译C模块。而Current版本每半年换一次大版本很多npm包还没来得及适配。我自己的开发机上装的就是Nodejs 20 LTS运行vue3和Express项目都非常稳。Windows安装的另一个细节是安装路径。默认路径是C:\Program Files\nodejs\这个路径本身没问题但如果你的项目要安装某个带原生模块的依赖比如sharp、canvas编译时可能会出现权限问题。建议装到C:\nodejs\这类不带空格的目录可以省掉不少麻烦。安装完成后打开命令行执行node -v和npm -v能正常输出版本号就说明Nodejs和npm已经就绪。npm源也是一个容易卡人的点。默认源在国内下载速度极慢而且经常出现超时失败建议直接配置为淘宝镜像源npm config set registry https://registry.npmmirror.com配置完成后可以用npm config get registry确认是否生效。这一步做完后续所有依赖安装会快一个量级。2.2 npm.ps1脚本执行策略问题从报错到根因翻车记录里最高频的报错长这样npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本很多同学第一反应是重装Nodejs结果装完还是同样的报错。这个问题的根因其实是Windows PowerShell的**执行策略Execution Policy**限制了.ps1脚本的运行。npm在Windows下是通过一个PowerShell脚本来启动的而PowerShell默认的执行策略是Restricted只允许运行经过签名的脚本于是npm就被拦截了。解决办法有两种我推荐第二种。第一种以管理员身份打开PowerShell执行Set-ExecutionPolicy RemoteSigned改成RemoteSigned后本地创建的脚本可以运行从网络下载的脚本需要签名才能运行安全性相对可控。但更省心的办法是绕开PowerShell脚本直接用CMD命令提示符打开CMD窗口里面的npm命令走的是npm.cmd根本不会触发PowerShell策略限制。我在开发阶段就一直用CMD操作npm从没遇到过这个报错。注意如果公司电脑有安全组策略管控Set-ExecutionPolicy可能改不动。此时用CMD或者Git Bash执行npm命令即可绕过无需强行修改系统策略。2.3 用Vite初始化Vue3项目并补齐商城目录结构环境就绪后创建Vue3项目的大路货方法是npm create vuelatest。这个命令会拉取官方的Vue3脚手架默认集成Vite、TypeScript、Vue Router和Pinia。对于商城项目我建议在脚手架引导时这样勾选TypeScript要。商城接口字段多且结构固定TS能帮你提前发现字段拼写错误。Vue Router要。商城需要多个页面路由。Pinia要。购物车、用户登录状态都需要跨组件共享。ESlint Prettier要。多人协作时统一代码风格省去后续扯皮。创建完成后我会在src目录下按模块划分目录。这里提供一个我实测好用的结构src/ api/ # 所有后端接口请求函数 assets/ # 静态资源 components/ # 通用组件商品卡片、价格展示等 router/ # 路由配置 stores/ # Pinia状态库 views/ # 页面组件首页、商品列表、详情、购物车、结算等 utils/ # 工具函数金额格式化、校验等这个结构的好处是把接口请求和页面组件拆开了。商城系统后端的接口改动非常频繁如果接口请求散落在各个页面组件里一旦接口路径变化你需要在所有页面里逐个找集中到api/目录后改一处即可全局生效。3. 商品模块设计电子产品SKU体系的数据库建模与接口实现商城系统里商品模块是最核心、也最能体现电子产品特殊性的部分。很多自研商城项目之所以做着做着就乱套基本都是在商品模型这一层没设计好。3.1 为什么电子产品需要SPU/SKU双层模型电子产品天然是多规格商品。以一台手机为例同一款手机有极光色、曜石黑两种颜色有8GB128GB和12GB256GB两种内存组合不同颜色和内存的组合对应不同的价格和库存。这种一个商品名对应多个具体可售款式的结构就是SPU/SKU体系SPUStandard Product Unit标准产品单元指的是你上架的一款手机包含标题、品牌、型号、主图、详情描述等公共信息。SKUStock Keeping Unit库存量单位指的是这款手机的具体款式比如极光色 8GB128GB它有自己的价格、库存、SKU编码和图片。如果前端只用一个商品表那每增加一个颜色组合就要新增一条Record标题、详情这些公共信息会在数据库里重复出现浪费存储且极易出现脏数据不同的Record公共字段值不一致。用SPU/SKU双层模型后公共信息只存一份具体款式挂在SPU下面数据结构非常清晰。在实际的MySQL设计里我通常会建这几张表spu表存商品公共信息sku表存每个款式的具体信息通过spu_id关联category表存商品分类brand表存品牌。其中sku表里会有一个specs字段用JSON格式存储规格参数比如{颜色: 极光色, 内存: 8GB128GB}用JSON而不用独立的规格表是因为电子产品的规格维度未来可能动态增加。今天手机还要选颜色内存明天可能还要选充电器套装版本如果为每种规格专门建一张表改动数据库结构成本太高。JSON格式搭配MySQL的JSON查询能力灵活性就大多了。3.2 商品列表与详情接口的代码实现后端使用Express框架版本4.x是最稳定的选择5.x刚发布时很多中间件还没完全兼容。项目初始化后安装这几个基础依赖npm install express cors mysql2 jsonwebtoken其中mysql2是操作MySQL的驱动cors用于处理跨域jsonwebtoken用于登录鉴权。基础入口文件长这样const express require(express); const cors require(cors); const productRouter require(./routes/product); const app express(); app.use(cors()); app.use(express.json()); app.use(/api/products, productRouter); app.listen(3000, () { console.log(服务器启动成功端口 3000); });商品列表接口我建议支持分类筛选、关键词搜索、价格排序和分页。一个典型的Express路由实现// routes/product.js const express require(express); const router express.Router(); const db require(../db); // GET /api/products?categoryId1keyword手机page1pageSize20sortprice_asc router.get(/, async (req, res) { const { categoryId, keyword, page 1, pageSize 20, sort } req.query; const offset (parseInt(page) - 1) * parseInt(pageSize); let where WHERE 11; const params []; if (categoryId) { where AND c.id ?; params.push(categoryId); } if (keyword) { where AND spu.name LIKE ?; params.push(%${keyword}%); } let orderBy spu.id DESC; if (sort price_asc) orderBy sku.price ASC; if (sort price_desc) orderBy sku.price DESC; const sql SELECT spu.id, spu.name, spu.main_image, MIN(sku.price) AS min_price FROM spu LEFT JOIN sku ON spu.id sku.spu_id ${where} GROUP BY spu.id ORDER BY ${orderBy} LIMIT ? OFFSET ? ; params.push(parseInt(pageSize), offset); const [rows] await db.query(sql, params); res.json({ code: 0, data: rows, total: rows.length }); }); module.exports router;这里有个细节值得注意列表接口查的是某款商品的起始价MIN(sku.price)而不是某个固定价格。因为同一个SPU下有多个SKU价格各不相同列表页通常展示的是¥4999起这种形式用户在进入详情页选择具体规格后才对上准确的SKU价格。3.3 价格与库存的并发安全写操作商城系统在高并发场景下最怕的就是库存超卖和价格错乱。库存扣减这个操作如果用先查库存再扣减的流程并发情况下会出大问题。举个例子只剩1件库存A用户和B用户同时下单两个请求都查到库存1都判断可以扣减结果两个订单都成功了库存变成了负数。正确的做法是把判断库存是否足够和扣减库存放在同一条SQL语句里完成利用数据库的行锁保证并发安全UPDATE sku SET stock stock - 1 WHERE id ? AND stock 1这条语句执行后返回的影响行数affectedRows如果是1说明扣减成功如果是0说明库存不足本次扣减失败。这样避免了先查后扣的时间窗口是电商库存场景的基础操作。价格方面前端展示的价格仅供参考真正的下单价格必须以后端数据库里通过接口实时查询到的SKU价格为准。否则用户如果抓到前端请求、手动篡改价格参数就能用1分钱下单。我在订单接口里通常是这样处理的前端只传SKU ID和数量后端在创建订单时重新从数据库读取价格并计算订单总金额绝不信任前端传过来的任何金额字段。4. 购物车与订单Vue3组合式API把状态管明白后端接口就绪后前端核心就是购物车和订单流程。这两个场景是Vue3组合式API的舒适区。4.1 用Pinia管理购物车状态的核心写法购物车状态天然是全局共享的用户在商品详情页点加入购物车商品列表页顶部的购物车角标要实时更新。所以必须用Pinia把购物车状态提升到全局。我用Pinia定义一个购物车store核心结构是// stores/cart.js import { defineStore } from pinia; export const useCartStore defineStore(cart, { state: () ({ items: [] }), getters: { totalCount: (state) state.items.reduce((sum, item) sum item.count, 0), selectedCount: (state) state.items .filter(item item.checked) .reduce((sum, item) sum item.count, 0), totalPrice: (state) state.items .filter(item item.checked) .reduce((sum, item) sum item.count * item.price, 0) }, actions: { addItem(sku) { const existed this.items.find(item item.skuId sku.id); if (existed) { existed.count 1; } else { this.items.push({ skuId: sku.id, spuId: sku.spuId, name: sku.name, specText: sku.specText, price: sku.price, image: sku.image, count: 1, checked: true }); } }, removeItem(skuId) { this.items this.items.filter(item item.skuId ! skuId); }, toggleCheck(skuId) { const item this.items.find(item item.skuId skuId); if (item) item.checked !item.checked; }, clearChecked() { this.items this.items.filter(item !item.checked); } } });这里的设计细节在于购物车里的每一项不是商品SPU而是具体的SKU。一个SKU才对应唯一的价格和库存购物车算总价时才能准确。同时每个购物车项要保存image和price的快照否则当商品列表页价格变动时购物车里的价格也会跟着变用户会在结算时看到价格跳动的诡异状况。4.2 购物车持久化与批量结算逻辑页面刷新后购物车数据不能丢。最简单的方案是用Vue3的watch监听购物车变化自动写入localStorage// 在组件 setup 中或在 store 定义外用 watch import { watch } from vue; import { useCartStore } from /stores/cart; const cartStore useCartStore(); watch( () cartStore.items, (newItems) { localStorage.setItem(cart, JSON.stringify(newItems)); }, { deep: true } );然后在store初始化时读取本地缓存作为初始值。这种方式适合未登录场景优点是零成本、不依赖后端。但它有个局限用户在A设备加购到B设备上看不到购物车内容。所以如果商城要求账号购物车同步就必须把购物车数据同步到后端每次加购、删购、改数量都调接口。从批量结算逻辑来看购物车里应有一个全选和单选机制。Pinia中全选本质上是把所有item的checked字段统一置为true或false。结算时只把checked true的项传给订单接口。这里我踩过一个坑如果购物车内的选中项对应的商品库存已经不足订单接口会返回失败但前端已经跳转到了订单确认页。所以前端在点击去结算前应该先对选中的SKU做一次库存预校验请求/api/skus/stock把库存不足的项在购物车里就标记出来提示用户删掉或减少数量。4.3 订单创建接口与库存二次校验创建订单的接口流程我梳理为三步顺序不能乱后端接收SKU列表数组每项包含skuId和count逐项从数据库读取SKU的实时价格和库存在事务中完成这几件事锁定SKU库存使用前面说的UPDATE ... WHERE stock count原子扣减、插入订单主表和订单明细表、返回订单ID。用事务Transaction很关键。如果扣减库存成功了但插入订单表失败了事务回滚后库存会被自动恢复避免用户没下单成功但库存少了的不一致。使用mysql2的connection.beginTransaction()、commit()、rollback()即可实现。订单状态在设计时我先定义了一张状态表用一个整数表示状态值含义0待支付1已支付2已发货3已完成4已取消下单成功后订单状态是0待支付。用户支付成功后前端轮询订单详情接口或后端通过WebSocket主动推送订单状态变为1已支付此时前端引导用户进入支付成功页面。这套状态机虽然简单但对一个中小型商城已经足够用。5. 前后端联调时绕不开的CORS与鉴权问题前后端联调阶段最大的两个拦路虎就是跨域和权限。Vue3前端开发服务器跑在5173端口Nodejs后端跑在3000端口浏览器会因同源策略拦截跨域请求。5.1 axios实例封装与拦截器设计前端请求推荐用axios但一定不要直接在页面组件里import axios from axios而是封装成一个实例统一设置baseURL、超时时间和请求拦截器// utils/request.js import axios from axios; import { ElMessage } from element-plus; import router from /router; const request axios.create({ baseURL: /api, timeout: 10000 }); // 请求拦截器自动附带 token request.interceptors.request.use((config) { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); // 响应拦截器统一处理错误码 request.interceptors.response.use( (response) { const res response.data; if (res.code ! 0) { ElMessage.error(res.message || 请求失败); return Promise.reject(new Error(res.message)); } return res.data; }, (error) { if (error.response error.response.status 401) { localStorage.removeItem(token); router.push(/login); } ElMessage.error(error.message || 网络错误); return Promise.reject(error); } ); export default request;封装的核心价值是统一处理了两类重复逻辑一是鉴权Token的自动附带二是接口返回码的统一判断。商品列表页、购物车页、订单页都只需要调用封装后的request不需要再重复写Token逻辑和错误提示。5.2 CORS跨域的两种解决思路对比解决跨域我在实战中有两种常用思路各有利弊。第一种后端开启CORS。就在Express入口文件里加一行app.use(cors())所有来源的请求都被允许访问后端接口。这种方式实现简单但等于后端对任何域名都开放了接口访问权在生产环境存在被爬接口的风险。如果要用建议配置白名单app.use(cors({ origin: [http://localhost:5173, https://yourdomain.com], credentials: true }));第二种用Vite开发服务器的代理功能proxy。在Vite配置文件中添加// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } })这样前端请求/api/products时Vite开发服务器会把它转发到http://localhost:3000/api/products对浏览器来说请求是同源的根本不会出现跨域问题。这种方式在后端无需做任何配置也天然避免了生产环境API被无限制访问的风险——因为生产环境前端构建产物和后端API通常通过Nginx同域部署跨域问题本来就不存在。我个人的建议开发阶段用Vite代理方案生产阶段用Nginx反代。这样CORS配置可以在生产环境完全关闭接口安全性更高。5.3 基于JWT的登录鉴权在商城中的落地商城系统必须登录才能下单所以登录鉴权是刚需。我选择用JWTJSON Web Token实现而不是传统的Session。原因有两方面一是Nodejs端不需要维护Session存储JWT自包含用户信息分布式部署时也无需共享Session二是前端拿到Token后存到localStorage每次请求放在Authorization头里实现非常省事。后端登录接口的核心逻辑const jwt require(jsonwebtoken); const SECRET_KEY your_secret_key; // POST /api/auth/login router.post(/login, async (req, res) { const { username, password } req.body; // 从数据库校验用户名密码 const user await db.query(SELECT * FROM user WHERE username ? AND password ?, [username, password]); if (user.length 0) { return res.json({ code: 1, message: 用户名或密码错误 }); } const token jwt.sign( { userId: user[0].id, username: user[0].username }, SECRET_KEY, { expiresIn: 7d } ); res.json({ code: 0, data: { token, userInfo: { id: user[0].id, username: user[0].username } } }); });注意数据库里绝不能存明文密码必须用bcrypt加盐哈希。bcrypt是密码学上推荐的慢哈希算法能有效抵抗暴力破解。注册时哈希后再入库登录时用哈希比对这个习惯要养成。前端登录成功后把Token存入localStorage。由于我们在请求拦截器里已经写了自动附带Token所以后续所有需要登录态的接口比如创建订单、查询订单都不需要再手动带Token了。唯一要做的是在路由配置里增加一个全局前置守卫router.beforeEach((to, from, next) { const token localStorage.getItem(token); const isLoginPage to.path /login; if (!token !isLoginPage) { next(/login); } else { next(); } });这个守卫的作用是用户没登录时访问任何需要登录的页面都会自动跳转到登录页。如果是公开商品浏览页面不需要加这个限制只对路由meta里标记了requiresAuth: true的页面生效。6. 部署上线的两个方案与性能优化建议开发联调全部完成后最后一步是部署上线。这一步我踩过的坑也不少重点讲两条部署路线和几个优化方向。6.1 从构建到部署的完整命令链在Nodejs服务端项目目录下我习惯用PM2作为进程守护工具。它的核心价值是当Nodejs进程崩溃或服务器重启后能自动拉起服务。安装和启动命令很简单npm install -g pm2 pm2 start app.js --name shop-server pm2 save pm2 startup执行完pm2 startup后PM2会生成一个开机自启的系统服务配置把pm2 save的进程列表在开机后自动恢复。这三条命令组合起来就能保证后端接口服务长期稳定运行。前端Vue3项目部署之前先执行构建命令npm run build构建完成后dist/目录下就是静态资源文件。部署方案有两种我按推荐程度排序。方案一推荐用Nginx托管静态文件并将/api路径反向代理到Nodejs服务。Nginx的关键配置长这样server { listen 80; server_name yourdomain.com; root /var/www/shop/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files $uri $uri/ /index.html;这行是SPA单页应用部署的关键。因为Vue Router用的是History模式刷新页面时如果路径是/cartNginx会先去磁盘找有没有对应的静态文件找不到就回退到index.html由前端路由接管。方案二简单直接用Nodejs托管静态资源。在生产环境可以安装express-static中间件来托管dist/目录。这种方式部署简单适合小型商城或内网系统但性能较弱且和API接口混在一个服务里后续扩容不如Nginx分离方式灵活。6.2 常见性能瓶颈与优化方向商城上线后最容易暴露的性能瓶颈是首页接口和商品列表接口的响应速度。我实测下来优化优先级是这样的第一商品列表接口必须做缓存。商品数据属于读多写少的数据价格和库存虽然在变动但列表页展示的启动价格可以接受5到10秒的延迟更新。我通常用Redis做一层缓存缓存键是product_list:{categoryId}:{page}缓存时间为300秒。当后台修改商品后主动删除相关缓存键下次请求重新回源数据库。第二首页不要在接口里做深度关联查询。首页需要展示多个楼层手机、笔记本、耳机有些同学会写一个接口把所有数据库表关联一遍导致首页响应时间飙升。正确的做法是拆分接口每个楼层独立请求自己的数据前端并行加载。Promise.all([fetchPhoneList(), fetchLaptopList(), fetchEarphoneList()])并行发3个请求比一个聚合接口要快得多。第三图片资源必须走CDN或者至少做压缩。电子产品商城的图片很多是高分辨率实拍图一张图2到3MB很常见。如果不做压缩用户一打开列表页就要下载几十MB图片页面直接卡死。我在部署阶段会把商品图片统一处理成WebP格式并生成多尺寸缩略图列表页用300px缩略图详情页用800px大图配合懒加载能明显提升页面体验。我在实际做商城项目的过程中最大的体会是一套商城系统真正的复杂度不在代码量而在状态流转的完整性和数据一致性上。购物车、订单、库存、价格每个环节都有各自的坑。Nodejs Vue3这套组合让我能用一套语言贯穿所有环节排查问题时不用在前端是JS、后端是Java的双重上下文里来回切换开发效率和心智负担都友好很多。如果你正在规划自己的商城项目我建议哪怕初期功能简单也要把商品模型SPU/SKU、库存扣减方式、订单状态机这三件事在设计阶段就想清楚。这三个地基一旦打好后续加优惠券、加会员价、加分销体系都只是往上盖楼。反之地基没打牢后面每加一个功能都会引发连锁改动。这篇分享里所有方法和代码都是我在实际项目中验证过的希望能帮你少走些弯路。