
你问我现在网上各种「前后端分离校园网上店铺」的项目源码都烂大街了照着抄一遍到底还能学到什么。我的看法是这类全栈项目最值钱的地方不在代码本身而在于它把 SpringBoot、Vue、MyBatis、MySQL 这四个东西怎么串成一条完整链路的过程。你以后去公司接触的电商系统跟这个校园网上店铺其实是一套骨架无非是业务更复杂、表更多、并发更高。校园店铺这种规模恰恰适合在可控范围内把数据表、后端接口、前端页面从头到尾跑通一遍。这篇文章就围绕这个项目展开我不会贴一整份源码让你复制而是把设计思路和关键实现拆开讲清楚为什么技术栈选了这四件套、数据库表怎么设计才对得上业务、后端每个模块的职责边界、前端页面怎么对接接口、最后怎么打包部署。每个环节我都尽量附上实际用过的实现方案和踩坑记录你可以直接照着这个思路去改造出自己的版本。先说清楚适合什么人看。你是正在做课程设计、毕业设计的学生或者刚入门全栈想找一个完整项目练手这篇内容能省你不少时间。如果你是工作了几年想复习一下前后端分离架构的细节也可以重点看接口设计、跨域处理、部署配置这几部分。1. 项目整体设计与技术选型思路1.1 「四件套」为什么能凑成一套经典组合校园网上店铺这种系统技术选型其实没什么悬念。核心就四个字各司其职。SpringBoot 解决后端开发效率Vue 解决前端交互体验MyBatis 解决数据访问灵活性MySQL 存数据。它们单拎出来都不算新技术但拼在一起刚好覆盖一条完整业务链路的全部环节。SpringBoot 最大的价值是帮后端省掉了配置地狱。早些年做 SSM 框架项目光是写 web.xml、spring-mvc.xml、mybatis-config.xml、数据源配置、日志配置就能耗掉半天时间业务代码还没开始写人先被配置文件搞烦了。SpringBoot 用「约定优于配置」把大部分默认配置直接给你配好你只要引入对应的 starter项目就能跑起来。校园店铺这种教学型项目最怕的不是业务复杂而是环境搭建复杂到劝退。用 SpringBoot注意力可以集中在 Controller、Service、Mapper 这条主线上。Vue 的优势在于响应式数据绑定和高频交互场景。网购里最典型的就是购物车商品数量加减、勾选、总价实时变化这种操作如果还用传统 jQuery 手动操作 DOM代码会越写越乱。Vue 的数据驱动视图意味着数据一变页面自动更新交互逻辑可以写得很简洁。再一个Vue 天生组件化商品卡片、分页组件、轮播图、弹窗提示都能拆成独立组件管理端和用户端可以复用。MyBatis 属于半自动 ORM 框架SQL 由开发者自己写。有人觉得这不如 JPA 省事但正是因为 SQL 可控你才能处理多条件动态查询、多表关联、聚合统计这些复杂场景。商品列表要按照价格区间筛选、按销量排序、按分类拼接条件这种查询用 MyBatis 的 XML 动态 SQL 写起来一目了然。真要在校园店铺项目里用 JPA 硬套入门成本反而高。MySQL 就不用多解释了开源免费部署简单性能碰到校园店铺这种数据量完全够用。这套组合下来前端独立、后端独立、数据库独立彼此只通过接口交互正好就是现代前后端分离开发的成熟分工。1.2 前后端分离到底分离了什么很多初学者以为「前后端分离」就是前端一个文件夹、后端一个文件夹这理解太表面了。真正的分离是两个工程在开发和部署环境上完全独立唯一的联系是接口文档和约定好的返回 JSON 结构。这个项目我建议拆成两个工程来管理后端工程 campus-shop-server端口 8080提供所有业务接口前端工程 campus-shop-web开发时跑在 5173 端口Vite 默认通过 axios 请求后端接口。开发过程中前端不需要知道后端内部怎么写的只需知道接口地址和返回字段后端也不需要关心页面长什么样只负责返回 JSON 数据。好处很明显同一套后端接口可以同时支持用户端 H5、管理后台甚至以后的手机小程序前后端可以并行开发效率更高代码边界清晰谁出了问题直接定位到哪个工程就行。但分离也带来一个麻烦跨域。浏览器有同源策略前端在 5173 端口访问 8080 端口的接口直接请求会被拦截报 CORS 错误。解决办法常见有两种后端加全局跨域过滤器或者前端开发服务器配置代理。我建议首选前端代理方案因为生产环境本来就要用 Nginx 做反向代理开发环境的代理和生产环境的代理思路一致省得改来改去。具体配置第 4 部分会写。1.3 角色划分与页面流转设计校园网上店铺系统至少要支撑三类角色游客、注册用户、管理员。游客可以看商品列表、商品详情、搜索商品但一旦要加购物车、下单、支付就必须先登录注册用户除了购物下单还能维护收货地址、查看历史订单、取消未发货订单管理员从后台登录负责商品分类管理、商品上下架、订单状态处理、用户禁用启用以及简单的销售统计。页面流转可以用一个典型购物流程串起来商品首页 - 商品列表/搜索 - 商品详情 - 加入购物车 - 登录验证 - 确认订单页 - 提交订单 - 模拟支付 - 订单列表页。管理端的页面则相对独立从登录进入后台布局通过侧边栏在商品管理、分类管理、订单管理、用户管理之间切换。前端路由需要用路由守卫把管理端页面保护起来只允许 admin 角色访问这一块后面也会讲到。2. 数据库设计与核心业务模块拆解2.1 三张核心业务表的关系设计数据库设计是这个项目的地基。表数量不能太少太少后面扩展麻烦也不能一上来就二十多张表维护成本太高。校园店铺这套系统9 到 10 张表是比较舒服的规模。我把最核心的三张表拿出来说。用户表user字段类型说明idbigint主键usernamevarchar(50)用户名唯一passwordvarchar(100)密码BCrypt 加密nicknamevarchar(50)昵称avatarvarchar(255)头像路径phonevarchar(20)手机号rolevarchar(20)角色标识 user/admincreate_timedatetime注册时间商品表goods字段类型说明idbigint主键category_idbigint所属分类namevarchar(100)商品名称covervarchar(255)封面图detailtext商品详情pricedecimal(10,2)价格stockint库存salesint销量statustinyint上架状态 1上架 0下架create_timedatetime发布时间订单表orders字段类型说明idbigint主键order_novarchar(32)订单编号user_idbigint下单用户total_pricedecimal(10,2)订单总价delivery_feedecimal(10,2)配送费statusint订单状态receivervarchar(50)收货人addressvarchar(255)收货地址pay_typetinyint支付方式create_timedatetime下单时间订单和商品之间是多对多关系一个订单包含多个商品一个商品也会出现在多个订单里。所以必须有一张订单明细表 order_item把订单 id 和商品 id 关联起来同时冗余记录下单那一刻的商品名称、价格、数量、图片。这里很多人不理解为什么要冗余总觉得查询实时商品表更「规范化」。真实项目里订单明细必须存快照否则三个月后你查一笔历史订单商品改了名、下架了、价格变了历史订单就变成一笔烂账了。电商系统里「快照」这个概念从这张小表就开始养成。2.2 校园场景比普通商城多考虑了哪些字段如果只是套通用商城模板这个项目和「网上商城管理系统」没有本质区别。既然叫校园店铺业务设计上就要补几个校园特有细节这也是答辩或者项目汇报时能体现出思考深度的地方。第一收货地址可以扩展成「宿舍区地址」。普通商城收货地址一般就是省市区街道门牌校园店铺可以单独建一张 dormitory_address 表字段包括校区、宿舍楼栋、楼层、门牌号、配送偏好时间。哪怕你前期不建表至少在订单表里预留一个 address 字段保存完整宿舍信息展示时才能直观体现「校园」这个词。第二支付方式里加一个「一卡通支付」。订单表里的 pay_type 字段我通常这样约定1 表示微信支付2 表示支付宝3 表示校园一卡通。校园场景下顺手提一嘴「对接一卡通消费系统」会显得你对业务是有思考的。第三计算金额时把配送费单独拆出来。校园店铺经常有「满 30 元免配送费」这类运营规则所以订单总额 商品总额 配送费 - 优惠金额。我在订单表里增加 delivery_fee 字段统计客单价和营收的时候很方便不用每次反推。2.3 数据库初始化与测试数据的准备建库建表的第一步建议独立创建数据库和专用账号不要所有项目共用 root 乱搞。实际建库代码我建议这样写CREATE DATABASE campus_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE campus_shop; CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, nickname VARCHAR(50), avatar VARCHAR(255), phone VARCHAR(20), role VARCHAR(20) DEFAULT user, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个硬性注意点。一是字符集用 utf8mb4别用 utf8。MySQL 里的 utf8 最多只支持 3 字节编码遇到生僻字或者 emoji 会直接报错。现在主流客户端和数据保存都默认使用完整 UTF-8也就是 utf8mb4。二是引擎用 InnoDB。购物车更新、订单创建、库存扣减都涉及事务操作InnoDB 支持事务和行级锁这是 MyISAM 给不了的。虽然测试数据量小感觉不出差别但从设计习惯上必须从一开始就用对。测试数据一定要提前备足。我最烦的就是空数据页面调试列表、搜索、分页、上下架逻辑都需要真实数据支撑。至少准备 2 个管理员账号、5 个普通用户、3 个以上分类、每个分类里 10 个左右商品这样整个演示流程才顺。3. 后端 SpringBoot MyBatis 搭建与核心实现3.1 项目初始化与依赖管理后端工程我习惯直接用 Spring Initializr 生成IDEA 里新建项目时就有这个入口。依赖选择上下面这几组是必须的dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency有一个高频坑MyBatis Starter 版本和 SpringBoot 版本必须匹配。SpringBoot 3.x 要用 mybatis-spring-boot-starter 3.xSpringBoot 2.x 用 2.x 系列。版本混用最常见的现象就是项目启动时报错找不到 SqlSessionFactory 相关的类或者 mapper 扫描不到。项目结构我是按这个来组织的campus-shop-server/ ├── src/main/java/com/campus/shop/ │ ├── controller/ # 接口层接收请求、返回结果 │ ├── service/ # 业务逻辑层 │ ├── mapper/ # MyBatis 数据接口 │ ├── entity/ # 实体类 │ ├── common/ # 返回值封装、全局异常、JWT 工具 │ └── config/ # 跨域配置、静态资源映射等 ├── src/main/resources/ │ ├── mapper/ # XML 映射文件 │ └── application.yml3.2 统一返回、异常处理和 JWT 认证这部分代码很琐碎但它是整个后端好不好用的基础。所有 Controller 接口不要直接返回实体对象或者 null否则前端无法统一判断请求结果。我习惯定义一个 R 类Data public class RT { private Integer code; private String message; private T data; public static T RT ok(T data) { RT result new R(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T RT fail(String message) { RT result new R(); result.setCode(500); result.setMessage(message); return result; } }返回格式统一成 {code, message, data}前端 axios 拦截器只需要判断 code 是不是 200能省掉大量重复代码。所有业务异常也可以统一抛自定义异常由全局异常处理器 RestControllerAdvice 捕获后转成统一格式返回避免后端一崩就把一整页堆栈信息扔给前端。登录认证我建议直接上 JWT。用户登录成功后后端返回 token前端把 token 存 localStorage之后每个请求在请求头 Authorization 带上 token。后端用拦截器在请求到达 Controller 之前校验 token。这个方案比 Session 轻量也更贴合前后端分离的架构。拦截器配置里白名单一定要排好。用户登录、用户注册、商品列表、商品详情、商品搜索、图片访问这些接口都是游客可访问的不能拦截registry.addInterceptor(new JwtInterceptor()) .addPathPatterns(/**) .excludePathPatterns( /user/login, /user/register, /goods/**, /category/list, /file/** );还有一个细节拦截器解析完 token 后把当前用户 id 放到 ThreadLocal 里后续 Service 层直接用。注意在拦截器的 afterCompletion 里移除 ThreadLocal否则线程池复用的时候下一次请求可能拿到上一个用户的身份这是一个很难排查的并发小坑。3.3 核心业务模块的实现要点商品模块。商品查询只需要做分页、条件搜索、详情返回。分页用 PageHelper查询前调用 PageHelper.startPage(pageNum, pageSize)后面第一条查询自动拼上 limit 语句。商品列表接口大概长这样GetMapping(/goods/list) public RPageResultGoodsVO list(RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, RequestParam(required false) String keyword, RequestParam(required false) Long categoryId) { PageHelper.startPage(pageNum, pageSize); ListGoodsVO list goodsMapper.selectGoodsList(keyword, categoryId); PageInfoGoodsVO pageInfo new PageInfo(list); PageResultGoodsVO result new PageResult(); result.setTotal(pageInfo.getTotal()); result.setList(pageInfo.getList()); return R.ok(result); }selectGoodsList 的 XML 里用动态 SQLselect idselectGoodsList resultTypecom.campus.shop.entity.GoodsVO select g.*, c.name as category_name from goods g left join category c on g.category_id c.id where g.status 1 if testkeyword ! null and keyword ! and g.name like concat(%, #{keyword}, %) /if if testcategoryId ! null and g.category_id #{categoryId} /if /where order by g.sales desc /select注意动态 SQL 一定用 MyBatis 的 标签千万别在 Java 代码里拼字符串。类似 select * from goods where name like %keyword% 这种写法既容易 SQL 注入又难维护。MyBatis 的 #{keyword} 会自动做参数转义这正是它作为数据访问层的价值。购物车模块。购物车表虽然简单但同一个用户对同一个商品加购时要更新数量而不是插入新记录。这段逻辑在 Service 层写Cart cart cartMapper.selectByUserIdAndGoodsId(userId, goodsId); if (cart ! null) { cart.setCount(cart.getCount() count); cartMapper.updateById(cart); } else { cart.setUserId(userId); cart.setGoodsId(goodsId); cart.setCount(count); cart.setSelected(true); cartMapper.insert(cart); }订单模块。下单是后端最考验逻辑的地方要同时做四件事校验商品状态和库存、计算总价商品总额加配送费、创建主订单和订单明细、扣减库存。这四步必须在一个事务里任何一个失败整体回滚。直接用 Transactional 注解交给 Spring 管事务。库存扣减我建议用带条件的更新语句update goods set stock stock - #{count}, sales sales #{count} where id #{id} and stock #{count}这种写法可以防止超卖因为 update 语句会在数据库层面做原子判断库存不够就更新 0 行业务里再检查影响行数即可。校园店铺这种并发量这个粒度完全够用不必上分布式锁。管理端统计。管理端首页的数字用户总数、商品总数、订单总数、销售总额直接用聚合 SQLselect count(*) from user; select count(*) from goods where status 1; select count(*) from orders; select coalesce(sum(total_price), 0) from orders where status in (2, 3);4. 前端 Vue 页面设计与联调细节4.1 Vue 项目搭建与路由划分前端我建议直接用 Vue 3 Vite Element Plus。创建命令npm create vitelatest campus-shop-web -- --template vue cd campus-shop-web npm install npm install vue-router4 pinia element-plus axios你在用 Vue 2 ElementUI 也没关系思路完全一样只是个别 API 名称有差异。目录结构我习惯这样分src/ ├── api/ # 所有请求接口封装 │ ├── goods.js │ ├── cart.js │ └── order.js ├── views/ # 页面级组件 │ ├── Home.vue │ ├── Login.vue │ ├── GoodsList.vue │ ├── GoodsDetail.vue │ ├── Cart.vue │ ├── Checkout.vue │ └── admin/ # 管理端页面 ├── router/index.js # 路由配置 ├── store/ # Pinia 状态管理 └── utils/request.js # axios 封装路由规划上把游客页面、用户页面、管理页面分开。加了 meta 信息用来做路由守卫判断const routes [ { path: /, component: Home }, { path: /goods, component: GoodsList }, { path: /goods/:id, component: GoodsDetail }, { path: /login, component: Login }, { path: /cart, component: Cart, meta: { requiresAuth: true } }, { path: /orders, component: OrderList, meta: { requiresAuth: true } }, { path: /admin, component: AdminLayout, meta: { requiresAuth: true, requiresAdmin: true }, children: [ { path: goods, component: AdminGoods }, { path: orders, component: AdminOrders }, { path: users, component: AdminUsers } ] } ];路由守卫是前端拦截的第一道防线router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.meta.requiresAuth !token) { next(/login); } else if (to.meta.requiresAdmin) { const user JSON.parse(localStorage.getItem(user) || {}); if (user.role ! admin) { next(/); } else { next(); } } else { next(); } });前端守卫只是用户体验层面的保护真正的安全必须靠后端拦截器再校验一次。两边都做才是合格的实现。4.2 axios 封装与登录态保持如果每个页面都直接调 this.$http.get代码会非常分散。我习惯先做一个统一的 request.js把 baseURL、请求头、token 附加、响应拦截都收拢起来import axios from axios; import { ElMessage } from element-plus; const request axios.create({ baseURL: /api, timeout: 10000 }); request.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization token; } return config; }); request.interceptors.response.use( response { const res response.data; if (res.code ! 200) { ElMessage.error(res.message || 请求失败); return Promise.reject(new Error(res.message)); } return res.data; }, error { ElMessage.error(error.message || 网络异常); return Promise.reject(error); } ); export default request;这里响应拦截器直接返回 res.data组件里写起来就很清爽了const list await request.get(/goods/list, { params: reqParams });拿到的就已经是后端 data 字段业务层不用再写 response.data.data 这种难看的长链。4.3 Vite 代理解决跨域开发时前端跑 5173后端跑 8080直接请求跨域。最省事的办法是在 vite.config.js 里配代理export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } });配完之后前端代码里所有请求都写相对路径 /api/xxx开发时 Vite 代理转发到后端生产时再用 Nginx 转发前端代码不需要区分环境。4.4 商品展示与购物车交互实现商品列表页用 Element Plus 的 el-card 做卡片展示再加一个搜索框、分类筛选、分页器。搜索触发重新加载时把分页参数重置回第一页这是一个很常见的细节。代码大致是const loadData async () { loading.value true; try { const res await request.get(/goods/list, { params: { pageNum: pageNum.value, pageSize: pageSize.value, keyword: keyword.value, categoryId: categoryId.value } }); list.value res.list; total.value res.total; } finally { loading.value false; } };购物车数量加减直接修改本地数据同时调接口更新数据库。这里有个容易踩的坑后端返回的 price 字段是 BigDecimal经过 Jackson 序列化后前端有可能拿到的是字符串而不是数字直接在 JS 里做加减乘除就会变成字符串拼接总价很容易算错。解决方案是后端给价格字段加 JsonFormat 相关配置统一转成数字或者前端先 parseFloat 再计算。我建议前者因为金额计算这种事前端本来就不该做太多浮点运算。5. 部署与常见问题排查5.1 本地开发环境准备清单整套环境我列一个参考版本组件版本建议说明JDK8 或 17SpringBoot 2 用 8SpringBoot 3 用 17MySQL5.7 或 8.08.0 兼容性更好Node.js16 及以上配合 Vite 使用Maven3.6 以上后端依赖管理IDEIntelliJ IDEA前后端都可以写启动 MySQL 后导入数据库脚本。Windows 安装 MySQL 最常见的坑是初始化后密码设置不明确、或者服务名冲突。建议用环境变量把 MySQL 的 bin 目录加进 PATH方便直接在命令行敲 mysql 命令。导入脚本source /path/to/campus_shop.sql;然后修改后端 application.ymlserver: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/campus_shop?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.DriverMySQL 8 的驱动类名是 com.mysql.cj.jdbc.Driver不是早期的 com.mysql.jdbc.Driver。URL 里的 serverTimezone 必须配否则启动时直接报时区错误。接着启动后端再启动前端。前端启动前先 npm install。npm 慢的话可以切换国内镜像源或用 pnpm差别不大。验证方式很简单浏览器访问 http://localhost:8080/api/goods/list能看到 JSON 说明后端通访问 http://localhost:5173 能打开首页说明前端通登录后打开购物车能加载数据说明前后端连通。5.2 上线的标准打包流程本地能跑只是第一步交付给别人或者部署到服务器要分别打包前后端。后端打包mvn clean package -DskipTests构建后在 target 目录生成 campus-shop-server-1.0.0.jar。部署时用java -jar campus-shop-server-1.0.0.jar想让它在服务器后台长期运行用 nohupnohup java -jar campus-shop-server-1.0.0.jar app.log 21 前端打包npm run build生成到 dist 目录。把 dist 里的静态文件交给 Nginx。Nginx 配置贴一个最简可用版本server { listen 80; server_name localhost; location / { root /usr/share/nginx/html/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; } }location / 里的 try_files 配置非常关键。Vue 用的是 history 路由用户直接访问 /order/123 这类深层页面时如果 Nginx 按路径找文件必然 404必须 try_files 重写到 index.html由前端路由接管。部署全链路是这样的用户请求 80 端口 - Nginx 返回前端静态文件 - 页面里的 /api 请求被 Nginx 转发到 8080 端口 - 后端返回 JSON - 前端渲染。整个过程前后端各管各的互不干扰。5.3 常见问题速查表现象原因解决办法启动报时区错误MySQL 连接参数缺 serverTimezoneURL 加 serverTimezoneAsia/Shanghai前后端端口跨域前端域名/端口和后端不同开发用 Vite 代理部署用 Nginx 转发前端展示不了上传的图片图片路径没有映射为静态资源后端加 WebMvcConfigurer 把上传目录映射为 /file/**登录接口一直报「登录失败」密码明文/加解密方式不一致后端统一用 BCrypt 加密和校验页面显示中文乱码客户端编码和数据库编码不一致统一 utf8mb4连接参数加 characterEncodingutf8打包后的前端刷新深层路由 404没配置 try_filesNginx 加 try_files $uri $uri/ /index.html商品列表搜不到数据接口参数名和前端不一致统一 pageNum、pageSize、keyword 等字段名jar 包部署内存不足服务器内存小java -jar 加 -Xms64m -Xmx256m前三个坑最常出现。尤其是图片上传后的静态资源映射很多人把图片存到本地某个文件夹就不管了前端当然访问不到。我当时的配置是这样的Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath System.getProperty(user.dir) /upload/; registry.addResourceHandler(/file/**) .addResourceLocations(file: uploadPath); } }再补一个不建议抓瞎的问题Vue 3 用 Pinia 保存用户信息时页面刷新后 store 数据会清空。要保证登录状态不丢把 user 对象也存一份 localStorage应用初始化时再恢复 store。这一步不做你会看到很诡异的现象登录后一切正常刷新一下购物车进不去了又得重新登录。根据我个人这两年的经验校园网上店铺这个项目最难的部分真不是什么高级框架特性而是把数据库、后端、前端这条链路完整打通。很多同学报错就上网搜搜到一个解决办法套上去没有去理解为什么。你只要把上面这几个最关键的点逐步跑通比如统一返回格式、JWT 拦截器、事务扣库存、Axios 封装、Nginx 代理整个项目就算真正内化成你自己的能力了。做完之后你还可以继续往深了加东西比如文件用 OSS 存储、订单加定时任务自动关闭、管理端加图表统计这些都是建立在骨架稳定之上的扩展但那就是另一个话题了。