Pinia与Composables的分工边界
Vue3组合式API带来了两种状态管理思路:Pinia管理全局共享状态,Composables封装组件级可复用逻辑。实际项目中两者的边界经常模糊——订单数据放Pinia还是Composable?分页逻辑写在哪?搞清这个问题是组织好Vue3状态管理的前提。
核心判断原则:跨组件共享且需要持久化的数据归Pinia,组件内或有限复用的逻辑归Composable。用户信息、购物车、权限列表属于Pinia;表单校验、分页、防抖请求属于Composable。
Pinia Store设计与模块拆分
大型应用的Store不应该把所有状态塞进一个文件。按业务领域拆分,每个Store职责单一。
用户认证Store
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { UserInfo } from '@/types'
export const useUserStore = defineStore('user', () => {
const token = ref<string>('')
const userInfo = ref<UserInfo | null>(null)
const isLoggedIn = computed(() => !!token.value)
const permissions = computed(() => userInfo.value?.permissions ?? [])
async function login(credentials: LoginForm) {
const res = await api.login(credentials)
token.value = res.token
userInfo.value = res.user
localStorage.setItem('token', res.token)
}
function logout() {
token.value = ''
userInfo.value = null
localStorage.removeItem('token')
}
function hasPermission(perm: string): boolean {
return permissions.value.includes(perm)
}
return { token, userInfo, isLoggedIn, permissions, login, logout, hasPermission }
})
购物车Store
// stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCartStore = defineStore('cart', () => {
interface CartItem {
id: string
name: string
price: number
quantity: number
}
const items = ref<CartItem[]>([])
const totalCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
function addItem(product: Omit<CartItem, 'quantity'>) {
const existing = items.value.find(i => i.id === product.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...product, quantity: 1 })
}
}
function removeItem(productId: string) {
const index = items.value.findIndex(i => i.id === productId)
if (index > -1) items.value.splice(index, 1)
}
return { items, totalCount, totalPrice, addItem, removeItem }
})
Composable封装组件级可复用逻辑
Composable的本质是把setup中的响应式逻辑提取为独立函数。与Pinia的区别:Composable每次调用创建独立的响应式实例,不跨组件共享。
分页逻辑Composable
// composables/usePagination.ts
import { ref, computed, watch } from 'vue'
export function usePagination(options: {
total: () => number
pageSize?: number
}) {
const currentPage = ref(1)
const pageSize = ref(options.pageSize ?? 20)
const totalPages = computed(() =>
Math.ceil(options.total() / pageSize.value)
)
const paginatedRange = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return { start, end }
})
function goToPage(page: number) {
if (page < 1 || page > totalPages.value) return
currentPage.value = page
}
function nextPage() {
goToPage(currentPage.value + 1)
}
function prevPage() {
goToPage(currentPage.value - 1)
}
watch(() => options.total(), () => {
if (currentPage.value > totalPages.value) {
currentPage.value = Math.max(1, totalPages.value)
}
})
return {
currentPage, pageSize, totalPages,
paginatedRange, goToPage, nextPage, prevPage,
}
}
防抖请求Composable
// composables/useDebouncedFetch.ts
import { ref, onUnmounted } from 'vue'
export function useDebouncedFetch<T>(fn: (params: string) => Promise<T>, delay = 300) {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
let timer: ReturnType<typeof setTimeout> | null = null
async function execute(params: string) {
if (timer) clearTimeout(timer)
return new Promise<void>((resolve) => {
timer = setTimeout(async () => {
loading.value = true
error.value = null
try {
data.value = await fn(params)
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
resolve()
}
}, delay)
})
}
onUnmounted(() => {
if (timer) clearTimeout(timer)
})
return { data, loading, error, execute }
}
Pinia与Composable在组件中的协作
在组件中同时使用Store和Composable,数据流清晰可控:
<script setup lang="ts">
import { useCartStore } from '@/stores/cart'
import { usePagination } from '@/composables/usePagination'
import { useDebouncedFetch } from '@/composables/useDebouncedFetch'
import { searchProducts } from '@/api'
const cartStore = useCartStore()
const { data: products, loading, execute: search } = useDebouncedFetch(searchProducts)
const filteredProducts = computed(() => {
const { start, end } = pagination.paginatedRange.value
return products.value?.slice(start, end) ?? []
})
const pagination = usePagination({
total: () => products.value?.length ?? 0,
pageSize: 12,
})
function onSearch(keyword: string) {
search(keyword)
pagination.goToPage(1)
}
function addToCart(product: Product) {
cartStore.addItem({
id: product.id,
name: product.name,
price: product.price,
})
}
</script>
这个模式中:购物车状态跨页面共享,用Pinia;搜索分页逻辑组件内闭环,用Composable。数据流向一目了然。
Pinia Store间跨模块通信
Store之间可以互相引用,实现跨模块状态联动。比如购物车总价需要在订单Store中使用:
// stores/order.ts
import { defineStore } from 'pinia'
import { useCartStore } from './cart'
export const useOrderStore = defineStore('order', () => {
const cartStore = useCartStore()
const orderSummary = computed(() => ({
items: cartStore.items,
total: cartStore.totalPrice,
count: cartStore.totalCount,
shipping: cartStore.totalPrice > 99 ? 0 : 10,
finalPrice: cartStore.totalPrice + (cartStore.totalPrice > 99 ? 0 : 10),
}))
async function submitOrder() {
if (cartStore.items.length === 0) {
throw new Error('购物车为空')
}
return api.createOrder({
items: cartStore.items,
total: orderSummary.value.finalPrice,
})
}
return { orderSummary, submitOrder }
})
Store间引用的注意事项:避免循环依赖。如果两个Store互相import,Pinia会在首次使用时才初始化,不会报错,但代码可读性差。更好的做法是将共享逻辑提取为独立的工具函数。
Pinia持久化与SSR适配
浏览器环境下,用户刷新页面后Pinia状态丢失。用pinia-plugin-persistedstate自动持久化到localStorage:
// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// Store中启用
export const useUserStore = defineStore('user', () => {
// ...
}, {
persist: {
key: 'yunthe-user',
paths: ['token', 'userInfo'],
storage: localStorage,
}
})
SSR场景下localStorage不可用,需要区分客户端和服务端:
export const useUserStore = defineStore('user', () => {
// ...
}, {
persist: {
key: 'yunthe-user',
paths: ['token', 'userInfo'],
storage: import.meta.env.SSR ? undefined : localStorage,
}
})
Pinia负责全局状态的存储和共享,Composable负责组件级逻辑的封装和复用——两者搭配使用才能发挥Vue3组合式API的最大价值。数据流从Composable向Store汇聚,Store向下分发,单向清晰可追踪。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-he-shi-api-zhuang-tai-guan-li-pinia-yu-composables/