Если вы работаете с Vue 3, скорее всего, вы уже слышали о Pinia — современном, легком и мощном инструменте для управления состоянием. Pinia — это официально рекомендованная библиотека для Vue.js, которая заменяет Vuex и делает вашу жизнь разработчика проще. В этой статье мы разберем Pinia вдоль и поперек, начав с основ и завершая продвинутыми техниками.

Навигация по статье

Часть 1. Основы Pinia

Что такое Pinia?

Pinia — это библиотека для управления состоянием в Vue.js приложениях. Она напоминает Vuex, но гораздо проще в использовании благодаря отсутствию избыточной структуры. Pinia построена на TypeScript, предлагает превосходный DX (developer experience) и тесно интегрируется с экосистемой Vue.

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

  • Простота: Убирает boilerplate-код и сложные концепции.
  • Реактивность: Использует Vue’s reactive API для управления состоянием.
  • TypeScript first: Отличная поддержка типов «из коробки».
  • Поддержка модулей: Легкое разделение на небольшие логические блоки.
  • SSR поддержка: Прекрасно работает в серверных приложениях.

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

Давайте начнем с установки и подключения Pinia в проекте Vue 3. Для этого убедитесь, что у вас установлен Vue 3.

Установка

Установить Pinia можно через npm или yarn:

npm install pinia
# или
yarn add pinia

Подключение Pinia в проект

Добавим Pinia в наш Vue 3 проект. Откройте main.js (или main.ts, если вы используете TypeScript) и подключите Pinia как плагин:

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

const app = createApp(App);

// Создаем экземпляр Pinia
const pinia = createPinia();

// Подключаем Pinia к приложению
app.use(pinia);

app.mount('#app');

Вот и все — Pinia подключена и готова к использованию.

Создание первого хранилища

В Pinia хранилище — это централизованное место для хранения состояния, похожее на модуль в Vuex. Однако Pinia убирает лишнюю сложность и позволяет писать лаконичный код.

Создаем хранилище

Создадим примерное хранилище для управления списком задач. Для этого создадим файл stores/taskStore.js:

import { defineStore } from 'pinia';

// Создаем хранилище с помощью defineStore
export const useTaskStore = defineStore('task', {
  // Состояние (state): данные, которые хранит хранилище
  state: () => ({
    tasks: [], // Список задач
  }),

  // Геттеры (getters): вычисляемые свойства
  getters: {
    // Возвращает количество задач
    taskCount: (state) => state.tasks.length,
  },

  // Действия (actions): методы для изменения состояния
  actions: {
    // Добавляет новую задачу
    addTask(task) {
      this.tasks.push(task);
    },

    // Удаляет задачу по индексу
    removeTask(index) {
      this.tasks.splice(index, 1);
    },
  },
});

Разберем код:

  • defineStore: Основной метод для создания хранилища. Первый аргумент — уникальный идентификатор хранилища.
  • state: Это функция, которая возвращает объект с начальными данными.
  • getters: Используются для вычисляемых значений на основе состояния.
  • actions: Методы, которые изменяют состояние (или выполняют любую логику).

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

Теперь подключим наше хранилище в Vue-компоненте. Предположим, у нас есть компонент TaskList.vue:

<template>
  <div>
    <h1>Список задач</h1>
    <ul>
      <li v-for="(task, index) in tasks" :key="index">
        {{ task }}
        <button @click="removeTask(index)">Удалить</button>
      </li>
    </ul>

    <input v-model="newTask" placeholder="Новая задача" />
    <button @click="addTask">Добавить</button>

    <p>Всего задач: {{ taskCount }}</p>
  </div>
</template>

<script>
import { ref } from 'vue';
import { useTaskStore } from '@/stores/taskStore';

export default {
  setup() {
    // Подключаем хранилище
    const taskStore = useTaskStore();

    // Локальное состояние для новой задачи
    const newTask = ref('');

    // Методы из хранилища
    const addTask = () => {
      if (newTask.value.trim()) {
        taskStore.addTask(newTask.value);
        newTask.value = ''; // Очищаем поле
      }
    };

    const removeTask = (index) => {
      taskStore.removeTask(index);
    };

    return {
      tasks: taskStore.tasks,
      taskCount: taskStore.taskCount,
      newTask,
      addTask,
      removeTask,
    };
  },
};
</script>

Разберем компонент:

  • useTaskStore: Подключение к хранилищу.
  • taskStore.tasks: Прямая реактивная связь с состоянием.
  • taskStore.taskCount: Использование геттера.
  • Методы addTask и removeTask: Вызываются из хранилища для изменения состояния.

Часть 2. Запросы к API и модульное разделение в Pinia

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

Асинхронные действия в Pinia

Pinia позволяет легко работать с асинхронными операциями, такими как запросы к API. Асинхронные действия можно объявлять в секции actions.

Пример: Получение данных из API

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

Изменим файл stores/taskStore.js:

import { defineStore } from 'pinia';

export const useTaskStore = defineStore('task', {
  state: () => ({
    tasks: [], // Список задач
    isLoading: false, // Флаг загрузки
    error: null, // Ошибка загрузки
  }),

  actions: {
    // Асинхронное действие для получения задач
    async fetchTasks() {
      this.isLoading = true;
      this.error = null;

      try {
        const response = await fetch('https://api.example.com/tasks');
        if (!response.ok) {
          throw new Error('Ошибка при загрузке задач');
        }
        const data = await response.json();
        this.tasks = data;
      } catch (err) {
        this.error = err.message;
      } finally {
        this.isLoading = false;
      }
    },
  },
});

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

Обновим наш компонент TaskList.vue, чтобы загрузить задачи при монтировании:

<template>
  <div>
    <h1>Список задач</h1>
    <div v-if="isLoading">Загрузка...</div>
    <div v-if="error">{{ error }}</div>

    <ul v-if="!isLoading && !error">
      <li v-for="(task, index) in tasks" :key="index">{{ task }}</li>
    </ul>

    <button @click="fetchTasks">Обновить задачи</button>
  </div>
</template>

<script>
import { onMounted } from 'vue';
import { useTaskStore } from '@/stores/taskStore';

export default {
  setup() {
    const taskStore = useTaskStore();

    // Загружаем задачи при монтировании
    onMounted(() => {
      taskStore.fetchTasks();
    });

    return {
      tasks: taskStore.tasks,
      isLoading: taskStore.isLoading,
      error: taskStore.error,
      fetchTasks: taskStore.fetchTasks,
    };
  },
};
</script>

Что здесь происходит:

  • Асинхронное действие: Мы используем async/await внутри действия для работы с API.
  • Обработка ошибок: Добавили флаг error для отображения ошибок в интерфейсе.
  • Флаг загрузки: Управляем отображением состояния загрузки через isLoading.
  • onMounted: Загружаем данные при монтировании компонента.

Модульное разделение хранилищ

В реальных проектах состояние и действия могут разрастаться. Pinia поддерживает модульное разделение — вы можете создавать несколько хранилищ для разных логических частей приложения.

Пример: Разделение на хранилища

Предположим, у нас есть приложение с пользователями и задачами. Мы создадим два отдельных хранилища: userStore и taskStore.

Хранилище пользователей (stores/userStore.js):

import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null, // Информация о текущем пользователе
    isAuthenticated: false,
  }),

  actions: {
    login(userData) {
      this.user = userData;
      this.isAuthenticated = true;
    },

    logout() {
      this.user = null;
      this.isAuthenticated = false;
    },
  },
});

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

Мы можем подключать несколько хранилищ одновременно. Например, компонент Dashboard.vue:

<template>
  <div>
    <h1>Добро пожаловать, {{ user?.name || 'Гость' }}</h1>
    <button v-if="!isAuthenticated" @click="login">Войти</button>
    <button v-if="isAuthenticated" @click="logout">Выйти</button>

    <task-list v-if="isAuthenticated" />
  </div>
</template>

<script>
import { useUserStore } from '@/stores/userStore';
import TaskList from './TaskList.vue';

export default {
  components: { TaskList },

  setup() {
    const userStore = useUserStore();

    const login = () => {
      userStore.login({ name: 'Иван Иванов', id: 1 });
    };

    const logout = () => {
      userStore.logout();
    };

    return {
      user: userStore.user,
      isAuthenticated: userStore.isAuthenticated,
      login,
      logout,
    };
  },
};
</script>

Что здесь важно:

  • Изоляция логики: Каждое хранилище отвечает только за свою часть состояния.
  • Легкость тестирования: Локализация логики упрощает тестирование.
  • Простота использования: Хранилища подключаются независимо друг от друга.

Улучшение работы с TypeScript

Pinia изначально проектировалась с поддержкой TypeScript. Это дает возможность создавать строготипизированные хранилища, что делает ваш код безопаснее и удобнее.

Пример: Типизация хранилища

Добавим типизацию в наше taskStore. Сначала определим интерфейс для задачи:

interface Task {
  id: number;
  title: string;
  completed: boolean;
}

Обновим хранилище задач:

import { defineStore } from 'pinia';

interface Task {
  id: number;
  title: string;
  completed: boolean;
}

export const useTaskStore = defineStore('task', {
  state: (): { tasks: Task[] } => ({
    tasks: [],
  }),

  actions: {
    addTask(task: Task) {
      this.tasks.push(task);
    },

    toggleTaskCompletion(taskId: number) {
      const task = this.tasks.find((t) => t.id === taskId);
      if (task) {
        task.completed = !task.completed;
      }
    },
  },
});

Теперь IDE будет подсказывать вам доступные свойства и методы, а также проверять их на этапе компиляции.

Часть 3. Продвинутые геттеры и взаимодействие между хранилищами в Pinia

Теперь, когда мы разобрались с базовыми функциями и асинхронными действиями, пора перейти к продвинутым возможностям Pinia. В этой части мы рассмотрим геттеры, взаимодействие между хранилищами, а также затронем оптимизацию производительности.

Продвинутые геттеры: больше, чем вычисляемые свойства

Геттеры в Pinia похожи на computed свойства Vue, но они могут быть не только вычисляемыми, но и принимать параметры. Это делает их чрезвычайно мощными.

Пример: Геттеры с параметрами

Давайте добавим в taskStore геттер, который фильтрует задачи по статусу выполнения.

Обновим taskStore:

import { defineStore } from 'pinia';

export const useTaskStore = defineStore('task', {
  state: () => ({
    tasks: [
      { id: 1, title: 'Купить молоко', completed: false },
      { id: 2, title: 'Написать статью', completed: true },
      { id: 3, title: 'Выучить Pinia', completed: false },
    ],
  }),

  getters: {
    // Геттер для фильтрации задач по выполнению
    filteredTasks: (state) => {
      return (completed) => state.tasks.filter((task) => task.completed === completed);
    },

    // Количество выполненных задач
    completedTaskCount: (state) => state.tasks.filter((task) => task.completed).length,
  },
});

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

Компонент FilteredTaskList.vue:

<template>
  <div>
    <h1>Список задач</h1>

    <h2>Выполненные задачи</h2>
    <ul>
      <li v-for="task in completedTasks" :key="task.id">{{ task.title }}</li>
    </ul>

    <h2>Невыполненные задачи</h2>
    <ul>
      <li v-for="task in pendingTasks" :key="task.id">{{ task.title }}</li>
    </ul>

    <p>Всего выполненных задач: {{ completedTaskCount }}</p>
  </div>
</template>

<script>
import { useTaskStore } from '@/stores/taskStore';

export default {
  setup() {
    const taskStore = useTaskStore();

    return {
      completedTasks: taskStore.filteredTasks(true),
      pendingTasks: taskStore.filteredTasks(false),
      completedTaskCount: taskStore.completedTaskCount,
    };
  },
};
</script>

Разбор:

  • Параметризированные геттеры: filteredTasks принимает аргумент completed, чтобы фильтровать задачи.
  • Комбинирование геттеров: completedTaskCount — пример вычисления на основе состояния.

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

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

Пример: Зависимость между userStore и taskStore

Допустим, задачи должны фильтроваться в зависимости от пользователя, который их создал. Мы обновим оба хранилища.

userStore:

import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    user: { id: 1, name: 'Иван Иванов' }, // Текущий пользователь
  }),
});

taskStore:

import { defineStore } from 'pinia';
import { useUserStore } from './userStore';

export const useTaskStore = defineStore('task', {
  state: () => ({
    tasks: [
      { id: 1, title: 'Купить молоко', completed: false, userId: 1 },
      { id: 2, title: 'Написать статью', completed: true, userId: 2 },
      { id: 3, title: 'Выучить Pinia', completed: false, userId: 1 },
    ],
  }),

  getters: {
    // Фильтруем задачи по текущему пользователю
    tasksForCurrentUser(state) {
      const userStore = useUserStore(); // Подключаем другое хранилище
      return state.tasks.filter((task) => task.userId === userStore.user.id);
    },
  },
});

Использование взаимодействия

Компонент UserTasks.vue:

<template>
  <div>
    <h1>Задачи пользователя {{ user?.name }}</h1>
    <ul>
      <li v-for="task in userTasks" :key="task.id">{{ task.title }}</li>
    </ul>
  </div>
</template>

<script>
import { useTaskStore } from '@/stores/taskStore';
import { useUserStore } from '@/stores/userStore';

export default {
  setup() {
    const taskStore = useTaskStore();
    const userStore = useUserStore();

    return {
      user: userStore.user,
      userTasks: taskStore.tasksForCurrentUser,
    };
  },
};
</script>

Что важно:

  • useUserStore внутри taskStore: Pinia автоматически поддерживает реактивность между хранилищами.
  • Чистая логика: Логика фильтрации изолирована в taskStore, а компонент остается простым.

Оптимизация производительности

Хотя Pinia сам по себе быстр, есть несколько подходов для оптимизации производительности.

Оптимизация реактивности

Pinia использует Vue’s реактивность, но иногда наблюдение за состоянием может быть избыточным. Вы можете использовать storeToRefs, чтобы извлечь только нужные свойства.

import { storeToRefs } from 'pinia';
import { useTaskStore } from '@/stores/taskStore';

export default {
  setup() {
    const taskStore = useTaskStore();
    const { tasks, completedTaskCount } = storeToRefs(taskStore);

    return {
      tasks,
      completedTaskCount,
    };
  },
};

Ленивая загрузка хранилищ

Вы можете загружать хранилища только тогда, когда они действительно нужны.

import { defineAsyncComponent } from 'vue';

// Ленивая загрузка компонента
const TaskComponent = defineAsyncComponent(() => import('./TaskList.vue'));

export default {
  components: {
    TaskComponent,
  },
};

Декомпозиция состояния

Делите состояние на отдельные хранилища, чтобы уменьшить область наблюдения за изменениями.

Часть 4. Серверный рендеринг и лучшие практики в Pinia

В заключительной части нашего подробного руководства мы рассмотрим работу Pinia с серверным рендерингом (SSR), поделимся лучшими практиками и приведем несколько реальных примеров. Давайте погрузимся!

Использование Pinia с серверным рендерингом (SSR)

Pinia полностью поддерживает SSR, и это одно из её больших преимуществ. Для корректной работы в SSR-приложениях нужно убедиться, что состояние хранилищ изолировано для каждого запроса. В противном случае все пользователи могут получать одно и то же состояние.

Как настроить Pinia для SSR

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

Убедитесь, что ваш проект использует Vue 3 SSR. Добавим Pinia в наше SSR-приложение.

Создайте серверный рендерер с использованием @vue/server-renderer.

npm install @vue/server-renderer

Настройка entry-server.js

Создайте серверный входной файл и подключите Pinia:

import { createSSRApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';

export function createApp() {
  const app = createSSRApp(App);
  const pinia = createPinia();

  app.use(pinia);

  return { app, pinia };
}

Серверное состояние

Pinia предоставляет метод pinia.state для извлечения текущего состояния. Сохраните его и отправьте клиенту.

import { renderToString } from '@vue/server-renderer';
import { createApp } from './entry-server';

export async function render(url) {
  const { app, pinia } = createApp();

  const appContent = await renderToString(app);

  // Сохраняем состояние Pinia
  const state = pinia.state.value;

  return { appContent, state };
}

Передача состояния клиенту

Обновите клиентский входной файл для загрузки серверного состояния:

import { createApp } from './entry-client';

const { app, pinia } = createApp();

// Восстанавливаем состояние
pinia.state.value = window.__PINIA_STATE__;

app.mount('#app');

Теперь ваше приложение с Pinia поддерживает SSR, а состояние восстанавливается при загрузке на клиенте.

Лучшие практики работы с Pinia

Делите состояние на модули

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

// stores/taskStore.js
export const useTaskStore = defineStore('task', { /* ... */ });

// stores/userStore.js
export const useUserStore = defineStore('user', { /* ... */ });

Избегайте прямой мутации состояния

Pinia позволяет изменять состояние напрямую, но это плохо влияет на читаемость и тестируемость. Всегда используйте actions для изменения состояния.

// Плохо
store.tasks.push(task);

// Хорошо
store.addTask(task);

Используйте storeToRefs

Чтобы избежать реактивных ловушек, извлекайте свойства хранилища с помощью storeToRefs.

import { storeToRefs } from 'pinia';

const { tasks, completedTaskCount } = storeToRefs(useTaskStore());
    

Типизируйте действия и состояние

Если вы используете TypeScript, всегда добавляйте типы для состояния и параметров действий. Это упростит разработку.

interface Task {
  id: number;
  title: string;
  completed: boolean;
}

state: (): { tasks: Task[] } => ({ tasks: [] });

actions: {
  addTask(task: Task) {
    this.tasks.push(task);
  }
}

Реальные примеры и шаблоны

Аутентификация пользователя

Хранилище:

import { defineStore } from 'pinia';

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null,
    token: null,
  }),

  actions: {
    async login(credentials) {
      const response = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify(credentials),
      });
      const data = await response.json();

      this.user = data.user;
      this.token = data.token;
    },

    logout() {
      this.user = null;
      this.token = null;
    },
  },
});

Использование:

<template>
  <div>
    <div v-if="!isAuthenticated">
      <button @click="login({ email: 'test@test.com', password: 'password' })">Войти</button>
    </div>
    <div v-else>
      <p>Привет, {{ user.name }}</p>
      <button @click="logout">Выйти</button>
    </div>
  </div>
</template>

<script>
import { useAuthStore } from '@/stores/authStore';

export default {
  setup() {
    const authStore = useAuthStore();

    return {
      user: authStore.user,
      isAuthenticated: !!authStore.token,
      login: authStore.login,
      logout: authStore.logout,
    };
  },
};
</script>

Фильтрация списка

Хранилище:

export const useProductStore = defineStore('product', {
  state: () => ({
    products: [
      { id: 1, name: 'Кофе', category: 'Напитки' },
      { id: 2, name: 'Чай', category: 'Напитки' },
      { id: 3, name: 'Хлеб', category: 'Еда' },
    ],
  }),

  getters: {
    filterByCategory: (state) => {
      return (category) => state.products.filter((p) => p.category === category);
    },
  },
});

Использование:

<template>
  <div>
    <h1>Продукты</h1>
    <button @click="setCategory('Напитки')">Напитки</button>
    <button @click="setCategory('Еда')">Еда</button>

    <ul>
      <li v-for="product in filteredProducts" :key="product.id">{{ product.name }}</li>
    </ul>
  </div>
</template>

<script>
import { useProductStore } from '@/stores/productStore';

export default {
  setup() {
    const productStore = useProductStore();
    const category = ref('Напитки');

    const filteredProducts = computed(() => productStore.filterByCategory(category.value));

    const setCategory = (newCategory) => {
      category.value = newCategory;
    };

    return { filteredProducts, setCategory };
  },
};
</script>

Итог

Мы рассмотрели:

  • Как использовать Pinia с SSR.
  • Лучшие практики для работы с Pinia.
  • Реальные примеры использования, такие как аутентификация и фильтрация данных.

Pinia — это мощный и простой инструмент для управления состоянием, который идеально подходит для Vue 3. Благодаря отличной поддержке TypeScript, простоте работы и гибкости, он становится стандартом в экосистеме Vue. Теперь вы готовы использовать Pinia в своих проектах и наслаждаться процессом разработки! 🚀

 

Комментарии

0

Без регистрации и смс