Перейти к содержимому

16. Pinia: продвинутые паттерны

Ты уже знаешь базовую Pinia. Теперь — мощные паттерны: сторы, использующие другие сторы, плагины, персистентность данных, HMR, SSR и юнит-тестирование. Это то, что нужно знать для продакшн-приложений 🚀


Store Composition — сторы используют другие сторы

Заголовок раздела «Store Composition — сторы используют другие сторы»

В Pinia нет встроенных модулей как в Vuex. Вместо этого сторы просто импортируют друг друга:

stores/user.ts
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 — использует userStore
import { 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/getters
export const useCartStore = defineStore('cart', {
state: () => ({ items: [] }),
actions: {
async checkout() {
const userStore = useUserStore() // ✅ Внутри action
if (!userStore.isLoggedIn) throw new Error('...')
}
}
})

Плагины позволяют добавлять функциональность ко всем сторам сразу:

// plugins/logging.ts — логирование всех actions
import 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(),
}
}
main.ts
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)

Самый популярный плагин — pinia-plugin-persistedstate:

Окно терминала
npm install pinia-plugin-persistedstate
main.ts
import { 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)
},
},
})

Pinia автоматически поддерживает HMR в Vite, но нужно добавить небольшой код:

stores/counter.ts
import { defineStore, acceptHMRUpdate } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
// ...
})
// Без этого при изменении стора состояние сбрасывается!
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot))
}

// 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')

stores/__tests__/counter.spec.ts
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)
})
})

// stores/websocket.ts — стор синхронизируется через WebSocket
export 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 }
})