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

Классы VS Функции для сторов

Вопреки распространённому мнению, Mobx не требует использования классов для написания сторов. Более того, в Mobx 6 появилась функция makeAutoObservable, которая ещё сильнее упрощает использование Mobx без классов. Ниже 2 эквивалентных стора, которые написаны с помощью разных подходов:

// Подход с классами
class Counter {
value = 0

constructor() {
makeAutoObservable(this)
}

increment() {
this.value++
}

isEven() {
return this.value % 2 === 0
}
}

// Подход с функциями и объектами:
const createCounter = () => {
return makeAutoObservable({
value: 0,
increment() {
this.value++
},
get isEven() {
return this.value % 2 === 0
}
})
}

В связи с этим возникает вопрос - стоит ли использовать классы для сторов? Если в мире React всё однозначно - классы считаются устаревшим подходом, то с Mobx сторами всё не так просто. Основные сложности сторов-объектов заключаются в отсутствии автоматического вывода типов TypeScript. Рассмотрим эти проблемы:

Union-типы

Пример стора с union-полями (например number | null или Song | undefined):

export class PlayerStore {
song?: Song;
isPlaying = false;

constructor() {
makeAutoObservable(this);
}

play(song: Song) {
this.song = song;
this.isPlaying = true;
}

pause() {
this.isPlaying = false;
}
}

Это стор, моделирующий плеер в приложении. Удобство классов в том, что класс уже является типом для TypeScript. Теперь тип имеет следующую форму:

type PlayerStore = {
song?: Song;
isPlaying: boolean;
playSong: (song: Song) => void;
pause: () => void;
}

Это правильный тип. Что будет, если мы поменяем класс на функцию и объект? Пример:

export const createPlayerStore = () => {
return makeAutoObservable({
song: undefined,
isPlaying: false,
playSong(song: Song) {
this.song = song;
this.isPlaying = true
},
pause() {
this.isPlaying = false;
}
})
}

Во-первых, у нас ещё нет типа PlayerStore. Его можно описать вручную:

export type PlayerStore = {
song?: Song;
isPlaying: boolean;
playSong: (song: Song) => void;
pause: () => void;
}

export const createPlayerStore = (): PlayerStore => {
return makeAutoObservable({
song: undefined,
isPlaying: false,
playSong(song: Song) {
this.song = song;
this.isPlaying = true
},
pause() {
this.isPlaying = false;
}
})
}

Однако налицо дублирование кода. Тип нужно будет обновлять каждый раз, когда в свойства, методы класса, либо в аргументы методов будут вноситься изменения. Есть ли способы выводить тип автоматически? Да, с помощью вспомогательного встроенного в TypeScript типа ReturnType:

export const createPlayerStore = () => {
return makeAutoObservable({
song: undefined,
isPlaying: false,
playSong(song: Song) {
this.song = song;
this.isPlaying = true
},
pause() {
this.isPlaying = false;
}
})
}

export type PlayerStore = ReturnType<typeof createPlayerStore>;

Однако это ещё не всё. Имеем тип PlayerStore, у которого поле song не имеет union-типа, то есть Song потерялся:

export type PlayerStore = {
song: undefined; // 👈 Должно быть `Song | undefined`
isPlaying: boolean;
playSong: (song: Song) => void;
pause: () => void;
}

Как же восстановить union-тип? Есть несколько обходных путей, но все они далеки от идеального.

Кастование типов

export const createPlayerStore = () => {
return makeAutoObservable({
song: undefined as Song | undefined,
// ...
})
}
// ...

Кастование типов TypeScript является плохой практикой, так как кастование - это способ заставить замолчать компилятор там, где он справедливо ругается. Результат - ошибки в рантайме. Пример:

class Dog {
bark() {}
}

const dog: Dog = {} as Dog;
dog.bark(); // Ошибка в рантайме, но нет ошибки компиляции

Подтверждение на TypeScript Playground.

Вспомогательная функция для вывода типов

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

const value = <T extends any>(value: T): T => value;

export const createPlayerStore = () => {
return makeAutoObservable({
song: value<Song | undefined>(undefined),
isPlaying: false,
playSong(song: Song) {
this.song = song;
this.isPlaying = true
},
pause() {
this.isPlaying = false;
}
})
}

export type PlayerStore = ReturnType<typeof createPlayerStore>;

Вывод

Финальный выбор, конечно же, остаётся за читателем. Мы лишь сравнили разные варианты. Не стоит предвзято относиться к классам, вместо этого нужно анализировать и принимать взвешенные решения. Классы действительно были неудачным выбором для компонентов, но для сторов они по-прежнему хорошо себя показывают. Классы используются во многих языках и в разных сферах разработки - на бекенде (пример из документации Nest.js, там всё на классах), при разработке игр (пример из игрового движка Unity, там тоже всё на классах), поэтому они по-прежнему актуальны, а в каких-то аспектах дают неоспоримые преимущества.