点餐页购物车联动重构
这次整理的对象来自 11Vue学习/program/vue-sell-app,但和上一轮一样,我们不直接修改这个独立 demo,而是把里面最有复用价值的交互逻辑沉淀成文档。
原始实现里最值得重构的部分,不是单个样式细节,而是这条完整的数据链路:商品列表 -> 购物车加减 -> 购物车汇总 -> 详情弹层 -> 滚动联动 -> 小球动画。这个链路很适合拿来训练组件边界划分和组合式 API 设计。
这次主要参考了下面这些文件:
11Vue学习/program/vue-sell-app/src/App.vue11Vue学习/program/vue-sell-app/src/components/goods/goods.vue11Vue学习/program/vue-sell-app/src/components/shopcart/shopcart.vue11Vue学习/program/vue-sell-app/src/components/cartcontrol/cartcontrol.vue11Vue学习/program/vue-sell-app/src/components/food/food.vue11Vue学习/program/vue-sell-app/src/components/header/header.vue
原实现里最重的耦合点
goods.vue 其实承担了太多职责。它既要请求商品数据,又要管理左右联动滚动,还要计算当前高亮菜单、整理已选商品、控制商品详情弹层、监听 cart.add 事件并触发购物车下落动画。
shopcart.vue 也不仅仅是一个底部购物车。它同时持有汇总金额、购买门槛文案、列表展开折叠、清空逻辑和动画球队列。对于 demo 来说这很常见,但如果业务继续扩展,很快就会出现两个问题:
- 页面级联动逻辑很难测试
- 任何一个局部需求变化,都会牵连多个组件
这个例子最适合抽象成“目录状态 + 购物车状态 + 滚动同步 + 动画通知”四条清晰的职责线。
推荐的拆分结构
更适合 Vue 3 的结构可以拆成下面这些模块:
pages/menu/index.vue:页面入口,负责首屏数据加载components/menu/MenuPage.vue:组合容器,连接商品目录、购物车和详情层components/menu/MenuSidebar.vue:左侧分类菜单components/menu/MenuFoodList.vue:右侧商品列表components/menu/MenuFoodCard.vue:单个商品卡片components/menu/FoodDetailDrawer.vue:商品详情弹层components/cart/CartBar.vue:底部购物车摘要栏components/cart/CartListDrawer.vue:购物车明细层components/cart/CartAction.vue:加减按钮组件composables/useMenuCatalog.ts:商品目录、当前分类和滚动同步composables/useCart.ts:购物车核心状态与派生统计composables/useDropQueue.ts:小球下落动画队列composables/useFoodDetail.ts:详情抽屉开关与当前商品
拆分以后,页面容器只组合状态,不直接处理实现细节。展示组件只接 props 和抛事件,复杂联动由 composable 负责。
先把商品和购物车模型定下来
原始 demo 里很多状态都是直接挂在 food 对象上,比如 food.count。这种方式在小例子里足够直观,但大型项目里更容易产生“谁拥有这份状态”的混乱。
先把领域模型收敛一下,后面的重构就会顺很多。
export interface Seller {
id: string
name: string
minPrice: number
deliveryPrice: number
}
export interface FoodItem {
id: string
name: string
description: string
icon: string
price: number
oldPrice?: number
sellCount: number
rating: number
}
export interface FoodCategory {
id: string
name: string
type: number
foods: FoodItem[]
}
export interface CartLine {
foodId: string
quantity: number
}这里有一个关键取舍:把购物车数量从 food.count 里独立出来,改成 CartLine[] 或 Record<string, number> 管理。这样商品目录负责展示商品,购物车负责维护数量,两边的边界会清晰很多。
购物车状态应该有单一数据源
原实现的 selectFoods 是在 goods.vue 里通过遍历 goods 临时收集出来的。这个方案虽然能用,但它让购物车依赖商品列表内部的可变状态,不利于后续同步、缓存和复用。
更合理的方式是把购物车抽成一个独立 composable,由它维护单一数据源,再把统计结果以 computed 形式暴露给 UI。
import { computed, shallowRef } from 'vue'
import type { FoodItem } from '../types/menu'
export interface CartEntry {
food: FoodItem
quantity: number
}
export function useCart() {
const entries = shallowRef<CartEntry[]>([])
const totalCount = computed(() => {
return entries.value.reduce((count, entry) => count + entry.quantity, 0)
})
const totalPrice = computed(() => {
return entries.value.reduce((price, entry) => {
return price + entry.food.price * entry.quantity
}, 0)
})
function add(food: FoodItem) {
const current = entries.value.find(entry => entry.food.id === food.id)
if (current) {
current.quantity += 1
entries.value = [...entries.value]
return
}
entries.value = [...entries.value, { food, quantity: 1 }]
}
function decrease(foodId: string) {
const current = entries.value.find(entry => entry.food.id === foodId)
if (!current) {
return
}
if (current.quantity <= 1) {
entries.value = entries.value.filter(entry => entry.food.id !== foodId)
return
}
current.quantity -= 1
entries.value = [...entries.value]
}
function clear() {
entries.value = []
}
function quantityOf(foodId: string) {
return entries.value.find(entry => entry.food.id === foodId)?.quantity ?? 0
}
return {
entries,
totalCount,
totalPrice,
add,
decrease,
clear,
quantityOf
}
}这样一来,底部购物车、商品卡片、详情弹层都只需要依赖同一份购物车状态,不需要各自维护数量逻辑。
配送门槛文案也应该变成派生状态
shopcart.vue 里的 payDesc 和 payClass 非常典型,适合保留,但它们更适合留在组合式逻辑里,而不是耦合在视图组件内部。
import { computed } from 'vue'
export function useCartSummary(totalPrice: Readonly<{ value: number }>, minPrice: Readonly<{ value: number }>) {
const payDescription = computed(() => {
if (totalPrice.value === 0) {
return `¥${minPrice.value}元起送`
}
if (totalPrice.value < minPrice.value) {
return `还差¥${minPrice.value - totalPrice.value}元起送`
}
return '去结算'
})
const canCheckout = computed(() => totalPrice.value >= minPrice.value)
return {
payDescription,
canCheckout
}
}这样以后如果结算文案要国际化,或者门槛计算规则要修改,只需要调整 composable,不必改动多个组件。
商品目录滚动同步应该独立封装
原始 goods.vue 里最核心也最值得保留的逻辑,是 listHeight 和 scrollY 配合算出 currentIndex。这本质上是一个“滚动位置映射到分类索引”的问题,很适合封装成 useMenuCatalog。
import { computed, shallowRef } from 'vue'
import type { FoodCategory } from '../types/menu'
export function useMenuCatalog() {
const categories = shallowRef<FoodCategory[]>([])
const sectionHeights = shallowRef<number[]>([])
const scrollTop = shallowRef(0)
const activeCategoryId = shallowRef<string | null>(null)
const activeIndex = computed(() => {
for (let index = 0; index < sectionHeights.value.length; index += 1) {
const current = sectionHeights.value[index]
const next = sectionHeights.value[index + 1]
if (next == null || (scrollTop.value >= current && scrollTop.value < next)) {
return index
}
}
return 0
})
function setCategories(value: FoodCategory[]) {
categories.value = value
activeCategoryId.value = value[0]?.id ?? null
}
function setSectionHeights(value: number[]) {
sectionHeights.value = value
}
function syncScrollTop(value: number) {
scrollTop.value = value
activeCategoryId.value = categories.value[activeIndex.value]?.id ?? null
}
return {
categories,
sectionHeights,
scrollTop,
activeCategoryId,
activeIndex,
setCategories,
setSectionHeights,
syncScrollTop
}
}这个抽象的价值在于,滚动同步终于从“某个页面的内部技巧”变成了“一个有明确输入输出的能力模块”。后面要接 better-scroll、原生滚动,或者虚拟列表,都可以只换接入层。
商品详情弹层不应该自己持有购物车规则
原始 food.vue 里既负责弹层显示,也负责评价筛选,还能在首次加入购物车时直接 Vue.set(this.food, 'count', 1)。从职责上看,这个组件知道得太多了。
更合理的写法是让详情抽屉只负责当前商品展示和行为抛出,购物车更新仍然交给 useCart。
import { shallowRef } from 'vue'
import type { FoodItem } from '../types/menu'
export function useFoodDetail() {
const visible = shallowRef(false)
const currentFood = shallowRef<FoodItem | null>(null)
function open(food: FoodItem) {
currentFood.value = food
visible.value = true
}
function close() {
visible.value = false
}
return {
visible,
currentFood,
open,
close
}
}把显示状态和购物车状态拆开以后,详情层可以被视为一个纯粹的视图容器,而不是另一个隐形状态中心。
小球下落动画建议改成通知队列
原始实现里 goods.vue 通过事件 cart.add 通知 shopcart,再调用 drop(target) 启动动画。这个思路本身很好,但 Vue 2 事件派发会让组件之间的依赖链不够直观。
如果按组合式思路重写,更推荐用显式通知队列,把“动画请求”从组件树事件中抽出来。
import { shallowRef } from 'vue'
export interface DropRequest {
x: number
y: number
}
export function useDropQueue() {
const queue = shallowRef<DropRequest[]>([])
function push(request: DropRequest) {
queue.value = [...queue.value, request]
}
function shift() {
const [first, ...rest] = queue.value
queue.value = rest
return first ?? null
}
return {
queue,
push,
shift
}
}把动画触发改成显式数据流以后,CartBar 只负责消费动画队列,MenuFoodCard 或 FoodDetailDrawer 只负责在“加入购物车”时提交一次请求。
页面容器只负责装配
当目录、购物车、详情层、动画队列都分离以后,真正的页面容器代码会清晰很多。它不再自己做所有计算,而是负责把各能力模块接起来。
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import CartBar from '../cart/CartBar.vue'
import FoodDetailDrawer from './FoodDetailDrawer.vue'
import MenuFoodList from './MenuFoodList.vue'
import MenuSidebar from './MenuSidebar.vue'
import { useCart } from '../../composables/useCart'
import { useDropQueue } from '../../composables/useDropQueue'
import { useFoodDetail } from '../../composables/useFoodDetail'
import { useMenuCatalog } from '../../composables/useMenuCatalog'
import { fetchMenuCategories } from '../../services/menu-service'
const cart = useCart()
const catalog = useMenuCatalog()
const foodDetail = useFoodDetail()
const dropQueue = useDropQueue()
const selectedFoods = computed(() => cart.entries.value)
onMounted(async () => {
const categories = await fetchMenuCategories()
catalog.setCategories(categories)
})
</script>
<template>
<section class="menu-page">
<MenuSidebar
:items="catalog.categories"
:active-index="catalog.activeIndex"
/>
<MenuFoodList
:items="catalog.categories"
:quantity-of="cart.quantityOf"
@add="cart.add($event.food); dropQueue.push($event.position)"
@decrease="cart.decrease($event)"
@open-food="foodDetail.open($event)"
/>
<CartBar
:entries="selectedFoods"
:total-count="cart.totalCount"
:total-price="cart.totalPrice"
@clear="cart.clear()"
/>
<FoodDetailDrawer
:visible="foodDetail.visible"
:food="foodDetail.currentFood"
:quantity-of="cart.quantityOf"
@close="foodDetail.close()"
@add="cart.add($event.food); dropQueue.push($event.position)"
@decrease="cart.decrease($event)"
/>
</section>
</template>这个容器虽然看起来还是很像页面根组件,但它已经从“全能实现体”变成了“状态装配器”。后续任何局部重构,都可以在不打穿整页的前提下单独推进。
购物车组件应该缩回展示职责
重构后的 CartBar 不需要再知道整个商品目录,也不应该自己去遍历商品集合。它只接收购物车摘要和显式事件。
<script setup lang="ts">
import type { CartEntry } from '../../composables/useCart'
defineProps<{
entries: CartEntry[]
totalCount: number
totalPrice: number
}>()
const emit = defineEmits<{
clear: []
checkout: []
}>()
</script>
<template>
<footer class="cart-bar">
<div class="summary">
<span>已选 {{ totalCount }} 件</span>
<strong>¥{{ totalPrice }}</strong>
</div>
<button type="button" @click="emit('clear')">
清空
</button>
<button type="button" @click="emit('checkout')">
去结算
</button>
</footer>
</template>当组件边界被收窄以后,动画、滚动、门槛判断、数量维护都不再需要留在这个组件里。
这次重构真正值得复用的经验
这个 demo 最大的收获,不是“把 Vue 2 改写成 Vue 3”,而是学会识别高耦合交互页里的几个常见状态中心:
- 目录状态:当前分类、高亮项、滚动位置
- 购物车状态:条目、数量、金额、清空
- 详情状态:当前商品、弹层开关、局部筛选
- 动画状态:加入购物车后的视觉反馈
只要把这四类状态拆开,你就能明显降低页面复杂度。
以后整理类似“列表 + 购物车 + 详情”结构时,可以优先检查这几个问题:
- 商品数量是不是还直接挂在商品对象上
- 汇总金额是不是在多个组件里重复计算
- 滚动同步是不是耦合在页面 methods 中
- 动画触发是不是依赖隐式组件事件
如果这些问题还存在,就说明这个页面仍然值得继续拆。
