Записки

Решение проблемы сборки Webpack'ом приложения использующего Mobx Root Store

Диспозиция и предыстория


Проект на ≈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(), но это недостижимый идеал. Наверное, это можно будет сделать только тогда, когда бандлер вроде вебпака перед сборкой проекта будет анализировать его с помощью ИИ который будет выявлять все явные и не явные зависимости которые могут возникнуть в рантайме, а пока такого нет приходится выкручиваться кто на что горазд.