先把话说在前面这个用 Node.js Vue 做的国风彩妆商城项目最难的从来不是把某个页面写出来而是把一个完整的“商品展示 → 登录注册 → 加入购物车 → 生成订单 → 订单管理”链路跑通再把环境配置、跨域、状态同步这些破事全部理顺。我为什么选“国风彩妆”这个方向因为它比什么都卖的综合商城更有辨识度——商品分类、视觉风格、文案命名都有独立设计的空间。你做一个“点绛唇”色号口红和做一个“红色口红”完全不是一个维度的开发体验。而技术栈正是标题里点名的 Node.js Vue前端用 Vue 做单页应用后端用 Node.js 提供接口。这套组合在后端不重的商城类项目中非常舒服前端组件化开发效率高接口层用 Express 写也很快。这篇博文我不会只给你贴代码我会把整个项目的选型理由、环境落地、数据库建模、路由设计、购物车实现、前后端联调、部署上线全部走一遍。适合谁看想系统做完一个前后端分离商城项目的学生、刚转行前端想用真实项目填简历的朋友、以及接了私活想快速搭一个化妆品商城模板的开发者。前面那部分环境配置的坑如果你已经轻车熟路可以直接跳到后面的路由和购物车部分。1. 国风彩妆商城项目从零开始的选型逻辑为什么是 Node.js Vue1.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 官网下载安装包时一定要选 LTSLong 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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/registerPOST用户注册用户/api/user/loginPOST用户登录返回 token用户/api/user/infoGET获取用户信息需登录商品/api/category/listGET获取分类列表商品/api/product/listGET商品列表支持分类/搜索/分页商品/api/product/detail/:idGET商品详情购物车/api/cart/listGET获取购物车列表需登录购物车/api/cart/addPOST加入购物车购物车/api/cart/updatePUT修改数量/勾选状态购物车/api/cart/deleteDELETE删除购物车商品订单/api/order/createPOST创建订单订单/api/order/listGET我的订单列表订单/api/order/detail/:idGET订单详情后台/api/admin/product/savePOST新增/编辑商品管理员后台/api/admin/order/listGET管理端订单列表管理员这份接口设计遵循了一个原则对外统一以 JSON 返回格式固定为 { code, message, data }。前端 axios 拦截器统一处理这个结构有错误就弹提示请求成功就取 data 字段清爽。3.4 后端业务层的关键实现要点Express 后端的核心代码结构我习惯分四层路由层只做请求分发controller 层做业务编排model 层做数据访问middleware 层做横切逻辑。以登录接口为例controller 的职责是校验参数、调用 model 查出用户、比对密码、签发 token。密码加密我用的是 bcryptjs。为什么不用 md5md5 是哈希算法不是加密算法而且彩虹表攻击太容易了我见过太多项目因为用 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 4Vue 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 如果用 createWebHistoryHTML5 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一劳永逸。