Руководство для начинающих по Pinia

В Vue3 управление состоянием — тема, которую невозможно обойти.

Pinia — это лёгкая библиотека управления состоянием для Vue.js, позволяющая совместно использовать и управлять состоянием между компонентами. Pinia можно представить как глобальное хранилище данных, из которого все компоненты могут получать или обновлять данные.

В этой главе мы познакомимся с Pinia — официально рекомендованной библиотекой управления состоянием для Vue3. По сравнению с Vuex, Pinia предлагает более простое решение для управления состоянием, которое лучше соответствует философии Composition API во Vue3.

На следующей схеме сравниваются структурные различия между ними:

  • Vuex использует единое хранилище с многоуровневыми модулями в древовидной структуре, с фиксированной иерархией, полагается на мутации и в целом более тяжёлый.
  • Pinia, напротив, состоит из нескольких независимых хранилищ; она плоская, лёгкая, не имеет разделения на модули и накладных расходов на пространства имён.

Ключевые возможности Pinia:

  • Поддерживает Vue2 и Vue3
  • Минималистичный дизайн API
  • Полная поддержка TypeScript
  • Поддерживает Composition API
  • Модульный дизайн без вложенных модулей

Плоская структура Pinia позволяет компонентам напрямую подключаться к любому хранилищу и брать то, что им нужно, без прохождения через древовидные модули или пути пространств имён. Поток состояния простой и прямой, с низкой связанностью.


Установка и настройка

Создайте проект с помощью Vite:

npm create vite@latest vue-pinia-demo --template vue
cd vue-pinia-demo
npm install

Установите Pinia

Сначала установите Pinia в вашем проекте на Vue3:

npm install pinia
# 或者
yarn add pinia

Pinia состоит из трёх частей:

  • state: хранит данные
  • getters: вычисляют производные данные
  • actions: выполняют логику, изменяют состояние

Настройте Pinia

Настройте Pinia в src/main.js или src/main.ts:

Пример

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

// Создайте экземпляр Pinia
const pinia = createPinia()
// Создайте приложение Vue
const app = createApp(App)

// Используйте Pinia
app.use(pinia)
app.mount('#app')

Создайте свой первый Store

Store — это хранилище данных в Pinia. Давайте создадим простое хранилище счётчика.

Определите Store

Создайте новый файл src/stores/useCounter.js:

Пример

// stores/useCounter.js
import { defineStore } from 'pinia'

// Определите хранилище с помощью defineStore
// Первый параметр — уникальный идентификатор хранилища
// Второй параметр — параметры конфигурации хранилища
export const useCounterStore = defineStore('counter', {
  // state: определяет данные состояния хранилища
  state: () => ({
    count: 0,
    name: 'My Counter'
  }),

  // getters: определяет вычисляемые свойства на основе state
  геттеры: {
    doubleCount: (state) => state.count * 2,
    // Используйте this для доступа к другим геттерам
    doubleCountPlusOne() {
      return this.doubleCount + 1
    }
  },

  // actions: определяет методы для изменения состояния
  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    },
    // Может принимать параметры
    incrementBy(amount) {
      this.count += amount
    },
    // Асинхронное действие
    async incrementAsync() {
      // Имитация асинхронной операции
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.count++
    }
  }
})

Объяснение структуры хранилища

Давайте разберём различные части хранилища с помощью таблицы:

Часть Назначение Пример
state Определяет хранимые данные count: 0
геттеры Вычисляемые свойства на основе состояния doubleCount: state => state.count * 2
actions Методы изменения состояния increment() { this.count++ }

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

Базовое использование

Код компонента для src/components/CounterComponent.vue выглядит так:

Пример

<!-- CounterComponent.vue -->
<template>
  <div class="counter">
    <h3>{{ store.name }}</h3>
    <p>Текущее значение: {{ store.count }}</p>
    <p>Удвоенное значение: {{ store.doubleCount }}</p>
    <p>Удвоенное значение плюс один: {{ store.doubleCountPlusOne }}</p>
   
    <button @click="store.increment()">+1</button>
    <button @click="store.decrement()">-1</button>
    <button @click="store.incrementBy(5)">+5</button>
    <button @click="store.incrementAsync()">Асинхронно +1</button>
   
    <button @click="reset">Сбросить</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '../stores/useCounter'

// Использовать store в setup
const store = useCounterStore()

// Метод для сброса состояния
function reset() {
store.$reset() // Метод $reset может сбросить состояние до его начального значения
}
</script>

Измените src/App.vue следующим образом:

Пример

<script setup>
import CounterComponent from './components/CounterComponent.vue'
</script>

<template>
  <CounterComponent />
</template>

Вся структура проекта:

Запуститеnpm run devкоманду и откройте в браузереhttp://localhost:5173/, чтобы увидеть результат:

Реактивная деструктуризация

Если вы хотите напрямую использовать свойства состояния в шаблоне, вы можете использоватьstoreToRefs:

Пример

<template>
  <div>
    <p>Количество: {{ count }}</p>
    <p>Имя: {{ name }}</p>
  </div>
</template>

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

const store = useCounterStore()

// Используйте storeToRefs, чтобы сохранить реактивность
const { count, name } = storeToRefs(store)

// Примечание: деструктуризация напрямую приведёт к потере реактивности!
// Неправильный способ: const { count, name } = store
</script>

Хранилище в стиле Composition API

Pinia также поддерживает определение хранилищ в стиле Composition API:

Пример

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

export const useUserStore = defineStore('user', () => {
  // состояние
  const user = ref(null)
  const isLoggedIn = ref(false)

  // геттеры
  const userName = computed(() => user.value?.name || 'Гость')
  const userAge = computed(() => user.value?.age || 0)

  // действия
  function login(userData) {
    user.value = userData
    isLoggedIn.value = true
  }

  function logout() {
    user.value = null
    isLoggedIn.value = false
  }

  return {
    user,
    isLoggedIn,
    userName,
    userAge,
    login,
    logout
  }
})

Взаимодействие между хранилищами

Несколько хранилищ могут вызывать друг друга:

Пример

// stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: []
  }),

  actions: {
    addItem(product) {
      this.items.push(product)
     
      // Вызвать действие из другого хранилища
      const userStore = useUserStore()
      if (userStore.isLoggedIn) {
        // Синхронизировать с корзиной покупок пользователя
        this.syncToUserCart()
      }
    }
  }
})

Практический пример: корзина покупок

Давайте создадим полный пример корзины покупок:

Пример

// stores/products.js
export const useProductStore = defineStore('products', {
  state: () => ({
    products: [
      { id: 1, name: 'Laptop', price: 5999, stock: 10 },
      { id: 2, name: 'Смартфон', price: 3999, stock: 20 },
      { id: 3, name: 'Беспроводные наушники', price: 299, stock: 50 }
    ]
  }),

  геттеры: {
    // Получить товар по ID
    getProductById: (state) => (id) => {
      return state.products.find(product => product.id === id)
    },
    // Получить товары в наличии
    availableProducts: (state) => {
      return state.products.filter(product => product.stock > 0)
    }
  }
})

Пример

// stores/cart.js
export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [], // { productId, quantity }
    discount: 0
  }),

  геттеры: {
    // Общее количество товаров в корзине
    totalItems: (state) => {
      return state.items.reduce((total, item) => total + item.quantity, 0)
    },
    // Общая сумма корзины
    totalPrice: (state) => {
      const productStore = useProductStore()
      return state.items.reduce((total, item) => {
        const product = productStore.getProductById(item.productId)
        return total + (product?.price || 0) * item.quantity
      }, 0)
    },
    // Итоговая цена после скидки
    finalPrice: (state) => {
      return state.totalPrice * (1 - state.discount / 100)
    }
  },

  actions: {
    // Добавить товар в корзину
    addToCart(productId, quantity = 1) {
      const existingItem = this.items.find(item => item.productId === productId)
     
      if (existingItem) {
        existingItem.quantity += quantity
      } else {
        this.items.push({ productId, quantity })
      }
    },

    // Удалить товар из корзины
    removeFromCart(productId) {
      this.items = this.items.filter(item => item.productId !== productId)
    },

    // Очистить корзину
    clearCart() {
      this.items = []
      this.discount = 0
    },

    // Установить скидку
    setDiscount(percent) {
      this.discount = Math.max(0, Math.min(100, percent))
    }
  }
})

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

Пример

<!-- ShoppingCart.vue -->
<template>
  <div class="shopping-cart">
    <h3>Корзина покупок ({{ cart.totalItems }} товаров)</h3>
   
    <div v-if="cart.items.length === 0">
      <p>Корзина пуста</p>
    </div>
   
    <div v-else>
      <div v-for="item in cartItems" :key="item.product.id" class="cart-item">
        <span>{{ item.product.name }}</span>
        <span>¥{{ item.product.price }}</span>
        <span>Количество: {{ item.quantity }}</span>
        <span>Подытог: ¥{{ item.product.price * item.quantity }}</span>
        <button @click="cart.removeFromCart(item.product.id)">Удалить</button>
      </div>
     
      <div class="cart-summary">
        <p>Итого: ¥{{ cart.totalPrice }}</p>
        <p v-if="cart.discount >0">Скидка: {{ cart.discount }}%</p>
        <p>Оплачено: ¥{{ cart.finalPrice }}</p>
      </div>
    </div>
  </div>
</template>

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

const cart = useCartStore()
const products = useProductStore()

// Вычислить сведения о товарах в корзине
const cartItems = computed(() => {
  return cart.items.map(item => ({
    product: products.getProductById(item.productId),
    quantity: item.quantity
})).filter(item => item.product) // Исключить несуществующие товары
})
</script>

Лучшие практики и рекомендации

1. Соглашения об именовании хранилищ

Пример

// Хорошее именование
export const useUserStore = defineStore('user', { /* ... */ })
export const useProductStore = defineStore('products', { /* ... */ })

// Именование, которого следует избегать
export const userStore = defineStore('user', { /* ... */ }) // Отсутствует префикс use

2. Инициализация состояния

Пример

// Рекомендуется: используйте функцию для возврата начального состояния
state: () => ({
  items: [],
  loading: false,
  error: null
})

// Не рекомендуется: прямое использование объекта
state: {
  items: []  // Это приведёт к тому, что все экземпляры будут использовать один и тот же массив!
}

3. Обработка асинхронных операций

Пример

actions: {
  async fetchUserData(userId) {
    this.loading = true
    this.error = null
   
    try {
      const response = await api.getUser(userId)
      this.user = response.data
    } catch (error) {
      this.error = error.message
    } finally {
      this.loading = false
    }
  }
}

4. Сохранение данных

Для данных, которые нужно сохранять, можно использовать плагин:

npm install pinia-plugin-persistedstate

Пример

import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

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

export const useUserStore = defineStore('user', {
  state: () => ({
    токен: '',
    userInfo: {}
  }),
 
  // Включить сохранение
  persist: true
})

Pinia API

Категория API Описание
Создание defineStore Определите независимое хранилище, содержащее state / getters / actions
Создание экземпляра createPinia Создайте корневой экземпляр Pinia для app.use()
Использование в компоненте useStore()(Определяемый пользователем, например,useUserStore) Вызов хранилища, возвращает реактивное состояние, геттеры и действия
State state Возвращает объект-функцию, используется для определения изменяемого глобального состояния
Операции с состоянием store.$state Прямое чтение/запись всего состояния (на уровне объекта)
Операции с состоянием store.$patch() Пакетное изменение состояния, поддерживает как объектный, так и функциональный режимы
Замена состояния store.$reset() Сброс к исходному состоянию, доступен только при определении через метод setup
Подписка на состояние store.$subscribe() Прослушивание изменений состояния, подходит для локального сохранения
Вызов действий store.$onAction() Прослушивание до и после вызовов действий, можно использовать для логирования и отслеживания
Геттер getters Производные данные, автоматически кешируемые на основе state
Плагины pinia.use() Регистрация плагинов для расширения возможностей хранилища
Метод определения defineStore(id, options) Синтаксис Options API
Метод определения defineStore(id, () => {...}) Синтаксис setup, возвращает state / getter / action
Свойства хранилища store.$id Уникальный идентификатор текущего хранилища
Свойства хранилища store.$ready(в некоторых версиях) Флаг состояния после завершения инициализации хранилища
Персистентность (плагин) persist Включение плагинов хранилища (например, localStorage)
Вспомогательные функции storeToRefs() Преобразование state / getters в refs с сохранением реактивности

Для получения дополнительной информации обратитесь к официальному сайту Pinia:https://pinia.vuejs.org/

Другие расширения