Vue3组合式API状态管理:Pinia与Composables的架构设计实践

Vue3状态管理的演进与Pinia的核心设计

Vue3的响应式系统基于Proxy实现,与Vue2的Object.defineProperty相比,能追踪对象属性的动态增删。响应式基础能力的提升使得状态管理方案也随之演进。Pinia作为Vuex的继任者,去掉了mutations的概念,State直接可写,Actions支持同步和异步,整体API更精简。在Vue3生态中,Pinia是官方推荐的状态管理方案,也是TypeScript实战中类型推导支持最好的选择。

Pinia Store的定义与组合模式

Pinia推荐使用组合式函数(Setup Store)定义Store,与Vue3的Composition API风格一致:

// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { UserInfo, LoginParams } from '@/types'

export const useUserStore = defineStore('user', () => {
  const token = ref('')
  const userInfo = ref(null)
  
  const isLoggedIn = computed(() => !!token.value)
  const username = computed(() => userInfo.value?.name ?? '未登录')
  
  async function login(params) {
    const res = await apiLogin(params)
    token.value = res.token
    userInfo.value = res.user
    localStorage.setItem('token', res.token)
  }
  
  function logout() {
    token.value = ''
    userInfo.value = null
    localStorage.removeItem('token')
  }
  
  function init() {
    const saved = localStorage.getItem('token')
    if (saved) token.value = saved
  }
  
  return { token, userInfo, isLoggedIn, username, login, logout, init }
})

Composables与Pinia的职责边界

前端工程化实践中,Composables和Pinia Store经常被混淆。判断标准很简单:跨组件共享的状态放Pinia Store,组件内部复用的逻辑放Composables

典型例子:分页逻辑。分页参数(当前页、每页条数、总数)只在单个列表页使用,不需要全局共享,适合封装为Composable:

// composables/usePagination.ts
import { ref, computed } from 'vue'

export function usePagination(pageSize = 20) {
  const currentPage = ref(1)
  const total = ref(0)
  const size = ref(pageSize)
  
  const totalPages = computed(() => Math.ceil(total.value / size.value))
  
  function setPage(page) {
    if (page < 1 || page > totalPages.value) return
    currentPage.value = page
  }
  
  function reset() {
    currentPage.value = 1
  }
  
  return { currentPage, total, size, totalPages, setPage, reset }
}

而用户认证状态、全局主题配置、购物车数据这类多个页面都需要读写的数据,必须放Pinia Store。

大型应用的Store模块化设计

随着业务增长,Store数量会迅速增加。模块化组织方式建议按业务域拆分,每个域一个目录:

stores/
├── user/
│   ├── index.ts
│   ├── auth.ts
│   └── profile.ts
├── order/
│   ├── index.ts
│   ├── list.ts
│   └── detail.ts
└── app.ts

跨Store组合的场景,直接在Action中调用另一个Store:

// stores/order/list.ts
export const useOrderListStore = defineStore('order-list', () => {
  const orders = ref([])
  
  async function fetchOrders() {
    const userStore = useUserStore()
    const res = await apiGetOrders(userStore.token)
    orders.value = res.data
  }
  
  return { orders, fetchOrders }
})

响应式布局下的状态持久化策略

Pinia的持久化推荐使用pinia-plugin-persistedstate插件。它默认用localStorage,可配置为sessionStorage或自定义存储(如IndexedDB):

// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

在Store定义中开启持久化:

export const useAppStore = defineStore('app', () => {
  const sidebarCollapsed = ref(false)
  const theme = ref('light')
  
  return { sidebarCollapsed, theme }
}, {
  persist: {
    pick: ['theme'],
  }
})

响应式布局的断点状态不需要持久化——窗口resize事件会重新触发计算。UI偏好类数据持久化,临时交互状态不持久化,这是基本原则。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-he-shi-api-zhuang-tai-guan-li-pinia-yu-composables/

(0)
小编小编
上一篇 2026年8月7日
下一篇 2026年8月7日

相关推荐

Vue3组合式API状态管理:Pinia与Composables的选型与实战

Vue3状态管理的两种路径

Vue3组合式API带来了Composables(组合函数)这一新范式,很多场景下不再需要Vuex/Pinia这样的全局状态库。但Composables和Pinia各有适用场景,选错方案会导致状态管理混乱或重复代码。本文从实际项目出发,梳理两者的边界和组合使用方式。

Composables:局部状态的封装单元

Composables是利用Vue3的reactive/refcomputed封装可复用逻辑的函数。当状态只在组件树局部共享时,Composables是最简洁的方案:

// useUserForm.ts
import { ref, computed } from "vue"

export function useUserForm() {
  const form = ref({
    name: "",
    email: "",
    role: "viewer"
  })
  
  const isValid = computed(() => {
    return form.value.name.length > 0 
      && /\S+@\S+\.\S+/.test(form.value.email)
  })

  const reset = () => {
    form.value = { name: "", email: "", role: "viewer" }
  }

  return { form, isValid, reset }
}

使用Composables的关键点:每次调用创建独立实例。同一个useUserForm()在不同组件中调用,各自维护独立状态,互不干扰。

这意味着Composables天然适合:表单状态管理、分页逻辑、筛选器状态等「同功能多实例」的场景。

Pinia:全局共享状态的正式方案

当多个不相关的组件树需要共享同一份数据时,Composables的局部作用域反而成了问题。Pinia作为Vue官方推荐的状态库,提供全局单例Store:

// stores/user.ts
import { defineStore } from "pinia"

export const useUserStore = defineStore("user", () => {
  const currentUser = ref(null)
  const permissions = ref([])
  const isLoggedIn = computed(() => currentUser.value !== null)

  async function login(credentials) {
    const res = await api.login(credentials)
    currentUser.value = res.user
    permissions.value = res.permissions
  }

  function logout() {
    currentUser.value = null
    permissions.value = []
  }

  return { currentUser, permissions, isLoggedIn, login, logout }
})

Pinia的Setup Store写法与Composables高度一致,降低了心智负担。但本质区别是:Pinia Store是全局单例,所有使用useUserStore()的组件操作的是同一份数据。

何时用Composables,何时用Pinia

选型依据不是代码量,而是状态的共享范围和生命周期:

用Composables的场景:

1. 状态只在当前组件及其子组件树中使用
2. 同一逻辑可能被多个组件独立使用(各自维护副本)
3. 状态的生命周期与组件绑定(组件销毁即清除)
4. 不需要跨路由保持状态

用Pinia的场景:

1. 多个不相关组件需要读写同一份数据
2. 状态需要跨路由持久化(如用户登录状态)
3. 需要在DevTools中追踪状态变化
4. 需要SSR时的状态序列化与水合

两者组合使用的实战模式

实际项目中,Composables和Pinia经常配合使用。典型模式:Pinia管理全局数据,Composables封装局部交互逻辑。

// composables/useUserTable.ts
import { useUserStore } from "@/stores/user"

export function useUserTable() {
  const userStore = useUserStore()
  
  // 局部状态:分页和筛选
  const page = ref(1)
  const pageSize = ref(20)
  const filter = ref("")
  
  // 从全局Store派生数据
  const filteredUsers = computed(() => {
    return userStore.users.filter(u => 
      u.name.includes(filter.value)
    )
  })
  
  const pagedUsers = computed(() => {
    const start = (page.value - 1) * pageSize.value
    return filteredUsers.value.slice(start, start + pageSize.value)
  })

  function loadPage(p: number) {
    page.value = p
    userStore.fetchUsers({ page: p, size: pageSize.value })
  }

  return { page, pageSize, filter, pagedUsers, loadPage }
}

Pinia Store的模块化拆分策略

大型项目中,Pinia Store的拆分粒度直接影响可维护性。推荐的拆分原则:

按业务域拆分,而非按技术层拆分。一个用户域的Store应包含用户数据、用户操作、用户相关配置,而不是把数据放一个Store、操作放另一个Store。

避免巨型Store。单个Store超过200行代码时,考虑按职责拆分子Store,通过storeToRefs和组合函数关联。

// stores/index.ts - Store间组合
export const useAppStore = defineStore("app", () => {
  const userStore = useUserStore()
  const permStore = usePermissionStore()

  const canAccess = (resource: string) => {
    return permStore.checkPermission(
      userStore.currentUser?.role, 
      resource
    )
  }

  return { canAccess }
})

响应式布局下的状态管理注意事项

在需要适配多种屏幕尺寸的应用中,状态管理还需考虑布局状态。常见的做法是用Composables封装响应式布局状态:

// composables/useResponsive.ts
import { ref, onMounted, onUnmounted } from "vue"

export function useResponsive() {
  const isMobile = ref(window.innerWidth < 768)
  const isTablet = ref(window.innerWidth >= 768 && window.innerWidth < 1024)
  
  const update = () => {
    isMobile.value = window.innerWidth < 768
    isTablet.value = window.innerWidth >= 768 && window.innerWidth < 1024
  }

  onMounted(() => window.addEventListener("resize", update))
  onUnmounted(() => window.removeEventListener("resize", update))

  return { isMobile, isTablet }
}

布局状态不需要进入Pinia,因为它是组件级的实时计算结果,无需跨组件共享或持久化。这种划分保持了状态管理的清晰边界——全局共享数据归Store,局部交互状态归Composables,实时计算状态归组件自身。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-he-shi-api-zhuang-tai-guan-li-pinia-yu-composables/

(0)
小编小编
上一篇 2026年8月7日
下一篇 2026年8月7日

相关推荐

Vue3组合式API状态管理:Pinia与Composables协作模式实战

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/

(0)
小编小编
上一篇 2026年7月30日
下一篇 2026年7月30日

相关推荐