这是一篇非常适合发在你博客 /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. 架构师视角的总结
为什么要这么折腾?直接写不好吗?
- 复用性 (Reusability):
- 你的
useCart逻辑不仅可以在“点餐页”用,还可以在“商品详情页”、“推荐页”里直接复用,不需要复制粘贴代码。
- 你的
- 可测试性 (Testability):
- 你可以专门对
useCart.ts写单元测试,验证价格计算对不对,而不需要去渲染 UI 组件。
- 你可以专门对
- 关注点分离 (SoC):
.vue文件只负责 “长什么样” (UI)。.ts文件负责 “怎么运作” (Logic)。
这不仅仅是写前端,这是在用架构思维治理前端代码。