Синглтон
Движок 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
будет общим для всех файлов, которые импортируют эту переменную.