Vuex/Pinia: управление состоянием
2025-08-10

Vuex/Pinia: управление состоянием

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

1. Зачем нужно управление состоянием?

В простых Vue.js приложениях данные могут передаваться между компонентами через props и события. Однако при росте приложения этот подход становится неэффективным:

  • Пропсы drilling — необходимость передавать данные через множество промежуточных компонентов
  • Дублирование логики — повторение одинаковой логики в разных компонентах
  • Сложность отладки — трудно отследить, где и как изменяются данные
  • Проблемы с синхронизацией — разные компоненты могут иметь разные версии одних и тех же данных

Централизованное управление состоянием решает эти проблемы, предоставляя единый источник истины для всех данных приложения.

2. Vuex 4: классическое решение

Vuex — это официальная библиотека для управления состоянием в Vue.js, которая следует паттерну Flux.

2.1. Основные концепции Vuex

Vuex основан на трёх ключевых концепциях:

  • State — объект, содержащий все данные приложения
  • Mutations — единственный способ изменения состояния (синхронно)
  • Actions — асинхронные операции, которые могут вызывать mutations

2.2. Установка и настройка Vuex

npm install vuex@next

Создание store:

// store/index.js
import { createStore } from 'vuex'

export default createStore({
  state: {
    count: 0,
    user: null,
    todos: []
  },

  getters: {
    doubleCount: (state) => state.count * 2,
    completedTodos: (state) => state.todos.filter(todo => todo.completed)
  },

  mutations: {
    INCREMENT(state) {
      state.count++
    },
    SET_USER(state, user) {
      state.user = user
    },
    ADD_TODO(state, todo) {
      state.todos.push(todo)
    }
  },

  actions: {
    async fetchUser({ commit }, userId) {
      try {
        const response = await fetch(`/api/users/${userId}`)
        const user = await response.json()
        commit('SET_USER', user)
      } catch (error) {
        console.error('Ошибка загрузки пользователя:', error)
      }
    },

    async addTodo({ commit }, todoText) {
      const todo = {
        id: Date.now(),
        text: todoText,
        completed: false
      }
      commit('ADD_TODO', todo)
    }
  }
})

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

<template>
  <div>
    <h2>Счётчик: {{ count }}</h2>
    <p>Удвоенное значение: {{ doubleCount }}</p>
    <button @click="increment">Увеличить</button>

    <div v-if="user">
      <h3>Пользователь: {{ user.name }}</h3>
      <p>Email: {{ user.email }}</p>
    </div>

    <div>
      <h3>Задачи</h3>
      <ul>
        <li v-for="todo in todos" :key="todo.id">
          {{ todo.text }} - {{ todo.completed ? 'Выполнено' : 'В процессе' }}
        </li>
      </ul>
      <input v-model="newTodo" @keyup.enter="addTodo" placeholder="Новая задача">
    </div>
  </div>
</template>

<script>
import { computed } from 'vue'
import { useStore } from 'vuex'

export default {
  name: 'VuexExample',
  setup() {
    const store = useStore()
    const newTodo = ref('')

    // Получение данных из store
    const count = computed(() => store.state.count)
    const user = computed(() => store.state.user)
    const todos = computed(() => store.state.todos)
    const doubleCount = computed(() => store.getters.doubleCount)

    // Методы для взаимодействия с store
    const increment = () => {
      store.commit('INCREMENT')
    }

    const addTodo = () => {
      if (newTodo.value.trim()) {
        store.dispatch('addTodo', newTodo.value.trim())
        newTodo.value = ''
      }
    }

    // Загрузка данных при монтировании
    onMounted(() => {
      store.dispatch('fetchUser', 1)
    })

    return {
      count,
      user,
      todos,
      doubleCount,
      newTodo,
      increment,
      addTodo
    }
  }
}
</script>

2.4. Модули Vuex

Для больших приложений Vuex поддерживает модули:

// store/modules/user.js
export default {
  namespaced: true,
  state: {
    profile: null,
    preferences: {}
  },
  mutations: {
    SET_PROFILE(state, profile) {
      state.profile = profile
    },
    SET_PREFERENCES(state, preferences) {
      state.preferences = preferences
    }
  },
  actions: {
    async updateProfile({ commit }, profileData) {
      // API вызов
      const response = await fetch('/api/profile', {
        method: 'PUT',
        body: JSON.stringify(profileData)
      })
      const profile = await response.json()
      commit('SET_PROFILE', profile)
    }
  }
}

// store/index.js
import userModule from './modules/user'

export default createStore({
  modules: {
    user: userModule
  }
})

3. Pinia: современная альтернатива

Pinia — это новая библиотека для управления состоянием, которая стала официальной рекомендацией для Vue 3. Она решает многие проблемы Vuex и предоставляет более простой и гибкий API.

3.1. Преимущества Pinia

  • Лучшая поддержка TypeScript — полная типизация из коробки
  • Более простой API — меньше boilerplate кода
  • DevTools поддержка — отличная интеграция с Vue DevTools
  • Модульность — каждый store является модулем по умолчанию
  • Лучшая производительность — оптимизирована для Vue 3

3.2. Установка Pinia

npm install pinia

Настройка в main.js:

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

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

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

3.3. Создание store с Pinia

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

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'Счётчик'
  }),

  getters: {
    doubleCount: (state) => state.count * 2,
    doubleCountPlusOne: (state) => state.count * 2 + 1
  },

  actions: {
    increment() {
      this.count++
    },

    decrement() {
      this.count--
    },

    async incrementAsync() {
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.count++
    },

    reset() {
      this.count = 0
    }
  }
})

3.4. Использование Pinia store

<template>
  <div>
    <h2>{{ store.name }}: {{ store.count }}</h2>
    <p>Удвоенное значение: {{ store.doubleCount }}</p>
    <p>Удвоенное + 1: {{ store.doubleCountPlusOne }}</p>

    <div class="buttons">
      <button @click="store.increment()">Увеличить</button>
      <button @click="store.decrement()">Уменьшить</button>
      <button @click="store.incrementAsync()">Увеличить асинхронно</button>
      <button @click="store.reset()">Сбросить</button>
    </div>
  </div>
</template>

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

export default {
  name: 'PiniaCounter',
  setup() {
    const store = useCounterStore()

    return {
      store
    }
  }
}
</script>

3.5. Сложный store с Pinia

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

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    isAuthenticated: false,
    loading: false,
    error: null
  }),

  getters: {
    userName: (state) => state.user?.name || 'Гость',
    userEmail: (state) => state.user?.email || '',
    isAdmin: (state) => state.user?.role === 'admin'
  },

  actions: {
    async login(credentials) {
      this.loading = true
      this.error = null

      try {
        const response = await fetch('/api/login', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify(credentials)
        })

        if (!response.ok) {
          throw new Error('Ошибка авторизации')
        }

        const user = await response.json()
        this.user = user
        this.isAuthenticated = true

        // Сохранение токена
        localStorage.setItem('token', user.token)

      } catch (error) {
        this.error = error.message
        throw error
      } finally {
        this.loading = false
      }
    },

    async logout() {
      try {
        await fetch('/api/logout', {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${localStorage.getItem('token')}`
          }
        })
      } catch (error) {
        console.error('Ошибка при выходе:', error)
      } finally {
        this.user = null
        this.isAuthenticated = false
        localStorage.removeItem('token')
      }
    },

    async fetchProfile() {
      if (!this.isAuthenticated) return

      this.loading = true

      try {
        const response = await fetch('/api/profile', {
          headers: {
            'Authorization': `Bearer ${localStorage.getItem('token')}`
          }
        })

        if (response.ok) {
          const profile = await response.json()
          this.user = { ...this.user, ...profile }
        }
      } catch (error) {
        this.error = error.message
      } finally {
        this.loading = false
      }
    }
  }
})

3.6. Композиция store'ов

Pinia позволяет легко композировать логику между разными store'ами:

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

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

  getters: {
    itemCount: (state) => state.items.length,
    isEmpty: (state) => state.items.length === 0
  },

  actions: {
    addItem(product) {
      const existingItem = this.items.find(item => item.id === product.id)

      if (existingItem) {
        existingItem.quantity++
      } else {
        this.items.push({
          ...product,
          quantity: 1
        })
      }

      this.calculateTotal()
    },

    removeItem(productId) {
      const index = this.items.findIndex(item => item.id === productId)
      if (index > -1) {
        this.items.splice(index, 1)
        this.calculateTotal()
      }
    },

    calculateTotal() {
      this.total = this.items.reduce((sum, item) => {
        return sum + (item.price * item.quantity)
      }, 0)
    },

    async checkout() {
      const userStore = useUserStore()

      if (!userStore.isAuthenticated) {
        throw new Error('Необходимо авторизоваться')
      }

      // Логика оформления заказа
      const response = await fetch('/api/orders', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${localStorage.getItem('token')}`
        },
        body: JSON.stringify({
          items: this.items,
          total: this.total,
          userId: userStore.user.id
        })
      })

      if (response.ok) {
        this.items = []
        this.total = 0
        return await response.json()
      }
    }
  }
})

4. Сравнение Vuex и Pinia

4.1. Когда использовать Vuex

  • Существующие проекты — если у вас уже есть Vuex store
  • Команда знает Vuex — если команда имеет опыт с Vuex
  • Простая архитектура — для небольших приложений
  • Совместимость — если нужно поддерживать старые версии Vue

4.2. Когда использовать Pinia

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

5. Лучшие практики

5.1. Организация store'ов

stores/
├── index.js          # Основной store (если используете Vuex)
├── modules/          # Модули Vuex
│   ├── user.js
│   ├── cart.js
│   └── products.js
└── stores/           # Pinia store'ы
    ├── user.js
    ├── cart.js
    └── products.js

5.2. Именование

  • Используйте понятные имена для store'ов и actions
  • Следуйте конвенциям: use[Name]Store для Pinia
  • Группируйте связанные actions и getters

5.3. Обработка ошибок

// stores/api.js
export const useApiStore = defineStore('api', {
  state: () => ({
    loading: false,
    error: null
  }),

  actions: {
    async apiCall(apiFunction) {
      this.loading = true
      this.error = null

      try {
        const result = await apiFunction()
        return result
      } catch (error) {
        this.error = error.message
        throw error
      } finally {
        this.loading = false
      }
    }
  }
})

5.4. Персистентность данных

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

// С Pinia
import { createPersistedState } from 'pinia-plugin-persistedstate'

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

// В store
export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    preferences: {}
  }),

  persist: {
    key: 'user-store',
    storage: localStorage,
    paths: ['preferences'] // Сохранять только preferences
  }
})

6. Миграция с Vuex на Pinia

6.1. Пошаговый план

  1. Установка Pinia и настройка в приложении
  2. Создание новых store'ов параллельно с существующими
  3. Постепенная замена использования в компонентах
  4. Тестирование каждого store'а
  5. Удаление Vuex после полной миграции

6.2. Основные изменения

// Vuex
const store = useStore()
store.commit('SET_USER', user)
store.dispatch('fetchUser', id)

// Pinia
const userStore = useUserStore()
userStore.user = user
userStore.fetchUser(id)

7. Заключение

Vuex и Pinia предоставляют мощные инструменты для управления состоянием в Vue.js приложениях. Vuex остаётся надёжным решением для существующих проектов, в то время как Pinia представляет собой современную альтернативу с улучшенным API и лучшей поддержкой TypeScript.

Выбор между ними зависит от конкретных требований проекта, опыта команды и версии Vue.js. Для новых проектов на Vue 3 рекомендуется использовать Pinia, так как она предоставляет более современный и гибкий подход к управлению состоянием.

Независимо от выбора, правильная архитектура store'ов и следование лучшим практикам помогут создать масштабируемое и поддерживаемое приложение с предсказуемым управлением состоянием.