Добрый день! Меня зовут Александр, я работаю frontend-разработчиком в компании Nord Clan. Сегодня я хотел бы представить вам статью, тему которой можно со смелостью отнести к «основам мироздания» frontend-а.

Возможно, вы не раз слышали выражение «красной нитью проходит», читая какую-нибудь рецензию на фильм или книгу, и это выражение помогало вам уловить основную идею, которую хочет донести автор.

В этой статье красной нитью будет сравнение двух подходов к написанию frontend-приложений: MVC и Flux. И хотя в интернете есть немало пояснений и сравнений по MVC и Flux, им не хватает последнего «пятого элемента» - практики (не огорчайте Брюса Уиллиса).

Поэтому красная нить повествования пройдет от MVC и до Flux, затронув также реализацию Flux в Redux, а мы по ходу повествования выделим основные особенности, сходства и недостатки конкретного подхода, подкрепив все практикой.

MVC

Казалось бы, нужно начать рассказ об MVC с его благородной истории, а потом создать три класса под каждую его буковку, чтобы все поняли, что это такое, преисполнились благоговением и захлопали в ладоши:)

Но я подумал, что лучше сразу обрисовать суть проблемы, с которой столкнулись разработчики Facebook (запрещенная в РФ организация, те-кого-нельзя-называть), когда работали с MVC на frontend.

Для начала стоит взглянуть как работает MVC на backend. Представляю вам схему создания веб-приложения на spring boot:

В Front controller приходит запрос, делегируется в Controller, Controller создает модель, которая передается в View Template, обрабатывается для дальнейшего отображения и весь шаблон отправляется обратно в Front Controller, который отсылает ответ на клиент. Все работает как часы, но отдаются нам обычные статичные страницы, которые рендерят переданную model.

Перенесемся в бородатые времена, когда frontend получил больше власти благодаря реактивным библиотекам, которые позволяли создавать динамические страницы без постоянной загрузки их с сервера с обновленным model.

Теперь с backend приходила model, а за отрисовку этих данных отвечал frontend, то есть View template был перенесен на frontend.

Окей, где хранить модель frontend-приложения согласно MVC? Вопрос риторический, конечно же в model! Возьмем в пример счетчик. Создадим CounterModel:

export class CounterModel {
  count = 0;
}

MicroEvent.mixin(CounterModel);

MicroEvent.mixin добавит к нашей модели методы, реализующие паттерн Наблюдатель, чтобы мы могли получать обновления модели в нужных компонентах.

Теперь создадим CounterController с методом updateCount, пусть он будет обновлять CounterModel:

export default class CounterController {
  updateCount() {
    CounterModel.count += 1;
    CounterModel.trigger('changeCount');
  }
}

Обновили модель - вызвали CounterModel.trigger, чтобы оповестить подписчиков об изменении модели.

Далее есть компонент Counter, в котором вызывается метод updateCount:

<>
  <button
    type="button"
    onClick={counterController.updateCount}
  >
    increment
  </button>
  <div>
    Count: {count}
  </div>
</>

В useEffect компонент подписывается на изменение CounterModel и вызывает перерисовку:

const [count, setCount] = useState(CounterModel.count);

useEffect (() => {
  CounterModel.bind('changeCount', () => {
    setCount (CounterModel.count);
  });
}, []);

Так-с, и зачем ваш хваленый Flux? По правде говоря, здесь можно действительно обойтись без Flux-подхода… По сути мы создали почти то же самое, что и в схеме организации веб-приложения на spring boot.

CounterController формирует новую модель, CounterModel обновляется и передается в view. Однако, у кого-то есть Халк, а у нас есть реактивность, а значит есть другой компонент, который очень уж хочет получить данные  из CounterModel, обновить свою модель SquareModel и прочитать данные из нее:

function Square() {
  const [square, setSquare] = useState(SquareModel.square);

  useEffect (() => {
    CounterModel.bind('changeCount', () => {
      squareController.updateSquare(CounterModel.count);
    });

    SquareModel.bind('changeSquare', () => {
      setSquare (SquareModel.square);
    });
  }, []);

  // ...

}

SquareController содержит метод для обновления SquareModel и оповещения подписчиков:

export default class SquareController {
  updateSquare (count) {
    SquareModel.square = count ** 2;
    SquareModel.trigger('changeSquare');
  }
}

Сейчас набросок схемы работы обновления счетчика выглядел бы так:

Думаю, что проблема уже видна.  Мы вынуждены напрямую изменять модель через контроллер и сразу же получать ее обновленное состояние в подписчиках. При этом, в SquareView при обновлении CounterModel мы еще и производим побочный эффект обновления SquareModel. А если CounterView так же подписано на обновление SquareModel и при обновлении он в свою очередь обновляет еще одну модель? Звучит страшно и на большом проекте будет трудно уследить все связи между моделями.

Хорошо, как Flux решил эту проблему большой запутанности обновления моделей и представлений?

Flux

Сразу с места в карьер, во-первых, теперь есть только одна model, которая представлена в виде синглтона, то есть инициализируется лишь единожды и используется по всему приложению:

class Store {
  counts = {
    count: 0
  }

  squares = {
    square: 0
  }
}

MicroEvents.mixin(Store);

export const singletonStore = new Store();

Подписка происходит не на множественные модели, а на один источник singletonStore:

function Counter() {
  const [count, setCount] = useState(singletoneStore.counts.count);

  useEffect(() => {
    singletonStore.bind('changeCount', () => {
      setCount(singletonStore.counts.count);
    })
  }, []);
}

Однако в компоненте Square теперь появилась ошибка… При «changeCount» не сработает диспатч экшена «square», так как Flux ставит в приоритет последовательное обновление модели, чтобы не создавать конфликты между обновлениями или рассинхронизацию:

function Square() {
  const [Square, setSquare) = useState(singLletonStore.squares.square);

  useEffect(() => {
    singletonStore.bind('changeCount', () => {
      AppDispatcher.dispatch({
        eventName: 'square',
      });
    });

    singletonStore.bind('changeSquare', () => {
      setSquare(singletonStore. squares.square);
    });
  }, []);

  // ...

}

Чтобы избежать этой ошибки, оставим только один тип события - «increment». Его будут обрабатывать разные модели, но в одном потоке событий.

Первый обработчик делает обновление модели count. Для этого зарегестрируем в контроллер AppDispatcher обработку события «increment». AppDispatcher.register вернет токен, который можно будет использовать в дальнейшем:

singletonStore.counts.dispatchToken = AppDispatcher.register((payload) => {
  switch (payload.eventName) {
    case 'increment':
      SingletonStore.counts.count += 1;
      singletonStore.trigger('changeCount');
      break;
  }

  return true;
});

Второй обработчик будет обновлять модель squares, но при этом будет ожидать завершения обновления модели counts за счет обращения к свойству модели counts.dispatchToken в вызове waitFor:

singletonStore.squares.dispatchToken = AppDispatcher.register((payload) => {
  switch (payload.eventName) {
    case 'increment':
      AppDispatcher.waitFor([singletonStore.counts.dispatchToken] );
      singletonStore.squares.square = singletonStore.counts.count ** 2;
      singletonStore.trigger('changeSquare');
      break;
  }

  return true;
});

Теперь удалим диспатч экшена «square» из компонента Square:

function Square() {
  const [square, setSquare] = useState(singletonStore.squares.square);

  useEffect(() => {
    singletonStore.bind('changeSquare', () => {
      setSquare(singletonStore.squares.square);
    });
  });

  // ...

}

Вуаля! Теперь все обновления модели проходят через единственный контроллер - AppDispatcher.

Он позволяет:

  • избежать выстраивания сложной взаимосвязанности между обновлениями разных моделей

  • создать один поток обновления модели

  • блокировать создание новых событий обновления моделей в процессе текущего обновления.

То есть обновление модели происходит не напрямую, а через вызов dispatch-методов:

<button
  type="button"
  onClick={() => {
    AppDispatcher.dispatch({
      eventName: 'increment'
    })
  }}
>

Теперь фраза «разделяй и властвуй» по отношению Flux к MVC приобретает более скромное, ужатое, но и более удобное толкование, так как больше нет множества моделей и контроллеров для них. Есть единый SingletonStore, и Dispatcher, мутирующий этот SingletonStore. И уже далее обновления «раскидываются» по компонентам, без выстраивания сложных двухсторонних связей.

Flux проще рассматривать как некоторый подход к реализации MVC на frontend, в основе которого лежат единая модель и единый контроллер. Единый контроллер организует все нужные обновления модели и представления, абстрагируя представление от модели, чтобы избежать двухсторонней связи.

Самой известной реализацией Flux является Redux.

Redux

Почему разработчики используют вместо оригинальной реализации Flux (библиотека flux) Redux? На это есть ряд причин, но, однако, нельзя со стопроцентной уверенностью заявлять, что Redux глобально отличается от Flux да и от того же «отца-MVC».

Первым важным отличием Redux можно назвать то, что модель (state) в нем иммутабельна, в том время как во Flux модель мутабельна.

То есть во Flux внутри Dispatcher мы можем делать все, что душе угодно, а это в дальнейшем может привести к тому, что сон будет неспокойным, что обновляя тот же самый счетчик, мы можем потенциально также обновить и другие данные, что может привести к неразберихе, которая может быть «похлеще» двухсторонней связи:

AppDispatcher.register((payload) => {
  switch (payload.eventName) {
    case 'increment':
      SingletonStore.counts.count += 1;
      SingletonStore.squares.square = singletonStore.counts.count ** 2;

      // ...

  }

  return true;
});

Иммутабельность достигается за счет чистых reducer-функций, которые комбинируются в один большой reducer, готовый для вызова.

Далее в Redux нет единого Dispatcher-контроллера, который отвечает за обработку входящих экшенов. Такой подход позволяет избежать сайд-эффектов по типу ожидания обновления той или иной части модели как мы делали это во Flux:

singletonStore.squares.dispatchToken = AppDispatcher.register((payload) => {
  switch (payload.eventName) {
    case 'increment':
      AppDispatcher.waitFor([singletonStore.counts.dispatchToken] );

      // ...

  }

  return true;
});

Опять же, это возможно за счет того, что каждый вызов dispatch-функции вызывает каждый reducer, который преобразует или возвращает тот же самый state по очереди:

Подводя итог, можно сказать, что MVC так и остается истоком красной нити, которая проходит как через Flux, так и через Redux.

Основной особенностью Flux является то, что он привнес более удобное обновление модели на frontend, а Redux «отшлифовал» этот слепок Flux-подхода, переработав формирование этой модели.

Репозиторий с примерами кода: ссылка на GitHub

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


  1. markelov69
    11.10.2022 10:36
    +3

    Статья про то как в прошлом веке писали фронтенд, и при этом вы не поставили пометку "средневековье". Ай ай ай, зачем вводить людей в заблуждение.

    Ведь все мы знаем, что все адекватные люди не живущие в далеком прошлом уже много лет используют для реакта MobX или же если не нравится реакт, то Vue. И там и там настоящая реактивность и разумеется мутабильность с автоматической подпиской/отпиской на изменения, ибо getters/setters в Javascript пришли ещё в 2010 году.


    1. melkor_morgoth Автор
      11.10.2022 11:02
      +1

      Суть статьи не заключается в том, чтобы побудить вас перейти с MVC на Redux, а в том, чтобы взглянуть на MVC-Flux-Redux спустя время и подвести итоги не только с теоретической стороны, но и с практической, так как в основном по этому вопросу предоставляют только теорию.


      1. markelov69
        11.10.2022 17:13
        +2

        А зачем глядеть на Flux/Redux и прочую шушеру спустя время?
        Уже все глядели 100500 раз и всем понятно что это не состоявшийся подход во фронтенде, от слова совсем.

        Я понимаю если бы вы сразу написали в первом предложении что речь идет про богом забытые вещи, которые в настоящее время ни в ком случае нельзя применять в разработке, вместо этого нужно применять React + MobX + Typescript. Чтобы не стрелять в ноги себе, другим, а так же не обрекать бизнес на изначально мертворожденный неподдерживаемый проект который будет все равно переписываться с нуля, но уже с использованием человеческого подхода.

        А так вы можете ввести людей в заблуждение которые начинают свой путь и они подумают что использовать Redux это нормально и потратят уйми времени и нервов просто в пустую. Вместо того, чтобы сразу начать с актуальных и нужных вещей.

        Почему вы не взглянули на паровой двигатель и на дрова в качестве топлива? Наверное потому что и так всем понятно что эта технология изжила себя давным давно и никто в здравом уме к ней никогда возвращаться не будет. Тоже самое и с Flux/Redux и прочей шушеры.


        1. roqer
          12.10.2022 08:57
          +1

          Почему Вы считаете, что подход с redux устарел? И почему пишете, что фронтенд перешёл от redux к mobx?


          1. markelov69
            12.10.2022 12:06
            +1

            Почему Вы считаете, что подход с redux устарел? И почему пишете, что фронтенд перешёл от redux к mobx?

            А вы писали проекты используя Redux и используя MobX? Там как бы разница вообще небо и земля. Вопрос "почему mobx, а не redux" сразу отпадает так то)

            Вот честно в тысячный раз очевидные вещи перечислять, почему Redux и все его производные и ему подобные это полнейшее дно уже нет желания. Если для вас это не очевидно, то вам просто без разницы что и как делать, главное чтобы ЗП платили.


            1. roqer
              12.10.2022 21:16

              Разрабатывал только на redux. Про mobx читал и смотрел теорию. Поэтому и хотел узнать о Вашем опыте :) Понимаю, что Вам не хочется писать сравнение в комментариях, поэтому буду благодарен, если скинете статьи/видео/рассказы (англоязычные), где описаны трудности связанные с разработкой на redux и его производных, и как mobx покрывает эти кейсы. Хотелось бы отметить, что интересуют кейсы с крупными проектами.


              1. markelov69
                12.10.2022 21:39

                Размер проекта вообще не важен, тут играет роль не mobx/redux и т.п., а играет роль разум и понимание как строить архитектуру проекта.

                Самое простое и быстрое это посмотреть сразу как все это работает, тут представлен ряд примеров связки react + mobx - https://codesandbox.io/s/solitary-currying-8g58ms?file=/src/index.js

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


        1. Nikitakun1
          12.10.2022 15:37

          Я тоже топлю за MobX, но мне стало интересно, как бы вы предложили решать обозначенную в статье проблему с тем, что классы с логикой подписаны на изменение полей друг друга, из-за чего возникают неочевидные связи в приложении? Я так понял, что решение автора через Flux/Redux состоит в том, что все подписано на сообщения (actions) в глобальной шине сообщений (flux/redux) и это, предположительно, уменьшает ментальную нагрузку на разработчика, который пытается понять логику в предложении.


          1. melkor_morgoth Автор
            12.10.2022 15:38

            Да, вы верно поняли.


          1. markelov69
            12.10.2022 16:02

            классы с логикой подписаны на изменение полей друг друга, из-за чего возникают неочевидные связи в приложении?

            А с чего вы взяли что возникают неочевидные связи в приложении при использовании MobX'a?

            Вы всё видите сверху вниз, слева направо, что класс X читает и изменяет такие-то переменные, а класс Y читает и изменяет в том числе те, которые читает и изменяет класс X. И где тут неразбериха я честно не понимаю? Все ведь лежит на ладони.