Как-то раз в Телеграмм-чате 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 и процветайте!


PS Остались острые вопросы, ответы в следующем подкасте.

Поделиться с друзьями
-->

Комментарии (21)


  1. nexmean
    03.07.2017 10:31

    Если честно, то непростой задачей представляется масштабирование примера приложения с одной чистой функцией в качестве бизнес логики до полноценного приложения в духе DDD.


    1. raveclassic
      03.07.2017 10:36

      Я так понял, будет следующая часть. Думается, одним из вариантов будут саги, которые хорошо ложатся на DDD.


  1. jakobz
    03.07.2017 13:03

    Мне вот куда интереснее как в redux делать повторно используемую логику. Скажем есть 5 master-detail страниц в приложении. На каждой — фильра с редактировнием и сохранением в URL, ленивая подгрузка данных, редактирование — с подгрузкой данных, валидацией, сохранением. Но в каждой, понятно, разные кнопочки с действиями, разный набор фильтров, колонок.

    Как такое делать в redux?


    1. comerc
      03.07.2017 15:07

      Пример выноса общего кода на redux-thunk:


      export const appLoad = (dispatch, config) =>
        new Promise((resolve, reject) => {
          let isTimeout = false
          let isFetch = false
          setTimeout(() => {
            isTimeout = true
            if (isFetch) {
              dispatch(setLoading(false))
            }
          }, 500) // демонстрировать state.app.isLoading не менее 500 мс
          axios(config)
            .then(response => {
              resolve(response.data)
            })
            .catch(error => {
              dispatch(setMainError(error.toString()))
              reject(error)
            })
            .then(() => {
              isFetch = true
              if (isTimeout) {
                dispatch(setLoading(false))
              }
            })
        })

      Можно попробовать — тынц


      1. jakobz
        03.07.2017 17:38

        А вот тут: https://github.com/comerc/yobr/blob/master/src/ducks/postForm.js — что делать, если форм будет штук 10 — у всех разные правила валидации, разные API для загрузки/сохранения, но в целом логика одинаковая?


      1. comerc
        06.07.2017 20:56

        Кстати, до меня дошло, что это просто функция redux-thunk, если немного подправить:


        const appLoad = config => dispatch => 
          // ...


    1. VolCh
      05.07.2017 08:43

      Компоненты высшего порядка (HOC) как вариант.


  1. dfuse
    06.07.2017 04:51
    +1

    В случае чистой синхронной функции — selector это что-то само собой разумеющееся. Это пока далеко от реальной жизни...


    В случае хитрой бизнес логики с асинхронными запросами лучше всего таки идти через action creator. Да хотя бы методом исключения: редьюсер синхронный, селектор синхронный, lifecycle-методы синхронные и вообще мы их не рассматриваем, остаются только action creator'ы, ну может middleware, но зачем?


    Принцип следующий:


    1. Диспатчим то, что пришло из формы синхронно
    2. Сразу в этом же action creator получаем новый полный стейт, по которому уже прошли редьюсеры (в стейте надо поставить какой-то флаг, что он недокалькулирован)
    3. Вырезаем от стейта часть, нужную для запроса на сервер (самый минимум) и засылаем
    4. Диспатчим результат работы сервера новым экшном
    5. По прошлой схеме набрасываемся на стейт редьюсерами (флаг можно снять) и селекторами (которые могут добить клиентские простые вычисления)

    Вся эта бодяга либо уже есть в виде библиотеки (кто-то точно до этого додумался) либо может быть описана в виде некой функции-фабрики action-creator'ов.


    Единственный минус — логика размазывается между селекторами и редьюсерами, но если четко обозначит сферы ответственности (редьюсер — операции закладки данных в стор, селектор — операции над данными без записи), то это не проблема.


    1. comerc
      06.07.2017 12:17

      lifecycle-методы синхронные и вообще мы их не рассматриваем

      Тут можно поспорить, я уже такого нагородил в componentDidMount :)


      И в Next.JS у компонентов есть асинхронный getInitialProps.


      1. dfuse
        06.07.2017 21:42

        Я в курсе, но для примера сознательно это не рассматривал.


    1. comerc
      06.07.2017 12:20

      в стейте надо поставить какой-то флаг, что он недокалькулирован

      в redux-act есть такая удобная штука — batch


  1. Druu
    06.07.2017 12:11
    +1

    > «где в React-приложении должен быть расположен код, отвечающий за бизнес-логику».

    В модели (это стор, то есть редьюсеры). А вызываться — из контроллера. Контроллер в редаксе — это набор action creators, то есть больше бизнес-логику вызывать просто неоткуда, других точек входа не существует. А селекторы — это view, в них бизнес-логику нельзя располагать ни в коем случае, только логику вида. Иначе unidirectional data flow оказывается нарушен.

    > Взглянем на action creator подробнее. Сначала ответим себе на вопрос: «у нас будет один action creator на все поля формы, или по отдельной функции на каждое из полей».

    Один action сreator, который принимает a,b,c,d и возвращает type: «UPDATE_RESULT», payload: f(a,b,c,d). Сами a,b,c,d в сторе хранить не надо, т.к. они не входят в модель. Только результат.


    1. comerc
      06.07.2017 12:30
      +1

      Сами a,b,c,d в сторе хранить не надо, т.к. они не входят в модель

      Я пока храню всё в сторе, игнорируя существование локального стейта компонентов, тупо для упрощения разработки.


      1. Druu
        06.07.2017 17:38

        > Я пока храню всё в сторе, игнорируя существование локального стейта компонентов

        С-но вы же жаловались на то, что стор слишком толстый — вот я его и сделал еще тоньше :)


        1. comerc
          06.07.2017 20:48

          Мопед не мой, статья — конспект подкаста ради фидбека. Я же иду по стопам Твиттера, они сначала все хранили в сторе редакса, и потом только в рамках оптимизации вынесли состояния в локальный стейт компонентов, там где это потребовалось.


          1. Druu
            07.07.2017 05:40

            > они сначала все хранили в сторе редакса

            Но вы же значение там хранить не хотите. У вас очень странным образом нежелание хранить в сторе модель (то есть то, что там хранить нужно) соседствует с желанием хранить состояние интерфейса (то, что как раз не обязательно).

            Кроме того, твиттер — плохой пример. У них очень простой интерфейс (по-этому вью-стейта там мало), и само состояние тоже маленькое, так что они себе могут позволить. Если внутренний стейт десятка сложных многошаговых форм перенести в редаксовский стор, то это будет уже сравнимо с текущим твиттеровским стейтом. А ведь это будет только небольшая часть приложения.

            Но, конечно, если известно, что приложение не разрастется и не будет предоставлять каких-то сложных взаимодействий — такой подход тоже вполне приемлем.


    1. VolCh
      06.07.2017 13:16

      А селекторы — это view, в них бизнес-логику нельзя располагать ни в коем случае, только логику вида. Иначе unidirectional data flow оказывается нарушен.

      Если, например, стор характеризуется каким-то сложно вычисляемым статусом и его надо и показать, и совершать какие-то бизнес-действия при достижении определенных значений, то неужели надо дублировать логику вычисления? Redux вообще ничего не знает о view по сути. Это всякие redux-react коннекторы связывают стор с вью.


      1. Druu
        06.07.2017 17:21

        > и его надо и показать, и совершать какие-то бизнес-действия при достижении определенных значений

        А вы можете в селекторах совершать какие-то действия? Наверное, нет.
        Нетривиальную логику в селекторы просто by design засунуть не удастся, по-этому такой вариант и обсуждать особого смысла не имеет. Ну а что до тривиальной — понятно же, что все подобные утверждения вроде «так делать нельзя» не имеют статуса максимы и по факту обозначают что-то вроде «нельзя без явных причин». Если у вас есть явная причина засунуть что-то в селектор (вы четко видите, что это проще и удобнее других решений при прочих равных) — ну ради бога. Главное, чтобы задача была качественно решена.


      1. comerc
        06.07.2017 20:51

        Абрамов в одном из роликов по редаксу демонстрировал, как переместить селекторы к редюсерам. Очевидно, с целью сконцентрировать бизнес-логику в одном месте.


        1. dfuse
          06.07.2017 23:42

          Можно линк на это видео?


          1. comerc
            07.07.2017 02:02