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


Предлагаю вашему вниманию результаты небольшого исследования, посвященного сравнению 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-приложений.


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




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


  1. Alexandroppolus
    11.08.2021 09:42
    +2

    Просто для чистоты эксперимента: если 2500 действий обернуть в batch(() => { ... }), будет быстрее? Оно ведь понятно, что из-за иммутабельности получается O(n^2), но объемы не те, чтобы только асимптотика так разваливала.

    Edit и Regular обернуть в React.memo.

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


  1. Spunreal
    11.08.2021 10:10

    К счастью, современный React предоставляет в наше распоряжение другой инструмент, который если не лучше, то уж точно не хуже Vuex

    Как-то незакончено получается. Не хватает названия или ссылки на инструмент.


    1. aio350 Автор
      11.08.2021 16:13

      я имел ввиду useContext + useReducer) + если обернуть редуктор в produce() из immer (и вместо state работать с draft), то можно мутировать состояние напрямую


  1. evgeniyPP
    11.08.2021 12:44

    Не очень понятна практическая польза сравнения Redux и Vuex. Это как сравнивать, кто сильнее: акула или медведь) У них всё равно разный ареал обитания и заменить один на другой не получится.


    1. untilx
      11.08.2021 16:00

      Очень странное заявление. А как тогда у автора получилось заменить одно на другое?


    1. Graff1406
      11.08.2021 16:06

      Правильно подметил. Но все таки это говорит о том что vuex как инструмент практичнее.


  1. DarthVictor
    11.08.2021 14:31

    Попытавшись определить причину такой медлительности Redux, я пришел к выводу, что все дело в манипуляциях с данными, которые совершает Redux с целью их нормализации с помощью адаптера сущностей.

    Заменил в изначальном Redux варианте циклические вызовы добавления на множественное добавление https://codesandbox.io/s/redux-todo-forked-resdk и вышло 7ms на каждое из 25 созданий в среднем и 80мс на общее обновление. В дев режиме со всеми дефолтными мидлварами тулкита и прочим.

    Так что дело точно не в EntityAdapter. Причем я специально не использовал из адаптера функции addMany и updateMany.


  1. Vlad_Murashchenko
    11.08.2021 15:51
    +3

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

    Архитектура Flux (в Redux) предполагает следующее:
    состояние изменяется только с помощью чистых функций — операций (actions);

    actions - это не чистые функции, а обычные объекты. И функции которые создают события (action creators) так же не обязательно должны быть чистыми. Чистыми функциями обязаны быть только reducers и selectors. И в этом очень много смысла. Я могу расписывать часами то, насколько сильно чистые функции упрощают жизнь, если понимать их преимущества.

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

    И делается это с помощью вызова dispatch 2500 раз. Это абсолютно не соответсвует идеям redux и здравому смыслу. Когда нужно добавить или отредактировать 2500 элементов, это должно делаться с помощью одной тракзакции. Об этом написано в strongly recommended правилах в redux style-guide:
    https://redux.js.org/style-guide/style-guide#avoid-dispatching-many-actions-sequentially

    Попытавшись определить причину такой медлительности Redux, я пришел к выводу, что все дело в манипуляциях с данными, которые совершает Redux с целью их нормализации с помощью адаптера сущностей.

    Я почти на 100% уверен, что проблема с производетельностью является производной нарушения правила выше и особенностями работы immer который redux-toolkit использует под коробкой. Дело в том, что immer работает очень медленно, если вызывать его в цикле как это делается в примерах статьи.
    Из документации immer:

    for (let x of y) produce(base, d => d.push(x)) is exponentially slower than produce(base, d => { for (let x of y) d.push(x)})

    В случае примеров статьи produce вызывается внутри dispatch который вызывается в цикле. Если бы изменение было тракзакционное, как должно быть, то этих проблем бы не возникло.

    На практике проблемы производительности обычно вызывает не скорость обновления данных, а лишние перерендеры и согласования виртуального DOM. И как раз этот нюанс упускает эта идея с useAppContext. При изменении состояния все компоненты которые используют useAppContext внутри себя будут обновлены, это особенность работы контекста.

    useSelector из react-redux работает таким образом, что если данные на которые ссылается селектор не изменились, компонент не обновлялся и команда react-redux приложила не мало усилий, чтобы сделать поведение useSelector именно таким и исправить баги "Stale Props" и "Zombie Children".


    1. Alexandroppolus
      11.08.2021 16:44

      команда react-redux приложила не мало усилий, чтобы ... исправить баги "Stale Props" и "Zombie Children".

      насколько я знаю (где-то читал), те усилия в основном свелись к навешиванию try...catch вокруг вызова селектора.


      1. Vlad_Murashchenko
        11.08.2021 17:17

        Да, так они исправили баг и скорее всего вы читал это вот здесь в доке. Однако не только. Также есть Subscription который используется внутри useSelector чтобы родительские компоненты обновилесь раньше дочерних. Еще там есть сравнивание состояний с предыдущим и forceUpdate при их изменении для пропуска ненужных рендеров.

        В общем, после чтения этого кода видно, что есть куча нюансов которые команда redux уже решили за нас. И я не вижу смысла повторять это все для того, чтобы перейти на useReducer + useContext. К тому же в этом подходе не закрыты еще 2 вопроса - асинхронность и интеграция с devtools. Они конечно разрешими, вот только зачем их решать самому если в итоге получится тот же redux?

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


        1. Alexandroppolus
          11.08.2021 17:28
          +2

          И я не вижу смысла повторять это все для того, чтобы перейти на useReducer + useContext.

          Да это понятно. О том, что контекст нихрена не стейт-манагер, уже сказано миллион раз. Просто благодаря хукам в Реакте стало легко работать с контекстом, и сотни велосипедистов-стейтманагерщиков ломанулись в эту пропасть :)


  1. Focushift
    16.08.2021 01:30

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

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