Redux Vs Vuex. Часть 2

от автора

Привет, друзья!

Предлагаю вашему вниманию результаты небольшого исследования, посвященного сравнению Redux и Vuex. Это вторая часть статьи, вот ссылка на первую.

Введение

Redux и Vuex — это библиотеки для управления состоянием приложений, написанных на React и Vue, соответственно. Каждая из них по-своему реализует архитектуру для создания пользовательских интерфейсов, известную под названием Flux.

Обратите внимание: Flux-архитектура предназначена для работы с глобальным или распределенным (global, shared) состоянием, т.е. состоянием, которое используется двумя и более автономными компонентами приложения. Автономными являются компоненты, между которыми не существует отношений или связи «предок-потомок» или «родитель-ребенок», т.е. это компоненты из разных поддеревьев дерева компонентов. Состояние, которое используется одним компонентом или передается от родительского компонента дочерним и обратно (в пределах одного поддерева), является локальным (local), оно должно храниться и управляться соответствующим компонентом. Разумеется, это не относится к корневому (root) компоненту.

Сказанное можно проиллюстрировать следующим образом (диаграмма посвящена коллокации, или совместному размещению состояний, но сути дела это не меняет):

Архитектура FluxRedux) предполагает следующее:

  • наличие единственного источника истины или места для хранения состояния — хранилища (store);
  • состояние изменяется только с помощью чистых функций — операций (actions);
  • операции изменяют состояние не напрямую, а через редуктор (reducer), который модифицирует состояние на основе типа (type) и опциональной (необязательной) полезной нагрузки (payload) операции;
  • операции отправляются в редуктор из слоя представления (view), пользовательского интерфейса, с помощью диспетчера (dispatcher);
  • выборка определенной части состояния или вычисление производных данных осуществляется с помощью селекторов (selectors). Вы можете думать о селекторах как об инструкциях SELECT из языка SQL
  • асинхронные операции, такие как HTTP- или AJAX-запросы, выполняются с помощью преобразователей (thunks).

Если вас интересует только код, то вот ссылка на репозиторий.

Демо React-приложения можно посмотреть здесь, а Vue-приложения — здесь.

Вы готовы? Тогда вперед!

Vuex

Vuex — это паттерн / библиотека для управления состоянием, вдохновленная Flux, Redux и Elm.

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

Это выглядит так:

API

Хранилище

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

  • state: object | function— значение начального состояния
  • mutations: { [string]: function } — объект с функциями для синхронной модификации состояния. Каждая функция в качестве первого аргумента получает текущее состояние (state), а в качестве второго аргумента — опциональную полезную нагрузку (payload)
  • actions: { [string]: function } — объект, содержащий функции, предназначенные для выполнения асинхронных операций и запуска мутаций для изменения состояния. Каждая операция в качестве второго аргумента, как и мутации, получает опциональную полезную нагрузку (payload), а в качестве первого аргумента — объект контекста (context) со следующими свойствами:
    • state — состояние
    • commit — метод для запуска мутации
    • dispatch — метод для запуска другой операции
    • getters — свойство для получения геттеров
  • getters: { [string]: function } — геттеры для извлечения части состояния или вычисления производных данных. Каждый геттер получает состояние (state) и геттеры (getters)

import { createStore } from 'vuex'  const store = createStore({  state() {    return {      count: 0    }  }  mutations: {    ['SET_COUNT'](state, payload) {      state.count = payload.count    },    ['INCREMENT'](state){      state.count++    },    ['DECREMENT'](state){      state.count--    },    ['INCREMENT_BY_AMOUNT'](state, payload) {      state.count += payload.amount    }  },  actions: {    increment({ commit }) {      commit('INCREMENT')    },    decrement({ commit }) {      commit('DECREMENT')    },    incrementByAmount({ commit }, payload) {      commit('INCREMENT_BY_AMOUNT', payload)    },    setCount({ commit }, payload) {      commit('SET_COUNT', payload)    },    setCountAsync({ dispatch }) {      const timerId = setTimeout(() => {        dispatch(setCount(10))        clearTimeout(timerId)      }, 1000)    }  } })

Экземпляр хранилища

Доступ к экземпляру хранилища в компоненте можно получить с помощью this.$store.

Свойства экземпляра хранилища:

  • state — состояние;
  • getters — геттеры.

Методы экземпляра хранилища:

  • commit — для запуска мутации;
  • dispatch — для запуска операции;
  • replaceState — для замены состояния; используется для гидратации состояния;
  • watch (function, callback)— для наблюдения за значением, возвращаемым function, и запуском callback при изменении этого значения;
  • subscribe (handler, options) — для подписки на изменения хранилища. Обработчик (handler) получает мутацию и модифицированное этой мутацией состояние и, помимо прочего, возвращает метод для отписки — const unsubsciribe = store.subscribe((mutation, state) => {}). По умолчанию каждый последующий обработчик добавляется в конец цепочки обработчиков. Для добавления обработчика в начало цепочки используется настройка prepend со значением truestore.subscribe(handler, { prepend: true })

Существуют несколько дополнительных методов для особых случаев.

Утилиты для привязки (binding)

  • mapState — для создания вычисляемых свойств компонента, возвращающих часть состояния;
  • mapGetters — для создания вычисляемых свойств компонента, возвращающих значения геттеров;
  • mapActions — для создания методов компонента, отправляющих операции;
  • mapMutations — для создания методов, запускающих мутации.

И это все, что вам нужно знать о Vuex, ну, почти, не считая продвинутых тем, вроде модулей или пространств имен. «Все? — спросите вы. — Но рассмотрению Redux Toolkit была посвящена целая статья!» Вот именно, друзья, вот именно.

Реализация хранилища

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

import { createStore } from 'vuex' // Утилита для выполнения HTTP-запросов import axios from 'axios'  // Адрес сервера const SERVER_URL = 'http://localhost:5000/todos'  // Асинхронная операция - получение задач от сервера const getTodos = async () => {  try {    const { data: todos } = await axios(SERVER_URL)    // возвращаем задачи и сообщение об успехе    return {      todos,      message: { type: 'success', text: 'Todos has been loaded' }    }  } catch (err) {    console.error(err.toJSON())    // возвращаем сообщение об ошибке    return {      message: {        type: 'error',        text: 'Something went wrong. Try again later'      }    }  } }  // Асинхронная операция - сохранение задач в БД const postTodos = async (newTodos) => {  try {    // получаем данные - существующие задачи    const { data: existingTodos } = await axios(SERVER_URL)     // перебираем существующие задачи    for (const todo of existingTodos) {      // формируем `URL` текущей задачи      const todoUrl = `${SERVER_URL}/${todo.id}`       // пытаемся найти общую задачу      const commonTodo = newTodos.find((_todo) => _todo.id === todo.id)       // если получилось      if (commonTodo) {        // определяем наличие изменений        if (          !Object.entries(commonTodo).every(            ([key, value]) => value === todo[key]          )        ) {          // если изменения есть, обновляем задачу на сервере,          // в противном случае, ничего не делаем          await axios.put(todoUrl, commonTodo)        }      } else {        // если общая задача отсутствует, удаляем задачу на сервере        await axios.delete(todoUrl)      }    }     // перебираем новые задачи и сравниваем их с существующими    for (const todo of newTodos) {      // если новой задачи нет среди существующих      if (!existingTodos.find((_todo) => _todo.id === todo.id)) {        // сохраняем ее в БД        await axios.post(SERVER_URL, todo)      }    }    // сообщение об успехе операции    return {      type: 'success',      text: 'Todos has been saved'    }  } catch (err) {    console.error(err.toJSON())    // сообщение о провале операции    return {      type: 'error',      text: 'Something went wrong. Try again later'    }  } }  // Создаем и экспортируем хранилище export default createStore({  // Начальное состояние  state: {    // для задач    todos: [],    // для статуса приложения    status: 'idle',    // для сообщения    message: {},    // для фильтра    filter: 'all'  },  // Мутации - синхронная модификация состояния  mutations: {    // добавление задач    ['SET_TODOS'](state, { todos, message }) {      if (todos) {        state.todos = todos      }      state.message = message      state.status = 'idle'    },    // установка статуса    ['SET_STATUS'](state, status) {      state.status = status    },    // добавление новой задачи    ['ADD_TODO'](state, todo) {      state.todos.push(todo)    },    // удаление задачи    ['REMOVE_TODO'](state, todo) {      state.todos.splice(state.todos.indexOf(todo), 1)    },    // обновление задачи    ['UPDATE_TODO'](state, { id, changes }) {      const index = state.todos.findIndex((todo) => todo.id === id)      state.todos.splice(index, 1, { ...state.todos[index], ...changes })    },    // завершение всех активных задач    ['COMPLETE_TODOS'](state) {      state.todos = state.todos.map((todo) =>        todo.done ? todo : { ...todo, done: !todo.done }      )    },    // удаление завершенных задач    ['CLEAR_COMPLETED'](state) {      state.todos = state.todos.filter((todo) => !todo.done)    },    // сохранение задач в БД    ['SAVE_TODOS'](state, message) {      state.message = message      state.status = 'idle'    },    // установка фильтра    ['SET_FILTER'](state, filter) {      state.filter = filter    },    // очистка сообщения    ['CLEAR_MESSAGE'](state) {      state.message = {}    }  },  // Операции - асинхронная логика и вызов мутаций  actions: {    // получение задач от сервера    fetchTodos({ commit, dispatch, state }) {      state.status = 'loading'      getTodos()        .then((todos) => {          commit('SET_TODOS', todos)        })        .then(() => {          dispatch('giveMeSomeTime')        })    },    // установка статуса    setStatus({ commit }, status) {      commit('SET_STATUS', status)    },    // добавление новой задачи    addTodo({ commit }, todo) {      commit('ADD_TODO', todo)    },    // обновление задачи    updateTodo({ commit }, payload) {      commit('UPDATE_TODO', payload)    },    // удаление задачи    removeTodo({ commit }, id) {      commit('REMOVE_TODO', id)    },    // завершение всех активных задач    completeTodos({ commit }) {      commit('COMPLETE_TODOS')    },    // удаление завершенных задач    clearCompleted({ commit }) {      commit('CLEAR_COMPLETED')    },    // сохранение задач в БД    saveTodos({ commit, state, dispatch }) {      state.status = 'loading'      postTodos(state.todos)        .then((message) => {          commit('SAVE_TODOS', message)        })        .then(() => {          dispatch('giveMeSomeTime')        })    },    // установка фильтра    setFilter({ commit }, filter) {      commit('SET_FILTER', filter)    },    // выполнение задержки перед очисткой сообщения    giveMeSomeTime({ commit }) {      const timerId = setTimeout(() => {        commit('CLEAR_MESSAGE')        clearTimeout(timerId)      }, 1500)    }  },  // Геттеры - получение части состояния  getters: {    // получение отфильтрованных задач    filteredTodos: ({ todos, filter }) => {      if (filter === 'all') return todos      return filter === 'active'        ? todos.filter((todo) => !todo.done)        : todos.filter((todo) => todo.done)    },    // получение статистики    todoStats: ({ todos }) => {      const total = todos.length      const completed = todos.filter((todo) => todo.done).length      const active = total - completed      const percent = total === 0 ? 0 : Math.round((active / total) * 100)       return {        total,        completed,        active,        percent      }    }  } })

В приведенном коде реализован такой же функционал, как и в случае с Redux, но самого кода в 2 раза меньше, да и архитектура более понятная и простая. Так что по данному критерию я присваиваю победу Vuex. Но что насчет производительности? Возможно, сложная архитектура Redux — залог высокой скорости выполнения операций чтения / записи данных? Скоро мы это выясним, но сначала проведем покомпонентное сравнение наших приложений.

Компоненты приложений

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

Фиктивная база данных (db.json)

{  "todos": [    {      "id": "1",      "text": "Eat",      "done": true,      "edit": false    },    {      "id": "2",      "text": "Code",      "done": true,      "edit": false    },    {      "id": "3",      "text": "Sleep",      "done": false,      "edit": false    },    {      "id": "4",      "text": "Repeat",      "done": false,      "edit": false    }  ] }

Основной файл приложения

React (index.jsx)

import React, { StrictMode } from 'react' import { render } from 'react-dom' // Провайдер для передачи состояния в дочерние компоненты import { Provider } from 'react-redux' // Хранилище и операции для получения задач от сервера и выполнения задержки перед очисткой хранилища import { store, fetchTodos, giveMeSomeTime } from './store'  // Основной компонент приложения import App from './App'  // Отправляем в редуктор операцию для получения задач от сервера // и следом за ней операцию для очистки сообщения с задержкой в 2 секунды store.dispatch(fetchTodos()).then(() => store.dispatch(giveMeSomeTime()))  render(  <StrictMode>    {/* Передаем хранилище в качестве пропа `store` */}    <Provider store={store}>      <App />    </Provider>  </StrictMode>,  document.getElementById('root') )

Vue (main.js)

import { createApp } from 'vue' // Основной компонент приложения import App from './App.vue' // Хранилище import store from './store' // Получаем задачи от сервера store.dispatch('fetchTodos')  createApp(App).use(store).mount('#app')

Основной компонент приложения

React (App.jsx)

// Хук для получения селекторов import { useSelector } from 'react-redux' // Компоненты приложения import New from './components/New' import Filters from './components/Filters' import List from './components/List' import Controls from './components/Controls' import Stats from './components/Stats' import Loader from './components/Loader' // Селектор для выборки всех задач import { selectTotal } from './store'  export default function App() {  const dispatch = useDispatch()  // Получаем общее количество задач  const total = useSelector(selectTotal)  // Получаем индикатор загрузки  const status = useSelector(({ todos }) => todos.status)  // Получаем сообщение  const message = useSelector(({ todos }) => todos.message)   /**   * Логика рендеринга:   * - компонент для добавления новой задачи (New) рендерится всегда   * - если есть сообщение, оно рендерится   * - если приложение находится в состоянии загрузки (status === 'loading'), рендерится только индикатор   * - если в массиве есть хотя бы одна задача (total > 0),   * рендерятся все остальные компоненты, в противном случае,   * рендерится только кнопка для сохранения задач из компонента `Controls`   */  return (    <div      className='container d-flex flex-column text-center mt-2 mb-2'      style={{ maxWidth: '600px' }}    >      <h1 className='mb-4'>Modern Redux Todo App</h1>      <New />      {message.text ? (        <div          className={`alert ${            message.type === 'success' ? 'alert-success' : 'alert-danger'          } position-fixed top-50 start-50 translate-middle`}          role='alert'          style={{ zIndex: 1 }}        >          {message.text}        </div>      ) : null}      {status === 'loading' ? (        <Loader />      ) : total ? (        <>          <Stats />          <div className='row'>            <Filters />            <List />            <Controls />          </div>        </>      ) : (        <div className='d-flex justify-content-end'>          <Controls />        </div>      )}    </div>  ) }

Vue (App.vue)

<template>  <div class="container d-flex flex-column text-center mt-2 mb-2">    <h1 class="mb-4">Modern Vuex Todo App</h1>    <New />    <div      v-if="message.text"      class="alert position-fixed top-50 start-50 translate-middle"      :class="messageClass"      role="alert"    >      {{ message.text }}    </div>    <Loader v-if="status === 'loading'" />    <template v-else-if="length">      <Stats />      <div class="row">        <Filters />        <List />        <Controls />      </div>    </template>    <div class="d-flex justify-content-end" v-else>      <Controls />    </div>  </div> </template>  <script> // Компоненты приложения import New from './components/New' import Stats from './components/Stats' import Filters from './components/Filters' import List from './components/List' import Controls from './components/Controls' import Loader from './components/Loader'  export default {  name: 'App',  components: { New, Stats, Filters, List, Controls, Loader },  computed: {    // Вычисляем сообщение    message() {      return this.$store.state.message    },    // Вычисляем CSS-класс для сообщения    messageClass() {      return this.message.type === 'success' ? 'alert-success' : 'alert-danger'    },    // Получаем статус приложения    status() {      return this.$store.state.status    },    // Получаем длину массива с задачами    length() {      return this.$store.state.todos.length    }  } } </script>  <style> .container {   max-width: 600px; } .alert {   z-index: 1; } </style>

Компонент списка

Список

React (index.jsx)

// Хук для получения селекторов import { useSelector } from 'react-redux' // Селектор для выборки отфильтрованных задач import { selectFilteredTodos } from '../../store' // Обычный элемент списка import Regular from './Regular' // Элемент списка для редактирования задачи import Edit from './Edit'  export default function List() {  // Получаем отфильтрованные задачи  const filteredTodos = useSelector(selectFilteredTodos)   /**   * Логика рендеринга:   * - рендерим только отфильтрованные задачи   * - и в зависимости от индикатора редактирования задачи,   * рендерим тот или иной элемент списка   */  return (    <div className=' col-6'>      <h3>Todos</h3>      <ul className='list-group'>        {filteredTodos.map((todo) =>          todo.edit ? (            <Edit key={todo.id} todo={todo} />          ) : (            <Regular key={todo.id} todo={todo} />          )        )}      </ul>    </div>  ) }

Vue (index.vue)

<template>  <div class="col-6">    <h3>Todos</h3>    <ul v-show="todos.length">      <li        v-for="todo in todos"        :key="todo.id"        class="list-group-item d-flex align-items-center"      >        <Regular :todo="todo" v-if="!todo.edit" />        <Edit :todo="todo" v-else />      </li>    </ul>  </div> </template>  <script> // Утилита для привязки геттеров к компоненту import { mapGetters } from 'vuex' // Компонент для обычной задачи import Regular from './Regular' // Компонент для редактируемой задачи import Edit from './Edit'  export default {  name: 'List',  components: { Regular, Edit },  computed: {    // Привязываем геттер к компоненту    ...mapGetters({ todos: 'filteredTodos' })  } } </script>

Компонент для обычной задачи

React (Regular.jsx)

// Хук для получения диспетчера import { useDispatch } from 'react-redux' // Операции для обновления и удаления задачи import { updateTodo, removeTodo } from '../../store'  export default function Regular({ todo }) {  // Получаем диспетчер  const dispatch = useDispatch()  // Извлекаем все свойства задачи  const { id, text, done, edit } = todo   return (    <li className='list-group-item d-flex align-items-center'>      <input        type='checkbox'        checked={done}        onChange={() => dispatch(updateTodo({ id, changes: { done: !done } }))}        className='form-check-input'      />      <p        className={`flex-grow-1 m-0 ${          done ? 'text-muted text-decoration-line-through' : ''        }`}      >        {text}      </p>      <button        onClick={() => dispatch(updateTodo({ id, changes: { edit: !edit } }))}        className='btn btn-outline-info'        disabled={done}      >        <i className='bi bi-pencil'></i>      </button>      <button        onClick={() => dispatch(removeTodo(id))}        className='btn btn-outline-danger'      >        <i className='bi bi-trash'></i>      </button>    </li>  ) }

Vue (Regular.vue)

<template>  <input    type="checkbox"    :checked="todo.done"    @change="completeTodo"    class="form-check-input"  />  <p class="flex-grow-1 m-0" :class="todoClass">    {{ todo.text }}  </p>  <button @click="editTodo" class="btn btn-outline-info" :disabled="todo.done">    <i class="bi bi-pencil"></i>  </button>  <button @click="removeTodo(todo)" class="btn btn-outline-danger">    <i class="bi bi-trash"></i>  </button> </template>  <script> // Утилита для привязки операций к методам компонента import { mapActions } from 'vuex'  export default {  name: 'Regular',  props: ['todo'],  computed: {    // Вычисляем дополнительный CSS-класс для задачи    todoClass() {      return this.todo.done ? 'text-muted text-decoration-line-through' : ''    }  },  methods: {    // Привязываем операции к методам компонента    ...mapActions(['updateTodo', 'removeTodo']),    // Метод для изменения индикатора завершенности задачи    completeTodo() {      this.updateTodo({ id: this.todo.id, changes: { done: !this.todo.done } })    },    // Метод для изменения индикатора редактирования задачи    editTodo() {      this.updateTodo({ id: this.todo.id, changes: { edit: !this.todo.edit } })    }  } } </script>

Компонент для редактируемой задачи

React (Edit.jsx)

import { useState, useEffect } from 'react' // Хук для получения диспетчера import { useDispatch } from 'react-redux' // Операции для обновления и удаления задачи import { updateTodo, removeTodo } from '../../store'  export default function Edit({ todo }) {  // Получаем диспетчер  const dispatch = useDispatch()  // Извлекаем все свойства задачи, кроме индикатора завершенности  const { id, text, edit } = todo  // Локальное состояние для текста редактируемой задачи  const [newText, setNewText] = useState(text)   // Функция для изменения текста  const onChangeText = ({ target: { value } }) => {    const trimmed = value.replace(/\s{2,}/g, ' ').trim()    setNewText(trimmed)  }  // Функция для завершения редактирования  const onFinishEdit = () => {    // Если текст отсутствует, вероятно, пользователь хочет удалить задачу    if (!newText) {      return dispatch(removeTodo(id))    }    // Отправляем операцию для обновления текста задачи    dispatch(updateTodo({ id, changes: { text: newText, edit: !edit } }))  }  // Функция для отмены редактирования  const onCancelEdit = () => {    // Отправляем операцию для изменения индикатора редактирования    dispatch(updateTodo({ id, changes: { edit: !edit } }))  }   // Функция для обработки нажатия клавиш клавиатуры:  // если нажата клавиша "Enter", завершаем редактирование,  // если нажата клавиша "Escape", отменяем редактирование  const onKeyDown = ({ key }) => {    switch (key) {      case 'Enter':        onFinishEdit()        break      case 'Escape':        onCancelEdit()        break      default:        break    }  }   // Регистрируем обработчик нажатия клавиш  // и удаляем его при размонтировании компонента  useEffect(() => {    window.addEventListener('keydown', onKeyDown)    return () => {      window.removeEventListener('keydown', onKeyDown)    }  })   return (    <li className='list-group-item d-flex align-items-center'>      <input        type='text'        value={newText}        onChange={onChangeText}        className='form-control flex-grow-1'      />      <button onClick={onFinishEdit} className='btn btn-outline-success'>        <i className='bi bi-check'></i>      </button>      <button onClick={onCancelEdit} className='btn btn-outline-warning'>        <i className='bi bi-x-square'></i>      </button>    </li>  ) }

Vue (Edit.vue)

<template>  <input type="text" v-model="newText" class="form-control flex-grow-1" />  <button @click="finishEdit" class="btn btn-outline-success">    <i class="bi bi-check"></i>  </button>  <button @click="cancelEdit" class="btn btn-outline-warning">    <i class="bi bi-x-square"></i>  </button> </template>  <script> // Утилита для привязки операций к методам компонента import { mapActions } from 'vuex'  export default {  name: 'Edit',  props: ['todo'],  data() {    // Локальное состояние для текста редактируемой задачи    return {      newText: this.todo.text    }  },  // Регистрируем обработчик нажатия клавиш клавиатуры при монтировании компонента  created() {    window.addEventListener('keydown', this.onKeyDown)  },  // Удаляем обработчик нажатия клавиш клавиатуры при размонтировании компонента  unmounted() {    window.removeEventListener('keydown', this.onKeyDown)  },  methods: {    // Привязываем операции к методам компонента    ...mapActions(['updateTodo', 'removeTodo']),    // Обработчик нажатия клавиш клавиатуры    onKeyDown({ key }) {      switch (key) {        // Если нажата клавиша `Enter`, вызываем метод для завершения редактирования        case 'Enter':          this.finishEdit()          break        // Если нажата клавиша `Escape`, вызываем метод для отмены редактирования        case 'Escape':          this.cancelEdit()          break        default:          break      }    },    // Метод для завершения редактирования    finishEdit() {      const text = this.newText.replace(/\s{2,}/g, ' ').trim()      if (!text) return this.removeTodo(this.todo)      this.updateTodo({        id: this.todo.id,        changes: { text, edit: !this.todo.edit }      })    },    // Метод для отмены редактирования    cancelEdit() {      this.updateTodo({ id: this.todo.id, changes: { edit: !this.todo.edit } })    }  } } </script>

Компонент для кнопок управления

React (Controls.jsx)

// Хуки для получения диспетчера и селекторов import { useDispatch, useSelector } from 'react-redux' // Операции для завершения всех активных задач, // удаления завершенных задач, // сохранения задач в БД, // очистки сообщения // и селектор для выборки всех задач import {  completeAllTodos,  clearCompletedTodos,  saveTodos,  giveMeSomeTime,  selectAll } from '../store'  export default function Controls() {  // Получаем диспетчер  const dispatch = useDispatch()  // Получаем все задачи  const todos = useSelector(selectAll)   /**   * Логика рендеринга:   * - если в массиве есть хотя бы одна задача,   * рендерятся кнопки для завершения всех активных задач, удаления завершенных задач и сохранения задач в БД,   * в противном случае, рендерится только кнопка для сохранения задач   */  return (    <div className='col-3 d-flex flex-column'>      <h3>Controls</h3>      {todos.length ? (        <>          <button            onClick={() => dispatch(completeAllTodos())}            className='btn btn-info mb-2'          >            Complete          </button>          <button            onClick={() => dispatch(clearCompletedTodos())}            className='btn btn-danger mb-2'          >            Clear          </button>        </>      ) : null}      <button        onClick={() =>          // Отправляем операцию для сохранения задач          // и следом за ней операцию для очистки сообщения с задержкой в 2 секунды          dispatch(saveTodos(todos)).then(() => dispatch(giveMeSomeTime()))        }        className='btn btn-success'      >        Save      </button>    </div>  ) }

Vue (Controls.vue)

<template>  <div class="col-3 d-flex flex-column">    <h3>Controls</h3>    <template v-if="length">      <button @click="completeTodos" class="btn btn-info mb-2">Complete</button>      <button @click="clearCompleted" class="btn btn-danger mb-2">Clear</button>    </template>    <button @click="saveTodos" class="btn btn-success">Save</button>  </div> </template>  <script> // Утилита для привязки операций к методам компонента import { mapActions } from 'vuex'  export default {  name: 'Controls',  computed: {    // Вычисляем длину массива с задачами    length() {      return this.$store.state.todos.length    }  },  methods: {    // Привязываем операции к методам компонента    ...mapActions(['completeTodos', 'clearCompleted', 'saveTodos'])  } } </script>

Компонент для фильтров

React (Filters.jsx)

// Хуки для получения диспетчера и селекторов import { useDispatch, useSelector } from 'react-redux' // Операция для установки значения фильтра import { setFilter } from '../store'  export default function Filters() {  // Получаем диспетчер  const dispatch = useDispatch()  // Получаем текущее значение фильтра  const { status } = useSelector((state) => state.filter)   return (    <div className='col-3'>      <h3>Filters</h3>      {['all', 'active', 'completed'].map((filter) => (        <div key={filter} className='form-check' style={{ textAlign: 'left' }}>          <input            id={filter}            type='radio'            checked={filter === status}            onChange={() => dispatch(setFilter(filter))}            className='form-check-input'          />          <label htmlFor={filter} className='form-check-label'>            {filter.toUpperCase()}          </label>        </div>      ))}    </div>  ) }

Vue (Filters.vue)

<template>  <div class="col-3">    <h3>Filters</h3>    <div      v-for="filter in ['all', 'active', 'completed']"      :key="filter"      class="form-check text-left"    >      <input        :id="filter"        type="radio"        :checked="filter === currentFilter"        @click="setFilter(filter)"        class="form-check-input"      />      <label :for="filter" class="form-check-label">        {{ filter.toUpperCase() }}      </label>    </div>  </div> </template>  <script> // Утилита для привязки операций к методам компонента import { mapActions } from 'vuex'  export default {  name: 'Filters',  computed: {    // Получаем текущее значение фильтра    currentFilter() {      return this.$store.state.filter    }  },  methods: {    // Привязываем операцию к методу компонента    ...mapActions(['setFilter'])  } } </script>  <style scoped> .text-left {   text-align: left !important; } </style>

Индикатор загрузки

React (Loader.jsx)

// Индикатор загрузки export default function Loader() {  return (    <div className='spinner-border text-primary m-auto' role='status'>      <span className='visually-hidden'>Loading...</span>    </div>  ) }

Vue (Loader.vue)

<template>  <div class="spinner-border text-primary m-auto" role="status">    <span class="visually-hidden">Loading...</span>  </div> </template>  <script> export default {   name: 'Loader' } </script>

Компонент для добавления новой задачи

React (New.jsx)

import { useState } from 'react' // Хук для получения диспетчера import { useDispatch } from 'react-redux' // Утилита для генерации уникальных идентификаторов import { nanoid } from '@reduxjs/toolkit' // Операция для добавления новой задачи import { addTodo } from '../store'  export default function New() {  // Получаем диспетчер  const dispatch = useDispatch()  // Локальное состояние для текста новой задачи  const [text, setText] = useState('')   // Функция для изменения текста задачи  const changeText = ({ target: { value } }) => {    // Заменяем два и более пробела на один и удаляем пробелы в начале и конце строки    const trimmed = value.replace(/\s{2,}/g, ' ').trim()    setText(trimmed)  }   // Функция для добавления задачи  const onAddTodo = (e) => {    e.preventDefault()     if (!text) return     const newTodo = {      id: nanoid(5),      text,      done: false,      edit: false    }    // Отправляем операцию для добавления новой задачи    dispatch(addTodo(newTodo))     setText('')  }   return (    <form onSubmit={onAddTodo} className='d-flex mb-4'>      <input        type='text'        placeholder='What needs to be done?'        value={text}        onChange={changeText}        className='form-control flex-grow-1'      />      <button className='btn btn-outline-success'>        <i className='bi bi-plus-square'></i>      </button>    </form>  ) }

Vue (New.vue)

<template>  <form @submit.prevent="addTodoMethod" class="d-flex mb-4">    <input      type="text"      placeholder="What needs to be done?"      class="form-control flex-grow-1"      v-model="text"    />    <button class="btn btn-outline-success">      <i class="bi bi-plus-square"></i>    </button>  </form> </template>  <script> // Утилита для привязки операций к методам компонента import { mapActions } from 'vuex' // Утилита для генерации уникальных идентификаторов import { nanoid } from 'nanoid'  export default {  name: 'New',  data() {    // Локальное состояние для текста новой задачи    return {      text: ''    }  },  methods: {    // Привязываем операцию к методу компонента    ...mapActions(['addTodo']),    // Метод для добавления новой задачи    addTodoMethod() {      const trimmed = this.text.replace(/\s{2,}/g, ' ').trim()      if (!trimmed) return      const todo = {        id: nanoid(5),        text: trimmed,        done: false,        edit: false      }      this.addTodo(todo)      this.text = ''    }  } } </script>

Last, but not least — компонент для статистики

React (Stats.jsx)

// Хук для получения селекторов import { useSelector } from 'react-redux' // Селектор для выборки статистики import { selectTodoStats } from '../store'  export default function Stats() {  // Получаем статистику (объект)  const stats = useSelector(selectTodoStats)   /**   * Логика рендеринга:   * - перебираем ключи и формируем с их помощью колонки-заголовки (выполняем "капитализацию" ключей)   * - перебираем значения и формируем с их помощью обычные колонки   */  return (    <div className='row'>      <h3>Statistics</h3>      <table className='table text-center'>        <thead>          <tr>            {Object.keys(stats).map(([first, ...rest], index) => (              <th scope='col' key={index}>                {`${first.toUpperCase()}${rest.join('').toLowerCase()}`}              </th>            ))}          </tr>          <tr>            {Object.values(stats).map((value, index) => (              <td key={index}>{value}</td>            ))}          </tr>        </thead>      </table>    </div>  ) }

Vue (Stats.vue)

<template>  <div class="row">    <h3>Statistics</h3>    <table class="table text-center">      <thead>        <tr>          <th scope="col" v-for="(_, name, index) in todoStats" :key="index">            {{ `${name[0].toUpperCase()}${name.slice(1).toLowerCase()}` }}          </th>        </tr>        <tr>          <td v-for="(val, _, index) in todoStats" :key="index">{{ val }}</td>        </tr>      </thead>    </table>  </div> </template>  <script> // Утилита для привязки геттеров к компоненту import { mapGetters } from 'vuex'  export default {  name: 'Stats',  computed: {    // Привязываем геттер к компоненту    ...mapGetters(['todoStats'])  } } </script>

Своими впечатлениями о работе с Vue я поделюсь как-нибудь в другой раз, но, в целом, могу сказать, что впечатления крайне положительные (есть много прикольных плюшек).

Измерение производительности

Я опишу измерение производительности на примере Redux-приложения, но точно такой же функционал реализован во Vuex-приложении.

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

Отключаем получение задач от сервера:

// store.dispatch(fetchTodos()).then(() => store.dispatch(giveMeSomeTime()))

Вносим изменения в App.jsx:

// Хук для получения диспетчера import { useDispatch } from 'react-redux' // Операции для добавления новой задачи, обновления задачи и селектор для получения всех задач import { addTodo, updateTodo, selectAll } from './store' // Утилита для генерации идентификаторов import { nanoid } from '@reduxjs/toolkit'

Получаем все задачи (они потребуются нам для обновления):

const todos = useSelector(selectAll)

Создаем функцию для записи 2500 задач:

const createManyTodos = () => {  // массив для времени выполнения операций  const times = []  // 25 итераций  for (let i = 0; i < 25; i++) {    // время начала выполнения операции    const start = Date.now()    // 100 задач    for (let i = 0; i < 100; i++) {      const id = nanoid()      const todo = {        id,        text: `Todo ${id}`,        done: false,        edit: false      }      // отправляем операцию для добавления новой задачи      dispatch(addTodo(todo))    }    // разница между началом и окончанием выполнения операции    const diff = Date.now() - start    // помещаем разницу в массив    times.push(diff)  }  // вычисляем среднее время выполнения операций  const time = times.reduce((a, c) => (a += c), 0) / 25  // и выводим его в консоль  console.log(time) }

Создаем функцию для обновления всех задач:

const updateAllTodos = () => {  // время начала выполнения операции  const start = Date.now()  // перебираем задачи  for (let i = 0; i < todos.length; i++) {    // отправляем операцию для обновления задачи - изменения значения индикатора ее завершенности    dispatch(updateTodo({ id: todos[i].id, changes: { done: true } }))  }  // вычисляем разницу между началом и окончанием выполнения операции  const diff = Date.now() - start  // и выводим ее в консоль  console.log(diff) }

Добавляем в разметку соответствующие кнопки:

<div className='d-flex gap-2 mb-2 align-items-center'>  <button className='btn btn-primary' onClick={createManyTodos}>    Create  </button>  <p className='mb-0'>many todos</p> </div> <div className='d-flex gap-2 mb-2 align-items-center'>  <button className='btn btn-primary' onClick={updateAllTodos}>    Update  </button>  <p className='mb-0'>all todos</p> </div>

У вас может возникнуть вопрос о том, какое влияние оказывает рендеринг задач на выполнение операций. Ответ: для Vuex почти никакое. Но в случае с Redux, на первый взгляд, может показаться, что он ожидает завершения отрисовки всех задач (даже когда такая отрисовка отключена), что делает его ОЧЕНЬ медленным. На самом деле, как мы увидим позже, все с точностью да наоборот: не рендеринг замедляет работу Redux, а Redux блокирует рендеринг.

Я обновил код в репозитории и песочницах, так что можете оценить производительность приложений самостоятельно.

Мои результаты выглядят следующим образом (они очень сильно меня удивили):

  • в режиме для разработки
    • запись задач
    • Redux400-450 мс
    • Vuex4-5 мс
    • обновление задач
    • Redux — чудовищные 18000-20000 мс
    • Vuex — в районе 1500 мс
  • в производственном режиме (с помощью serve)
    • запись задач
    • Redux — в среднем 300 мс
    • Vuex — около 4 мс
    • обновление задач
    • Redux — по-прежнему чудовищные 11000-13000 мс
    • Vuex — как ни странно, около 2000 мс (-500 мс по сравнению с режимом для разработки)

Таким образом, при выполнении операций записи скорость Vuex выше скорости Redux в 70-90 раз, а при выполнении операций обновления — в 5-6 раз.

Попытавшись определить причину такой медлительности Redux, я пришел к выводу, что все дело в манипуляциях с данными, которые совершает Redux с целью их нормализации с помощью адаптера сущностей. Причем, все настолько плохо, что попытка записи 10 000 задач «вешает» браузер (возможно, ваша машина сумеет с этим справиться, моя категорически отказывалась со мной сотрудничать). Для сравнения Vuex прекрасно с этим справляется (осторожно: отрисовка такого количества задач может оказаться непосильной задачей для браузера). На мой взгляд, игра, определенно, не стоит свеч. Нормализованные структуры, действительно, бывают удобными в некоторых ситуациях, но такое влияние на производительность является неприемлемым.

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

Мне, как человеку, который для разработки приложений использует в основном React, стало за него обидно. Поэтому я решил переписать хранилище с помощью хуков useContext и useReducer, а также парочки трюков:

import { createContext, useContext, useReducer, useMemo } from 'react' import axios from 'axios'  const SERVER_URL = 'http://localhost:5000/todos'  // константы const SET_TODOS = 'SET_TODOS' const SET_STATUS = 'SET_STATUS' const ADD_TODO = 'ADD_TODO' const UPDATE_TODO = 'UPDATE_TODO' const REMOVE_TODO = 'REMOVE_TODO' const COMPLETE_TODOS = 'COMPLETE_TODOS' const CLEAR_COMPLETED = 'CLEAR_COMPLETED' const SET_FILTER = 'SET_FILTER' const SET_MESSAGE = 'SET_MESSAGE'  // редуктор const reducer = (state, { type, payload }) => {  switch (type) {    case SET_TODOS:      return {        ...state,        todos: payload      }    case SET_STATUS:      return {        ...state,        status: payload      }    case ADD_TODO:      return { ...state, todos: state.todos.concat(payload) }    case UPDATE_TODO:      return {        ...state,        todos: state.todos.map((todo) =>          todo.id === payload.id ? { ...todo, ...payload.changes } : todo        )      }    case REMOVE_TODO:      return {        ...state,        todos: state.todos.filter((todo) => todo.id !== payload)      }    case COMPLETE_TODOS:      return { ...state, todos: state.todos.map((todo) => todo.done === true) }    case CLEAR_COMPLETED:      return {        ...state,        todos: state.todos.filter((todo) => todo.done === true)      }    case SET_FILTER:      return {        ...state,        filter: payload      }    case SET_MESSAGE:      return {        ...state,        message: payload      }    default:      return state  } }  // задержка const giveMeSomeTime = async () =>  await new Promise((resolve) => {    const timerId = setTimeout(() => {      resolve()      clearTimeout(timerId)    }, 2000)  })  // создатель операций // принимает диспетчер const createActions = (dispatch) => ({  setTodos: (todos) => ({    type: SET_TODOS,    payload: todos  }),  setStatus: (status) => ({    type: SET_STATUS,    payload: status  }),  addTodo: (todo) => ({    type: ADD_TODO,    payload: todo  }),  updateTodo: (payload) => ({    type: UPDATE_TODO,    payload  }),  removeTodo: (todoId) => ({    type: REMOVE_TODO,    payload: todoId  }),  completeTodos: () => ({    type: COMPLETE_TODOS  }),  clearCompleted: () => ({    type: COMPLETE_TODOS  }),  setFilter: (filter) => ({    type: SET_FILTER,    payload: filter  }),  setMessage: (message) => ({    type: SET_MESSAGE,    payload: message  }),  async fetchTodos() {    dispatch(this.setStatus('loading'))     try {      const { data: todos } = await axios(SERVER_URL)       dispatch(this.setTodos(todos))       dispatch(        this.setMessage({ type: 'success', text: 'Todos has been loaded' })      )    } catch (err) {      console.error(err.toJSON())       dispatch(        this.setMessage({          type: 'error',          text: 'Something went wrong. Try again later'        })      )    } finally {      dispatch(this.setStatus('idle'))       await giveMeSomeTime()       dispatch(this.setMessage({}))    }  },  async saveTodos(newTodos) {    dispatch(this.setStatus('loading'))     try {      const { data: existingTodos } = await axios(SERVER_URL)       for (const todo of existingTodos) {        const todoUrl = `${SERVER_URL}/${todo.id}`         const commonTodo = newTodos.find((_todo) => _todo.id === todo.id)         if (commonTodo) {          if (            !Object.entries(commonTodo).every(              ([key, value]) => value === todo[key]            )          ) {            await axios.put(todoUrl, commonTodo)          }        } else {          await axios.delete(todoUrl)        }      }       for (const todo of newTodos) {        if (!existingTodos.find((_todo) => _todo.id === todo.id)) {          await axios.post(SERVER_URL, todo)        }      }       dispatch(        this.setMessage({ type: 'success', text: 'Todos has been saved' })      )    } catch (err) {      console.error(err.toJSON())       dispatch(        this.setMessage({          type: 'error',          text: 'Something went wrong. Try again later'        })      )    } finally {      dispatch(this.setStatus('idle'))       await giveMeSomeTime()       dispatch(this.setMessage({}))    }  } })  // создатель селекторов // принимает состояние const createSelectors = (state) => ({  selectFilteredTodos() {    const { todos, filter } = state    if (filter === 'all') return todos    return filter === 'active'      ? todos.filter((todo) => !todo.done)      : todos.filter((todo) => todo.done)  },  selectTodoStats() {    const { todos } = state    const { length } = todos     const completed = todos.filter((todo) => todo.done).length    const active = length - completed    const percent = length === 0 ? 0 : Math.round((active / length) * 100)     return {      total: length,      completed,      active,      percent    }  } })  // начальное состояние const initialState = {  todos: [],  status: 'idle',  message: {},  filter: 'all' }  // контекcт const Context = createContext()  // провайдер export const Provider = ({ children }) => {  const [state, dispatch] = useReducer(reducer, initialState)   // операции меняться не будут, поэтому их можно мемоизировать  // "свежесть" изменяемого состояния обеспечивается диспетчером  const actions = useMemo(() => createActions(dispatch), [])  // селекторы должны повторно вычисляться при каждом изменении состояния  const selectors = createSelectors(state)   return (    <Context.Provider value={{ state, dispatch, actions, selectors }}>      {children}    </Context.Provider>  ) }  // хук для использования контекста export const useAppContext = () => useContext(Context)

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

Демо этого приложения можно посмотреть здесь. Оно также лежит в репозитории.

Для измерения производительности я применил ту же технику и получил такие результаты:

  • в режиме для разработки
    • запись — около 1 мс
    • обновление — около 10 мс
  • в производственном режиме
    • запись — 1 мс
    • обновление — 5-7 мс

Таким образом, по сравнению с Vuex мы получаем прирост производительности: для записи — в 4-5 раз, для обновления (внимание!) — в 100-150 раз.

Данный вариант хранилища является настолько производительным, что время записи 10000 задач также составляет в среднем 1 мс, не считая времени рендеринга. Что касается последнего, то при сравнении Vue и React, первый справляется с отрисовкой большого количества DOM-элементов немного лучше, но я бы не сказал, что разница является существенной.

Заключение

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

К счастью, современный React предоставляет в наше распоряжение другой инструмент, который если не лучше, то уж точно не хуже Vuex. Интерфейс этого инструмента можно сильно упростить, но это тема для отдельной статьи. Для себя я сделал такой вывод: нам больше не нужен Redux для управления состоянием React-приложений.

Надеюсь, вы не зря потратили время и нашли для себя что-то интересное. Благодарю за внимание и хорошего дня!



ссылка на оригинал статьи https://habr.com/ru/articles/572248/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *