Диспозиция и предыстория
Проект на ≈12 000 строк, под капотом React, Mobx и написано всё на Typescript. Сборка, естественно, происходит Webpack’ом. На каждую сущность есть своё хранилище, которое, в свою очередь никак не связано с другими. Это не особо доставляет неудобств, но всё же периодически приходится выполнять операции в духе: «возьми данные из одного стора, затем второго и третьего, скомбинируй их как-то, а потом вызови с полученными данными в качестве аргументов функцию из четвёртого стора, и всё это внутри ещё одной функции, которая получает данные от API». Это утрированный пример, и в реальном коде чаще приходилось просто брать поле из одного стора и вызывать с ним в качестве параметра функцию из другого. Кажется, такой код совсем не пахнет как Teen Spirit.
В какой-то момент этот утрированный пример стал почти реальностью, и я решил, что надо добавить связи между хранилищами и, тем самым, разгрузить методы внутри компонентов от лишнего кода. Таким образом у меня получится максимально отдалить сторы от компонентов, в которых они используются, и сделать шаг в сторону более явственно представленной архитектуры MVVM.
В разделе Mobx best practices про комбинирование нескольких хранилищ, сказано буквально следующее: An effective pattern is to create a RootStore that instantiates all stores, and share references.
Что ж, сказано — сделано. Немного модифицировав пример из best practices написал такой код (здесь и далее я буду использовать код из подготовленного мной репозитория со всем минимально необходимым для воспроизведения ошибки):
root.js — корневое хранилище
class RootStore {
stores = {}
setChildStore = (name, store) => {
this.stores[name] = store
}
}
const Root = new RootStore()
export class RootInitiator {
constructor() {
this.root = Root
Root.setChildStore(
this.constructor.name.charAt(0).toLowerCase() +
this.constructor.name.slice(1),
this
)
}
}
first.store.js — первый стор
import { RootInitiator } from "./root.store";
class FirstStore extends RootInitiator {
constructor() {
super()
}
callme = () => {
const secondStore = this.root.stores.secondStore
return secondStore.getHello()
}
}
const firstStore = new FirstStore()
export default firstStore
second.store.js — второй стор
import { RootInitiator } from "./root.store";
class SecondStore extends RootInitiator {
constructor() {
super()
}
getHello = () => `Hello world`
}
const secondStore = new SecondStore()
export default secondStore
app.jsx — основной компонент
class App extends Component {
render() {
return this.props.firstStore.callme()
}
}
const app = inject('firstStore')(App)
export default app
Как видите, ничего такого, за что можно под зад коленом и вон из профессии.
Объясню только зачем понадобился класс RootInitiator. Всё просто: чтобы совершать меньше ненужных телодвижений во время создания новых сторов и модифицирования старых. Просто пишем class SecondStore extends RootInitiator, а затем внутри класса
constructor() {
super()
}
Всё! Магия начинает работать автоматически! Вызов super(), напомню, это вызов конструктора родительского класса, т.е. RootInitiator. В этом конструкторе вызывается функция из класса Root, которая записывает в список дочерних хранилищ не класс RootInitiator, как может показаться неопытному падавану, а инстанс дочернего класса. Чёрная магия ООП в действии, не меньше! Первым параметром в функцию передаётся ключ, по которому мы будем потом обращаться за доступом к инициализируемому в данный момент стору. Логично, чтобы он был такой же, как и имя стора поэтому берём имя конструктора this.constructor.name и приводим его к camelCase, чтобы он соответствовал имени переменной с инстансом этого класса. Вторым параметром передаём this, который в данный момент указывает на экземпляр класса FirstStore или SecondStore.
После создания класса в нём уже есть ссылка на корневое хранилище, другие сторы уже знают о его существовании и могут использовать. В отличие от примера в документации Mobx, в моём коде не нужно делать никакие лишние импорты в файл с RootStore и совершать какие-либо другие телодвижения. Всё это великолепие прекрасно работало, но гладко было на бумаге, да забыли про овраги.
Анамнез
Итак, что мы имеем? Всё как по учебнику: локально работает, а на тестовом сервере компонент падает (у меня все более-менее важные и/или сложные компоненты обёрнуты в HOC который ловит ошибки в дочернем компоненте и тем самым не даёт упасть всему приложению). В консоли ошибка просто классика в жанре JavaScript:
TypeError: Cannot read property 'getHello' of undefined
Запускаем локально Webpack в режиме разработки (--mode=development) и всё работает. Если пересобрать приложение без этого ключа (или --mode=production), то видим ту же самую ошибку.
Расследование
«Прежде кумекай, потом кукарекай»
Я.А. Козловский
М.: Советская Россия. 1975. 24 стр.
Мягкий переплёт, энциклопедический ф-т.
Сначала я начал гуглить по ключевым словам «Mobx», «root store» вместе с текстом ошибки, а лучше бы просто подумал. Глядишь сэкономил бы денёк из тех трёх, что прошло от первого запроса в Гугл до одобрения мерж реквеста ревьювером.
Первая мысль была такой себе, но рабочей: принудительно выставить в конфиге вебпака mode: 'development'. Один известный рыцарь джедай как-то сказал: — «…Но помни: гнев, страх и грязные хаки — это всё ведет на тёмную сторону Силы. Как только ты сделаешь первый шаг по тёмному пути, ты уже не сможешь с него свернуть». Йоду магистра уважаю я.
Вторая мысль была более рациональна: надо посмотреть, что вообще идёт не так? Обычно это ведёт меня в глубины исходного кода тех модулей которые я использую, затем я долго и мучительно пытаюсь продебажить их шаг за шагом, запутываюсь, прокастинирую, снова берусь за поиски решения и нахожу его где-нибудь среди комментариев к issue на гитхабе. Stackoverflow для нубов. Истинно вам говорю!
Код собранный вебпаком для продакшена (как это вообще сказать по-русски?) отличается от исходников тем, что помимо непосредственной сборки всего твоего говнокода в один файл и тришейкинга он ещё его и всячески его минифицирует. Отлично! Находим в документации к вебпаку параметр optimization.minimize и добавляем его в наш конфиг. Пересобираем приложение и видим ту же ошибку, но теперь она хотя бы читабельна:
Ставим точку останова в файле source.js на 67 строке
…и видим, что что-то пошло не так. Точнее, это можно было увидеть ещё в стектрейсе ошибки, ну да не суть. В списке дочерних сторов есть все наши хранилища, но у них изменённые имена. Очевидно, что вебпак сделал это для защиты от коллизии имён.
На третью мысль натолкнул меня автоматически вставленный вебпаком комментарий:
// CONCATENATED MODULE: ./src/first.store.ts
Он навёл меня на параметр конфигурации optimization.concatenateModules. Добавляем его в конфиг, пересобираем и… О БОЖЕ ВСЁ РАБОТАЕТ! АЛЛИЛУЯ!
Не верим своим глазам, открываем девтулзы и видим прекрасное:
Сгенерированный код стал немного гаже. Необходимости читать его перед сном вместо молитвы у меня нет и посему на этом я решил остановится, написал длинный коммент в webpack.config.js, поясняющий, что это и зачем, написал багрепорт в гитхабе Webpack, сделал MR в рабочем репозитории и отдал его на ревью.
И не прошёл его.
Наш тимлид зацепился как раз за эти изменения в конфиге. В качестве решения проблемы он предлагал руками задать имя класса различные способами, но все они не работали потому, что все они работали в рантайме, а вебпак ничего не знает про то, что там происходит, но именно эта мысль о том, что надо задать имя стора самостоятельно и подтолкнула меня к решению проблемы.
Решение
Решение проблемы оказалось простым и элегантным:
export class RootInitiator {
constructor(name) {
this.root = Root
Root.setChildStore(name, this)
}
}
Видите изменения? Теперь мы вместо того, чтобы взять имя конструктора текущего класса внутри RootInitiator получаем его явно из дочернего класса:
class SecondStore extends RootInitiator {
constructor() {
super('secondStore')
}
getHello = () => `Hello world`
}
Вот и вся разница.
Теперь даже после минимизации вебпаком у нас есть доступ к сторам друг из друга:
На мой взгляд это решение максимально простое и элегантное, но не совсем. Идеальным был бы вариант как в изначальном коде: просто вызов super(), но это недостижимый идеал. Наверное, это можно будет сделать только тогда, когда бандлер вроде вебпака перед сборкой проекта будет анализировать его с помощью ИИ который будет выявлять все явные и не явные зависимости которые могут возникнуть в рантайме, а пока такого нет приходится выкручиваться кто на что горазд.