先把话说在前面:这个用 Node.js + Vue 做的国风彩妆商城项目,最难的从来不是把某个页面写出来,而是把一个完整的“商品展示 → 登录注册 → 加入购物车 → 生成订单 → 订单管理”链路跑通,再把环境配置、跨域、状态同步这些破事全部理顺。
我为什么选“国风彩妆”这个方向?因为它比什么都卖的综合商城更有辨识度——商品分类、视觉风格、文案命名都有独立设计的空间。你做一个“点绛唇”色号口红和做一个“红色口红”完全不是一个维度的开发体验。而技术栈正是标题里点名的 Node.js + Vue:前端用 Vue 做单页应用,后端用 Node.js 提供接口。这套组合在后端不重的商城类项目中非常舒服,前端组件化开发效率高,接口层用 Express 写也很快。
这篇博文我不会只给你贴代码,我会把整个项目的选型理由、环境落地、数据库建模、路由设计、购物车实现、前后端联调、部署上线全部走一遍。适合谁看?想系统做完一个前后端分离商城项目的学生、刚转行前端想用真实项目填简历的朋友、以及接了私活想快速搭一个化妆品商城模板的开发者。前面那部分环境配置的坑如果你已经轻车熟路,可以直接跳到后面的路由和购物车部分。
1. 国风彩妆商城项目从零开始的选型逻辑:为什么是 Node.js + Vue
1.1 项目定位与需求拆解
做这类商城项目,我习惯先把自己当成产品经理,把“国风彩妆商城”这句话拆成一串可落地的功能点,然后再去想技术选型。
国风彩妆的核心场景是很清晰的:用户打开网站,首先看到一个有国风气质的首页,可能是水墨渲染的 Banner,也可能是一句“朱砂点绛,远山含黛”之类的文案。接下来是商品分类,比如按“唇妆、眼妆、底妆、颊妆”拆分成几个大类,每个大类下面又有具体商品。用户点击商品进入详情页,选择规格(色号、容量、数量),加入购物车,然后登录、结算、生成订单、查看订单列表。
把这个流程落到模块上,至少包括以下这些:
- 用户模块:注册、登录、用户信息维护,密码要加密存库
- 商品模块:商品分类、商品列表、商品详情、搜索
- 购物车模块:添加商品、修改数量、删除商品、勾选结算商品
- 订单模块:创建订单、订单列表、订单状态流转(待付款、待发货、已发货、已完成)
- 后台管理模块:商品管理、分类管理、订单管理、统计报表
我当时做这个项目时还加了一个品牌故事页面,毕竟国风彩妆卖的是一种审美,没有品牌故事支撑,整个网站的气质会差一大截。这个页面不涉及复杂逻辑,但对于练习组件设计和嵌套路由非常有用。
需求拆解完之后你会发现:这是一个典型的中小型全栈项目,前端交互复杂度中等,后端逻辑不重,最关键的是要把流程的闭环做完整。
1.2 为什么技术栈落在 Node.js + Vue 而不是其他组合
我见过不少人纠结这个问题——“我用 Spring Boot + Vue 不也行吗?用 Python Django 不行吗?”
行,当然行。但 Node.js + Vue 对于这个场景有几个实打实的优势,我一个个说。
第一个优势是语言统一。前端写 JavaScript/TypeScript,后端也是 JavaScript/TypeScript,整个项目只有一种语言。这意味着什么?意味着你不必在前端和后端之间来回切换思维模式,数据结构的定义可以在前后端复用。比如商品对象,后端返回的 JSON 结构,前端直接用,连个多余的转换层都不用写。对于个人开发者或者小团队,这个东西能省下巨量的沟通和心智成本。
第二个优势是生态成熟。Express 虽然是很多人口中的“上古框架”,但它的中间件生态极其稳定,处理静态资源、解析请求体、跨域配置、文件上传,每个场景都有成熟的方案。而 Vue 在前端世界里的地位更不用说了,组件化、响应式、路由、状态管理,一套组合拳下来,商城这种列表页加详情页加状态变更的典型 CRUD 场景,写起来非常顺手。
第三个优势是部署轻量。你不用在整个服务器上装一套 JDK、Tomcat 之类的重型环境,只需要一个 Node.js 运行时,用 pm2 一拉就能跑起来。对于商城这种业务量不算大的项目,Node.js 的并发表现完全够用。
我个人的观点是:选择技术栈的时候,别总想“什么最牛”,而要想“什么组合能让我最快、最稳地把闭环跑通”。Node.js + Vue 在这个问题上几乎是最优解。
1.3 项目整体目录结构设计
项目采用前后端分离,目录结构分成 server 和 client 两个部分。这是我的习惯,不建议把前后端混在一个目录里。
guofeng-mall/ ├── server/ # Node.js 后端 │ ├── app.js # 入口文件 │ ├── config/ # 配置(数据库、token、端口等) │ ├── routes/ # 路由(user、product、cart、order、admin) │ ├── models/ # 数据模型层 │ ├── controllers/ # 业务逻辑控制层 │ ├── middleware/ # 中间件(鉴权、错误处理、日志) │ └── utils/ # 工具函数 ├── client/ # Vue 前端 │ ├── src/ │ ├──├── api/ # axios 请求统一封装 │ ├──├── assets/ # 静态资源、样式 │ ├──├── components/ # 通用组件 │ ├──├── router/ # 路由配置 │ ├──├── store/ # 状态管理(购物车、用户) │ ├──├── views/ # 页面组件 │ ├──├── App.vue │ └──├── main.js └── README.md这个结构的好处是:前后端可以分开开发和部署,联调的时候只要确认接口地址和返回格式一致就行。我在项目里用了 npm-run-all 这个工具,可以在根目录一键同时启动前后端,省去开两个终端的麻烦。
2. 开发环境落地:Node.js 安装、环境变量与 npm 报错的完整解法
2.1 Node.js 安装的正确姿势
很多新手一上来就踩坑,我先说安装这件事。Node.js 安装其实不难,但有几个地方需要选对。
到 Node.js 官网下载安装包时,一定要选 LTS(Long Term Support)版本,不要选 Current 版本。LTS 是稳定版,适合开发和生产环境。Current 版本是尝鲜版,虽然有一些新特性,但某些第三方依赖可能还没有完全适配,项目跑着跑着出现奇怪的问题,排查起来特别浪费时间。
安装过程中有几个细节需要注意:安装路径尽量不要选默认的 C 盘,可以改成 D:\nodejs 或者其他路径,这样以后重装系统不会丢配置。安装向导里有一个 “Add to PATH” 的选项,这个一定要勾上。如果你不勾,后面就得手动配置环境变量。
装完之后打开终端(cmd 或 PowerShell),输入两条命令验证:
node -v # 输出类似 v18.20.4 npm -v # 输出类似 10.7.0两条命令都有输出,说明你的 Node.js 安装成功了。这里有一个很容易忽略的点:安装完之后要重新打开一个终端窗口,否则 PATH 不会刷新,你直接输入 node 会提示“不是内部或外部命令”。
2.2 Windows 下环境变量配置的完整说明
如果你的安装路径比较特殊,或者安装的时候没勾选 Add to PATH,就需要手动配置环境变量了。
打开“系统属性 → 高级 → 环境变量”,在“系统变量”里找到 Path,点击编辑,新增以下两条:
- 你的 Node.js 安装目录,比如 D:\nodejs
- 全局 node_modules 目录,推荐设为 D:\nodejs\node_global
第二条的目的是把 npm 全局安装的包统一放到一个你指定的目录里。默认情况下 npm 会把全局包装到 C:\Users\你的用户名\AppData\Roaming\npm 下,但这个目录在 C 盘,容易越积越大。我习惯把它改掉。
改完之后,设置 npm 的全局包目录和缓存目录:
npm config set prefix "D:\nodejs\node_global" npm config set cache "D:\nodejs\node_cache"然后再手动把 D:\nodejs\node_global 加到环境变量 Path 里。这样以后 npm install -g 安装的工具,在任意终端都能直接运行。
2.3 那个“npm.ps1 无法加载”的经典报错,根因和处理方案
这个报错在热搜词里出现了很多次,我看到的时候特别有共鸣,因为我自己也被它卡过一次:
npm : 无法加载文件 D:\program files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。
这个问题的根因其实跟 Node.js 本身一点关系都没有,是 Windows PowerShell 的执行策略在限制你。
Windows 系统出于安全考虑,默认执行策略是 Restricted,意思是不允许任何脚本文件运行。而 npm 是一个 .ps1 后缀的 PowerShell 脚本,所以 PowerShell 直接把它拦住了。解决方式有两种,我用得最多的是第一种:
以管理员身份打开 PowerShell,运行:
Set-ExecutionPolicy RemoteSigned输入 Y 确认。RemoteSigned 的意思是:本地创建的脚本可以运行,从网上下载的脚本必须有受信任的签名。设置完之后重新打开终端,npm 命令就能正常执行了。
第二种方式是不用 PowerShell,改用 CMD 或 Git Bash。cmd 不执行 PowerShell 脚本,所以不会报这个错。但如果你的工作流里已经习惯了用 PowerShell,还是建议直接改执行策略。
注意一个细节:有些电脑可能因为公司安全策略,管理员权限也被限制了,那可以去“Windows PowerShell (管理员)”这个入口右键打开再执行。如果还是不行,就真的用 CMD 吧,项目照样跑。
2.4 镜像源与依赖安装提速
国内网络环境下,npm install 直接拉官方源会慢到怀疑人生。我建议第一次安装依赖前就把镜像源换好:
npm config set registry https://registry.npmmirror.com/换完之后可以验证一下:
npm config get registry输出如果显示 npmmirror 的那个地址,说明生效了。这是我踩过不少次坑之后养成的习惯——先换源再装依赖。淘宝镜像源现在改名叫 npmmirror 了,但配置地址没变,这个地址长期有效。
环境配置这块讲得比较细,是因为我真心觉得:项目跑不动的时候,百分之八十的问题都出在环境上。环境搞定了,后面写代码的效率会高非常多。
3. 商城核心架构与数据建模:从商品 SKU 到订单状态的完整设计
3.1 数据表设计思路:先想清楚业务怎么走
一个电商项目的后端,数据模型设计如果歪了,后面想改会非常痛苦。我在设计这张表之前,先画了一条业务数据流:
用户 → 商品分类 → 商品 → 购物车 → 订单 → 订单明细
这条链路里的每一环都需要对应的表来承接。我基于 MySQL 做了以下这些表:
user 用户表:
CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT '密码(bcrypt加密后)', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `avatar` varchar(255) DEFAULT NULL COMMENT '头像URL', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;category 商品分类表:
CREATE TABLE `category` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL COMMENT '分类名称', `icon` varchar(255) DEFAULT NULL COMMENT '分类图标', `sort` int(11) DEFAULT 0 COMMENT '排序权重', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;product 商品表:
CREATE TABLE `product` ( `id` int(11) NOT NULL AUTO_INCREMENT, `category_id` int(11) NOT NULL COMMENT '所属分类', `name` varchar(100) NOT NULL COMMENT '商品名称', `subtitle` varchar(200) DEFAULT NULL COMMENT '副标题/卖点', `image` varchar(255) DEFAULT NULL COMMENT '主图', `gallery` text COMMENT '轮播图列表(JSON数组)', `price` decimal(10,2) NOT NULL COMMENT '价格', `original_price` decimal(10,2) DEFAULT NULL COMMENT '原价', `stock` int(11) NOT NULL DEFAULT 0 COMMENT '库存', `sales` int(11) DEFAULT 0 COMMENT '销量', `specs` text COMMENT '规格(如不同的色号)', `detail` text COMMENT '富文本详情HTML', `status` tinyint(4) DEFAULT 1 COMMENT '1上架 0下架', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;cart 购物车表、orders 订单表、order_item 订单明细表我就不全部贴出来了,每个人的字段习惯不一样,但核心逻辑是一致的:总价用 decimal 类型存储,不要用 float,否则金额会算错;订单状态用整数表示,代码里定义常量来对应。
3.2 国风特色的数据设计细节
国风彩妆商城的数据设计里,我加了几个有意思的字段。
商品表的 specs 字段存的是色号信息,比如“豆沙红、枫叶红、朱砂红”,每个色号对应一个 JSON 数组,前端拿到之后渲染成规格选择器。price 字段按分类可以给一个基础价,具体色号如果有价格差异,我再存一个 spec_price 的 JSON。
商品表还加了一个 theme 标签字段,用来标记“敦煌系列”、“水墨系列”、“花钿系列”这种主题套装。这个字段的价值在于,首页可以做主题运营位,比如桃花节专区、七夕国风专场,运营同学要换内容时只需要改标签,不需要改代码。
分类表的 icon 字段我存的是一个 SVG 图标的字符串,这样前端渲染的时候可以直接 v-html 输出,不用额外发图片请求。这个细节看起来小,但对加载速度的影响还是有的。
3.3 API 接口规划与路由分层
后端我用的是 Express,路由设计上按资源来拆分,一眼就能看出这个项目有哪些功能。下面这份接口清单基本就是整个商城后端的工作量:
| 模块 | 接口 | 方法 | 说明 |
|---|---|---|---|
| 用户 | /api/user/register | POST | 用户注册 |
| 用户 | /api/user/login | POST | 用户登录,返回 token |
| 用户 | /api/user/info | GET | 获取用户信息(需登录) |
| 商品 | /api/category/list | GET | 获取分类列表 |
| 商品 | /api/product/list | GET | 商品列表,支持分类/搜索/分页 |
| 商品 | /api/product/detail/:id | GET | 商品详情 |
| 购物车 | /api/cart/list | GET | 获取购物车列表(需登录) |
| 购物车 | /api/cart/add | POST | 加入购物车 |
| 购物车 | /api/cart/update | PUT | 修改数量/勾选状态 |
| 购物车 | /api/cart/delete | DELETE | 删除购物车商品 |
| 订单 | /api/order/create | POST | 创建订单 |
| 订单 | /api/order/list | GET | 我的订单列表 |
| 订单 | /api/order/detail/:id | GET | 订单详情 |
| 后台 | /api/admin/product/save | POST | 新增/编辑商品(管理员) |
| 后台 | /api/admin/order/list | GET | 管理端订单列表(管理员) |
这份接口设计遵循了一个原则:对外统一以 JSON 返回,格式固定为 { code, message, data }。前端 axios 拦截器统一处理这个结构,有错误就弹提示,请求成功就取 data 字段,清爽。
3.4 后端业务层的关键实现要点
Express 后端的核心代码结构我习惯分四层:路由层只做请求分发,controller 层做业务编排,model 层做数据访问,middleware 层做横切逻辑。以登录接口为例,controller 的职责是校验参数、调用 model 查出用户、比对密码、签发 token。
密码加密我用的是 bcryptjs。为什么不用 md5?md5 是哈希算法,不是加密算法,而且彩虹表攻击太容易了,我见过太多项目因为用 md5 存密码而出事。bcryptjs 一种加盐哈希,同样的密码每次生成的哈希值都不同,暴力破解成本指数级上升。前端注册页提交的密码,到后端就执行 bcrypt.hashSync(password, 10),登录时执行 bcrypt.compareSync。
Token 签发我用的 jsonwebtoken。登录成功后把用户 id 和角色字段放进去,签发一个时效 7 天的 token 给前端。后面每次请求,前端在 header 里带上 Authorization: Bearer xxxx,中间件验证通过后才往下走。
// middleware/auth.js const jwt = require('jsonwebtoken'); module.exports = function (req, res, next) { const token = req.headers.authorization?.split(' ')[1]; if (!token) { return res.status(401).json({ code: 401, message: '未登录,请先登录' }); } try { const decoded = jwt.verify(token, process.env.JWT_SECRET); req.userId = decoded.id; req.userRole = decoded.role; next(); } catch (e) { return res.status(401).json({ code: 401, message: '登录已过期,请重新登录' }); } };这种中间件设计的好处是,购物车和订单相关的路由只要在挂载时加上 auth 中间件,就能自动具备登录校验能力,不用在每个接口里重复写。
4. vue-router 在国风商城中的路由设计:从页面骨架到动态权限控制
4.1 路由层级规划:一个商城页面的目录结构
前端项目里最容易被忽视、后期却最难改的就是路由设计。路由设计得清楚,整个项目的页面层级一目了然;设计得混乱,菜单一多就理不清谁是谁的父级了。
我的国风商城路由分三层:
第一层是通用页面:首页、商品列表页、商品详情页、品牌故事页,这些页面任何用户都能访问。
第二层是用户中心:购物车、订单结算、我的订单、个人信息,这些页面需要登录才能访问。
第三层是后台管理:商品管理、分类管理、订单管理,这些页面只有管理员能访问。
Vue Router 4(Vue 3 项目)配置示例:
// router/index.js import { createRouter, createWebHistory } from 'vue-router'; const routes = [ { path: '/', component: () => import('@/views/Home.vue'), meta: { title: '首页' } }, { path: '/list', component: () => import('@/views/ProductList.vue'), meta: { title: '全部商品' } }, { path: '/detail/:id', component: () => import('@/views/ProductDetail.vue'), meta: { title: '商品详情' } }, { path: '/cart', component: () => import('@/views/Cart.vue'), meta: { title: '购物车', requiresAuth: true } }, { path: '/checkout', component: () => import('@/views/Checkout.vue'), meta: { title: '确认订单', requiresAuth: true } }, { path: '/orders', component: () => import('@/views/OrderList.vue'), meta: { title: '我的订单', requiresAuth: true } }, { path: '/login', component: () => import('@/views/Login.vue'), meta: { title: '登录' } }, { path: '/admin', component: () => import('@/views/admin/AdminLayout.vue'), meta: { title: '后台管理', requiresAuth: true, requiresAdmin: true }, children: [ { path: 'products', component: () => import('@/views/admin/ProductManage.vue') }, { path: 'categories', component: () => import('@/views/admin/CategoryManage.vue') }, { path: 'orders', component: () => import('@/views/admin/OrderManage.vue') } ] } ];这里面有几个细节值得说。组件全部用了动态导入,也就是路由懒加载。商城页面如果一次性把所有组件都打进主包,首屏加载会慢得难受。懒加载之后,访问哪个页面加载哪个页面的代码,首屏体验好很多。
4.2 路由守卫:登录拦截与页面权限控制的完整实现
有了 routes 配置,还需要一层“门卫”来控制谁能进哪个页面。Vue Router 提供了 beforeEach 全局前置守卫,我在里面做了两件事:一是设置页面标题,二是判断登录状态。
router.beforeEach((to, from, next) => { document.title = to.meta.title ? `${to.meta.title} | 国风美妆` : '国风美妆'; const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }); return; } if (to.meta.requiresAdmin) { const role = localStorage.getItem('userRole'); if (role !== 'admin') { next({ path: '/', message: '无权限访问' }); return; } } next(); });这个守卫有一个很关键的体验细节:未登录用户访问购物车时,会被重定向到登录页,并且通过 query 参数记录了他原本想去的地址 redirect。登录成功后,前端把 redirect 拿出来,直接跳回原先的页面。这个体验对电商网站来说非常重要,用户加了一堆商品去结算,结果因为没登录被打回首页,这谁也接受不了。
4.3 动态路由:管理员权限与环节点
热搜词里也出现了“vue动态路由”,这个词听起来高大上,但实现逻辑其实一点不复杂。它解决的问题是:不同角色登录后看到的菜单和能访问的页面不一样。
我的做法是:路由表拆成两条。第一条是基础路由,所有用户都能访问的页面,打包时直接注册。第二条是管理路由,管理员专属的页面,在用户登录后根据角色动态添加。
// 登录成功后 const adminRoutes = [ { path: '/admin', component: AdminLayout, meta: { requiresAdmin: true }, children: [...] } ]; router.addRoute(adminRoutes);动态路由的好处是,普通用户根本不会在前端路由表里加载管理页面,减少了无意义的代码下载,也起到了一定的权限隔离作用。当然,真正安全的后端接口权限校验还是要做,前端动态路由只是为了更好的体验。
4.4 国风商城路由设计的体验优化点
路由这一块我额外做了两件事,体验提升非常明显。
第一件是路由切换时的滚动重置。单页应用滚动位置混乱的问题大家应该都知道,我在 router.afterEach 里加了一句话:
router.afterEach(() => { window.scrollTo(0, 0); });第二件是路由缓存的运用。商品列表页如果每次从详情页返回都重新请求数据,用户会很烦。我用 keep-alive 包裹了 List 组件,同时 inlcude 里写上需要缓存的组件名,这样返回列表页时数据不会丢失,滚动条也能留在原来的位置。这里注意一个坑:被缓存的组件里不要写死轮询逻辑,否则页面一直发请求,非常浪费性能。
5. 前端核心页面实战:国风商品列表、购物车与结算流程的实现细节
5.1 商品列表页:优雅地处理加载、筛选与分页
商品列表页是用户见得最多的页面,也是前端交互的试金石。我这个项目的列表页要支持:分类切换、关键字搜索、价格排序、上拉加载更多。
技术实现上,我用 Composition API 的 setup 语法来组织逻辑。关键的状态有四个:商品数组 products、当前页 page、总数 total、加载状态 loading。分类切换时会触发一个 watch,把 page 重置为 1,然后重新请求接口。
这里有一个最容易犯错的地方:并发请求的竞态问题。用户快速在唇妆、眼妆、底妆之间切换,上一次请求可能比下一次请求晚返回,导致页面显示了旧分类的商品。我处理这个问题的思路是:每次发请求之前记录一个请求序号,响应回来时判断序号是否已经过期。
let reqSeq = 0; async function loadProducts(categoryId) { const currentSeq = ++reqSeq; loading.value = true; try { const res = await getProductList({ categoryId, page: page.value }); if (currentSeq !== reqSeq) return; // 过期请求直接丢弃 products.value = res.data.list; total.value = res.data.total; } finally { loading.value = false; } }这种“请求序号”方案比取消请求更简单可靠,性能开销也小。类似的竞态问题也出现在搜索框的防抖处理上,用户每输入一个字符都去请求一次显然不妥,我用了一个 300ms 的防抖函数,只有输入停下超过 300ms 才会发起实际请求。
5.2 商品详情页的规格选择逻辑
商品详情页看似简单,实际写起来坑很多。国风彩妆这个品类尤其明显——一支口红可能有 6 个色号,每个色号的库存不一样,价格也可能不一样。
我在详情页里的处理方式是:后端返回 specs 字段,是一个 JSON 数组,类似这样的结构:
[ { "name": "山茶红", "price": 129.00, "stock": 50 }, { "name": "朱砂橘", "price": 139.00, "stock": 30 }, { "name": "枫叶棕", "price": 119.00, "stock": 0 } ]前端渲染一个色号选择器,用户点击某个色号时,页面上的价格、库存、按钮状态都会跟着变。库存为 0 的色号要置灰,并且不能点击。
购买按钮的状态判断是这样:如果当前选中规格库存为 0,按钮显示“暂时缺货”;如果用户没登录,按钮跳转登录页;如果都正常,点击后将商品加入购物车。这种状态联动逻辑写清楚之后,用户体验会非常流畅。
5.3 购物车状态管理:Vuex/Pinia 与 localStorage 双保险
购物车是商城项目里状态管理最典型的场景。它有一个特点:购物车状态既需要在前端多个组件里共享(购物车图标上的角标数、购物车列表页、结算页),又需要和后端同步。
我在项目里用的是 Vuex 的 module 模式,单独建了一个 cart 模块。state 里存 cartList 数组,getters 里有 totalCount(总数量)和 totalPrice(总价格),mutations 有 ADD_CART、REMOVE_CART、UPDATE_QUANTITY。actions 里调用后端接口,成功后 commit mutation。
这里我说一个实际经验:很多教程只教前端状态管理,不提和后端的数据同步,结果页面一刷新购物车全空了。我的方案是双重保险——前端操作购物车时,先把最新的购物车存到 localStorage,同时调接口更新后端数据库。页面初始化时,先去 localStorage 取一份数据做即时渲染,然后调接口从后端拉真正的数据,以接口数据为准。
这样做的用户体验是:即使接口挂了,购物车页面也不会瞬间空白;而接口恢复后又能拿到服务器上的最终状态。代价是逻辑稍微复杂一点,但这个复杂度是值得的。
5.4 结算流程:从购物车到订单的状态流转
结算页是购物链路里最关键的页面,因为它涉及“购物车商品状态”和“订单生成”两个大模块的联动。
用户在购物车页面勾选商品,选中商品的价格总和显示在底部结算栏。点击“去结算”后,跳转到 checkout 页面。这个页面要做的事有:展示当前勾选的商品列表、展示用户默认收货地址、计算总金额、选择支付方式(我做的项目里是模拟支付,实际上线时可以接微信或支付宝)。
创建订单的接口调用成功后,后端会返回一个订单号。前端要做两件事:第一,跳转到订单详情页展示订单号、金额和状态;第二,把本地购物车中已经生成订单的商品移除。这里最容易犯的错误是整单删除购物车数据,而不是只删除已下单的那部分商品。如果用户购物车里还有没勾选的商品,也被一起删了,那就成了事故。
订单状态流我是这样定义的:
| 状态值 | 含义 | 前端显示 |
|---|---|---|
| 0 | 待付款 | 去支付 |
| 1 | 已付款 | 等待发货 |
| 2 | 已发货 | 确认收货 |
| 3 | 已完成 | 完成闭环 |
| -1 | 已取消 | 订单关闭 |
后端在订单接口里接收用户传入的 orderStatus 动作(如 pay、confirm),然后做状态流转和校验。这种设计避免了前端直接把状态值写进接口,谁想改状态就能改状态的隐患。
6. 前后端联调中的真实踩坑:跨域、拦截器与 token 失效处理
6.1 跨域问题的三种解法与最终选择
前后端分离项目,联调第一个碰上的往往就是跨域问题。浏览器的同源策略导致前端地址是 localhost:5173,后端接口是 localhost:3000,端口不同就构成了跨域。
跨域的解法常见有三种:CORS、前端代理、后端代理。我在这个项目里开发环境和生产环境用了不同方案。
开发环境里,我用的是 Vite 的 proxy 配置。这种方式最优雅,因为它对前端代码完全透明——前端代码里请求的是 /api/xxx,代理服务器把它转发到 http://localhost:3000/api/xxx,浏览器从头到尾只和同源的 Vite 开发服务器通信,不存在跨域。
// vite.config.js export default { server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } } };生产环境我则是在 Nginx 上配了反向代理,把所有 /api 路径的请求转发到 Node.js 服务。
后端配合着也开了 CORS,用 cors 中间件,允许前端域名访问。这样做的原因是万一有特殊场景需要跨域请求,不至于被卡死。
说一句我的观点:开发环境用代理是首选,因为它的侵入性最小。CORS 方案会在浏览器的 Network 面板里出现一条 OPTIONS 预检请求,排查问题的时候多一层噪音。
6.2 axios 封装:拦截器才是真正的前端守护层
axios 封装这个事,网上教程很多,但我发现很多人只是照着抄,不知道拦截器设计的意义是什么。我讲一下我这个项目的封装思路,这么设计是有明确理由的。
先看代码:
// api/http.js import axios from 'axios'; import { ElMessage } from 'element-plus'; const http = axios.create({ baseURL: '/api', timeout: 10000 }); // 请求拦截器:统一注入 token http.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); // 响应拦截器:统一处理业务错误和登录失效 http.interceptors.response.use( response => { const res = response.data; // 根据后端返回的 code 判断业务是否成功 if (res.code !== 200) { ElMessage.error(res.message || '请求失败'); return Promise.reject(new Error(res.message)); } return res.data; }, error => { if (error.response?.status === 401) { ElMessage.error('登录已过期,请重新登录'); localStorage.removeItem('token'); localStorage.removeItem('userInfo'); // 跳转到登录页,并且记住当前页面 const currentPath = window.location.pathname; window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`; } else { ElMessage.error(error.response?.data?.message || '网络异常,请稍后再试'); } return Promise.reject(error); } );请求拦截器的价值是:你不需要在每个接口调用前手动拼 token,不用担心哪个接口漏了鉴权头。响应拦截器的价值是:token 过期时,全球统一处理,不会出现十个接口同时报错、用户被弹窗轰炸的尴尬。
这里有一个小细节我要单独说明:响应拦截器里我对 code 和 HTTP 状态码做了区分。业务逻辑失败时,比如库存不足、余额不够,后端返回 HTTP 200 但业务 code 是 50001,这种情况 ElMessage 弹一次就够。而 HTTP 401 是纯鉴权失败,需要做重定向。两者混为一谈,用户的体验会混乱。
6.3 token 失效与前端状态同步的那些坑
Token 失效这个场景我必须要单独拿出来讲,因为做商城项目时,这个坑几乎是必踩的。
用户登录后,我们把用户信息和 token 存在 localStorage。token 有 7 天有效期。假设用户在使用过程中,token 过期了,这时候他执行了一个加入购物车的操作。请求发出,后端返回 401。前端拦截器收到 401,做了两件事:清 localStorage、跳登录页。
这个流程本身没问题,但其实有一个隐藏的体验漏洞:用户本来购物车里有三件商品,因为 token 过期跳转登录页后,他重新登录成功,回来看购物车——发现购物车空了。
为什么空了?因为购物车数据在前端状态管理里是登录后从后端拉取的。用户 token 过期时,Vuex 里的 cartList 可能还存着旧数据,但跳转登录页时如果我把 localStorage 里的购物车数据也一并清掉了,那数据就真的丢了。
我的解决方案:响应拦截器在 401 时只清 token 和 userInfo,不清 cartData。购物车的数据在 Vuex 里单独维护,并且持久化到独立的 localStorage key “cartData”,不混在 userInfo 那一个 key 里。重新登录后,购物车页面会先展示 localStorage 里的旧数据,后端接口同步完成后以服务端数据为准。这样用户看到的购物车不会闪空,心理上也不会慌。
6.4 联调阶段必踩的日期、金额与状态码格式问题
联调阶段还有三类小问题,虽不难处理,但很常见,统一定好规范能省很多沟通成本。
第一类是时间格式。后端接口返回的 create_time 是 MySQL 的 datetime 字符串,比如 2024-06-01T08:00:00.000Z,前端直接显示会被时区搞乱。我统一在后端返回时把时间格式化为 YYYY-MM-DD HH:mm:ss,前端不用做任何转换。
第二类是金额精度。我在数据库层就用 decimal 存金额,但 JSON 返回时可能会变成字符串“129.00”。前端如果要算总价,直接用字符串做加法会出错。我在 axios 响应拦截器里做了一个可选的数字格式化函数,订单详情接口返回时把总价转成数字。更保险的做法是:前端所有金额计算都先把字符串 parseFloat(注意 JS 浮点坑)再算,或者干脆后端把总价算好返回。
第三类是状态码语义不统一。有人用 HTTP 200/404/500,有人用 code 字段 200/500。我规定后端 code 字段一律用整数,200 为成功,50010 为业务失败,401 为未登录,403 为无权访问。这样前端拦截器一个 switch 就能处理完所有情况。
7. 打包部署与性能优化:从本地跑到线上可用的完整路径
7.1 前端构建与基础优化
开发一切正常之后,项目上线前还有最后一段路要走:构建部署。
Vue 项目构建很简单,npm run build之后会生成一个 dist 目录,里面是压缩优化后的静态文件。但构建之前,有几个配置值得检查。
第一个是 publicPath。如果你部署在域名根目录,publicPath 用默认的 / 就行。如果部署在子目录下,比如 https://example.com/mall,那就必须把 publicPath 改成 /mall/,否则 JS、CSS、图片的路径全部 404。
第二个是路由模式。Vue Router 如果用 createWebHistory(HTML5 history 模式),在生产环境必须有 Nginx 的 try_files 配置兜底,否则刷新页面会 404。如果不想折腾 Nginx,可以改用 createWebHashHistory,地址栏会多一个 # 号,但刷新问题自动消失。我自己的偏好是 history 模式配 Nginx,因为地址栏干净,对 SEO 也相对友好。
第三是构建体积。初始的 Vue 项目直接把 Element Plus、axios 全量打进去,包体积常常超过 500KB,首屏加载很慢。我的优化方式是:Element Plus 按需引入,图表类组件单独抽象成懒加载页面,另外把第三方库通过 splitChunks 拆到独立的 chunks,利用浏览器缓存机制,下次访问不需要重新下载。
7.2 服务端部署与 Nginx 反向代理配置
服务器上部署 Node.js 项目,我用 pm2 来管理进程。它的价值是:Node 服务如果崩溃了,pm2 能自动帮你重启,日志也会统一收集,不至于服务挂了毫无察觉。
部署步骤核心是这样:
- 把 server 目录上传到服务器,安装依赖
- 配置 .env 文件里的数据库、JWT_SECRET、端口等信息
- 执行 pm2 start app.js --name guofeng-server
- 检查 pm2 logs 确认启动成功
Nginx 的配置里最重要的一段是 location 匹配,把它贴给大家参考:
server { listen 80; server_name yourdomain.com; # 前端静态文件 root /var/www/guofeng/dist; index index.html; # 前端路由重写:保证 history 模式下刷新不 404 location / { try_files $uri $uri/ /index.html; } # API 反向代理到 Node.js 服务 location /api/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 静态资源长缓存 location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico)$ { expires 30d; add_header Cache-Control "public, no-transform"; } }这段配置的价值在于:用户首次访问时加载静态资源,之后 30 天内浏览器会直接从本地缓存读取,不回源服务器,服务端压力大幅下降。需要注意的问题是,如果后续修改了前端代码重新构建,文件名有 hash 会更新,但旧文件名缓存会留存。我习惯在发布新版本时先把静态资源的缓存时间缩短到 5 分钟,等确认稳定后再调回 30 天。
7.3 后台管理端的一点实现思路
商城通常需要后台管理端,我的做法是复用了同一个 Vue 项目,在路由层做角色区分。管理员登录后能看到后台管理页面的入口,页面里主要实现商品 CRUD、订单状态更新、销售数据概览。
商品管理的核心是商品表单:名称、分类、主图、价格、库存、详情 HTML 等字段。图片上传我用的是 multer 处理,上传到服务器后返回一个 URL,前端回显。注意 multer 上传时需要限制文件大小和类型,不然会被塞入恶意文件。
订单管理页面就是一张表格,支持按状态筛选,管理员可以点击“发货”按钮更新订单状态。这里最关键的一点是权限校验,后端接口里全部用 beforeAdmin 中间件拦截,确认当前登录用户的角色是 admin,否则返回 403。前端隐藏入口只是体验层面的设计,真正的安全防线永远在后端。
7.4 汇总一下我在这个项目里最想提示的几个优化点
如果能给刚起步做类似项目的朋友几句靠谱建议,我会说这几条:
第一,尽可能把状态码和错误处理最早统一定下来,定好了就别在联调阶段改了。前后端各守各的约定,能省下一大批焦虑时间。
第二,购物车里商品的库存状态在做订单时要重新校验一遍。用户加入购物车时库存是充足的,但结账时可能已经卖光了。我在 /api/order/create 接口里对每个商品做了库存校验,如果库存不足直接返回“商品 xxx 库存不足”,绝不在前端去判断。
第三,订单号的生成一定要保证全局唯一。我用的方案是时间戳 + 用户 id + 随机数,再加一个唯一索引兜底。虽然生成线上订单号一般需要分布式 ID 方案,但商城项目这样已经足够稳。
最后再分享一个实操小技巧:开发阶段把后端 Node 服务保存重启的 nodemon 配上,可以极大提升效率。改完代码自动重启,不需要手动 Ctrl-C 重新跑。我在 server/package.json 里加了这个:
"scripts": { "dev": "nodemon app.js", "start": "node app.js" }开发时跑 npm run dev,生产部署跑 npm start,一劳永逸。