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

Синглтон

Движок JS позволяет создать синглтон - объект, существующий в единственном экземпляре на всё приложение. Экземпляр доступен из любого места приложения и предоставляет общий доступ к состоянию для всех компонентов, где будет использоваться. Пример:

class BannerStore {
isOpen = false

constructor() {
makeAutoObservable(this)
}

open() {
this.isOpen = true
}

close() {
this.isOpen = false
}
}

export const bannerStore = new BannerStore()

Подключаем его к компоненту:

import { bannerStore } from "./banner-store";

export const Page = observer(() => {
return (
<div>
{bannerStore.isOpen && <Banner />}
...
</div>
);
})

Это самый простой способ, но у него есть ряд недостатков:

SSR

Если запустить сервер на Node.js, то каждый модуль будет проинициализирован ровно один раз. Если создать синглтон с помощью экспорта из модуля, то состояние стора будет общим для всех пользователей одновременно. Стор создаётся один раз на время жизни сервера и состояние стора становится общим для всех запросов. Пример:

-- Запрос 1 - Алиса запрашивает главную страницу
-- на сервере Алиса проходит аутентификацию.
-- В AuthStore флаг isLoggedIn меняется на true, а observable поле user меняется на { name: 'Alice' }

-- Запрос 2 - Анонимный пользователь запрашивает главную страницу
-- сервер отдаёт страницу с залогиненным пользователем Алисой

Это неправильное поведение. Поэтому в SSR-окружении сторы должны создаваться на каждый запрос. Подробнее работа с SSR рассмотрена в отдельной главе.

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

Так как стор является синглтоном, то его состояние будет общим для всех тестовых сценариев. Например, вы хотите протестировать React-компонент, использующий BannerStore. Пример сценария - баннер по умолчанию закрыт, но пользователь может его открыть после нажатия на кнопку.

describe("Page", () => {
it("allows to open banner", () => {
const wrapper = mount(<Page />);
expect(wrapper.text()).not.toContain("Banner");
wrapper.find("button").simulate("click");
expect(wrapper.text()).toContain("Banner");
});
});

Но если мы хотим написать ещё один сценарий возникнет неожиданная проблема - после рендера баннер будет открыт. Он остался открытым после предыдущего теста. Хорошая практика при написании тестов - каждый тест должен быть независимым. Тесты не должны влиять друг на друга, это позволит быстрее обнаружить проблему когда тесты упадут и нужно будет выяснять причину. Для того чтобы в каждом теста предусловия были одинаковые мы можем добавить метод reset в BannerStore:

class BannerStore {
...

+ reset() {
+ this.isOpen = false
+ }
}

Этот метод можно вызывать в начале каждого теста:

describe('Page', () => {
+ beforeEach(() => {
+ bannerStore.reset()
+ })
...

Но в таком случае повышается риск человеческой ошибки. У каждого стора должен быть метод reset, а в этот метод каждый раз нужно будет добавлять новые поля для восстановления изначального значения.

Не особенность Mobx

Данная проблема не специфична для Mobx или классов. В JS модули могут быть stateful, то есть иметь состояние. Простой пример stateful модуля:

export let count = 0

export const increase = () => count++

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