Привет, друзья!
На днях мне на глаза попалась статья, посвященная разработке корзины товаров на React с помощью Redux Toolkit для управления состоянием приложения и Redux Persist для хранения состояния в localStorage.
В этой заметке я покажу, как реализовать аналогичный функционал с помощью Zustand, что позволит вам наглядно убедиться в его преимуществах перед Redux
как с точки зрения количества кода, так и с точки зрения производительности.
Если вам это интересно, прошу под кат.
Реализация корзины товаров с помощью Redux
С вашего позволения, я не буду полностью переводить указанную выше статью, а ограничусь кодом, непосредственно относящимся к Redux
.
Итак, что нужно сделать для управления состоянием корзины товаров с помощью Redux
и localStorage
?
- Устанавливаем 3 зависимости:
npm i @reduxjs/toolkit react-redux redux-persist
- Определяем часть состояния (state slice) корзины:
// src/redux/cartSlice.js
import { createSlice } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
// начальное состояние
initialState: {
cart: [],
},
reducers: {
// метод для добавления товара в корзину
addToCart: (state, action) => {
const itemInCart = state.cart.find((item) => item.id === action.payload.id);
if (itemInCart) {
itemInCart.quantity++;
} else {
state.cart.push({ ...action.payload, quantity: 1 });
}
},
// метод для увеличения количества товара, находящегося в корзине
incrementQuantity: (state, action) => {
const item = state.cart.find((item) => item.id === action.payload);
item.quantity++;
},
// метод для уменьшения количества товара, находящегося в корзине
decrementQuantity: (state, action) => {
const item = state.cart.find((item) => item.id === action.payload);
if (item.quantity === 1) {
item.quantity = 1
} else {
item.quantity--;
}
},
// метод для удаления товара из корзины
removeItem: (state, action) => {
const removeItem = state.cart.filter((item) => item.id !== action.payload);
state.cart = removeItem;
},
},
});
// редуктор
export const cartReducer = cartSlice.reducer;
// операции (экшены)
export const {
addToCart,
incrementQuantity,
decrementQuantity,
removeItem,
} = cartSlice.actions;
- Определяем хранилище:
// src/redux/store.js
import { configureStore } from "@reduxjs/toolkit";
import { cartReducer } from "./cartSlice";
export const store = configureStore({
reducer: cartReducer
});
- Оборачиваем основной компонент приложения в провайдер хранилища:
// src/index.js
import { Provider } from 'react-redux';
import { store } from './redux/store';
root.render(
<React.StrictMode>
<BrowserRouter>
<Provider store={store}>
<App />
</Provider>
</BrowserRouter>
</React.StrictMode>
);
- Извлекаем состояние из хранилища с помощью хука
useSelector
:
import { useSelector } from 'react-redux';
// в компоненте
const cart = useSelector((state) => state.cart);
- Отправляем операции в редуктор для модификации состояния с помощью метода
dispatch
, возвращаемого хукомuseDispatch
:
import { useDispatch } from 'react-redux';
import { addToCart } from "../redux/cartSlice";
// в компоненте
const dispatch = useDispatch();
dispatch(
addToCart(item)
);
- Для хранения состояния в
localStorage
хранилище определяется следующим образом:
// src/redux/store.js
import { configureStore } from "@reduxjs/toolkit";
import { cartReducer } from "./cartSlice";
import storage from 'redux-persist/lib/storage';
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER,
} from 'redux-persist';
const persistConfig = {
key: 'root',
storage,
};
const persistedReducer = persistReducer(persistConfig, cartReducer);
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
});
export const persistor = persistStore(store);
- А основной компонент приложения оборачивается еще в один провайдер:
import { store, persistor } from './redux';
import { PersistGate } from 'redux-persist/integration/react';
root.render(
<React.StrictMode>
<BrowserRouter>
<Provider store={store}>
<PersistGate loading={<div>Loading...</div>} persistor={persistor}>
<App />
</PersistGate>
</Provider>
</BrowserRouter>
</React.StrictMode>
);
Согласитесь, кода получилось многовато. При этом, у нас нет ни одной асинхронной операции, которые в Redux
обрабатываются особым образом.
Реализации корзины товаров с помощью Zustand
Клонируем начальный проект из статьи:
git clone -b starter https://github.com/Tammibriggs/shopping-cart.git
Переходим в директорию и устанавливаем Zustand
:
cd shopping-cart
npm i zustand
Создаем в директории src
файл store/cart.js
следующего содержания:
import create from "zustand";
const useCartStore = create((set, get) => ({
// начальное состояние
cart: [],
// метод для добавления товара в корзину
addToCart: (item) => {
const { cart } = get();
const itemInCart = cart.find((i) => i.id === item.id);
const newCart = itemInCart
? cart.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
)
: [...cart, { ...item, quantity: 1 }];
set({ cart: newCart });
},
// метод для удаления товара из корзины
removeItem: (id) => {
const newCart = get().cart.filter((i) => i.id !== id);
set({ cart: newCart });
},
// метод для увеличения количества товара, находящегося в корзине
incrementQuantity: (id) => {
const newCart = get().cart.map((i) =>
i.id === id ? { ...i, quantity: i.quantity + 1 } : i
);
set({ cart: newCart });
},
// метод для уменьшения количества товара, находящегося в корзине
decrementQuantity: (id) => {
const newCart = get().cart.map((i) =>
i.id === id ? { ...i, quantity: i.quantity - 1 } : i
);
set({ cart: newCart });
},
// дополнительно
// метод для получения общего количества наименований товаров, находящихся в корзине
getTotalItems: () => get().cart.length,
// метод для получения общего количества товаров, находящихся в корзине
getTotalQuantity: () => get().cart.reduce((x, y) => x + y.quantity, 0),
// метод для получения общей стоимости товаров, находящихся в корзине
getTotalPrice: () => get().cart.reduce((x, y) => x + y.price * y.quantity, 0),
}));
Внимание: это все, что требуется для управления состоянием приложения с помощью Zustand
.
Посмотрим на использование хука useCartStore
в компонентах приложения.
Главная страница
На главной странице из состояния извлекается общее количество наименований товаров, находящихся в корзине:
// pages/Home.js
import useCartStore from "../store/cart";
function Home() {
// возвращаем объект для того, чтобы повторно рендерить компонент при любом изменении состояния
const { getTotalItems } = useCartStore(({ getTotalItems }) => ({
getTotalItems,
}));
// ...
}
Количество наименований отображается рядом с иконкой (кнопкой) корзины:
<div className="shopping-cart" onClick={() => navigate("/cart")}>
<ShoppingCart id="cartIcon" />
<p>{getTotalItems() || 0}</p>
</div>
Страница корзины
На странице корзины из состояние извлекаются товары, добавленные в корзину:
// pages/Cart.js
const cart = useCartStore(({ cart }) => cart);
Товары используются для рендеринга соответствующих карточек:
<div>
<h3>Shopping Cart</h3>
{cart.map((i) => (
<CartItem key={i.id} {...i} />
))}
</div>
Карточка товара на главной странице
В карточке товара для главной странице из состояния извлекается метод для добавления товара в корзину:
// components/Item.js
const addToCart = useCartStore(({ addToCart }) => addToCart);
Данный метод можно мемоизировать следующим образом:
const addItem = useCallback(() => {
addToCart({ id, title, image, price });
}, []);
Вызывается он при нажатии соответствующей кнопки:
<button onClick={addItem}>Add to Cart</button>
Карточка товара на странице корзины
В карточке товара для страницы корзины из состояния извлекаются методы для удаления товара из корзины, увеличения и уменьшения количества товаров, находящихся в корзине:
// components/CartItem.js
const { incrementQuantity, decrementQuantity, removeItem } = useCartStore(
({ incrementQuantity, decrementQuantity, removeItem }) => ({
incrementQuantity,
decrementQuantity,
removeItem,
})
);
Данные методы вызываются при нажатии соответствующих кнопок:
<div className="cartItem">
<img className="cartItem__image" src={image} alt="item" />
<div className="cartItem__info">
<p className="cartItem__title">{title}</p>
<p className="cartItem__price">
<small>$</small>
<strong>{price}</strong>
</p>
<div className="cartItem__incrDec">
<button
onClick={() => {
if (quantity === 1) return;
decrementQuantity(id);
}}
>
-
</button>
<p>{quantity}</p>
<button onClick={() => incrementQuantity(id)}>+</button>
</div>
<button
className="cartItem__removeButton"
onClick={() => removeItem(id)}
>
Remove
</button>
</div>
</div>
Статистика
В компоненте статистики из состояния извлекаются методы для получения общего количества и стоимости товаров, находящихся в корзине:
// components/Total.js
const { getTotalQuantity, getTotalPrice } = useCartStore(
({ getTotalQuantity, getTotalPrice }) => ({
getTotalQuantity,
getTotalPrice,
})
);
Данные методы вызываются для рендеринга соответствующих данных:
<p className="total__p">
total ({getTotalQuantity()} items) :{" "}
<strong>${getTotalPrice()}</strong>
</p>
Таким образом, мы видим, что управлять состоянием приложения с помощью Zustand
гораздо проще, чем с помощью Redux
. Для реализации корзины товаров требуется в 3 раза меньше кода.
Хранение состояния в localStorage
Для хранения состояния в localStorage
с помощью Zustand
достаточно обернуть функцию обратного вызова, передаваемую в create()
, в посредник persist
, указав ключ и используемое хранилище:
import { persist } from "zustand/middleware";
const useCartStore = create(
persist(
(set, get) => ({
// ...
}),
{
name: "cart-storage",
getStorage: () => localStorage,
}
)
);
Готово.
Сравнение производительности
Immer, встроенный в Redux Toolkit
, делает его чудовищно медленным. Проведем небольшой эксперимент.
Для чистоты эксперимента удалим весь код, связанный с хранением состояния в localStorage
.
Определим функции для добавления 2500 товаров в хранилище:
// redux
// src/App.js
// ...
import { addToCart } from "./redux/cartSlice";
function App() {
const dispatch = useDispatch();
const addToCart2500Items = () => {
const times = [];
let id = 0;
for (let i = 0; i < 25; i++) {
const start = performance.now();
for (let j = 0; j < 100; j++) {
const item = {
id: id++,
title: "title",
image: "image",
price: "price",
};
// !
dispatch(addToCart(item));
}
const difference = performance.now() - start;
times.push(difference);
}
const time = Math.round(times.reduce((a, c) => (a += c), 0) / 25);
console.log("Time:", time);
};
// вызываем функцию после полной загрузки страницы
useEffect(() => {
window.addEventListener("load", addToCart2500Items);
return () => {
window.removeEventListener("load", addToCart2500Items);
};
}, []);
// ...
}
// zustand
// ...
import useCartStore from "./store/cart";
function App() {
const addToCart = useCartStore(({ addToCart }) => addToCart);
const addToCart2500Items = () => {
const times = [];
let id = 0;
for (let i = 0; i < 25; i++) {
const start = performance.now();
for (let j = 0; j < 100; j++) {
const item = {
id: id++,
title: "title",
image: "image",
price: "price",
};
// !
addToCart(item);
}
const difference = performance.now() - start;
times.push(difference);
}
const time = Math.round(times.reduce((a, c) => (a += c), 0) / 25);
console.log("Time:", time);
};
useEffect(() => {
window.addEventListener("load", addToCart2500Items);
return () => {
window.removeEventListener("load", addToCart2500Items);
};
}, []);
// ...
}
Получаем следующие средние значения (на вашей машине эти значения, скорее всего, будут немного отличаться):
-
Zustand
— 10 мс; -
Redux
— 250 мс.
Внимание: для добавления в хранилище 2500 товаров Redux
требуется (sic!) в 25 раз больше времени, чем Zustand
.
Обновление и удаления товаров дают аналогичные результаты. Полагаю, цифры говорят сами за себя.
Что насчет размера пакетов? — спросите вы. Пожалуйста:
Это все, чем я хотел поделиться с вами в данной заметке. Надеюсь, вы нашли для себя что-то интересное и не зря потратили время. Благодарю за внимание и happy coding!
Комментарии (3)
yroman
22.08.2022 12:14+1Данный метод можно мемоизировать следующим образом:
Зачем делать кривую мемоизацию? Чтобы потом ловить странные баги? Либо делайте как нужно, с прописыванием зависимостей, либо вообще не делайте, далеко не факт, что она тут нужна.
belousovnikita92
23.08.2022 00:28Тулкит это целый комбайн с экосистемой, сравнивать его с просто стором не совсем корректно.
-
Если будет задача массового добавления товаров в корзину, то не лучше ли будет делать это одним экшном вместо N*количество товаров? Кейс кажется выдуманным и в реальном проекте за такое сразу дадут по шапке
markelov69
Мда, что Redux убожество, что Zustand убожество. Как вообще можно про них говорить/думать/писать/применять в работе? Уже ж много лет все нормальные люди юзают MobX.