16. Pinia: продвинутые паттерны
🍍⚡ Продвинутая Pinia
Заголовок раздела «🍍⚡ Продвинутая Pinia»Ты уже знаешь базовую Pinia. Теперь — мощные паттерны: сторы, использующие другие сторы, плагины, персистентность данных, HMR, SSR и юнит-тестирование. Это то, что нужно знать для продакшн-приложений 🚀
Store Composition — сторы используют другие сторы
Заголовок раздела «Store Composition — сторы используют другие сторы»В Pinia нет встроенных модулей как в Vuex. Вместо этого сторы просто импортируют друг друга:
import { defineStore } from 'pinia'import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => { const currentUser = ref<User | null>(null) const isLoggedIn = computed(() => !!currentUser.value)
return { currentUser, isLoggedIn }})// stores/cart.ts — использует userStoreimport { defineStore, storeToRefs } from 'pinia'import { ref, computed, watch } from 'vue'import { useUserStore } from './user'
export const useCartStore = defineStore('cart', () => { const userStore = useUserStore() const { currentUser, isLoggedIn } = storeToRefs(userStore)
const items = ref<CartItem[]>([])
// Computed зависит от другого стора const canCheckout = computed(() => isLoggedIn.value && items.value.length > 0 )
// Реагируем на смену пользователя watch(currentUser, (newUser, oldUser) => { if (!newUser && oldUser) { // Пользователь вышел — очищаем корзину items.value = [] } })
async function checkout() { if (!isLoggedIn.value) { throw new Error('Нужно авторизоваться') }
// Используем данные из другого стора const userId = currentUser.value!.id await api.checkout(userId, items.value) items.value = [] }
return { items, canCheckout, checkout }})⚠️ В Options Store используй
useOtherStore()внутри методов, а не вstate()— иначе проблемы с порядком инициализации!
// В Options Store — вызывай другие сторы внутри actions/gettersexport const useCartStore = defineStore('cart', { state: () => ({ items: [] }),
actions: { async checkout() { const userStore = useUserStore() // ✅ Внутри action if (!userStore.isLoggedIn) throw new Error('...') } }})Plugins — плагины Pinia
Заголовок раздела «Plugins — плагины Pinia»Плагины позволяют добавлять функциональность ко всем сторам сразу:
// plugins/logging.ts — логирование всех actionsimport type { PiniaPluginContext } from 'pinia'
export function LoggingPlugin({ store }: PiniaPluginContext) { // Перехватываем все actions store.$onAction(({ name, args, after, onError }) => { console.log(\`[ACTION] \${store.$id}.\${name}\`, args)
after((result) => { console.log(\`[ACTION DONE] \${store.$id}.\${name}\`, result) })
onError((error) => { console.error(\`[ACTION ERROR] \${store.$id}.\${name}\`, error) }) })}// plugins/timestamp.ts — добавляем свойство ко всем сторамimport type { PiniaPluginContext } from 'pinia'
declare module 'pinia' { export interface PiniaCustomProperties { $createdAt: Date $updatedAt: Date }}
export function TimestampPlugin({ store }: PiniaPluginContext) { store.$createdAt = new Date()
store.$subscribe(() => { store.$updatedAt = new Date() })
return { $createdAt: new Date(), $updatedAt: new Date(), }}import { createApp } from 'vue'import { createPinia } from 'pinia'import { LoggingPlugin } from './plugins/logging'import { TimestampPlugin } from './plugins/timestamp'
const pinia = createPinia()pinia.use(LoggingPlugin)pinia.use(TimestampPlugin)Persistence — персистентность через плагин
Заголовок раздела «Persistence — персистентность через плагин»Самый популярный плагин — pinia-plugin-persistedstate:
npm install pinia-plugin-persistedstateimport { createPinia } from 'pinia'import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()pinia.use(piniaPluginPersistedstate)// stores/user.ts — с персистентностьюimport { defineStore } from 'pinia'
export const useUserStore = defineStore('user', { state: () => ({ token: null as string | null, user: null as User | null, preferences: { theme: 'dark' as 'light' | 'dark', language: 'ru', }, }),
// persist: true — сохранять всё в localStorage persist: true,})// Тонкая настройкаexport const useAuthStore = defineStore('auth', { state: () => ({ token: null as string | null, user: null as User | null, sessionId: '', // НЕ сохраняем lastActive: new Date(), // НЕ сохраняем }),
persist: { // Куда сохранять storage: localStorage, // или sessionStorage, или кастомное
// Какие поля сохранять (whitelist) pick: ['token', 'user'],
// Или что исключить (blacklist) // omit: ['sessionId', 'lastActive'],
// Кастомный ключ в storage key: 'my-app-auth',
// Хуки beforeRestore: (ctx) => { console.log('Восстанавливаем стор:', ctx.store.$id) }, afterRestore: (ctx) => { console.log('Стор восстановлен:', ctx.store.$state) }, },})HMR — горячая замена модулей
Заголовок раздела «HMR — горячая замена модулей»Pinia автоматически поддерживает HMR в Vite, но нужно добавить небольшой код:
import { defineStore, acceptHMRUpdate } from 'pinia'
export const useCounterStore = defineStore('counter', () => { // ...})
// Без этого при изменении стора состояние сбрасывается!if (import.meta.hot) { import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot))}SSR — серверный рендеринг
Заголовок раздела «SSR — серверный рендеринг»// server.ts (Nuxt или кастомный SSR)import { createPinia } from 'pinia'import { createSSRApp } from 'vue'
export async function renderPage(url: string) { // Создаём новый экземпляр Pinia для каждого запроса! // Это важно — иначе состояние будет общим между пользователями const pinia = createPinia() const app = createSSRApp(App)
app.use(pinia)
// Рендерим const html = await renderToString(app)
// Сериализуем состояние для передачи клиенту const state = JSON.stringify(pinia.state.value)
return { html, // Встраиваем в HTML для гидрации на клиенте stateScript: \`<script>window.__pinia_state__ = \${state}</script>\`, }}// client.ts — гидрацияimport { createPinia } from 'pinia'
const pinia = createPinia()
// Восстанавливаем состояние с сервераif (window.__pinia_state__) { pinia.state.value = window.__pinia_state__}
createApp(App).use(pinia).mount('#app')Unit Testing — тестирование сторов
Заголовок раздела «Unit Testing — тестирование сторов»import { setActivePinia, createPinia } from 'pinia'import { beforeEach, describe, it, expect, vi } from 'vitest'import { useCounterStore } from '../counter'
describe('useCounterStore', () => { beforeEach(() => { // Создаём свежую Pinia перед каждым тестом setActivePinia(createPinia()) })
it('начальное состояние', () => { const store = useCounterStore() expect(store.count).toBe(0) expect(store.doubleCount).toBe(0) })
it('increment увеличивает count', () => { const store = useCounterStore() store.increment() expect(store.count).toBe(1) store.increment() expect(store.count).toBe(2) })
it('$reset возвращает к начальному состоянию', () => { const store = useCounterStore() store.increment() store.increment() store.$reset() expect(store.count).toBe(0) })
it('fetchCount вызывает API и обновляет count', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ count: 42 }) }) global.fetch = mockFetch
const store = useCounterStore() await store.fetchCount()
expect(store.count).toBe(42) expect(mockFetch).toHaveBeenCalledWith('/api/count') })})// Тестирование стора с зависимостямиimport { useCartStore } from '../cart'import { useUserStore } from '../user'
describe('useCartStore', () => { beforeEach(() => { setActivePinia(createPinia()) })
it('checkout требует авторизации', async () => { const cartStore = useCartStore() const userStore = useUserStore()
// Пользователь не авторизован userStore.currentUser = null cartStore.items = [{ id: 1, name: 'Test', price: 100 }]
await expect(cartStore.checkout()).rejects.toThrow('Нужно авторизоваться') })
it('checkout очищает корзину после успеха', async () => { const cartStore = useCartStore() const userStore = useUserStore()
userStore.currentUser = { id: 1, name: 'Яша' } cartStore.items = [{ id: 1, name: 'Test', price: 100 }]
vi.spyOn(api, 'checkout').mockResolvedValue({ success: true }) await cartStore.checkout()
expect(cartStore.items).toHaveLength(0) })})Storeの подписка на внешние источники
Заголовок раздела «Storeの подписка на внешние источники»// stores/websocket.ts — стор синхронизируется через WebSocketexport const useWebSocketStore = defineStore('websocket', () => { const messages = ref<Message[]>([]) const connected = ref(false) let ws: WebSocket | null = null
function connect(url: string) { ws = new WebSocket(url)
ws.onopen = () => { connected.value = true } ws.onclose = () => { connected.value = false }
ws.onmessage = (event) => { const msg = JSON.parse(event.data) messages.value.push(msg) } }
function disconnect() { ws?.close() ws = null }
function send(data: unknown) { ws?.send(JSON.stringify(data)) }
// Очищаем соединение при уничтожении стора // (например, при unmount компонента, который создал стор) onUnmounted(disconnect)
return { messages, connected, connect, disconnect, send }})