Skip to content

Vue.js 状态管理

什么是状态管理?

状态管理是指在应用中管理和共享数据的方式。当应用变得复杂时,组件之间需要共享状态,这时就需要一个集中式的状态管理方案。

为什么需要状态管理?

问题场景

组件 A
  ├── 组件 B
  │   └── 组件 D
  └── 组件 C
      └── 组件 E

如果组件 D 和组件 E 需要共享数据,通过 props 和 events 会很繁琐。

Vue 3 状态管理方案

1. 简单状态管理(Reactive)

对于简单应用,可以使用 reactive 创建全局状态:

javascript
// store.js
import { reactive } from 'vue'

export const store = reactive({
  count: 0,
  increment() {
    this.count++
  }
})
vue
<!-- ComponentA.vue -->
<script setup>
import { store } from './store'
</script>

<template>
  <div>
    <p>Count: {{ store.count }}</p>
    <button @click="store.increment()">增加</button>
  </div>
</template>

2. Pinia(推荐)

Pinia 是 Vue 3 官方推荐的状态管理库,比 Vuex 更简单、更符合 Vue 3 的设计理念。

安装 Pinia

bash
npm install pinia

创建 Store

javascript
// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // state
  const count = ref(0)
  const name = ref('Counter')
  
  // getters
  const doubleCount = computed(() => count.value * 2)
  
  // actions
  function increment() {
    count.value++
  }
  
  function decrement() {
    count.value--
  }
  
  async function incrementAsync() {
    await new Promise(resolve => setTimeout(resolve, 1000))
    count.value++
  }
  
  return {
    count,
    name,
    doubleCount,
    increment,
    decrement,
    incrementAsync
  }
})

在应用中使用

javascript
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const pinia = createPinia()
const app = createApp(App)

app.use(pinia)
app.mount('#app')

在组件中使用

vue
<script setup>
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

<template>
  <div>
    <h2>{{ counter.name }}</h2>
    <p>Count: {{ counter.count }}</p>
    <p>Double: {{ counter.doubleCount }}</p>
    
    <button @click="counter.increment()">增加</button>
    <button @click="counter.decrement()">减少</button>
    <button @click="counter.incrementAsync()">异步增加</button>
  </div>
</template>

实战示例:购物车 Store

javascript
// stores/cart.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCartStore = defineStore('cart', () => {
  // State
  const items = ref([])
  
  // Getters
  const totalItems = computed(() => {
    return items.value.reduce((sum, item) => sum + item.quantity, 0)
  })
  
  const totalPrice = computed(() => {
    return items.value.reduce((sum, item) => {
      return sum + item.price * item.quantity
    }, 0)
  })
  
  const isEmpty = computed(() => items.value.length === 0)
  
  // Actions
  function addItem(product) {
    const existingItem = items.value.find(item => item.id === product.id)
    
    if (existingItem) {
      existingItem.quantity++
    } else {
      items.value.push({
        ...product,
        quantity: 1
      })
    }
  }
  
  function removeItem(productId) {
    const index = items.value.findIndex(item => item.id === productId)
    if (index > -1) {
      items.value.splice(index, 1)
    }
  }
  
  function updateQuantity(productId, quantity) {
    const item = items.value.find(item => item.id === productId)
    if (item) {
      item.quantity = Math.max(1, quantity)
    }
  }
  
  function clearCart() {
    items.value = []
  }
  
  async function checkout() {
    // 模拟 API 调用
    await new Promise(resolve => setTimeout(resolve, 1000))
    
    const order = {
      items: items.value,
      total: totalPrice.value,
      timestamp: new Date()
    }
    
    clearCart()
    return order
  }
  
  return {
    items,
    totalItems,
    totalPrice,
    isEmpty,
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    checkout
  }
})

使用购物车 Store

vue
<script setup>
import { useCartStore } from '@/stores/cart'

const cart = useCartStore()

const products = [
  { id: 1, name: 'iPhone 14', price: 5999 },
  { id: 2, name: 'MacBook Pro', price: 12999 },
  { id: 3, name: 'iPad Air', price: 4399 },
  { id: 4, name: 'AirPods Pro', price: 1999 }
]

async function handleCheckout() {
  try {
    const order = await cart.checkout()
    alert(`订单已提交!总金额:¥${order.total}`)
  } catch (error) {
    alert('结算失败,请重试')
  }
}
</script>

<template>
  <div class="shopping-app">
    <div class="products">
      <h2>商品列表</h2>
      <div v-for="product in products" :key="product.id" class="product-card">
        <h3>{{ product.name }}</h3>
        <p class="price">¥{{ product.price }}</p>
        <button @click="cart.addItem(product)">加入购物车</button>
      </div>
    </div>
    
    <div class="cart">
      <h2>购物车 ({{ cart.totalItems }})</h2>
      
      <div v-if="cart.isEmpty" class="empty">
        购物车是空的
      </div>
      
      <div v-else>
        <div v-for="item in cart.items" :key="item.id" class="cart-item">
          <div class="item-info">
            <h4>{{ item.name }}</h4>
            <p>¥{{ item.price }}</p>
          </div>
          <div class="item-actions">
            <input 
              type="number" 
              :value="item.quantity"
              @input="cart.updateQuantity(item.id, Number($event.target.value))"
              min="1"
            />
            <button @click="cart.removeItem(item.id)" class="remove-btn">
              删除
            </button>
          </div>
        </div>
        
        <div class="cart-footer">
          <div class="total">
            <strong>总计:¥{{ cart.totalPrice }}</strong>
          </div>
          <button @click="handleCheckout" class="checkout-btn">
            结算
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.shopping-app {
  display: grid;
  grid-template-columns: 2fr 1fr;
  gap: 20px;
  padding: 20px;
  max-width: 1200px;
  margin: 0 auto;
}

.products {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 15px;
  align-content: start;
}

.product-card {
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 8px;
  text-align: center;
}

.price {
  font-size: 20px;
  font-weight: bold;
  color: #42b983;
  margin: 10px 0;
}

button {
  padding: 8px 16px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:hover {
  background-color: #35a372;
}

.cart {
  position: sticky;
  top: 20px;
  height: fit-content;
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 8px;
  background-color: #f9f9f9;
}

.empty {
  text-align: center;
  color: #999;
  padding: 40px 0;
}

.cart-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 15px 0;
  border-bottom: 1px solid #ddd;
}

.item-actions {
  display: flex;
  gap: 10px;
  align-items: center;
}

.item-actions input {
  width: 60px;
  padding: 5px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.remove-btn {
  background-color: #f56c6c;
}

.cart-footer {
  margin-top: 20px;
  padding-top: 20px;
  border-top: 2px solid #ddd;
}

.total {
  font-size: 18px;
  margin-bottom: 15px;
  text-align: right;
}

.checkout-btn {
  width: 100%;
  padding: 12px;
  font-size: 16px;
}
</style>

实战示例:用户认证 Store

javascript
// stores/auth.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useAuthStore = defineStore('auth', () => {
  // State
  const user = ref(null)
  const token = ref(localStorage.getItem('token') || null)
  const loading = ref(false)
  
  // Getters
  const isAuthenticated = computed(() => !!token.value)
  const isAdmin = computed(() => user.value?.role === 'admin')
  
  // Actions
  async function login(credentials) {
    loading.value = true
    try {
      // 模拟 API 调用
      await new Promise(resolve => setTimeout(resolve, 1000))
      
      // 模拟响应
      const response = {
        user: {
          id: 1,
          name: credentials.username,
          email: `${credentials.username}@example.com`,
          role: credentials.username === 'admin' ? 'admin' : 'user'
        },
        token: 'fake-jwt-token-' + Date.now()
      }
      
      user.value = response.user
      token.value = response.token
      localStorage.setItem('token', response.token)
      
      return response
    } catch (error) {
      throw new Error('登录失败')
    } finally {
      loading.value = false
    }
  }
  
  function logout() {
    user.value = null
    token.value = null
    localStorage.removeItem('token')
  }
  
  async function fetchUser() {
    if (!token.value) return
    
    loading.value = true
    try {
      // 模拟 API 调用
      await new Promise(resolve => setTimeout(resolve, 500))
      
      user.value = {
        id: 1,
        name: '张三',
        email: 'zhangsan@example.com',
        role: 'user'
      }
    } catch (error) {
      logout()
    } finally {
      loading.value = false
    }
  }
  
  async function updateProfile(data) {
    loading.value = true
    try {
      await new Promise(resolve => setTimeout(resolve, 1000))
      user.value = { ...user.value, ...data }
    } finally {
      loading.value = false
    }
  }
  
  return {
    user,
    token,
    loading,
    isAuthenticated,
    isAdmin,
    login,
    logout,
    fetchUser,
    updateProfile
  }
})

使用认证 Store

vue
<script setup>
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'

const auth = useAuthStore()

const loginForm = ref({
  username: '',
  password: ''
})

async function handleLogin() {
  try {
    await auth.login(loginForm.value)
    alert('登录成功!')
  } catch (error) {
    alert(error.message)
  }
}

function handleLogout() {
  auth.logout()
  alert('已退出登录')
}
</script>

<template>
  <div class="auth-demo">
    <div v-if="!auth.isAuthenticated" class="login-form">
      <h2>登录</h2>
      <form @submit.prevent="handleLogin">
        <input 
          v-model="loginForm.username" 
          placeholder="用户名"
          required
        />
        <input 
          v-model="loginForm.password" 
          type="password"
          placeholder="密码"
          required
        />
        <button type="submit" :disabled="auth.loading">
          {{ auth.loading ? '登录中...' : '登录' }}
        </button>
      </form>
      <p class="hint">提示:输入 "admin" 作为用户名可获得管理员权限</p>
    </div>
    
    <div v-else class="user-profile">
      <h2>欢迎,{{ auth.user.name }}!</h2>
      <div class="user-info">
        <p><strong>邮箱:</strong>{{ auth.user.email }}</p>
        <p><strong>角色:</strong>{{ auth.user.role }}</p>
        <span v-if="auth.isAdmin" class="badge">管理员</span>
      </div>
      
      <div v-if="auth.isAdmin" class="admin-panel">
        <h3>🔧 管理员面板</h3>
        <p>只有管理员可以看到这个面板</p>
      </div>
      
      <button @click="handleLogout" class="logout-btn">退出登录</button>
    </div>
  </div>
</template>

<style scoped>
.auth-demo {
  max-width: 500px;
  margin: 20px auto;
  padding: 30px;
  border: 1px solid #ddd;
  border-radius: 8px;
}

.login-form input {
  width: 100%;
  padding: 10px;
  margin-bottom: 15px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.login-form button {
  width: 100%;
  padding: 12px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
}

.login-form button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}

.hint {
  margin-top: 15px;
  font-size: 14px;
  color: #666;
  text-align: center;
}

.user-info {
  padding: 20px;
  background-color: #f9f9f9;
  border-radius: 8px;
  margin: 20px 0;
}

.badge {
  display: inline-block;
  padding: 4px 12px;
  background-color: #42b983;
  color: white;
  border-radius: 12px;
  font-size: 12px;
}

.admin-panel {
  padding: 20px;
  background-color: #fff3cd;
  border: 1px solid #ffc107;
  border-radius: 8px;
  margin: 20px 0;
}

.logout-btn {
  width: 100%;
  padding: 12px;
  background-color: #f56c6c;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
}
</style>

Store 之间的组合

javascript
// stores/user.js
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'

export const useUserStore = defineStore('user', () => {
  const auth = useAuthStore()
  
  async function updateSettings(settings) {
    if (!auth.isAuthenticated) {
      throw new Error('请先登录')
    }
    
    // 更新设置逻辑
  }
  
  return {
    updateSettings
  }
})

Pinia 插件

javascript
// plugins/piniaLogger.js
export function piniaLogger(context) {
  context.store.$subscribe((mutation, state) => {
    console.log(`[${context.store.$id}] ${mutation.type}`, state)
  })
}

// main.js
import { createPinia } from 'pinia'
import { piniaLogger } from './plugins/piniaLogger'

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

持久化存储

javascript
// stores/settings.js
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'

export const useSettingsStore = defineStore('settings', () => {
  const theme = ref(localStorage.getItem('theme') || 'light')
  const language = ref(localStorage.getItem('language') || 'zh-CN')
  
  // 监听变化并保存到 localStorage
  watch(theme, (newTheme) => {
    localStorage.setItem('theme', newTheme)
  })
  
  watch(language, (newLang) => {
    localStorage.setItem('language', newLang)
  })
  
  function toggleTheme() {
    theme.value = theme.value === 'light' ? 'dark' : 'light'
  }
  
  return {
    theme,
    language,
    toggleTheme
  }
})

总结

  • 简单应用:使用 reactive 创建全局状态
  • 复杂应用:使用 Pinia 进行状态管理
  • Pinia 优势
    • 更简单的 API
    • 完整的 TypeScript 支持
    • 更好的开发工具支持
    • 模块化设计
    • 支持插件系统
  • 最佳实践
    • 按功能模块划分 Store
    • 使用组合式 API 风格
    • 合理使用 getters 缓存计算结果
    • 异步操作放在 actions 中

下一步

接下来学习 动画和过渡,了解如何为应用添加动画效果。