🎯 核心原则
Vue 文件只做「展示和交互」,Composables 做「逻辑和数据」
📋 明确分类清单
✅ 应该写在 Vue 文件中的内容
1. UI 相关的状态
vue
<script setup>
// ✅ UI 交互状态
const isModalOpen = ref(false) // 弹窗开关
const isDropdownVisible = ref(false) // 下拉菜单
const activeTab = ref(0) // 当前激活的 tab
const isHovering = ref(false) // 鼠标悬停状态
const selectedRowId = ref(null) // 当前选中的行
// ✅ 表单输入绑定(UI 层)
const formData = reactive({
name: '',
email: '',
password: ''
})
// ✅ 动画/过渡相关
const isAnimating = ref(false)
const transitionName = ref('fade')
</script>
2. 纯 UI 逻辑(不涉及数据获取)
vue
<script setup>
// ✅ 切换 UI 元素
const toggleModal = () => {
isModalOpen.value = !isModalOpen.value
}
// ✅ 表单验证(同步、前端验证)
const validateForm = () => {
if (!formData.name) {
errorMessage.value = '请输入姓名'
return false
}
if (formData.password.length < 6) {
errorMessage.value = '密码至少6位'
return false
}
return true
}
// ✅ 格式化显示(纯展示用)
const formatDate = (date) => {
return new Date(date).toLocaleDateString('zh-CN')
}
// ✅ UI 交互逻辑
const handleScroll = (e) => {
const scrollTop = e.target.scrollTop
isNavbarTransparent.value = scrollTop < 100
}
// ✅ 本地计算属性(基于 UI 状态)
const isFormValid = computed(() => {
return formData.name && formData.password.length >= 6
})
const buttonText = computed(() => {
return isLoading.value ? '提交中...' : '提交'
})
</script>
3. 组件生命周期钩子(仅调用逻辑)
vue
<script setup>
import { useUser } from '@/composables/useUser'
import { useAnalytics } from '@/composables/useAnalytics'
const { fetchUser, user } = useUser()
const { trackPageView } = useAnalytics()
// ✅ 组件挂载时调用 composable 的方法
onMounted(() => {
fetchUser() // 调用 composable 的逻辑
trackPageView() // 调用 composable 的逻辑
})
// ✅ 监听路由变化
watch(() => route.params.id, (newId) => {
fetchUser(newId) // 调用 composable 的逻辑
})
</script>
4. 模板 ref(DOM 引用)
vue
<script setup>
// ✅ 引用 DOM 元素
const inputRef = ref(null)
const modalRef = ref(null)
const chartRef = ref(null)
// ✅ 直接的 DOM 操作(简单的)
const focusInput = () => {
inputRef.value?.focus()
}
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
</script>
<template>
<input ref="inputRef" />
<div ref="modalRef">弹窗内容</div>
<canvas ref="chartRef"></canvas>
</template>
✅ 应该写在 Composables 中的内容
1. 所有 HTTP 请求和 API 调用
javascript
// composables/useUsers.js
export function useUsers() {
const users = ref([])
const loading = ref(false)
// ✅ HTTP 请求逻辑
const fetchUsers = async () => {
loading.value = true
try {
const { data } = await axios.get('/api/users')
users.value = data
} finally {
loading.value = false
}
}
const createUser = async (userData) => {
const { data } = await axios.post('/api/users', userData)
users.value.unshift(data)
return data
}
return { users, loading, fetchUsers, createUser }
}
2. 全局/跨组件状态
javascript
// composables/useAuth.js
export function useAuth() {
// ✅ 全局认证状态
const user = ref(null)
const isAuthenticated = ref(false)
const token = ref(localStorage.getItem('token'))
// ✅ 登录/登出逻辑
const login = async (credentials) => {
const { data } = await authApi.login(credentials)
token.value = data.token
user.value = data.user
isAuthenticated.value = true
localStorage.setItem('token', data.token)
}
const logout = () => {
token.value = null
user.value = null
isAuthenticated.value = false
localStorage.removeItem('token')
}
return { user, isAuthenticated, login, logout }
}
3. 复杂的业务逻辑
javascript
// composables/useShoppingCart.js
export function useShoppingCart() {
const cart = ref([])
// ✅ 计算总价(业务逻辑)
const totalPrice = computed(() => {
return cart.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
})
// ✅ 库存检查
const checkStock = async (productId, quantity) => {
const { data } = await inventoryApi.checkStock(productId)
return data.available >= quantity
}
// ✅ 添加购物车(含业务规则)
const addToCart = async (product, quantity = 1) => {
// 检查库存
const hasStock = await checkStock(product.id, quantity)
if (!hasStock) {
throw new Error('库存不足')
}
// 检查是否已存在
const existingItem = cart.value.find(item => item.id === product.id)
if (existingItem) {
existingItem.quantity += quantity
} else {
cart.value.push({ ...product, quantity })
}
// 保存到本地存储
saveCartToLocal()
}
return { cart, totalPrice, addToCart }
}
4. 数据持久化和缓存
javascript
// composables/useLocalStorage.js
export function useLocalStorage(key, defaultValue) {
const data = ref(defaultValue)
// ✅ 读取本地存储
const loadData = () => {
const stored = localStorage.getItem(key)
if (stored) {
data.value = JSON.parse(stored)
}
}
// ✅ 保存到本地存储
const saveData = () => {
localStorage.setItem(key, JSON.stringify(data.value))
}
// ✅ 监听变化自动保存
watch(data, saveData, { deep: true })
loadData()
return { data, saveData, loadData }
}
5. 副作用和订阅(WebSocket、定时器等)
javascript
// composables/useWebSocket.js
export function useWebSocket(url) {
const messages = ref([])
const isConnected = ref(false)
let ws = null
// ✅ WebSocket 连接逻辑
const connect = () => {
ws = new WebSocket(url)
ws.onopen = () => { isConnected.value = true }
ws.onmessage = (event) => { messages.value.push(JSON.parse(event.data)) }
}
const send = (data) => {
ws?.send(JSON.stringify(data))
}
// ✅ 清理逻辑
const disconnect = () => {
ws?.close()
isConnected.value = false
}
return { messages, isConnected, connect, send, disconnect }
}
6. 可复用的辅助函数
javascript
// composables/useDebounce.js
export function useDebounce(fn, delay = 300) {
let timer = null
// ✅ 防抖逻辑
const debouncedFn = (...args) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
return debouncedFn
}
// composables/usePagination.js
export function usePagination(itemsPerPage = 10) {
const currentPage = ref(1)
const totalItems = ref(0)
// ✅ 分页计算逻辑
const totalPages = computed(() => Math.ceil(totalItems.value / itemsPerPage))
const startIndex = computed(() => (currentPage.value - 1) * itemsPerPage)
const endIndex = computed(() => startIndex.value + itemsPerPage)
const nextPage = () => {
if (currentPage.value < totalPages.value) currentPage.value++
}
const prevPage = () => {
if (currentPage.value > 1) currentPage.value--
}
return { currentPage, totalPages, startIndex, endIndex, nextPage, prevPage }
}
🎨 实战对比示例
完整组件示例(展示正确的分工)
vue
<!-- UserProfile.vue - 只负责展示和交互 -->
<template>
<div class="user-profile">
<!-- UI 状态控制显示 -->
<div v-if="loading" class="loading-spinner">
加载中...
</div>
<div v-else-if="error" class="error-message">
错误: {{ error }}
<button @click="retry">重试</button>
</div>
<div v-else class="user-info">
<!-- 展示数据 -->
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
<!-- UI 交互元素 -->
<button @click="toggleEditMode">
{{ isEditMode ? '取消' : '编辑' }}
</button>
<!-- UI 状态控制的表单 -->
<form v-if="isEditMode" @submit.prevent="handleUpdate">
<input v-model="editForm.name" />
<input v-model="editForm.email" />
<button type="submit">保存</button>
</form>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useUser } from '@/composables/useUser'
import { useNotification } from '@/composables/useNotification'
// ✅ 从 composable 获取数据和业务逻辑
const { user, loading, error, fetchUser, updateUser } = useUser()
const { showSuccess, showError } = useNotification()
// ✅ UI 状态(放在组件中)
const isEditMode = ref(false)
const editForm = reactive({
name: '',
email: ''
})
// ✅ UI 逻辑(放在组件中)
const toggleEditMode = () => {
isEditMode.value = !isEditMode.value
if (isEditMode.value) {
// 复制数据到编辑表单
editForm.name = user.value.name
editForm.email = user.value.email
}
}
// ✅ 调用 composable 的业务逻辑
const handleUpdate = async () => {
try {
await updateUser(editForm)
showSuccess('更新成功')
isEditMode.value = false
} catch (err) {
showError('更新失败')
}
}
const retry = () => {
fetchUser()
}
// ✅ 生命周期钩子(调用逻辑)
onMounted(() => {
fetchUser()
})
</script>
对应的 Composable
javascript
// composables/useUser.js - 包含所有业务逻辑
import { ref } from 'vue'
import axios from 'axios'
export function useUser() {
// ✅ 数据状态
const user = ref(null)
const loading = ref(false)
const error = ref(null)
// ✅ HTTP 请求逻辑
const fetchUser = async (userId) => {
loading.value = true
error.value = null
try {
const { data } = await axios.get(`/api/users/${userId || 'me'}`)
user.value = data
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// ✅ 业务逻辑
const updateUser = async (userData) => {
loading.value = true
try {
const { data } = await axios.put(`/api/users/${user.value.id}`, userData)
user.value = data
return data
} finally {
loading.value = false
}
}
// ✅ 其他业务逻辑
const changePassword = async (oldPassword, newPassword) => {
// 密码修改逻辑
}
const uploadAvatar = async (file) => {
// 头像上传逻辑
}
return {
user,
loading,
error,
fetchUser,
updateUser,
changePassword,
uploadAvatar
}
}
📝 快速判断清单(查表用)
| 内容类型 | 放 Vue 文件 | 放 Composables |
|---|---|---|
| 弹窗开关状态 | ✅ | ❌ |
| HTTP 请求 | ❌ | ✅ |
| 表单输入绑定 | ✅ | ❌ |
| 全局用户状态 | ❌ | ✅ |
| 本地计算属性(UI相关) | ✅ | ❌ |
| 跨组件共享的计算属性 | ❌ | ✅ |
| 点击事件处理(简单) | ✅ | ❌ |
| 复杂的业务规则 | ❌ | ✅ |
| 路由参数监听 | ✅ | ❌ |
| WebSocket 连接 | ❌ | ✅ |
| 本地存储操作 | ❌ | ✅ |
| 表格选中行 ID | ✅ | ❌ |
| 购物车总价计算 | ❌ | ✅ |
| 防抖/节流函数 | ❌ | ✅ |
| 定时器 | ❌ | ✅ |
| 表单验证(简单) | ✅ | ❌ |
| 表单验证(复杂,需调接口) | ❌ | ✅ |
| 动画状态 | ✅ | ❌ |
🎯 最终原则
- Vue 文件是「视图层」:只关心"显示什么"和"用户点了什么"
- Composables 是「逻辑层」:关心"数据从哪里来"和"业务怎么处理"
- 能复用的放 Composables:如果逻辑可能在多个组件用到,直接放 composable
- 和 UI 强相关的放组件:弹窗开关、tab 切换、hover 状态等
这样组织代码,Vue 组件会非常清爽,只专注于 UI 展示和交互,而所有复杂的数据处理、API 调用、业务逻辑都在 composables 中,易于测试和复用。