Как стать автором
Обновить

Красная нить MVC-Flux-Redux

Блог компании Норд Клан JavaScript *

Добрый день! Меня зовут Александр, я работаю 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

Теги:
Хабы:
Всего голосов 8: ↑5 и ↓3 +2
Просмотры 2.5K
Комментарии 10
Комментарии Комментарии 10

Публикации

Информация

Сайт
nordclan.com
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия