javascript

ООП для управления состоянием во Vue

  • вторник, 17 марта 2026 г. в 00:00:06
https://habr.com/ru/articles/1010774/

Чувак, уже есть Pinia, Pinia Colada, TanStack Query для Vue, зачем ещё один способ управлять состоянием во Vue?

Да меня просто задолбало писать везде флаги отслеживания состояния для действий и делать store через фабричную функцию, как это делали наши праотцы в начале 2000-х. Запоминать зоопарк из разных composable-функций и хуков. Изучать какую-то другую систему реактивности, типа MobX, тоже не хочу. Это какой-то зашквар.

Я хочу использовать то, что есть: реактивность Vue везде, классы, методы. Хочу использовать классические шаблоны: SOLID, DDD и т.п. Хочу, чтобы Действие или запрос сами отслеживали и сообщали своё состояние. Хочу сконцентрироваться на бизнес-логике и писать меньше шаблонного кода.

И это возможно, покажу, как это работает на примере.

Постановка задачи

Пример будет максимально простым. 2 модели: пользователь и счётчик. Счётчик доступен только для аутентифицированного пользователя. По клику на кнопках пользователь может увеличить, уменьшить или сбросить счётчик.
Можно смотреть на это как на корзину, с возможностью положить, удалить товар или очистить.
Бизнес-логика максимально упрощена, чтобы показать подход и возможности.

Что будет в таком маленьком примере:

  1. отслеживание и отображение состояния каждого Действия;

  2. модели на классах;

  3. переиспользование модели в компонентах;

  4. зависимость одной модели от другой;

  5. разбиение на домены и слои;

  6. внедрение и инверсия зависимостей.

И для этого не нужно изучать новые подходы, архитектуру, сложное API и т.п.
Всё новое будет максимально приближено к стандартным возможностям TypeScript и Vue.

Пример реализован на Nuxt и Vue 3. Для реализации моделей, переиспользования и всего остального используем vue-modeler. vue-modeler помогает сделать всё описанное выше максимально простым образом.

Форма логина

Начнём с Vue-компонента, чтобы сразу были понятны преимущества.

Панель пользователя показывает форму логина или приветствие.
В шаблоне много элементов и условий, которые управляют отображением, но внутри <script> нет дополнительных переменных. Все условия используют состояние напрямую из действий, создавать и контролировать дополнительные вычисляемые свойства не нужно.

Код максимально читаемый. user.login.exec(value), user.login.isPending, user.login.error не требуют пояснений — так будет по всему проекту. Единый стандарт.

<template>
  <div class="user-panel">
    <template v-if="user.isGuest">
      <div class="user-panel__form">
        <label for="user-name">Name</label>
        <input
          id="user-name"
          v-model="name"
          type="text"
          placeholder="Enter name"
          :disabled="user.login.isPending"
        >

        <button
          :disabled="!name.trim() || user.login.isPending"
          @click="onLogin"
        >
          {{ user.login.isPending ? 'Logging in…' : 'Log in' }}
        </button>

        <p v-if="user.login.error" class="user-panel__error">
          {{ user.login.error.message }}
        </p>
      </div>
    </template>

    <div v-else class="user-panel__bar">
      <span>Hello, {{ user.name }}</span>
      <button
        :disabled="user.logout.isPending"
        @click="user.logout.exec()"
      >
        {{ user.logout.isPending ? 'Logging out…' : 'Log out' }}
      </button>
    </div>
  </div>
</template>

Получение user выглядит как обычная composable-функция.


<script setup lang="ts">
import { ref } from 'vue'
import { useUser } from '../dc'

const user = useUser()
const name = ref('')

async function onLogin() {
  const value = name.value.trim()
  if (!value) return
  user.login.exec(value)
}
</script>

Код компонента счётчика выглядит аналогично. Не буду загромождать статью — можно посмотреть здесь

Там есть особенность counter.hasPendingActions — встроенное свойство ProtoModel, которое равно true, если хотя бы одно действие модели выполняется. Не нужно проверять каждое действие отдельно.

Перейдём к определению модели пользователя.

Класс модели

Класс модели — это стандартный класс, унаследованный от ProtoModel. Методы мутации состояния обёрнуты в декоратор action.

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

Действие — это объект первого класса, который хранит операцию для изменения состояния модели, имеет методы управления выполнением и свойства для контроля состояния выполнения.

Действие за пределами класса нельзя вызвать как функцию, поэтому у него есть метод exec, который сохраняет сигнатуру исходного метода.

Если нужно следи��ь за своим же действием внутри класса модели, то нужно получить его как объект. Это несложно. Подробнее здесь.
В остальном всё одинаково.

Несмотря на такие метаморфозы, проверки типов и подсказки TypeScript работают как надо. IDE будет корректно подсказывать свойства и методы, навигация внутри IDE будет работать правильно, а не как в Pinia.

Действие никогда не выбрасывает ошибки выполнения. Оно перехватывает их и сохраняет в свойстве error как специальный объект ActionError. Этим обеспечивается единый и одинаковый механизм обработки ошибок между моделью и UI-компонентом.

Полный код класса модели пользователя:

import { ProtoModel, action } from '@vue-modeler/model'

interface ApiService {
  login: (name: string) => Promise<string>
  logout: () => Promise<void>
}

export class User extends ProtoModel {
  protected _jwt = ''
  protected _name= ''

  constructor(
    private apiService: ApiService,
  ) {
    super()
  }

  get name(): string {
    return this._name
  }

  get isGuest(): boolean {
    return !this._jwt
  }

  @action async login(name: string): Promise<void> {
    this._jwt = await this.apiService.login(name)
    this._name = name
  }

  @action async logout(): Promise<void> {
    await this.apiService.logout()
    this._jwt = ''
    this._name = ''
  }
}

В результате:

  • Можем отслеживать статус выполнения без написания доп. кода.

  • Из коробки доступны блокировка, отмена выполнения Действия как методы.

  • Работа с действиями и обработка ошибок однообразна и предсказуема.

  • Код бизнес-логики содержит только логику изменения состояния модели.

  • Статус выполнения, ошибки — это свойства Действия.

  • Объём кода меньше в разы по сравнению с др��гими подходами.

Создаём экземпляр пользователя

Чтобы получить модель, нужно создать экземпляр. Для этого в каждом классе есть статический метод model. Он унаследован из ProtoModel. Сигнатура метода соответствует сигнатуре конструктора. В остальном всё стандартно. Проверка типов работает.

import { User } from './user/user'
import { apiService as userApiService } from './user/api-service'

const user = User.model(userApiService)

User.model(...) под капотом создаёт new User(apiService) и оборачивает экземпляр в shallowReactive, делая его реактивным для Vue. Можно создавать свои статические конструкторы.
Подробнее здесь.

Переиспользование

Модель отвечает только за состояние. За переиспользование и жизненный цикл модели отвечает контейнер. Здесь соблюдён принцип единственной ответственности.
Чтобы переиспользовать модель, её нужно зарегистрировать в контейнере.

import { provider } from '@vue-modeler/dc'
import { User } from './user/user'
import { apiService as userApiService } from './user/api-service'

export const useUser = provider(() => User.model(userApiService))

Результат регистрации — привычная функция-провайдер в виде useUser(). Её нужно использовать в UI-компонентах и других фабричных функциях. Она всегда вернёт один и тот же экземпляр. Изменение состояния в одном месте мгновенно отражается во всех остальных.

Контейнер создаёт модель 1 раз при первом вызове провайдера. Он отслеживает использование модели, автоматически удалит, если она больше не используется. При удалении он вызовет destructor модели. Он есть во всех моделях, унаследован от ProtoModel.
Можно создать постоянные модели, они живут после использования.

Вот пример переисползования:

<!-- app.vue -->
<script setup lang="ts">
import { useUser } from './dc'

const user = useUser()
</script>
<!-- counter/app-counter.vue -->
<script setup lang="ts">
import { useCounter, useUser } from './dc'

const counter = useCounter()
const user = useUser()
</script>

Подробнее в документации контейнера зависимостей.

Расслоение

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

Вот так выглядит структура проекта:

app/
├── dc.ts                     # контейнер: регистрация и связывание моделей
├── app.vue                   # корневой компонент (представление)
├── user/
│   ├── user.ts               # модель (бизнес-логика)
│   ├── user-panel.vue        # компонент (представление)
│   └── api-service.ts        # сервис (инфраструктура)
└── counter/
    ├── counter.ts            # модель (бизнес-логика)
    ├── app-counter.vue       # компонент (представление)
    └── api-service.ts        # сервис (инфраструктура)

Каждый домен (user, counter) содержит три слоя:

  • модель (user.ts, counter.ts) — бизнес-логика и состояние;

  • представление (user-panel.vue, app-counter.vue) — UI-компоненты;

  • инфраструктура (api-service.ts) — взаимодействие с внешним миром (API, хранилища).

Внедрение

Counter зависит от API и от User, т.е. существует зависимость между доменами и слоями.
Все зависимости определяем через интерфейсы. Файл с классом модели больше не содержит прямые импорты, только интерфейсы. Модель ничего не знает о других доменах или слоях, её легко переиспользовать в других проектах.

import { ProtoModel, action } from '@vue-modeler/model'
import type { ShallowReactive } from 'vue'

interface ApiService {
  init: () => Promise<number>
  inc: (currentCount: number) => Promise<number>
  dec: (currentCount: number) => Promise<number>
}

interface User {
  isGuest: boolean
}

export class Counter extends ProtoModel {
  protected _count = 0

  constructor(
    private apiService: ApiService,
    private user: ShallowReactive<User>
  ) {
    super()
    // ...
  }

  // ...
}

Counter не импортирует класс User и не знает о конкретном apiService. Зависимости приходят через конструктор. Разработчик регистрирует в контейнере фабричную функцию для получения модели, в ней он получает зависимости через функции-провайдеры, передаёт зависимости в конструктор модели:

import { provider } from '@vue-modeler/dc'
import { Counter } from './counter/counter'
import { apiService as counterApiService } from './counter/api-service'
import { User } from './user/user'
import { apiService as userApiService } from './user/api-service'

export const useUser = provider(() => User.model(userApiService))
export const useCounter = provider(() => Counter.model(counterApiService, useUser()))

useUser() внутри фабрики useCounter — это тот же самый экземпляр User, что используется в компонентах.

Все границы выстроены через интерфейсы — как завещали предки.
Проект приобретает чёткую структуру и дизайн по доменам и слоям.
Это очень сильно улучшает DX.

Зависимые состояния

Counter инициализирует счётчик, когда пользователь залогинился. Чтобы это реализовать:

  1. Передаём User как зависимость в конструктор Counter.

  2. Создаем наблюдатель за user.isGuest через this.watch.

  3. В наблюдателе вызываем Действие.

export class Counter extends ProtoModel {
  protected _count = 0

  constructor(
    private apiService: ApiService,
    private user: ShallowReactive<User>
  ) {
    super()

    this.watch(
      () => this.user.isGuest,
      (isGuest: boolean) => {
        if (isGuest) {
          this.reset()
          return
        }

        this.init()
      },
      { immediate: true }
    )
  }

  @action async init(): Promise<void> {
    this._count = await this.apiService.init()
  }
  
  ...
}

Когда пользователь логинится (isGuest становится false) — Counter автоматически загружает начальное значение через this.init(). Когда пользователь разлогинивается — счётчик сбрасывается через this.reset().

Важно использовать this.watch, а не просто watch из Vue. Подробнее в разделе Наблюдатели.

Тестирование

Модель — экземпляр стандартного класса. Зависимости легко внедряются через конструктор. Написание юнит-тестов — стандартный процесс. При тестировании моделей нужно помнить о том, что это реактивный объект, и нужно вызывать nextTick для обновления реактивности.

Зависимости мокаются через конструктор — никаких магических хелперов, DI-фреймворков или глобального состояния.

Тесты на модели можно посмотреть в репозитории проекта: user.test.ts, counter.test.ts

Итого

Даже на таком маленьком примере мы получили:

  1. значительное уменьшение кода: нет шаблонного кода, нет лишних вычисляемых свойств;

  2. чёткие границы доменов и слоёв на уровне семантики и файловой структуры;

  3. слабую связанность через интерфейсы и внедрение зависимостей;

  4. автоматическое следование практикам чистого кода;

  5. единые стандарты обработки ошибок;

  6. стандартные подходы к тестированию;

  7. единообразный код работы с Действиями независимо от разработчика.

Есть неочевидные возможности:

  1. Действие — это объект первого класса, который сохраняет контекст модели. Его можно передавать и использовать везде отдельно от модели.

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

Всё это позволяет реализовывать такие шаблоны, как CommandExecutor, Repository и т.п. Код становится максимально однообразным. Это значит: лучше DX, легче писать промты. Но об этом отдельно.

Документация vue-modeler.

Я автор этого проекта. Мы используем его в проде. Уже есть сформированные шаблоны и для решения типичных и нетипичных задач. При наличии времени и возможностей планирую писать о них у себя в телеграме.