Как-то раз в Телеграмм-чате React_JS (кстати, русскоязычный чат, присоединяйтесь) обсуждали вопрос: "где в React-приложении должен быть расположен код, отвечающий за бизнес-логику". Вариантов несколько, мнения разделились. Ну а я решил записать подкаст (автор @PetrMyazin).
Рассмотрим частный пример с бизнес-логикой исключительно на клиенте. Приложение "кредитный калькулятор". Пользователь вводит в форму исходные данные: сумму, срок кредита, еще какие-то параметры; и на лету получает результат, например, сумму переплаты. Весь хитрый алгоритм расчёта суммы переплаты известен и уже реализован в виде JS-функции, назовём её f, которая принимает несколько параметров — те самые данные из формы, пусть будут a-b-c-d, а возвращает эта функция числовой результат (сумму переплаты, обозначим её как x) — это наша бизнес-логика.
Обратите внимание, что функция чистая. Её результат зависит только от входящих параметров. Она не производит никаких side-эффектов, не читает из глобальных переменных. Также эту функцию можно назвать независимой от фреймворка, ведь она одинаково будет работать и в React-приложении, и в Angular, и в Ember, и в jQuery, и даже на VanillaJS.
Но мы решили строить интерфейс на React+Redux, так что теперь возник вопрос: "в какой момент запускать вычисление нашей бизнес-логики". Ещё раз подчеркну, что в этом подкасте мы рассмотрим только упрощённый пример — все вычисления на клиенте, никаких запросов к серверу. В реальном приложении у нас навернка будет часть логики завязана на общение с сервером, но будет и такая часть, которая полностью может быть вычислена на клиенте по уже имеющимся данным. Так что все дальнейшие рассуждения применимы именно ко второй части — вычислениям на клиенте.
Что же, взглянем на форму. Она состоит: из пары текстовых полей, из слайдеров, выпадающих списков, чекбоксов. Введённые пользователем значения этих полей станут входящими параметрами в функцию f. Первый вопрос: "где и как будем хранить значения полей". Вариантов всего два: либо в локальном состоянии самих компонент (чекбоксов, слайдеров и тому подобных), либо в Redux-store.
Локальный state, размазанный по нескольким компонентам, в данном случае неудобен, т.к. нам в итоге нужно получить все значения одновременно, чтобы передать их параметрами a-b-c-d в функцию бизнес-логики. Нам хотелось бы хранить всё в одном месте: либо в неком общем предке (помните lifting state up), либо в Redux-store. Поскольку темой этого подкаста является обзор бизнес-логики в Redux-приложении, будем хранить в Redux.
Пройдём по шагам всю цепочку событий и попробуем прикинуть, в какой же момент лучше всего вызвать функцию бизнес-логики f.
Итак, пользователь меняет что-то на форме, вводит новую цифру в текстовое поле ввода, или двигает слайдер. Срабатывает некий обработчик handleChange — это первое место для вызова бизнес-логики. Но в обработчике handleChange мы можем прочитать значение только текущего измененного поля ввода из event.target.value, а остальные данные формы нам пока недоступны. Напомню, функция f — чистая, она не читает никаких данных из глобальных переменных. Она только принимает параметры a-b-c-d, и возвращает результат x. Значит сам по себе обработчик handleChange не подходит.
Следующий шаг — это функция action-creator. Здесь, с помощью Redux-thunk, мы можем получить весь объект состояния, т.е. получить a-b-c-d для вызова f. Action-creator — хороший претендент, запомним его.
Идём дальше. Поток выполнения переходит к запуску всех редюсер-функций. Редюсеры можно реализовать по-разному. Например, заготовим четыре отдельных функций для поля a, для поля b, для поля c и для поля d. В этом случае каждый из редюсеров имеет доступ к предыдущему состоянию своего поля ввода и к объекту action. Нам же, для запуска функции f, нужны все значения a-b-c-d одновременно. При такой организации редюсеров, единственный шанс — это если мы пробросим все четыре значения a-b-c-d внутри объекта action. По сути, мы таким образом передаем в объект action почти полную копию store. Звучит не очень, не хочется так делать.
Другой способ организации редюсеров — это единый редюсер, который будет обновлять все поля формы сразу, этакий formData-редюсер. Он принимает предыдущее состояние полей формы в виде объекта, содержащего a-b-c-d, обновляет изменившееся значение, а затем запускает функцию бизнес-логики f. Но постойте, а куда мы будем складывать результат вычислений, ту самую сумму переплаты x? Находясь внутри formData-редюсер, единственное место, куда мы можем сохранить x — в тот же самый объект, который теперь у нас будет иметь пять полей: a-b-c-d и x. Впору переименовать этот редюсер в formDataAndResult-редюсер. Он делает всё: и изменение формы запоминает, и результат вычисления бизнес-логики запоминает. Всё в одном большом объекте из пяти полей. Звучит тоже не очень. Слишком жирный редюсер, слишком много всего. Redux настраивает нас на функциональные подходы, на композицию редюсеров. А мы тут пишем одну большую функцию для управления всем сразу.
Дальше у нас запускаются селекторы в container components. Селекторы — это функции в mapStateToProps. Нас интересует селектор для компонента отображающего финальный результат х. Да, здесь можно сделать вызов функции бизнес-логики, т.к. селектор имеет доступ ко всему state, причем к уже обновленному state со свежими значениями a-b-c-d (сделаю на этом небольшой акцент); а результат вычисления функции f, вызванной внутри селектора, попадает в props компонента, отображающего сумму переплаты — вполне рабочее решение.
Если же не в селекторе, то последний шанс вызвать бизнес-логику — это вызвать функцию f непосредственно в методе render компонента x, того самого, который отображает сумму переплаты. Для этого в props придётся прокинуть все четыре требуемые значения a-b-c-d. На мой взгляд это менее изящное решение, чем предыдущее. Селекторы представляются более правильным местом, чтобы запустить вычисления, и передать уже готовый x для отображения.
Мы рассмотрели все варианты. Ну почти все. Можно было обсудить стрёмные способы, типа запуска расчётов в компонент will receive props, или ещё что-нибудь придумать. Но не будем тратить время.
Итого, у нас есть два явных кандидата на запуск бизнес-логики — это action creator и селектор компонента x.
Взглянем на action creator подробнее. Сначала ответим себе на вопрос: "у нас будет один action creator на все поля формы, или по отдельной функции на каждое из полей". Если мы сделаем отдельные функции, то получим четыре action creater-а: changeA, changeB, changeC и changeD; которые на самом деле будут похожи друг на друга, как две капли воды. Если мы хотим вызвать функцию f внутри action creator, эти вызовы придётся скопировать в код всех четырёх функций. Много копипасты. Хотя, кто плотно работает с Redux, boilerplate-кода не боится. Здесь можно организовать фабрику — create action creator, чтобы избежать копирования кода.
Но я предлагаю не углубляться в этом направлении, давайте лучше опишем одну функцию action creator, она будет принимать fieldName и newValue. fieldName — это строковая переменная, обозначающее поле ввода, в которое пользователь что-то ввёл. Используя Redux-thunk, мы можем получить доступ ко всему текущему state внутри нашего action creator-а, что нам и нужно для вызова функции f.
Но обратите внимание, что функция f должна получить самые свежие значения a-b-c-d, а тот state, который мы получим благодаря Redux-thunk, это уже устаревший state. Одно из полей имеет старое значение. Перед тем, как вызывать функцию f, нам нужно понять, в какой именно параметр подставить newValue. С точки зрения JS-синтаксиса, тут можно придумать с десяток элегантных и не очень решений. Но факт остаётся фактом, если мы хотим вызвать f внутри action creator, нам нужно взять текущий state, т.е. уже немного устаревший, и объединить его с только что пришедшим newValue. Не напоминает ли это нам редюсер-функцию, которая занимается ровно тем же самым? Получается, что нам придётся продублировать логику редюсера внутри action creater-а, чтобы сформировать самые свежие значения a-b-c-d.
Дальше-больше. Допустим мы получили результат вызова функции бизнес-логики внутри action creater-а, но теперь нам надо довести результирующее значение x до компонента, отображающего сумму переплаты. Придётся пробрасывать через Redux store, написав соответствующий редюсер и селектор. Сделав это, обратим внимание, что store теперь хранит и данные формы a-b-c-d, и одновременно результат вычисления x. Store получился избыточним. Помимо исходных данным, в нём ещё и результат производных вычислений. Это нехорошо по многим причинам. Некоторые из них мы обсуждали в предыдущих выпусках. Вывод: action creator — плохой выбор для вызова функции бизнес-логики.
Переходим к варианту с вызовом функции бизнес-логики из селектора. Его механику я уже упомянул выше. Селектор компонента, отображающего сумму переплаты, имеет доступ ко всему store. Причём к самым актуальным значениям a-b-c-d. Никакого дублирования кода редюсера. Имея a-b-c-d внутри селектора, мы вызываем f, а результат x передаём в качестве props в компонент, отображающий сумму переплаты. Просто, логично, без дублирования кода и без избыточного состояния. Селектор — отличное место для вызова функции бизнес-логики, которое представлено чистой функции от данных.
Заострю внимание на последней фразе, что мы рассматривали исключительно вычисление на клиенте. Если же ваша бизнес-логика — это не чистая функция, а целый процесс, с походом на сервер, т.е. с некими side-эффектами, то это уже тема отдельного разговора. Тут как раз надо обратить внимание и на action creator, и на middleware. Но обсудим это в другой раз.
Пишите на React, и процветайте!
Автор: comerc