Skip to content

这是一篇非常适合发在你博客 /tech/ 栏目下的前端进阶文章。

文章的核心切入点是 “前端代码的架构设计”,把 Vue 3 的 Composables(组合式函数) 比作后端的 Service 层。这种类比非常符合你“架构师”的身份,也能让看你博客的后端同学瞬间秒懂前端的精髓。


markdown
---
title: Vue 3 实战:像写后端 Service 一样写前端逻辑 —— 组合式函数 (Composables) 封装指南
author: Sail
date: 2026-01-05
tags:
  - Vue3
  - TypeScript
  - Architecture
  - Frontend
description: 很多后端转全栈的开发者写 Vue 时,喜欢把所有逻辑堆在页面文件里,导致代码难以维护。本文介绍如何利用 Vue 3 的 Composables 特性,将业务逻辑抽离为独立的“前端 Service”,实现逻辑复用与 UI 解耦。
---

# Vue 3 实战:像写后端 Service 一样写前端逻辑 —— 组合式函数 (Composables) 封装指南

> **前言**
> 在传统的 Vue 2 (Options API) 开发中,我们习惯把数据 (`data`)、方法 (`methods`)、计算属性 (`computed`) 全部塞进一个 `.vue` 文件里。当页面逻辑复杂时,这个文件会变成几千行的“面条代码”。
>
> 到了 Vue 3,**Composition API** 的出现,让我们拥有了类似后端 **Service 层** 的能力:**将业务逻辑抽离成独立的 TS 文件**
>
> 今天以 **“购物车逻辑”** 为例,演示如何优雅地封装前端逻辑。

## 1. 痛点:臃肿的组件

在重构前,一个点餐页面的代码结构往往是这样的:

**文件名:`OrderPage.vue`**

```html
<script setup>
// 😭 所有的逻辑都混在一起
const cartList = ref([])
const goodsList = ref([])
const showCart = ref(false)

function addToCart(item) { /* 几十行逻辑 */ }
function clearCart() { /* 几十行逻辑 */ }
function calculateTotal() { /* 计算逻辑 */ }
function submitOrder() { /* 下单逻辑 */ }
// ... 还有 API 调用、页面生命周期 ...
</script>

问题:这就像把 Controller、Service、Dao 全写在一个 Java 类里,维护简直是噩梦。


2. 解决方案:抽离逻辑 (The Hook)

我们创建一个独立的 TypeScript 文件,专门处理购物车的业务逻辑。这在 Vue 中被称为 Composable(通常以 use 开头命名),你可以把它理解为前端的 Service 类

文件名:src/hooks/useCart.ts

typescript
import { ref, computed } from 'vue'

// 定义商品接口 (类似后端的 DTO)
export interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
  image: string;
}

/**
 * 购物车逻辑封装
 * 相当于后端的 CartService
 */
export function useCart() {
  // --- State (相当于类的成员变量) ---
  const cartItems = ref<CartItem[]>([]);
  const isCartOpen = ref(false);

  // --- Getters (相当于 get 方法) ---
  // 计算总价
  const totalPrice = computed(() => {
    return cartItems.value.reduce((sum, item) => sum + item.price * item.quantity, 0).toFixed(2);
  });

  // 计算总数量
  const totalCount = computed(() => {
    return cartItems.value.reduce((sum, item) => sum + item.quantity, 0);
  });

  // --- Actions (相当于业务方法) ---
  
  // 添加商品
  const addToCart = (product: CartItem) => {
    const existingItem = cartItems.value.find(item => item.id === product.id);
    if (existingItem) {
      existingItem.quantity++;
    } else {
      cartItems.value.push({ ...product, quantity: 1 });
    }
  };

  // 减少/移除商品
  const removeFromCart = (productId: string) => {
    const index = cartItems.value.findIndex(item => item.id === productId);
    if (index === -1) return;

    const item = cartItems.value[index];
    if (item.quantity > 1) {
      item.quantity--;
    } else {
      cartItems.value.splice(index, 1);
    }
  };

  // 清空购物车
  const clearCart = () => {
    cartItems.value = [];
    isCartOpen.value = false;
  };

  const toggleCartPopup = () => {
    if (cartItems.value.length > 0) {
      isCartOpen.value = !isCartOpen.value;
    }
  };

  // --- Return (暴露给外部的接口) ---
  return {
    cartItems,
    isCartOpen,
    totalPrice,
    totalCount,
    addToCart,
    removeFromCart,
    clearCart,
    toggleCartPopup
  };
}

3. 页面集成:UI 与 逻辑分离

现在,我们的组件(Controller/View)只需要负责展示和调用,代码瞬间清爽了。

文件名:src/components/ProductCard.vue

html
<template>
  <div class="product-card-container">
    <!-- 商品列表区域 -->
    <div class="goods-list">
      <div v-for="item in products" :key="item.id" class="goods-item shadow-sm">
        <img :src="item.image" class="goods-img" />
        <div class="goods-info">
          <h3 class="goods-title">{{ item.name }}</h3>
          <div class="action-row">
            <span class="price-tag">¥{{ item.price }}</span>
            
            <!-- 调用 Hook 里的方法 -->
            <button @click="addToCart(item)" class="btn-add">
              加入购物车
            </button>
          </div>
        </div>
      </div>
    </div>

    <!-- 底部购物车栏 -->
    <div class="cart-bar" @click="toggleCartPopup">
      <div class="cart-icon-wrapper">
        <i class="fas fa-shopping-cart"></i>
        <!-- 使用 Hook 里的计算属性 -->
        <span v-if="totalCount > 0" class="badge">{{ totalCount }}</span>
      </div>
      <div class="price-info">
        合计:<span class="total-price">¥{{ totalPrice }}</span>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
// 1. 引入我们的 "Service"
import { useCart, type CartItem } from '@/hooks/useCart';

// 模拟商品数据
const products = ref<CartItem[]>([
  { id: '101', name: '招牌红烧肉', price: 38.00, quantity: 0, image: '/imgs/food1.jpg' },
  { id: '102', name: '清炒时蔬', price: 18.00, quantity: 0, image: '/imgs/food2.jpg' },
]);

// 2. 实例化 Hook (就像 Java 里的 @Autowired)
const { 
  totalPrice, 
  totalCount, 
  addToCart, 
  toggleCartPopup 
} = useCart();

</script>

<style scoped>
/* 使用 BEM 命名规范或 Tailwind 都可以 */
.product-card-container {
  @apply p-4 bg-gray-50 min-h-screen;
}

.goods-item {
  @apply flex bg-white p-3 rounded-xl mb-3 border border-gray-100;
}

.goods-img {
  @apply w-20 h-20 rounded-lg object-cover mr-3;
}

.goods-info {
  @apply flex-1 flex flex-col justify-between;
}

.goods-title {
  @apply font-bold text-gray-800 text-sm;
}

.action-row {
  @apply flex justify-between items-center mt-2;
}

.price-tag {
  @apply text-red-500 font-bold text-lg;
}

.btn-add {
  @apply bg-blue-500 text-white px-3 py-1 rounded-full text-xs active:scale-95 transition-transform;
}

/* 购物车悬浮栏 */
.cart-bar {
  @apply fixed bottom-4 left-4 right-4 h-12 bg-gray-900 rounded-full flex items-center px-4 text-white shadow-xl z-50;
}

.badge {
  @apply absolute -top-2 -right-2 bg-red-500 text-white text-xs w-5 h-5 flex items-center justify-center rounded-full;
}
</style>

4. 架构师视角的总结

为什么要这么折腾?直接写不好吗?

  1. 复用性 (Reusability)
    • 你的 useCart 逻辑不仅可以在“点餐页”用,还可以在“商品详情页”、“推荐页”里直接复用,不需要复制粘贴代码。
  2. 可测试性 (Testability)
    • 你可以专门对 useCart.ts 写单元测试,验证价格计算对不对,而不需要去渲染 UI 组件。
  3. 关注点分离 (SoC)
    • .vue 文件只负责 “长什么样” (UI)。
    • .ts 文件负责 “怎么运作” (Logic)。

这不仅仅是写前端,这是在用架构思维治理前端代码。