← Курс/Pinia — управление состоянием#237 из 257+30 XP

Pinia — управление состоянием для Vue 3

Что такое Pinia

**Pinia** — официальное хранилище состояния для Vue 3 (заменила Vuex). Она предоставляет централизованное хранилище для данных, которые нужно разделять между несколькими компонентами.

Ключевые преимущества перед Vuex:

  • Нет мутаций — state меняется прямо в actions
  • Полная поддержка TypeScript без лишних настроек
  • Модульная архитектура без вложенных модулей
  • Отличная интеграция с Vue DevTools
  • Создание стора

    // stores/counter.js
    import { defineStore } from 'pinia'
    import { ref, computed } from 'vue'
    
    // Синтаксис Composition API (рекомендуемый)
    export const useCounterStore = defineStore('counter', () => {
      // state — реактивные переменные
      const count = ref(0)
      const step = ref(1)
    
      // getters — вычисляемые свойства
      const doubled = computed(() => count.value * 2)
      const canDecrement = computed(() => count.value > 0)
    
      // actions — методы для изменения state
      function increment() {
        count.value += step.value
      }
    
      function decrement() {
        if (canDecrement.value) count.value -= step.value
      }
    
      async function fetchCount() {
        const res = await fetch('/api/count')
        count.value = await res.json()
      }
    
      return { count, step, doubled, canDecrement, increment, decrement, fetchCount }
    })

    Использование стора в компонентах

    <template>
      <div>
        <p>Счётчик: {{ counter.count }}</p>
        <p>Удвоенный: {{ counter.doubled }}</p>
        <button @click="counter.increment()">+{{ counter.step }}</button>
        <button @click="counter.decrement()" :disabled="!counter.canDecrement">
          -{{ counter.step }}
        </button>
      </div>
    </template>
    
    <script setup>
    import { useCounterStore } from '@/stores/counter'
    
    const counter = useCounterStore()
    // counter.count, counter.doubled — реактивны
    // counter.increment() — вызов action
    </script>

    Options синтаксис

    // Альтернативный синтаксис (похож на Vuex)
    export const useCounterStore = defineStore('counter', {
      state: () => ({
        count: 0,
        step: 1,
      }),
      getters: {
        doubled: (state) => state.count * 2,
      },
      actions: {
        increment() {
          this.count += this.step
        },
        async fetchCount() {
          this.count = await fetch('/api/count').then(r => r.json())
        }
      }
    })

    Сравнение Pinia vs Vuex

    | Возможность | Pinia | Vuex 4 |

    |---|---|---|

    | Мутации | Нет (actions напрямую) | Обязательные |

    | TypeScript | Отличная | Посредственная |

    | DevTools | Да | Да |

    | Модули | Каждый store — модуль | Вложенные модули |

    | Размер | ~1 KB | ~10 KB |

    | Composition API | Нативно | Через хелперы |

    Pinia и localStorage

    // Персистентность через плагин или вручную
    import { watch } from 'vue'
    
    const store = useUserStore()
    
    // Загрузить при старте
    const saved = localStorage.getItem('user-store')
    if (saved) Object.assign(store, JSON.parse(saved))
    
    // Сохранять при изменениях
    watch(
      () => store.$state,
      (state) => localStorage.setItem('user-store', JSON.stringify(state)),
      { deep: true }
    )

    DevTools

    Pinia автоматически интегрируется с Vue DevTools: можно видеть все сторы, их state, историю actions и делать time-travel debugging.

    Примеры

    Мини-стор через замыкание — аналог того, как Pinia организует state, getters и actions

    // Реализация паттерна Pinia через замыкания в чистом JS.
    // Singleton-сторы: один экземпляр на id.
    
    const _stores = new Map()
    
    function defineStore(id, setup) {
      // Возвращаем фабричную функцию (useXxxStore)
      return function useStore() {
        // Singleton: если стор уже создан — возвращаем его
        if (_stores.has(id)) {
          return _stores.get(id)
        }
    
        // Создаём стор один раз
        const rawResult = setup()
    
        // Разделяем на state, getters и actions
        const state = {}
        const getters = {}
        const actions = {}
    
        for (const [key, value] of Object.entries(rawResult)) {
          if (typeof value === 'function') {
            // Функции — это getters или actions
            // Различаем: getters не принимают аргументов (условно)
            actions[key] = value
          } else {
            state[key] = value
          }
        }
    
        // Создаём Proxy для прозрачного доступа
        const store = new Proxy(
          { ...state },
          {
            get(target, key) {
              // Приоритет: actions > state
              if (key in actions) return actions[key].bind(store)
              if (key in target) return target[key]
              if (key === '$state') return { ...target }
              if (key === '$reset') {
                return () => {
                  const fresh = setup()
                  for (const k of Object.keys(state)) {
                    if (!(fresh[k] instanceof Function)) {
                      target[k] = fresh[k]
                    }
                  }
                  console.log(`[${id}] $reset`)
                }
              }
            },
            set(target, key, value) {
              const old = target[key]
              target[key] = value
              console.log(`[${id}] ${key}: ${JSON.stringify(old)} -> ${JSON.stringify(value)}`)
              return true
            }
          }
        )
    
        _stores.set(id, store)
        return store
      }
    }
    
    // --- Создаём стор корзины покупок ---
    
    const useCartStore = defineStore('cart', () => {
      const items = []
      const discount = 0
    
      function addItem(item) {
        items.push(item)
        console.log(`[cart] Добавлен: ${item.name}`)
      }
    
      function removeItem(id) {
        const idx = items.findIndex(i => i.id === id)
        if (idx !== -1) {
          console.log(`[cart] Удалён: ${items[idx].name}`)
          items.splice(idx, 1)
        }
      }
    
      function getTotal() {
        const sum = items.reduce((acc, item) => acc + item.price * item.qty, 0)
        return sum * (1 - discount / 100)
      }
    
      return { items, discount, addItem, removeItem, getTotal }
    })
    
    // --- Демонстрация singleton ---
    console.log('=== Singleton: один экземпляр ===')
    const cart1 = useCartStore()
    const cart2 = useCartStore()
    console.log('cart1 === cart2:', cart1 === cart2)  // true
    
    console.log('\n=== Работа со стором ===')
    cart1.addItem({ id: 1, name: 'Vue 3 курс', price: 1000, qty: 1 })
    cart1.addItem({ id: 2, name: 'Pinia книга', price: 500, qty: 2 })
    cart2.discount = 10  // cart1 и cart2 — один объект
    
    console.log('Итого:', cart1.getTotal())  // (1000 + 1000) * 0.9 = 1800
    console.log('Items count:', cart1.items.length)