
Привет, друзья!
Предлагаю вашему вниманию результаты небольшого исследования, посвященного сравнению Redux и Vuex. Это вторая часть статьи, вот ссылка на первую.
Введение
Redux и Vuex — это библиотеки для управления состоянием приложений, написанных на React и Vue, соответственно. Каждая из них по-своему реализует архитектуру для создания пользовательских интерфейсов, известную под названием Flux.
Обратите внимание: Flux-архитектура предназначена для работы с глобальным или распределенным (global, shared) состоянием, т.е. состоянием, которое используется двумя и более автономными компонентами приложения. Автономными являются компоненты, между которыми не существует отношений или связи "предок-потомок" или "родитель-ребенок", т.е. это компоненты из разных поддеревьев дерева компонентов. Состояние, которое используется одним компонентом или передается от родительского компонента дочерним и обратно (в пределах одного поддерева), является локальным (local), оно должно храниться и управляться соответствующим компонентом. Разумеется, это не относится к корневому (root) компоненту.
Сказанное можно проиллюстрировать следующим образом (диаграмма посвящена коллокации, или совместному размещению состояний, но сути дела это не меняет):

Архитектура Flux (в Redux) предполагает следующее:
- наличие единственного источника истины или места для хранения состояния — хранилища (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со значениемtrue—store.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 — залог высокой скорости выполнения операций чтения / записи данных? Скоро мы это выясним, но сначала проведем покомпонентное сравнение наших приложений.
Компоненты приложений
Дабы не утомлять тех читателей, которым данный раздел неинтересен, я помещу код компонентов под кат.
{ "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>
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 блокирует рендеринг.
Я обновил код в репозитории и песочницах, так что можете оценить производительность приложений самостоятельно.
Мои результаты выглядят следующим образом (они очень сильно меня удивили):
- в режиме для разработки
- запись задач
Redux—400-450мсVuex—4-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-приложений.
Надеюсь, вы не зря потратили время и нашли для себя что-то интересное. Благодарю за внимание и хорошего дня!

