尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

前后端分离架构实战:Vue3+ThinkPHP8+MySQL8商城开发全解析

前后端分离架构实战:Vue3+ThinkPHP8+MySQL8商城开发全解析 简介NIUSHOP V6是一套深度整合PHP8、ThinkPHP8与MySQL8的现代化开源商城系统采用前后端API接口彻底分离的架构适合企业建站、新零售/网店商城以及有二次开发需求的团队。前端基于ViteTypeScriptVue3ElementPlus构建后端内置用户权限、代码生成器、表单设计、云存储、短信发送、素材中心、微信/公众号、支付与模板消息推送等模块同时借助Workman实现消息队列和计划任务处理开箱即用能有效降低企业级应用开发门槛。资源包共2000个文件大小约123.33MB包含664个JS逻辑文件、196个Vue组件、235个CSS样式文件以及MD说明文档、JSON配置文件、SQL数据库脚本、Shell部署脚本等类型覆盖前端交互、接口文档、数据初始化与自动化部署目录划分清晰便于按模块学习。目前已有154人学习下载。通过这份资料开发者既可以快速搭建一套完整的商城基础框架也能从中理解前后端分离下的API设计、TP8权限体系、消息队列应用及模块化目录组织方式节省从零搭建与踩坑的时间。1. 为什么说 NIUSHOP V6 的“前后端API分离”不是换个目录结构那么简单前后端分离这个说法已经喊了很多年但多数开源商城交付的仍然是“后端套模板、前端局部用 Vue”的折中方案。NIUSHOP V6 真正值得琢磨的不是 Vue3、TypeScript、ElementPlus 这些新词而是把前后端 API 接口分离做成了默认架构前端工程独立构建、独立发布后端只提供 JSON 接口同一套 API 可以直接给小程序和 App 复用。这对二开团队最直接的好处是派一个前端就能独立改商城界面不用再去 PHP 模板里找 HTML。它把 TP8 PHP8 MySQL8 这套组合带进了开源商城赛道适合被 ThinkPHP 3.2 或 5.x 绑住、又想把前端切到 Vue3 的团队当基座。读完你可以照着搭出一套同构的前后端工程。2. 前端工程用 ViteTypeScriptVue3ElementPlus 搭商城客户端的工程骨架2.1 先确认 Node 版本和 vue-ts 模板Vue3 商城安装及环境配置的第一道坎在 Windows 上开发 Vue3 商城第一道坎不是编辑器而是 Node 版本。Vite 5 之后要求 Node 18如果你机器上还是 12.x 或 14.xnpm create这一步就会直接报错或生成旧版依赖。装好 LTS 版本后用官方脚手架建工程# 官方脚手架创建 vue3 ts 工程模板固定为 vue-ts npm create vitelatest . -- --template vue-ts # 安装依赖后启动开发服务默认端口 5173 npm install npm run dev模板参数里vue-ts表示 Vue3 TypeScript 的完整预设脚手架会同时生成tsconfig.json、vite.config.ts和src/vite-env.d.ts。拿下来先做两件事一是把tsconfig.json的strict保持开启Vue3 配合严格类型检查才能在早期拦下ref(null)之后到处判空的问题二是确认package.json的 build 脚本里有vue-tsc -b没有就补上否则构建不查类型接口数据类型写错要等上线才炸。Vue3 对比 Vue2 的响应式底层换成Proxy商品列表里直接cartItems[0].quantity 3是有效果的不再需要 Vue2 的$set。这个差异在购物车场景特别明显老代码从 Vue2 迁到 Vue3 时常踩的坑就是“改了数组不更新”。另外建议装 Pinia 而不是 Vuex 处理购物车这类跨页面共享状态defineStore的写法比 Vuex 的 mutation 薄很多import { defineStore } from pinia export const useCartStore defineStore(cart, { // state 里用 as CartItem[] 给空数组标记类型 state: () ({ items: [] as CartItem[] }), getters: { totalAmount: (state) state.items.reduce((sum, item) sum item.price * item.quantity, 0), }, actions: { add(item: CartItem) { this.items.push(item) }, }, })state里用as CartItem[]给空数组标记类型是 TypeScript 推导空数组的关键不加这个标注items会被推断成never[]后面任何 push 都报类型错误。reduce一定要给初始值0否则求和结果可能是联合类型TS 直接不让返回。computed在 Pinia 里对应getters购物车总金额放这里比放在组件里更合理。提示npm create vite如果与本地 Node 版本不匹配先执行node -v确认版本是 18再考虑升级别急着改模板。2.2 用 ApiResult 泛型管住商品列表和 SKU 的类型定义商城前端的核心难点不是页面而是接口返回结构不统一。常见做法是在src/api下建一个统一入口先定义响应外壳// src/types/api.ts export interface ApiResultT { code: number message: string data: T } // 分页外壳后端 paginate 返回的结构可以直接对上 export interface PageResultT { list: T[] total: number page: number pageSize: number } export interface SkuItem { skuId: number spec: string[] price: number stock: number } export interface GoodsItem { goodsId: number goodsName: string price: number skuList: SkuItem[] }ApiResultT是外壳PageResultT是分页外壳把这两个泛型组合起来ApiResultPageResultGoodsItem就能直接表达“商品分页接口的完整返回结构”。请求层用 axios 实例统一处理 code 判断// src/api/request.ts import axios from axios export const request axios.create({ // 开发环境走相对路径 /api交给 Vite 转发生产环境由 Nginx 同源处理 baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 10000, }) request.interceptors.response.use((response) { // 先把响应收窄到已知结构避免业务代码里到处 any const res response.data as ApiResultunknown if (res.code ! 0) { return Promise.reject(new Error(res.message)) } return response })baseURL走VITE_API_BASE_URL这个环境变量.env.development里不配开发环境就走相对路径/api生产环境在 Nginx 再做同源转发。拦截器把非 0 的 code 统一 reject业务代码里拿到的都是成功数据错误只用 catch 一处处理。这里没有单独抹掉data返回值是为了保留 axios 的响应结构具体取值时再收窄。2.3 ElementPlus 按需引入与商城后台 UI 的自适应商城的管理后台vue3 后台管理系统最常见的形态组件用得比商城前台多如果main.ts里全量use(ElementPlus)首屏包体积会明显涨。ElementPlus 官方提供了按需方案依赖两个 Vite 插件// vite.config.ts import AutoImport from unplugin-auto-import/vite import Components from unplugin-vue-components/vite import { ElementPlusResolver } from unplugin-vue-components/resolvers export default defineConfig({ plugins: [ vue(), // AutoImport 负责 ElMessage 这类命令式 API 的自动导入 AutoImport({ resolvers: [ElementPlusResolver()] }), // Components 负责模板里 el-button 这类标签对应组件和样式的按需加载 Components({ resolvers: [ElementPlusResolver()] }), ], })AutoImport解决ElMessage、ElMessageBox这类命令式调用的自动导入Components负责模板里el-button这种标签对应的组件和样式。两个插件都走ElementPlusResolver不用手动 import 样式。图标组件element-plus/icons-vue不在自动识别范围内需要显式引入后再传给el-icon。主题定制直接覆盖 CSS 变量:root { --el-color-primary: #c62f2f; --el-border-radius-base: 4px; }全量引入省配置但增体积按需自动导入省体积但每次新组件需要重新触发插件扫描。差异在下表对比项全量引入按需自动导入打包体积全组件进包后台可接受前台偏大仅打包用到的组件前台友好配置成本app.use(ElementPlus)一行两个 unplugin 插件约五行业务配置样式覆盖全局变量覆盖同样支持 CSS 变量加了一层按需加载陷阱无ElMessage需手动注册或交给 AutoImport做完这三步商城前端的骨架基本立住。接下来要看后端把接口数据喂到什么程度。3. 后端侧ThinkPHP8PHP8MySQL8 的接口设计与数据层改造3.1 先算技术账TP3.2 为什么搬不到 PHP8TP8 的控制器基线搜“thinkphp 3.2 版本兼容 php8”的人基本都是被老商城困住的项目。TP3.2 是 PHP5 时代的路由和M()模型写法PHP8 移除的each()、字符串{0}偏移、构造函数返回值语法它都有用到。想让它跑在 PHP8 上靠入口文件的兼容 patch 只能解决表面报错深层问题在数据库驱动和路由解析。NIUSHOP V6 直接上 TP8等于放弃老版本兼容负担把开发基线抬到 PHP8。TP8 在骨架上的差异非常直接控制器类进入app\controller命名空间模型用app\model路由定义在route目录。依赖注入默认可用控制器构造器里直接声明要注入的服务?php declare(strict_types1); namespace app\controller; use app\model\Goods; use think\response\Json; class GoodsController { public function __construct(private readonly Goods $goods) { } public function page(int $page 1, int $pageSize 10): Json { $list $this-goods -with(skuList) -where(status, 1) -order(sort_order, desc) -paginate([ list_rows $pageSize, page $page, ]); return json([ code 0, message ok, data $list, ]); } }private readonly Goods $goods是 PHP 8.1 的构造器属性提升加只读属性容器解析控制器时会自动注入 Goods 模型实例不需要写new Goods()。declare(strict_types1)让方法参数的类型约束严格生效避免类型隐式转换把分页参数搞乱。分页用 TP8 自带的paginate返回结构里已经带 total、current_page 等字段前端PageResultT能直接对上。注意返回的code用了数字 0后台管理列表接口最好全项目统一叫成功码不要一个接口返回0、另一个返回200。从 TP5.x 迁到 TP8 还算顺从 TP3.2 迁过来就要重写整个数据访问层。老项目如果还想在 PHP8 上苟撑一副兼容补丁不是长久之计。3.2 商品 SKU 的 JSON 存储与类目排名MySQL8 的 JSON_TABLE 和窗口函数MySQL8 相比 5.7 真正拉开差距的两个特性是 JSON_TABLE 和窗口函数。商城商品 SKU 的规格数据最适合存 JSON每个商品的规格数量不定关系表会撑出大量行。规格过滤时最常见做法是用 JSON_CONTAINS 精确匹配-- 精确匹配规格数组中 颜色红 的 SKU SELECT sku_id, price, stock FROM goods_sku WHERE JSON_CONTAINS( spec_list, JSON_OBJECT(name, 颜色, value, 红) );这个写法能命中基础查询但多规格组合时AND链会越来越长。MySQL 8.0 引入的 JSON_TABLE 可以把 JSON 数组展开成虚拟表再和主表 JOIN-- 同时匹配 颜色红 且 容量256G 的 SKU SELECT s.sku_id, s.price FROM goods_sku s JOIN JSON_TABLE( s.spec_list, $[*] COLUMNS ( spec_name VARCHAR(50) PATH $.name, spec_value VARCHAR(50) PATH $.value ) ) spec WHERE (spec.spec_name 颜色 AND spec.spec_value 红) OR (spec.spec_name 容量 AND spec.spec_value 256G) GROUP BY s.sku_id, s.price HAVING COUNT(DISTINCT CONCAT(spec.spec_name, spec.spec_value)) 2;HAVING COUNT(DISTINCT ...) 2这一段是“规格同时命中”的精髓JOIN 后同一 SKU 会产生多行命中两个规格时计数为 2缺一个就只有 1。这里没法走常规 B 树索引数据量大时要配合商品表的状态和分类先收敛范围否则 JSON_TABLE 全表展开代价很高。类目“每类销量 Top 3”这种需求老写法是自连接加临时表MySQL8 里一个窗口函数解决-- 每个类目下销量前 3 的商品 SELECT category_id, goods_id, goods_name, sales FROM ( SELECT category_id, goods_id, goods_name, sales, ROW_NUMBER() OVER ( PARTITION BY category_id ORDER BY sales DESC ) AS rn FROM goods WHERE status 1 ) t WHERE t.rn 3 ORDER BY category_id, rn;子查询里的ROW_NUMBER() OVER (PARTITION BY category_id ...)给每个类目内部编了 1、2、3 的序号外层WHERE rn 3直接截取跟 GROUP BY 的区别是它保留原始行而不是聚合掉。还能顺手带出正序总数-- 一次查询同时拿当前页数据和总条数 SELECT goods_id, COUNT(*) OVER () AS total_cnt FROM goods WHERE status 1 LIMIT 20;这个COUNT(*) OVER ()和 SQL_CALC_FOUND_ROWS 不一样后者在 MySQL 8.0.17 起已被弃用窗口函数是分页接口拿 total 的现代写法。三类查询能力对比场景MySQL 5.7 的老办法MySQL8 推荐做法SKU 规格多条件筛选JSON_CONTAINS 链式拼接JSON_TABLE JOIN COUNT 匹配每类 Top N自连接子查询逻辑绕ROW_NUMBER 窗口函数分页总条数SQL_CALC_FOUND_ROWS FOUND_ROWS()COUNT(*) OVER() 同结果集带出3.3 API 响应外壳与 JWT 鉴权的最小实现后端所有接口统一返回{ code, message, data }code 用 PHP8 的 enum 枚举管理避免魔法数字散落?php namespace app\common; // 统一业务状态码前端只认这个枚举里的数字 enum ApiCode: int { case Success 0; case InvalidParam 40001; case Unauthorized 40101; case Forbidden 40301; case NotFound 40401; case ServerError 50001; }enum 比常量类好在能参与类型声明控制器方法签名里写ApiCode $code传入非枚举值直接 TypeError。鉴权部分建议用 JWT 而不是服务端 session商城前后端分离后小程序、App 都要复用同一套登录态。JWT 不强制引入外部包一个生成和校验的 service 用手写 HMAC 也可以?php // base64url 编码把 / 替换成 URL 安全字符 function base64url_encode(string $data): string { return rtrim(strtr(base64_encode($data), /, -_), ); } function encodeJwt(array $claims, string $secret): string { $header base64url_encode(json_encode([alg HS256, typ JWT])); $payload base64url_encode(json_encode($claims)); $signature hash_hmac(sha256, $header . . . $payload, $secret); // 三段式 tokenheader.payload.signature return $header . . . $payload . . . base64url_encode($signature); }base64url_encode负责把标准 base64 的、/、替换成 URL 安全字符否则 token 放在 header 里容易因特殊字符出错。校验侧把 token 拆三段后重算签名比对hash_equals防时序攻击再看 payload 里 exp 是否过期。配合 TP8 中间件在路由文件里对需要登录的接口统一挂中间件商品列表公开、下单接口受保护一套代码同时服务前端和未来的 App。4. 联调与部署Vite 转发规则、Nginx 伪静态与 MySQL8 环境排错4.1 本地联调Vite 开发服务器把 /api 指到 TP8 后端前端 5173 端口、后端 TP8 开发服务器在 8080 端口直接跨域请求会有 CORS 问题。最省事的办法是让 Vite 开发服务器把/api开头的请求转给后端// vite.config.ts server: { port: 5173, host: true, // /api 开头的请求转发到本地后端开发服务器 proxy: { /api: { target: http://127.0.0.1:8080, changeOrigin: true, // 去掉 /api 前缀再转发匹配后端不带前缀的路由 rewrite: (path) path.replace(/^\/api/, ), }, }, },target是后端开发服务器地址changeOrigin: true会把请求头的 Host 改成后端地址避免后端做域名校验时判定成非法请求rewrite里把/api前缀去掉是为了匹配 TP8 里不带前缀的路由定义。如果你的后端路由本来就带/api段去掉 rewrite 那行即可。提示改了vite.config.ts要重启 dev server 才生效热更新不会自动加载配置这是本地联调最常见的困惑。4.2 生产部署Nginx 上 Vue3 history 路由与 TP8 入口的共存前端 build 出来的 dist 和后端项目不需要放同一目录。常见做法是前端产物放/var/www/shop-web/dist后端代码放/var/www/shop-serverNginx 一个 server 块同时处理静态资源和接口server { listen 80; server_name shop.example.com; root /var/www/shop-web/dist; index index.html; # 前端 history 路由刷新商品页时回退到 index.html location / { try_files $uri $uri/ /index.html; } # 后端 API重写路径后交给 PHP-FPM location /api/ { rewrite ^/api/(.*)$ /index.php?s$1 break; fastcgi_pass 127.0.0.1:9000; include fastcgi_params; fastcgi_param SCRIPT_FILENAME /var/www/shop-server/public/index.php; } }location /用try_files $uri $uri/ /index.html是 Vue3 history 路由的关键用户直接刷新/goods/100这个路径时服务器上不存在对应文件就回退到 index.html前端路由重新接管。location /api/里用 rewrite 把/api/goods/page变成/index.php?s/goods/page这是 TP 传统的 PATHINFO 兼容入口再交给 9000 端口的 PHP-FPM 处理。常见的部署故障在下表现象原因处理刷新商品详情页 404history 路由没有 try_files 回退补上try_files $uri $uri/ /index.html接口全部 404rewrite 规则把/api去掉了但后端路由有/api去掉 rewrite 或后端路由去掉前缀接口 502PHP-FPM 没起或 fastcgi_pass 端口不对systemctl status php-fpm确认 9000 或 socket页面白屏控制台 404 加载 chunk 失败Vite base 没配子目录子目录部署时给 vite.config.ts 加base: /admin/4.3 MySQL8 初始化与连接报错的三类现场“centos7.9 离线 mysql8”和“mysql8 绿色版免安装”共同指向一个问题内网环境装 MySQL8 时没有 yum 源只能用 rpm 包或者 tar 免安装包。tar 方式的核心命令是这样的# 初始化数据目录mysql 用户必须对目录有权限 mkdir -p /data/mysql chown mysql:mysql /data/mysql /usr/local/mysql/bin/mysqld --initialize-insecure \ --basedir/usr/local/mysql --datadir/data/mysql--initialize-insecure生成一个无密码的 root 账号适合首次开机后用 socket 方式登录再改密。初始化时一定要让 datadir 目录属于 mysql 用户否则启动时目录权限检查不过错误日志里会出现 “Permission denied”。连接层面三个高频现场。第一Access denied for user rootlocalhost多半是初始化后没设密码或老客户端用空密码连被拒第二MySQL8 默认的caching_sha2_password认证插件老版本 JDBC、Navicat、PHP 老驱动会直接 auth 失败临时做法是把账号切回老插件-- 切换账号认证插件为 mysql_native_password ALTER USER shop% IDENTIFIED WITH mysql_native_password BY 新密码; -- 或者直接创建新账号并授权 CREATE USER shop% IDENTIFIED BY 密码; GRANT ALL PRIVILEGES ON shop_db.* TO shop%; FLUSH PRIVILEGES;shop%里的%表示允许任意主机登录。本地用 localhost、远程用 127.0.0.1 访问时MySQL 会把它当成两个不同账号root只在 localhost 有权限时远程就连不上。第三Windows 上“本地计算机上的 mysql8 服务启动后停止”这类报错先别急着重装打开 my.ini检查 basedir 和 datadir 路径是否真实存在再用mysqld --console前台启动真正的错误信息会直接打在终端里比看 Windows 事件日志快得多。PHP8 连接 MySQL8 时caching_sha2_password是原生支持的碰到 PDO 连接报认证失败优先确认扩展版本而不是全局把默认认证插件改掉——全局改法在当前版本里已逐渐失效且影响所有账号。5. NIUSHOP V6 商城二开时先做的 4 个增强类型收敛、幂等键、SKU 检索与慢查询商城二开时最怕的不是新功能难度而是既有接口数据类型变化导致的连锁报错。下面四个增强按优先级排能快速降低维护成本。5.1 接口返回的“空数组”用 TypeScript 收窄后端业务失败如库存不足也走 HTTP 200 时前端统一拦截器不会 rejectdata 里带着错误信息。把成功和失败拆成两个形态// 成功和失败是两个互斥分支配合 if 收窄类型 type ApiStateT | { ok: true; data: T } | { ok: false; message: string }后端 code0 视为 ok否则进 message 分支。页面里用if (state.ok)后TypeScript 自动把 data 收窄成 T。空数组要声明成GoodsItem[]不要写成[{}]——[{}]会让数组元素的类型变成空对象map 里取 item.price 直接报类型错误。5.2 下单接口加幂等键防重复提交下单是资金操作用户双击提交会生成两单。前端生成随机 UUID 放进Idempotency-Key请求头后端用 Redis 判重$redisKey order:idem: . md5($uid . : . $idempotencyKey); // setnx 原子操作同一时刻只有一个请求能拿到锁 if (Redis::setnx($redisKey, 1)) { Redis::expire($redisKey, 600); // 执行下单逻辑 } else { // 返回上一次处理结果 }setnx是原子操作同一时刻只有一个请求能拿到锁第二个请求直接返回已存在的订单号10 分钟过期兜住了“已支付但返回超时”的重试场景。5.3 SKU 筛选走冗余列别让 JSON_TABLE 扛全部第 3 章的 JSON_TABLE 适合后台对账不适合商城首页这种高频筛选。前台商品列表的“颜色红”常见做法是 SKU 表加color_name varchar(50)、capacity_name varchar(50)这类冗余列并建普通索引把规格值最常用的一两个维度固化出来。JSON 保留给详情页的规格展示查询和展示各干各的互不拖累。5.4 用慢查询日志定位接口卡顿接口偶发超时先看 MySQL-- 开发或低峰期临时开启长期开启会放大写盘 SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;日志开启后超过 1 秒的查询会记进去。用mysqldumpslow -s t /var/lib/mysql/*-slow.log按时间排序看 Top 慢语句再对语句EXPLAIN重点看 type 列是不是 ALL。商城最常踩的是WHERE status1 ORDER BY sort_order DESC这种组合缺复合索引建(status, sort_order)后扫描行数能降两个数量级。SKU 筛选那种 JSON 展开后的 JOIN 也要在 explain 里确认驱动表顺序。本文还有配套的精品资源点击获取
返回列表