Skip to content

点餐页购物车联动重构

这次整理的对象来自 11Vue学习/program/vue-sell-app,但和上一轮一样,我们不直接修改这个独立 demo,而是把里面最有复用价值的交互逻辑沉淀成文档。

原始实现里最值得重构的部分,不是单个样式细节,而是这条完整的数据链路:商品列表 -> 购物车加减 -> 购物车汇总 -> 详情弹层 -> 滚动联动 -> 小球动画。这个链路很适合拿来训练组件边界划分和组合式 API 设计。

这次主要参考了下面这些文件:

  • 11Vue学习/program/vue-sell-app/src/App.vue
  • 11Vue学习/program/vue-sell-app/src/components/goods/goods.vue
  • 11Vue学习/program/vue-sell-app/src/components/shopcart/shopcart.vue
  • 11Vue学习/program/vue-sell-app/src/components/cartcontrol/cartcontrol.vue
  • 11Vue学习/program/vue-sell-app/src/components/food/food.vue
  • 11Vue学习/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。这种方式在小例子里足够直观,但大型项目里更容易产生“谁拥有这份状态”的混乱。

先把领域模型收敛一下,后面的重构就会顺很多。

ts
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。

ts
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 里的 payDescpayClass 非常典型,适合保留,但它们更适合留在组合式逻辑里,而不是耦合在视图组件内部。

ts
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 里最核心也最值得保留的逻辑,是 listHeightscrollY 配合算出 currentIndex。这本质上是一个“滚动位置映射到分类索引”的问题,很适合封装成 useMenuCatalog

ts
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

ts
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 事件派发会让组件之间的依赖链不够直观。

如果按组合式思路重写,更推荐用显式通知队列,把“动画请求”从组件树事件中抽出来。

ts
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 只负责消费动画队列,MenuFoodCardFoodDetailDrawer 只负责在“加入购物车”时提交一次请求。

页面容器只负责装配

当目录、购物车、详情层、动画队列都分离以后,真正的页面容器代码会清晰很多。它不再自己做所有计算,而是负责把各能力模块接起来。

vue
<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 不需要再知道整个商品目录,也不应该自己去遍历商品集合。它只接收购物车摘要和显式事件。

vue
<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 中
  • 动画触发是不是依赖隐式组件事件

如果这些问题还存在,就说明这个页面仍然值得继续拆。

共 20 个模块,1301 篇 Markdown 文档。