浏览器状态架构:通过购物车实现的模式驱动探索
有没有想过在 React 应用程序中处理购物车状态的最佳方法是什么?你应该使用 Context 吗?LocalStorage?或者也许是更强大的东西,比如 IndexedDB?在本文中,我们将使用不同的方法构建相同的购物车,并通过真实的工作代码比较它们的优缺点。
总结
挑战
建立购物车乍一看很简单,但有几个要求需要考虑:
让我们使用不同的方法构建同一辆购物车,看看它们如何堆叠。
方法 1:React Context
首先,让我们看一下使用 React Context 的最简单的方法:
import React, { createContext, useContext, useState } from 'react'; // Context definition const CartContext = createContext(); const CartProvider = ({ children }) => { const [items, setItems] = useState([]); const addItem = (product) => { setItems(prev => { const existingItem = prev.find(item => item.id === product.id); if (existingItem) { return prev.map(item => item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item ); } return [...prev, { ...product, quantity: 1 }]; }); }; const updateQuantity = (productId, quantity) => { if (quantity < 1) return; setItems(prev => prev.map(item => item.id === productId ? { ...item, quantity } : item ) ); }; const removeItem = (productId) => { setItems(prev => prev.filter(item => item.id !== productId)); }; return (sum + (item.price * item.quantity), 0) }}> {children} ); }; // Sample products data const SAMPLE_PRODUCTS = [ { id: 1, name: 'Laptop', price: 999 }, { id: 2, name: 'Smartphone', price: 699 }, { id: 3, name: 'Headphones', price: 199 }, ]; // Product List Component const ProductList = () => { const { addItem } = useContext(CartContext); return (); }; // Shopping Cart Component const ShoppingCart = () => { const { items, updateQuantity, removeItem, total } = useContext(CartContext); return (Products
{SAMPLE_PRODUCTS.map(product => ())}{product.name}
${product.price}
); }; // Main App Component const App = () => { return (Shopping Cart
{items.length === 0 ? (Your cart is empty
) : ({items.map(item => ()}))}{item.name}
${item.price}
updateQuantity(item.id, parseInt(e.target.value))} className="w-16 p-1 border rounded" />Total: ${total.toFixed(2)}
); }; export default App; Shopping Cart Demo
### Pros of Context Approach ✅ Simple to implement ✅ Real-time updates ✅ No extra dependencies ✅ Perfect for small applications ### Cons of Context Approach ❌ Loses state on page refresh ❌ No cross-tab synchronization ❌ Memory-only storage ❌ Not suitable for large datasets
方法 2:使用 Context 的 LocalStorage
让我们使用 LocalStorage 持久性来增强我们的 Context 方法:
import React, { createContext, useContext, useState, useEffect } from 'react'; const CartContext = createContext(); const STORAGE_KEY = 'shopping-cart'; const CartProvider = ({ children }) => { const [items, setItems] = useState(() => { if (typeof window === 'undefined') return []; const stored = localStorage.getItem(STORAGE_KEY); return stored ? JSON.parse(stored) : []; }); useEffect(() => { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); }, [items]); const addItem = (product) => { setItems(prev => { const existingItem = prev.find(item => item.id === product.id); if (existingItem) { return prev.map(item => item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item ); } return [...prev, { ...product, quantity: 1 }]; }); }; const updateQuantity = (productId, quantity) => { if (quantity < 1) return; setItems(prev => prev.map(item => item.id === productId ? { ...item, quantity } : item ) ); }; const removeItem = (productId) => { setItems(prev => prev.filter(item => item.id !== productId)); }; const clearCart = () => { setItems([]); localStorage.removeItem(STORAGE_KEY); }; return (sum + (item.price * item.quantity), 0) }}> {children} ); }; // Sample products data const SAMPLE_PRODUCTS = [ { id: 1, name: 'Laptop', price: 999 }, { id: 2, name: 'Smartphone', price: 699 }, { id: 3, name: 'Headphones', price: 199 }, ]; // Product List Component const ProductList = () => { const { addItem } = useContext(CartContext); return (); }; // Shopping Cart Component const ShoppingCart = () => { const { items, updateQuantity, removeItem, clearCart, total } = useContext(CartContext); return (Products
{SAMPLE_PRODUCTS.map(product => ())}{product.name}
${product.price}
); }; // Main App Component const App = () => { return ({items.length === 0 ? (Shopping Cart
{items.length > 0 && ( )}Your cart is empty
) : ({items.map(item => ()}))}{item.name}
${item.price}
updateQuantity(item.id, parseInt(e.target.value))} className="w-16 p-1 border rounded" />Total: ${total.toFixed(2)}
); }; export default App; Shopping Cart Demo (with LocalStorage)
LocalStorage 方法的优点
✅ 刷新后依然有效
✅ 易于实施
✅ 无需额外依赖
✅ 离线工作
LocalStorage 方法的缺点
❌存储空间有限(5-10MB)
❌同步操作可能会阻塞 UI
❌ 没有结构化查询
❌ 没有内置索引
❌ 需要手动进行跨表同步
方法 3:带有自定义钩子的 IndexedDB
现在让我们看看使用 IndexedDB 的更强大的解决方案:
import React, { createContext, useContext, useState, useEffect } from 'react'; // IndexedDB setup const initDB = () => { return new Promise((resolve, reject) => { const request = indexedDB.open('ShoppingCartDB', 1); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains('cart')) { db.createObjectStore('cart', { keyPath: 'id' }); } }; }); }; // Cart Context const CartContext = createContext(); const CartProvider = ({ children }) => { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [db, setDb] = useState(null); // Initialize IndexedDB useEffect(() => { initDB() .then(database => { setDb(database); // Load initial data const transaction = database.transaction('cart', 'readonly'); const store = transaction.objectStore('cart'); const request = store.getAll(); request.onsuccess = () => { setItems(request.result); setLoading(false); }; }) .catch(error => { console.error('Failed to initialize DB:', error); setLoading(false); }); }, []); // Handle cross-tab communication useEffect(() => { const channel = new BroadcastChannel('shopping-cart'); const handleMessage = (event) => { if (event.data.type === 'CART_UPDATED' && db) { const transaction = db.transaction('cart', 'readonly'); const store = transaction.objectStore('cart'); const request = store.getAll(); request.onsuccess = () => { setItems(request.result); }; } }; channel.addEventListener('message', handleMessage); return () => { channel.removeEventListener('message', handleMessage); channel.close(); }; }, [db]); const notifyOtherTabs = () => { const channel = new BroadcastChannel('shopping-cart'); channel.postMessage({ type: 'CART_UPDATED' }); channel.close(); }; const addItem = async (product) => { if (!db) return; const existingItem = items.find(item => item.id === product.id); const newItem = existingItem ? { ...existingItem, quantity: existingItem.quantity + 1 } : { ...product, quantity: 1 }; // Optimistic update setItems(prev => { if (existingItem) { return prev.map(item => item.id === product.id ? newItem : item ); } return [...prev, newItem]; }); try { const transaction = db.transaction('cart', 'readwrite'); const store = transaction.objectStore('cart'); await new Promise((resolve, reject) => { const request = store.put(newItem); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); notifyOtherTabs(); } catch (error) { console.error('Failed to add item:', error); setItems(prev => existingItem ? prev : prev.filter(item => item.id !== product.id)); } }; const updateQuantity = async (productId, quantity) => { if (!db || quantity < 1) return; const item = items.find(item => item.id === productId); if (!item) return; const updatedItem = { ...item, quantity }; // Optimistic update setItems(prev => prev.map(item => item.id === productId ? updatedItem : item) ); try { const transaction = db.transaction('cart', 'readwrite'); const store = transaction.objectStore('cart'); await new Promise((resolve, reject) => { const request = store.put(updatedItem); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); notifyOtherTabs(); } catch (error) { console.error('Failed to update quantity:', error); setItems(prev => prev.map(item => item.id === productId ? { ...item, quantity: item.quantity } : item) ); } }; const removeItem = async (productId) => { if (!db) return; const removedItem = items.find(item => item.id === productId); // Optimistic update setItems(prev => prev.filter(item => item.id !== productId)); try { const transaction = db.transaction('cart', 'readwrite'); const store = transaction.objectStore('cart'); await new Promise((resolve, reject) => { const request = store.delete(productId); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); notifyOtherTabs(); } catch (error) { console.error('Failed to remove item:', error); if (removedItem) { setItems(prev => [...prev, removedItem]); } } }; const clearCart = async () => { if (!db) return; const oldItems = [...items]; // Optimistic update setItems([]); try { const transaction = db.transaction('cart', 'readwrite'); const store = transaction.objectStore('cart'); await new Promise((resolve, reject) => { const request = store.clear(); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); notifyOtherTabs(); } catch (error) { console.error('Failed to clear cart:', error); setItems(oldItems); } }; return (sum + (item.price * item.quantity), 0) }}> {children} ); }; // Sample products data const SAMPLE_PRODUCTS = [ { id: 1, name: 'Laptop', price: 999 }, { id: 2, name: 'Smartphone', price: 699 }, { id: 3, name: 'Headphones', price: 199 }, ]; // Product List Component const ProductList = () => { const { addItem } = useContext(CartContext); return (); }; // Shopping Cart Component const ShoppingCart = () => { const { items, loading, updateQuantity, removeItem, clearCart, total } = useContext(CartContext); if (loading) { return (Products
{SAMPLE_PRODUCTS.map(product => ())}{product.name}
${product.price}
); } return (Shopping Cart
Loading cart...
); }; // Main App Component const App = () => { return ({items.length === 0 ? (Shopping Cart
{items.length > 0 && ( )}Your cart is empty
) : ({items.map(item => ()}))}{item.name}
${item.price}
updateQuantity(item.id, parseInt(e.target.value))} className="w-16 p-1 border rounded" />Total: ${total.toFixed(2)}
); }; export default App; Shopping Cart Demo (with IndexedDB)
混合方法的优点
✅ 刷新和浏览器重启后依然有效
✅ 离线工作
✅ 高效处理大型数据集
✅ 跨表同步
✅ 乐观更新,获得更好的用户体验
✅ 结构化查询和索引
✅ 使用回滚来处理错误
混合方法的缺点
❌ 实现起来更复杂
❌需要了解 IndexedDB
❌ 稍微多一些样板代码
超越实施:架构视角
在深入研究具体实现之前,必须了解基于浏览器的状态管理提出了一组有限的基本挑战和相应的解决方案。
浏览器环境为我们提供了独特的功能(Context、localStorage、IndexedDB)和约束(存储限制、选项卡隔离、离线场景),创建了明确的设计空间。
我们探索的每种解决方案(从简单的 Context 到复杂的 IndexedDB 实现)都不仅仅是一种编码模式,而是对特定架构需求的具体响应。通过了解这些反复出现的属性(如状态持久性、实时同步和性能)及其关系(如即时性和一致性之间的矛盾),我们可以做出明智的决定,确定哪种实现最适合我们的需求。
我们不能将这些解决方案视为从简单到复杂的范围,而是可以将它们视为满足特定需求组合的基本模式的不同组合。
这种架构视角不仅可以帮助我们选择正确的方法,还可以帮助我们了解随着需求的变化,我们的解决方案需要如何发展。
结论
使用不同的方法构建购物车后,以下是每种方法的使用时间:
所有方法的完整代码都可以在此 GitHub 存储库中找到:购物车选项
与远程数据库同步
使用 IndexedDB 的一个强大优势是能够通过后台同步实现离线优先功能,