Действие (Action)
В @vue-modeler/model действие — это объект первого класса, который хранит операцию для изменения состояния модели, имеет методы управления выполнением и свойства для контроля состояния выполнения.
Общая концепция
- Действие объявляется как метод класса модели с декоратором
@action. Метод асинхронный и не возвращает данных. - Метод преобразуется в действие при создании модели.
- Действие принадлежит модели, в которой объявлено.
- Действие при любом использовании сохраняет контекст модели.
Такой подход дает ряд преимуществ:
- статус выполнения доступен для отслеживания без написания шаблонного кода
- отмена, блокировка, разблокировка доступны как методы — не нужно изобретать "велосипед" ;
- работа с действиями и обработка ошибок единообразна и предсказуема;
- код бизнес-логики содержит только логику изменения состояния модели;
- объем кода меньше в разы по сравнению с другими подходами.
Вот наглядный пример.
Без действий (шаблонный код):
export const useCart = defineStore('cart', () => {
const items = shallowRef<Product[]>([])
// Флаги выполнения операции
const isAddingProduct = ref(false)
const addError = ref<Error | null>(null)
async function addProduct(product: Product): Promise<void> {
// Проверяем, что операция не выполняется
if (isAddingProduct.value) return
// Устанавливаем флаг выполнения операции
isAddingProduct.value = true
// Сбрасываем ошибку
addError.value = null
// Выполняем операцию
try {
await api.addToCart(product)
// Сохраняем новое состояние в модели
items.value = [...items.value, product]
} catch (error) {
// Сохраняем ошибку
addError.value = error as Error
// Пробрасываем ошибку дальше
throw error
} finally {
// Сбрасываем флаг выполнения операции
isAddingProduct.value = false
}
}
return {
items,
isAddingProduct,
addError,
addProduct
}
})
const cart = useCart()
await cart.addProduct(newProduct)
watch(cart.isAddingProduct, (value) => {
console.log('isAddingProduct', value)
})
watch(cart.addError, (value) => {
console.log('addError', value)
})Разберем, что происходит в этом коде.
Разработчик пишет шаблонный код для каждой операции изменения состояния:
- Устанавливает флаг выполнения операции
- Готовит данные для сохранения
- Сохраняет новое состояние в хранилище
- Если сохранение:
- успешно — коммитит новое состояние в модель
- не успешно — устанавливает флаг ошибки, ошибка сохраняется или прокидывается дальше
- Удаляет флаг выполнения операции
Есть особые случаи:
- Нужно следить, чтобы операция не была вызвана, пока не завершился предыдущий вызов, иначе может возникнуть неконсистентное состояние.
- Случается, что операцию нужно отменить или заблокировать.
Это тоже шаблонный код, но разработчики по разному обрабатывают эти случаи. Такой код сложно поддерживать и тестировать.
Действие-объект берет на себя всё, включая особые случаи. Позволяет разработчику сконцентрироваться на бизнес-логике (п. 2, 3, 4.1), избавляет от когнитивной нагрузки. Код всех операций стандартизирован и не нужно изобретать велосипед.
То же самое, но с действиями:
class Cart extends ProtoModel {
protected _items: Product[] = []
...
get items(): Product[] {
return this._items
}
@action async addProduct(product: Product): Promise<void> {
// Отправляем данные на серверы
await this.apiService.addToCart(product);
// Сохраняем данные в модели
this._items.push(product);
}
}
const cart = Cart.model(apiService);
await cart.addProduct.exec(newProduct);
watch(cart.addProduct.isPending, (value) => {
console.log('isPending', value)
})
watch(cart.addProduct.error, (value) => {
console.log('error', value)
})Как использовать действия
Разберем на примере добавления товара в корзину.
// Определяем класс модели.
// Не забываем унаследоваться от ProtoModel. Это обязательно.
class CartModel extends ProtoModel {
...
// Декоратор @action указывает, что это будет действие.
@action async addProduct(product: Product): Promise<void> {
// Отправляем данные на серверы
await this.apiService.addToCart(product);
// Сохраняем данные в модели
this._items.push(product);
}
}
// Создаем модель
const cartModel = CartModel.model(apiService)
// Выполняем действие
await cartModel.addProduct.exec(newProduct);
// Следим за статусом выполнения операции
watch(
cartModel.addProduct.isPending,
console.log
)
// Следим за ошибками
watch(
cartModel.addProduct.error,
console.log
)Объявляем
Просто добавляем @action к асинхронному методу, который не возвращает данных.
// Определяем класс модели. Не забываем унаследоваться от ProtoModel.
class CartModel extends ProtoModel {
...
@action async addProduct(product: Product): Promise<void> {
// Отправляем данные на серверы
await this.apiService.addToCart(product);
// Сохраняем данные в модели
this._items.push(product);
}
}Обязательные условия:
- Класс должен наследоваться от
ProtoModel. - Метод должен возвращать
Promise<void>. - Метод должен быть декорирован через
@action.
Получаем как объект
После создания модели действие — это свойство модели и объект. Для получения действия используйте имя метода, которое было объявлено как действие. TypeScript корректно определяет типы, поэтому автодополнение свойств и методов для действия будет работать.
// ✅ Правильно - используем .exec()
await cartModel.addProduct.exec(productId);
// ❌ Неправильно - TypeScript будет ругаться
await cartModel.addProduct(productId);Внутри класса модели TypeScript видит действие как метод, но на самом деле это объект. Чтобы получить доступ к свойствам и методам действия и избежать ошибок типов, нужно получить действие через метод модели this.action(this.addProduct). Он вернет действие с правильными типами, и проблем с TypeScript не будет. Теперь действие можно использовать так же, как и во внешнем контексте: выполнять, следить за состоянием и т.п.
// ❌ Неправильно. Для TypeScript this.addProduct - это метод,
// и у него нет свойств
const error = this.addProduct.error;
// ✅ Правильно - получаем действие как объект, и у него есть свойства
const error = this.action(this.addProduct).error;Действие-объект сохраняет контекст модели. Можно безопасно сохранять его в переменную и использовать в других местах.
const addProductAction = cart.addProduct;
await addProductAction.exec(productId);
watch(addProductAction.isPending, (value) => {
console.log('isPending', value)
})
watch(addProductAction.error, (value) => {
console.log('error', value)
})Выполняем
Во внешнем контексте получаем действие-объект через свойство модели и вызываем метод exec(...). Он копирует сигнатуру исходного метода, поэтому проверки типов будут работать.
// ✅ Правильно - используем .exec()
await cartModel.addProduct.exec(productId);
// ❌ Неправильно - TypeScript будет ругаться, потому что здесь действие уже объект
await cartModel.addProduct(productId);Внутри класса модели есть 2 способа выполнить действие:
- получить как объект через
this.action(this.addProduct)и вызвать методexec(...). - вызвать действие как метод модели:
this.addProduct(newProduct). Это возможно, потому что TS "видит" действие как метод внутри класса.
// ❌ Неправильно. Внутри класса так не работает,
// потому что TypeScript видит действие как метод,
// а не как объект.
await this.addProduct.exec(productId);
// ✅ Правильно: получаем как объект, вызываем exec
await this.action(this.addProduct).exec(productId);
// ✅ Так тоже можно, если нужно проверить состояние
const addProductAction = this.action(this.addProduct);
await addProductAction.exec(productId);
if (addProductAction.error) {
// Обрабатываем ошибку
}
// ✅ Это тоже работает. Вызываем действие как метод модели.
await this.addProduct(productId);WARNING
Внутри класса вызов this.addProduct() выглядит как обычный вызов метода, но на самом деле это не так. Декоратор @action подменяет оригинальный метод и "под капотом" получает действие как объект и вызывает метод exec(...). Поэтому try...catch не будет работать, как ожидается. См. Обработка ошибок.
Действие асинхронное, поэтому exec(...) всегда возвращает Promise<void>. Вы не получите данные, даже если попробуете их вернуть. Это сделано осознанно: действие должно менять состояние модели, а не возвращать данные.
Следим за состоянием
Действие может находиться в одном из пяти состояний, каждое из которых отражается в соответствующем свойстве:
isReady— действие готово к выполнению (начальное состояние)isPending— действие выполняется в данный моментisAbort— действие было отмененоisLock— действие заблокировано и не может быть запущеноerror— действие завершилось с ошибкой (содержитActionErrorилиnull)
Все свойства реактивны и доступны только для чтения. В любой момент времени только одно из булевых свойств (isReady, isPending, isAbort, isLock) будет true, остальные — false. Свойство error может быть null или содержать объект ошибки.
Внутри UI компонент можно использовать все стандартные средства наблюдения за реактивными переменными: watch, watchEffect, computed.
<template>
<div>
<p>isPending: {{ cartModel.addProduct.isPending }}</p>
<p>error: {{ error.message }}</p>
</div>
</template>
<script setup>
const cartModel = useCartModel()
watch(
() => cartModel.addProduct.isPending,
(value) => console.log(value)
)
const error = computed(() => cartModel.addProduct.error?.cause ?? cartModel.delProduct.error?.cause )
</script>Внутри класса модели нужно использовать только this.watch и this.computed. Эти методы доступны во всех моделях.
Если нужно наблюдать за собственным действием, его нужно получить как объект.
class CartModel extends ProtoModel {
...
constructor () {
super()
this.watch(
() => this.action(this.addProduct).isPending,
(value) => console.log('addProduct isPending', value)
)
}
@action async addProduct(product: Product): Promise<void> {
...
}
}Если действие принадлежит другой модели, то его действие уже объект, можно обращаться по имени метода.
class CartModel extends ProtoModel {
...
constructor (
readonly user: Model<User>
) {
super()
this.watch(
() => this.user.login.isPending,
(isPending) => console.log('user login isPending', isPending)
)
}
}Обрабатываем ошибки
Любое действие может завершиться с ошибкой. Их можно разделить на 3 группы:
- Исключения возникают в бизнес-логике или слое инфраструктуры: ошибки валидации данных, авторизации, нехватки товара, любой неуспешный ответ от сервера или БД.
- Системные ошибки выбрасываются самим интерпретатором JS: RangeError, ReferenceError, SyntaxError, TypeError, URIError, EvalError.
- Внутренние ошибки выбрасываются внутри библиотеки при нарушении логики работы действия или попытке выполнить действие в неверном состоянии.
Системные и внутренние ошибки не должны возникать в продакшене. Они приводят к неожиданному поведению и падению приложения. Они могут быть пойманы, но не обработаны, или обработаны не корректно, что вызовет неожиданное поведение.
Исключения - ожидаемы, должны быть обработаны и отображены пользователю в каком-то виде.
Метод exec(...) перехватывает только исключения, оборачивает их в ActionError и сохраняет в свойстве error для обработки. Свойство error доступно только для чтения и реактивно. Повторный запуск действия сбросит ошибку. ActionError обеспечивает единообразный интерфейс для обработки.
class CartModel extends ProtoModel {
...
@action async addProduct(product: Product): Promise<void> {
// это будет перехвачено
throw new Error('Product not found')
// здесь тоже будет перехвачено, если там ошибка
await this.apiService.addProduct(product)
}
}Так как exec перехватывает исключения, то try...catch не работает. Проверяйте свойство error после выполнения действия.
try {
// ❌ это не работает.
// exec перехватит исключение,
// сохранит в error,
// завершит выполнение как обычно.
await action.exec()
} catch (error) {
// ❌ сюда никогда не попадем.
console.error('Error:', error.message)
}
// ✅ это работает.
await action.exec()
// ✅ проверяем свойство error.
if (action.error?.cause instanceof HttpError) {
console.error('HTTP error:', action.error.cause.message)
}
if (action.error?.cause instanceof BusinessError) {
console.error('Business error:', action.error.cause.message)
}Действия могут вызывать друг друга. Если в дочернем действии возникла ошибка, она там и останется, потому что перехвачена в exec. Родительское действие продолжит выполнение, как-будто ошибки не было. Чтобы прервать выполнение, нужно пробросить ошибку в родительское действие. Это можно сделать методом ActionError.throwCause() без дополнительных проверок.
class ChildModel extends ProtoModel {
...
@action async childAction(): Promise<void> {
throw new Error('Child error')
}
}
class ParentModel extends ProtoModel {
constructor (
readonly child: Model<ChildModel>
) {
super()
}
@action async parentAction(): Promise<void> {
await this.child.childAction.exec()
// пробрасываем ошибку в родительское действие
this.child.childAction.error?.throwCause()
// или так, но больше кода. Лучше использовать throwCause().
if (this.child.childAction.error) {
throw this.child.childAction.error.cause
}
}
}Если нужно, чтобы exec не перехватывал ошибку, то можно:
- создать свой класс ошибок,
- унаследовать от
ActionInternalError, - оборачивать ошибки в него и кидать дальше.
Для таких ошибок работает стандартный try...catch процесс. Но так лучше не делать.
Используйте прихват в exec и свойство error, этого достаточно для большинства случаев.
TIP
execперехватывает только исключения, try...catch не работает.- проверяйте свойство
errorпосле выполнения действия. - для проброса ошибки дальше используйте метод
throwCause().
Отменяем выполнение
Для отмены есть метод abort(). Процесс отмены построен на AbortController.
Чтобы метод abort() работал, нужно выполнить следующие условия:
- При объявлении действия последний аргумент метода должен иметь тип
AbortController. - Аргумент должен быть опциональным, чтобы TypeScript не требовал его явного указания при вызове
exec. - Код внутри действия должен использовать переданный
AbortController, иначе отменить операцию не получится.
Работает это так:
- Метод
exec:- создает
AbortController, если он не передан явно, или использует тот, что передали; - сохраняет его как состояние на время выполнения действия;
- передает
AbortControllerв исходный метод, всегда в последнем аргументе.
- создает
- Исходный метод:
- использует переданный
AbortControllerв запросах и других операциях.
- использует переданный
- Метод
abort:- вызывает метод
abortна сохраненномAbortController, это провоцирует выбрасывание исключения.
- вызывает метод
- Метод
exec:- перехватывает исключение;
- убеждается, что исключение является отменой;
- переводит действие в состояние
abort, сохраняет причину отмены в свойствеabortReason; - завершается успешно и не возвращает данных, но действие остаётся в состоянии
abort.
class CartModel extends ProtoModel {
...
@action async addProduct(
product: Product,
abortController = new AbortController(), // ✅ Правильно
): Promise<void> {
await this.apiService.addProduct(product, abortController) // ✅ Правильно
}
// ❌ для этого действия abort() не работает, потому что не использует переданный AbortController
@action async deleteProduct(productId: number): Promise<void> {
const abortController = new AbortController() // ❌ Неправильно
await this.apiService.deleteProduct(productId, abortController) // ❌ Неправильно
}
}
const cartModel = CartModel.model(apiService)
await cartModel.addProduct.exec(product)
// Отменяем операцию
cartModel.addProduct.abort()Бывают случаи, когда одно действие вызывает другое, и может быть отменено согласно бизнес логике. Тогда дочернее действие тоже должно быть отменено автоматически.
Чтобы это работало, необходимо:
- дочернее действие поддерживало
AbortController; AbortControllerбыл общим — его нужно явно передать в дочернее действие последним аргументом.
class SomeModel extends ProtoModel {
@action childAction(abortController?: AbortController ): Promise<void> {
...
}
@action parentAction(abortController?: AbortController ): Promise<void> {
// ✅ Правильно. Передаем abortController дальше, он общий
await this.action(this.childAction).exec(abortController)
}
}