浏览器状态架构:通过购物车实现的模式驱动探索

有没有想过在 React 应用程序中处理购物车状态的最佳方法是什么?你应该使用 Context 吗?LocalStorage?或者也许是更强大的东西,比如 IndexedDB?在本文中,我们将使用不同的方法构建相同的购物车,并通过真实的工作代码比较它们的优缺点。

总结

  • 上下文对于实时更新非常有用,但在刷新时会丢失状态
  • 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 (
        

    Products

    {SAMPLE_PRODUCTS.map(product => (

    {product.name}

    ${product.price}

    ))}
    ); }; // Shopping Cart Component const ShoppingCart = () => { const { items, updateQuantity, removeItem, total } = useContext(CartContext); 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)}

    )}
    ); }; // Main App Component const App = () => { return (

    Shopping Cart Demo

    ); }; export default App;
    ### 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 (
        

    Products

    {SAMPLE_PRODUCTS.map(product => (

    {product.name}

    ${product.price}

    ))}
    ); }; // Shopping Cart Component const ShoppingCart = () => { const { items, updateQuantity, removeItem, clearCart, total } = useContext(CartContext); return (

    Shopping Cart

    {items.length > 0 && ( )}
    {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)}

    )}
    ); }; // Main App Component const App = () => { return (

    Shopping Cart Demo (with LocalStorage)

    ); }; export default App;

    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 (
        

    Products

    {SAMPLE_PRODUCTS.map(product => (

    {product.name}

    ${product.price}

    ))}
    ); }; // Shopping Cart Component const ShoppingCart = () => { const { items, loading, updateQuantity, removeItem, clearCart, total } = useContext(CartContext); if (loading) { return (

    Shopping Cart

    Loading cart...

    ); } return (

    Shopping Cart

    {items.length > 0 && ( )}
    {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)}

    )}
    ); }; // Main App Component const App = () => { return (

    Shopping Cart Demo (with IndexedDB)

    ); }; export default App;

    混合方法的优点

    ✅ 刷新和浏览器重启后依然有效

    ✅ 离线工作

    ✅ 高效处理大型数据集

    ✅ 跨表同步

    ✅ 乐观更新,获得更好的用户体验

    ✅ 结构化查询和索引

    ✅ 使用回滚来处理错误

    混合方法的缺点

    ❌ 实现起来更复杂

    ❌需要了解 IndexedDB

    ❌ 稍微多一些样板代码

    超越实施:架构视角

    在深入研究具体实现之前,必须了解基于浏览器的状态管理提出了一组有限的基本挑战和相应的解决方案。

    浏览器环境为我们提供了独特的功能(Context、localStorage、IndexedDB)和约束(存储限制、选项卡隔离、离线场景),创建了明确的设计空间。

    我们探索的每种解决方案(从简单的 Context 到复杂的 IndexedDB 实现)都不仅仅是一种编码模式,而是对特定架构需求的具体响应。通过了解这些反复出现的属性(如状态持久性、实时同步和性能)及其关系(如即时性和一致性之间的矛盾),我们可以做出明智的决定,确定哪种实现最适合我们的需求。

    我们不能将这些解决方案视为从简单到复杂的范围,而是可以将它们视为满足特定需求组合的基本模式的不同组合。

    这种架构视角不仅可以帮助我们选择正确的方法,还可以帮助我们了解随着需求的变化,我们的解决方案需要如何发展。

    结论

    使用不同的方法构建购物车后,以下是每种方法的使用时间:

  • 仅使用上下文:适用于没有持久性需求的小型应用程序
  • 使用 LocalStorage + Context:适用于具有简单存储需求的中型应用
  • 使用 IndexedDB + 自定义 Hook:适用于需要可靠性和性能的生产应用程序
  • 使用混合方法:实现功能和复杂性的最佳平衡
  • 所有方法的完整代码都可以在此 GitHub 存储库中找到:购物车选项

    与远程数据库同步

    使用 IndexedDB 的一个强大优势是能够通过后台同步实现离线优先功能,