Pull to refresh
Аурига
Аурига — это люди

React vs Vue vs Angular. Часть 3

Level of difficultyMedium
Reading time15 min
Views12K

Управление глобальным состоянием

В прошлой части мы рассмотрели методологии оптимизации и реюзабельности логики, а также подход к роутингу каждого фреймворка. Однако давайте теперь переключим наше внимание на другой важный аспект – управление глобальным состоянием. В этом разделе на примере Angular, React и Vue мы рассмотрим уникальные стратегии, используемые этими фреймворками для работы с глобальными состояниями. По мере изучения этих подходов мы узнаем, как разработчики эффективно решают сложную задачу поддержания синхронизации данных в приложениях.

Все 3 фреймворка для этой задачи используют решения, которые базируются на архитектуре Flux.

Flux модель
Flux модель

Flux-модель предполагает, что данные идут только в одном направлении, начиная с «действий» (actions) – простых объектов, содержащих новые данные и тип. Затем диспетчер производит обратные вызовы, зарегистрированные в «хранилище» (store), и отправляет туда действия для изменения данных. Позже интерфейс или, если угодно, вид, отображает изменения состояния хранилища. Давайте рассмотрим более подробно, как каждый фреймворк реализует данную архитектуру.

1) React Redux

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

Архитектура Redux предполагает наличие следующих элементов:

  • Наличие единственного источника истины/хранилища состояния – store;

  • Состояние изменяется только чистыми функциями, определяемыми в редукторе (reducer);

  • Редуктор изменяет состояние с помощью операций (action) на основании их типа (type) и опциональной полезной нагрузки (payload);

  • Операции отправляются в редуктор из слоя представления (view) пользовательского интерфейса с помощью диспетчера (dispatcher);

  • Возвращение определенной части состояния или вычисление данных на его основании осуществляется с помощью селекторов (selector);

  • Асинхронные операции по типу вызовов API выполняются с помощью преобразователей (thunks).

    Это можно выразить приблизительно такой диаграммой:

React Redux модель
React Redux модель

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

Основные составляющие нового тулкита, используемые практически всегда:

  • configureStore() — обертка для createStore(), метода инициализации хранилища из Redux, которая упрощает его создание и настройку. Данный метод позволяет автоматически объединять отдельные редукторы (slice reducers), отвечающие за изменение определенной части состояния (частичные редукторы), с помощью combineReducers() из Redux. Он также позволяет добавлять посредников (middlewares – функции, позволяющие перейти операциям из синхронного в асинхронное выполнение). Также метод интегрирует в хранилище Redux Thunk — преобразователя для обработки результатов асинхронных операций;

  • createSlice() — данный метод принимает объект, содержащий редуктор, название части состояния (state slice) и начальное значение состояния и автоматически генерирует частичный редуктор с соответствующими создателями операций (action creators);

  • createAsyncThunk() — данный метод предназначен для выполнения асинхронных операций: он принимает тип операции и функцию, возвращающую промис, и генерирует преобразователь операции (thunk). Преобразователь, в свою очередь, отправляет типы операций pending/fulfilled/rejected в частичный редуктор.

Рассмотрим простой пример на приложении-счётчике:

Redux Toolkit счётчик
Redux Toolkit счётчик

counterSlice.js

import { createSlice } from '@reduxjs/toolkit';

export const slice = createSlice({
  name: 'counter',
  initialState: {
    value: 0,
  },
  reducers: {
    increment: state => {
      state.value += 1;
    },
    decrement: state => {
      state.value -= 1;
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload;
    },
  },
});

export const { increment, decrement, incrementByAmount } = slice.actions;

export const selectCount = state => state.counter.value;

export default slice.reducer;

Как мы видим, createSlice() позволяет создать объект reducers. Его ключами являются названия операций, которые будут потом отправлены диспетчеру. Ранее, используя стандартный Redux, разработчикам приходилось отдельно создавать редуктор, описывать в нем чистые функции с ссылкой на название-тип и отдельно создавать операции с определенным типом:

// редуктор 
const counterReducer = (state = { value: 0 }, action) => {
  switch (action.type) {
    case "INCREMENT":
      return {
	  ...state,
         value: state.value + 1,
      };
    ...
    default:
      return state;
  }
};
export default counterReducer;

// операции
export const increment = () => {
  return {
    type: "INCREMENT",
  };
};
...

Так что оптимальность Redux Toolkit видна налицо. Дальше рассмотрим создание самого хранилища:

store.js

import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../components/counter/counterSlice';

export default configureStore({
  reducer: {
    counter: counterReducer,
  },
});

При наличии других редукторов их можно просто добавить в объект reducer, и они автоматически объединятся в один основной редуктор хранилища. Дальше можно использовать операции уже непосредственно в компонентах:

Counter.js

import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
  decrement,
  increment,
  incrementByAmount,
  selectCount,
} from './counterSlice';
import styles from './Counter.module.css';

export function Counter() {
  const count = useSelector(selectCount);
  const dispatch = useDispatch();
  const [incrementAmount, setIncrementAmount] = useState('2');

  return (
    <div>
      <div className={styles.row}>
        <button
          className={styles.button}
          aria-label="Increment value"
          onClick={() => dispatch(increment())}
        >
          +
        </button>
        <span className={styles.value}>{count}</span>
        <button
          className={styles.button}
          aria-label="Decrement value"
          onClick={() => dispatch(decrement())}
        >
          -
        </button>
      </div>
      <div className={styles.row}>
        <input
          className={styles.textbox}
          aria-label="Set increment amount"
          value={incrementAmount}
          onChange={e => setIncrementAmount(e.target.value)}
        />
        <button
          className={styles.button}
          onClick={() =>
            dispatch(incrementByAmount(Number(incrementAmount) || 0))
          }
        >
          Add Amount
        </button>
      </div>
    </div>
  );
}

React Redux предоставляет 2 хука – useSelector(), принимающий селектор и извлекающий часть состояния, а также подписку на его обновления. Это ведет к автоматическому перерасчету значений на любое изменение, после чего другой хук – useDispatch() позволяет оправлять операции в редуктор и выполнять изменения состояния.

Таким образом, Redux Toolkit – это хороший инструмент для управления глобальным состоянием React приложений, решающий многие проблемы громоздкого Redux-синтаксиса. Однако минусы производительности библиотеки остаются: позже мы сравним их с Vuex

2) Vuex

Как и в Redux, единственным местом или источником истины для хранения состояния в Vuex является state. Состояние так же не изменяется напрямую. Вместо этого пользовательский интерфейс/слой представления view запускает операции (actions), которые могут выполнять другие операции или фиксировать изменения (commit) состояния посредством мутаций (mutations). Мутации также могут быть выполнены непосредственно из слоя представления. Основное различие между мутациями и операциями заключается в том, что мутации должны быть синхронными, в то время как операции предназначены для реализации мутаций с последующей логикой, в том числе асинхронной. Однако мутации и операции часто пересекаются, а слой представления взаимодействует только с операциями. Также Vuex использует геттеры (getters) вместо селекторов для извлечения частей состояния.

 Это можно изобразить приблизительно так:

Vuex модель
Vuex модель

Для создания хранилища используется метод createStore(), которому передается объект со следующими настройками:

  • state — значение начального состояния;

  • mutations — объект с функциями для синхронной(!) модификации состояния. Каждая функция в качестве первого аргумента получает текущее состояние (state), а в качестве второго аргумента — опциональную полезную нагрузку (payload);

  • actions — объект, содержащий функции, предназначенные для выполнения операций (в том числе синхронных) и запуска мутаций для изменения состояния. Каждая операция в качестве второго аргумента, как и мутации, получает опциональную полезную нагрузку (payload), а в качестве первого аргумента — объект контекста (context) со следующими свойствами:

    • state — состояние

    • commit — метод для запуска мутации

    • dispatch — метод для запуска другой операции

    • getters — свойство для получения геттеров

  • getters  — геттеры для извлечения части состояния или вычисления производных данных. Каждый геттер получает состояние (state) и возвращает значение на его основании.

Доступ к хранилищу из компонентов осуществляется с помощью this.$store. Получаемый экземпляр имеет такие свойства как state и getters, и такие методы для выполнения мутаций и запуска операций, как commit и dispatch соответственно. Также для удобства  разработчиков Vuex предоставляет утилиты для привязки методов компонента к свойствам хранилища – mapState, mapGetters, mapActions и mapMutations.

Рассмотрим пример двойного счётчика:

Vuex счётчик
Vuex счётчик

counter.js

export default {
  namespaced: true,
  state: () => ({
    counter: 0
  }),
  getters: {
    total: state => state.counter
  },
  mutations: {
    increment(state) {
      state.counter += 1;
    },
    decrement(state) {
      state.counter -= 1;
    },
    reset(state, { value }) {
      state.counter = value;
    }
  },
  actions: {
    increment: ({ commit }) => {
      commit("increment");
    },
    decrement: ({ commit }) => {
      commit("decrement");
    },
    reset: ({ commit }, payload) => {
      commit("reset", payload);
    }
  }
};

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

index.js

import Vue from "vue";
import Vuex from "vuex";

import counter1 from "./modules/counter";
import counter2 from "./modules/counter";

Vue.use(Vuex);

export default new Vuex.Store({
  modules: {
    counter1: counter1,
    counter2: counter2
  }
});

Теперь настало время объяснить, почему пример использует 2 счетчика. Как мы видим, механикой использования нескольких блоков логики для одного и того же хранилища является объект modules, позволяющий создавать отдельные независимые модули со своим набором состояния, геттеров и так далее. В то время как Redux, по сути, тоже имеет разные блоки логики в виде частичных редукторов, но они всегда собираются в один основной редуктор. Благодаря такому различию мы также смогли для упрощения примера безнаказанно использовать одну и ту же логику из counter.js для разных модулей.

Далее создадим методы для использования операций хранилища в рутовом App.vue и передадим их в Counter компонент для обработки клик-событиями:

App.vue

<template>
  <div id="app">
    <Counter :total="total1" target="counter1"
      @increment="increment" 
      @decrement="decrement" 
      @reset="reset"
    />
    <Counter :total="total2" target="counter2"
      @increment="increment" 
      @decrement="decrement" 
      @reset="reset"
    />
  </div>
</template>

<script>
import { mapGetters} from "vuex";
import Counter from "./components/Counter";

export default {
  name: "App",
  components: {
    Counter
  },
  methods: {
    increment(target) {
      this.$store.dispatch(`${target}/increment`);
    },
    decrement(target) {
      this.$store.dispatch(`${target}/decrement`);
    },
    reset(target) {
      this.$store.dispatch(`${target}/reset`, {
        value: 0
      });
    }
  },
  computed: {
    ...mapGetters({
      total1: "counter1/total",
      total2: "counter2/total"
    })
  }
};
</script>

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

Counter.vue

<template>
  <div class="counter">
    <div class="total">{{ total }}</div>
    <button @click="$emit('increment', target)">+</button>
    <button @click="$emit('decrement', target)">-</button>
    <button @click="$emit('reset', target)">C</button>
  </div>
</template>

<script>
export default {
  name: "Counter",
  props: ["total", "target"],
};
</script>

Здесь уже непосредственно производятся операции над состояниями каждого модуля, различаемые с помощью пропа «target».

Vuex был создан под экосистему Vue с учётом проблем, с которыми сталкивался Redux. Судя по обширной документации Redux по вопросам производительности, минусы в этой области есть, и разработчикам нужно будет разбираться в сценариях оптимизации различных юз-кейсов. Основные минусы Redux:

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

  • Reselect часто использовался вместе с Redux в качестве библиотеки селекторов состояния для создания геттеров состояния эффекта. Redux Toolkit решил эту проблему включением Reselect в свой состав.

  • Redux полагается на неизменяемость состояния, что может вызвать много работы для GC (Garbage Collection) во время выполнения - подробнее об этом позже.

Поскольку главный редуктор будет рекурсивно вызывать все свои вложенные редукторы для вычисления следующего гигантского состояния хранилища, это вызовет повторный рендеринг во всех подключенных React-компонентах (useSelector, конечно, поможет с оптимизацией, но в масштабных приложениях замедление производительности будет заметным). Vuex преодолел это за счет мгновенного O(1) поиска необходимых мутаций в структуре хранилища. Это не только позволяет избежать задержек в апдейте состояния, но и благодаря системе реактивности Vue, повторный рендеринг будет происходить только у компонента, запрашивающего определенный фрагмент глобального состояния.

Разработчики часто сталкиваются с необходимостью вычисления данных из своего хранилища для отображения на пользовательском интерфейсе, таких как имя пользователя, общая цена, заметки to do и так далее. Именно здесь на помощь Redux приходил Reselect в качестве сопутствующей библиотеки, а позже стал частью Redux Toolkit. Результат работы селектора состояния кэшировался до тех пор, пока один из его аргументов не изменится, и только тогда композитные данные заново вычислялись. Это, конечно, хорошо и приятно, но разработчикам все еще предстояло изучить больше концепций, и проблемы при этом были неизбежны. И снова Vuex делает это очень интуитивно понятным благодаря тому, что в проект встроено нечто под названием getter. Поскольку приложение Vue потребляет состояние из хранилища Vuex, оно будет использовать красоту системы реактивности Vue, используя метод computed, как было показано в примере. UI будет соответствующим образом перерисовываться только в тех случаях, когда значения геттеров в computed переменных будет меняться.

Также, изменения в Redux производятся чистыми функциями. Большинство React приложений, скорее всего, делают глубокие копии состояния (концепция неизменяемости объектов React) и вносят изменения для создания нового состояния. Обычно при этом создается ссылка на новый объект состояния, а старое выбрасывается для сбора GC. Когда ваше приложение производит частые манипуляции с хранилищем, это обычно оказывает большое давление на GC и приводит к сбоям в его работе. Эту проблему можно решить с помощью библиотек неизменяемых структур данных, таких как Immutable.js или seamless-immutable, которые повторно используют существующие объекты вместо создания новых. Но, опять же, это приведет к необходимости изучать больше концепций и дискутировать о принятии окончательного решения: что использовать. В Vue это проще простого, поскольку каждое свойство объекта может быть реактивным само по себе - любая мутация будет вызывать обновления только в тех компонентах, которые действительно зависят от измененного состояния.

Интересным фактом о производительных проблемах Redux является разработка библиотеки Recoil самими же создателями React, и новое решение имеет некоторые сходства с реактивностью Vue.

3) Angular NgRx

NgRx является де-факто самой популярной библиотекой менеджмента глобального состояния и, по сути, тем же Redux в обертке под лучшую совместимость с Angular. Имплементация Redux в NgRx полностью базируется на одной из самых важных библиотек Angular – RxJs (отсюда и название, Ng это Angular, а Rx, cоответственно, RxJs). RxJs – это инструмент для реактивного программирования, то есть работы с асинхронными потоками данных, он считается стандартом индустрии. Библиотека широко использует такое понятие, как Observable, или, грубя говоря, «наблюдаемое» и Observer – “наблюдатель”. Обращение потока данных в наблюдаемое и последующая подписка наблюдателя на него позволяет удобно реагировать на асинхронные события и обрабатывать их. Зачастую этого достаточно для обмена данных и состояния между несколькими компонентами или в небольших приложениях. NgRx выносит это на более высокий уровень, покрывая все приложение и используя концепции RxJs для своевременного обновления состояния и возвращения данных различным компонентам.

Архитектура NgRx состоит из тех же компонентов, что и Redux – единого хранилища, редукторов, операций (actions), селекторов для вывода частей глобального состояния и слежки за их изменениями. Также существуют так называемые эффекты (effects) – аналог thunk, дополнительные функции, обрабатывающие асинхронную логику операций, такую как обращение к API или вызов других операций. Все это можно изобразить следующей диаграммой:

NgRx модель
NgRx модель

К сожалению, у NgRx нет аналога Redux Toolkit, поэтому стоит ожидать большого количества шаблонного синтаксиса при раздельном написании редукторов и операций. Рассмотрим на примере счетчика:

counter.reducer.ts

import { CounterActions, CounterActionTypes } from '../actions/counter.actions';

export const initialState: number = 0;

export function reducer(state = initialState, action: CounterActions): number {
  switch (action.type) {
    case CounterActionTypes.Increment:
      return state + 1;
    case CounterActionTypes.Decrement:
      return state - 1;
    case CounterActionTypes.Reset:
      return 0;
    default:
      return state;
  }
}

Здесь наш редуктор и логика изменений состояния на разные операций. Ниже код самих операций:

counter.actions.ts

export class CounterIncrement implements Action {
  readonly type = CounterActionTypes.Increment;
}

export class CounterDecrement implements Action {
  readonly type = CounterActionTypes.Decrement;
}

export class CounterReset implements Action {
  readonly type = CounterActionTypes.Reset;
}

export type CounterActions = CounterIncrement | CounterDecrement | CounterReset;

Как видим, операциям присваивается тип, который будет идентификатором для выполнения нужной логики.

index.ts

import { ActionReducerMap } from '@ngrx/store';
import * as fromCounter from './counter.reducer';

export interface State {
  counter: number;
}

export const reducers: ActionReducerMap<State> = {
  counter: fromCounter.reducer,
};

ActionReducersMap – аналог combineReducers в Redux – необходим для формирования главного редуктора. После этого наши операции готовы к использованию в компонентах:

counter.component.ts

import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import * as fromStore from '../reducers/index';
import {
  CounterIncrement,
  CounterDecrement,
  CounterReset,
} from '../actions/counter.actions';
import { Observable } from 'rxjs/Observable';

@Component({
  selector: 'app-counter',
  template: `
    <div class="counter">
      <div class="total">{{total | async}}</div>
      <button (click)="increment()">+</button>
      <button (click)="decrement()">-</button>
      <button (click)="reset()">C</button>
    </div>`,
  styles: [
    `
  .counter {
    display: flex;
    flex-direction: row;
    justify-content: center;
    border: 1px solid #9999;
    border-radius: 10px;
    padding: 10px;
  }
  .counter button {
    width: 40px;
    margin: 10px;
  }
  .total {
    margin: 10px;
    font-size: 80px;
  }
  `,
  ],
})
export class CounterComponent implements OnInit {
  total: Observable<number>;
  constructor(private store: Store<fromStore.State>) {}

  ngOnInit() {
    this.total = this.store.select('counter');
  }

  increment() {
    this.store.dispatch(new CounterIncrement());
  }

  decrement() {
    this.store.dispatch(new CounterDecrement());
  }

  reset() {
    this.store.dispatch(new CounterReset());
  }
}

Здесь конструктор класса компонента инициализирует хранилище и создает переменную total для хранения селектора из хранилища. При помощи Observable интерфейса, Angular подписывается на изменения селектора и повторно рендерит компонент при них, отображая в данном случае новое значение с помощью хука жизненного цикла ngOnInit(). Также создаются локальные методы, выполняющие ранее созданные операции.

Главной проблемой глобального менеджмента состояния в Angular все так же остается необходимость прежде всего свыкнуться с написанием кода на TypeScript. NgRx также на шаг позади в плане имплементации упрощенного варианта написания шаблонных частей для создания глобального состояния. Никто не запрещает пытаться использовать Redux Toolkit в Angular, но разработчики столкнутся в таком случае с почти полным отсутствием поддержки и документации, поэтому такой вариант не является адекватной заменой. Сохраняются и ранее рассмотренные проблемы продуктивности в виду повторения архитектуры Redux.

Заключение

Объективно говоря, каждый фреймворк прекрасно справляется с задачей создания веб-приложений. Команды, поддерживающие каждый из них, на постоянной основе занимаются улучшениями и стараются поддерживать современные тенденции. Однако разработка веб-приложений на заканчивается на использовании того или иного инструмента, важен также человеческий ресурс и его способность адаптироваться к использованию того или иного инструмента. Давайте рассмотрим топ-10 компаний/cервисов использующих каждый из 3 фреймворков:

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

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

Vue, наиболее «юный» фреймворк, разработанный изначально бывшим инженером из Google – Эваном Ю, был нацелен взять лучшее из «миров» React и Angular и получил уникальную систему реактивности компонентов. Команда Vue делала выводы из плюсов и минусов подходов в реюзабельности логики, управления состоянием и т. д., вследствие чего интуитивность, удобность и низкий порог вхождения продолжали завоевывать все больше поклонников. Множество относительно молодых компаний дали шанс этому фреймворку и не пожалели, ведь со временем Vue из темплейтного фреймворка для небольших приложений смог стать решением для проектов любого масштаба.

По моему личному мнению, трудно прогадать с выбором React: с точки зрения разработки огромное сообщество и детальная документация помогут даже самым начинающим, а с точки зрения проекта подкупает относительная простота поиска React-разработчиков с разным уровнем опыта. Однако не стоит скептически смотреть и на Vue: девиз «взять лучшее из Angular и React» - это не просто слова. Фреймворк отлично справляется с задачей интуитивного использования даже более продвинутых концепций. Возможно, поиск опытных Vue-разработчиков сейчас является проблемой, но это быстро меняется. Главное правильно оценить ваши ресурсы и требования.

Tags:
Hubs:
Total votes 4: ↑3 and ↓1+2
Comments35

Articles

Information

Website
hr.auriga.ru
Registered
Founded
Employees
501–1,000 employees
Location
Россия