You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用Signals与LocalStorage实现购物车:组件未响应信号问题

购物车信号响应异常:总价实时更新但商品列表无刷新

我用Signals结合LocalStorage实现购物车,定义了totalAmount和cart两个信号。总价能正常响应式更新,但购物车商品条目无反应——更新数量或删除商品时,总价无需刷新页面就会变化,但页面上的商品数量和条目始终保持原样。我查阅了相关教程和文档,认为代码逻辑没问题,但就是无法实现商品列表的响应式更新。


cart.js 代码

import { signal, effect } from '@preact/signals-react';

const initialCart = JSON.parse(localStorage.getItem('cart')) || [];
const initialTotalAmount = JSON.parse(localStorage.getItem('totalAmount')) || 0;

export const cart = signal(initialCart);
export const totalAmount = signal(initialTotalAmount);

export const addToCart = (item) => {
  const newCart = [...cart.value];
  const existingItem = newCart.find(cartItem => cartItem.id === item.id);
  if (existingItem) {
    existingItem.quantity += 1;
  } else {
    newCart.push({ ...item, quantity: 1 });
  }
  cart.value = newCart;
  calculateTotal();
};

export const removeFromCart = (itemId) => {
  cart.value = cart.value.filter(item => item.id !== itemId);
  calculateTotal();
};

export const updateQuantity = (itemId, quantity) => {
  const newCart = [...cart.value];
  const item = newCart.find(item => item.id === itemId);
  if (item) {
    item.quantity = quantity;
  }
  cart.value = newCart;
  calculateTotal();
};

export const clearCart = () => {
  cart.value = [];
  totalAmount.value = 0;
};

export const calculateTotal = () => {
  totalAmount.value = cart.value.reduce((acc, item) => {
    const itemTotal = parseFloat(item.attributes.price) * item.quantity;
    return acc + itemTotal;
  }, 0);
};

effect(() => {
  localStorage.setItem('cart', JSON.stringify(cart.value));
});

effect(() => {
  localStorage.setItem('totalAmount', JSON.stringify(totalAmount.value));
});

菜单组件(功能正常)

'use client';

import React, { useEffect, useState } from 'react';
import { Container, Typography, Card, CardContent, CardMedia, Button, Grid } from '@mui/material';
import { fetchMenuItems } from '../../lib/api';
import { addToCart } from '../../signals/cart';

const Menu = () => {
  const [menuItems, setMenuItems] = useState([]);

  useEffect(() => {
    const getMenuItems = async () => {
      try {
        const items = await fetchMenuItems();
        setMenuItems(items.data);
      } catch (error) {
        console.error("Error fetching menu items:", error);
      }
    };

    getMenuItems();
  }, []);

  return (
    <Container maxWidth="lg">
      <Typography variant="h4" gutterBottom>Menu</Typography>
      <Grid container spacing={4}>
        {menuItems.map((item) => (
          <Grid item key={item.id} xs={12} sm={6} md={4}>
            <Card>
              <CardMedia
                component="img"
                height="140"
                image={`http://localhost:1337${item.attributes.image.data.attributes.url}`}
                alt={item.attributes.name}
              />
              <CardContent>
                <Typography variant="h5">{item.attributes.name}</Typography>
                <Typography variant="body2" color="textSecondary">{item.attributes.description}</Typography>
                <Typography variant="h6">${item.attributes.price}</Typography>
                <Typography variant="body2">Size: {item.attributes.size}</Typography>
                <div>
                  <Button variant="contained" color="primary" onClick={() => addToCart(item)}>Add to Cart</Button>
                </div>
              </CardContent>
            </Card>
          </Grid>
        ))}
      </Grid>
    </Container>
  );
};

export default Menu;

购物车组件(响应异常)

'use client';

import React, { useEffect } from 'react';
import { Container, Typography, List, ListItem, ListItemText, IconButton, Button } from '@mui/material';
import { cart, totalAmount, removeFromCart, updateQuantity, clearCart } from '../../signals/cart';
import { useComputed } from '@preact/signals-react';

const Cart = () => {
  const cartItems = useComputed(() => cart.value);
  const total = useComputed(() => totalAmount.value);

  const handleIncreaseQuantity = (id) => {
    const item = cartItems.value.find(item => item.id === id);
    if (item) {
      updateQuantity(id, item.quantity + 1);
    }
  };

  const handleDecreaseQuantity = (id) => {
    const item = cartItems.value.find(item => item.id === id);
    if (item && item.quantity > 1) {
      updateQuantity(id, item.quantity - 1);
    }
  };

  useEffect(() => {
    // Debug log to verify the totalAmount and cartItems values
    console.log("Cart Items:", cartItems.value);
    console.log("Total Amount:", total);
  }, [cartItems, total]);

  return (
    <Container maxWidth="sm">
      <Typography variant="h4" gutterBottom>Shopping Cart</Typography>
      <List>
        {cartItems.value.length > 0 ? (
          cartItems.value.map((item) => (
            <ListItem key={item.id} divider>
              <ListItemText
                primary={item.attributes.name}
                secondary={`Price: $${item.attributes.price} x Quantity: ${item.quantity}`}
              />
              <div>
                <IconButton edge="start" aria-label="decrease" onClick={() => handleDecreaseQuantity(item.id)}>
                  ➖
                </IconButton>
                <IconButton edge="end" aria-label="increase" onClick={() => handleIncreaseQuantity(item.id)}>
                  ➕
                </IconButton>
                <IconButton edge="end" aria-label="delete" onClick={() => removeFromCart(item.id)}>
                  🗑️
                </IconButton>
              </div>
            </ListItem>
          ))
        ) : (
          <Typography variant="h6">Your cart is empty.</Typography>
        )}
      </List>
      <Typography variant="h6">Total: ${total}</Typography>
      <Button variant="contained" color="primary" href="/checkout">Proceed to Checkout</Button>
      <Button variant="contained" color="secondary" onClick={clearCart}>Clear Cart</Button>
    </Container>
  );
};

export default Cart;

调试补充:console.log(cartItems.value)输出

[
    {
        "id": 2,
        "attributes": {
            "name": "Hamburger",
            "description": "A delicious burger",
            "price": 5,
            "size": "Large",
            "category": "Entrees",
            "createdAt": "2024-07-10T20:37:57.287Z",
            "updatedAt": "2024-07-10T20:37:58.201Z",
            "publishedAt": "2024-07-10T20:37:58.200Z",
            "image": {
                "data": {
                    "id": 1,
                    "attributes": {
                        "name": "pizza-5275191_1920.jpg",
                        "alternativeText": null,
                        "caption": null,
                        "width": 1920,
                        "height": 1282,
                        "formats": {
                            "thumbnail": {
                                "name": "thumbnail_pizza-5275191_1920.jpg",
                                "hash": "thumbnail_pizza_5275191_1920_00d531d4b4",
                                "ext": ".jpg",
                                "mime": "image/jpeg",
                                "path": null,
                                "width": 234,
                                "height": 156,
                                "size": 12.06,
                                "sizeInBytes": 12064,
                                "url": "/uploads/thumbnail_pizza_5275191_1920_00d531d4b4.jpg"
                            },
                            "small": {
                                "name": "small_pizza-5275191_1920.jpg",
                                "hash": "small_pizza_5275191_1920_00d531d4b4",
                                "ext": ".jpg",
                                "mime": "image/jpeg",
                                "path": null,
                                "width": 500,
                                "height": 334,
                                "size": 41.47,
                                "sizeInBytes": 41472,
                                "url": "/uploads/small_pizza_5275191_1920_00d531d4b4.jpg"
                            },
                            "large": {
                                "name": "large_pizza-5275191_1920.jpg",
                                "hash": "large_pizza_5275191_1920_00d531d4b4",
                                "ext": ".jpg",
                                "mime": "image/jpeg",
                                "path": null,
                                "width": 1000,
                                "height": 668,
                                "size": 123.53,
                                "sizeInBytes": 123526,
                                "url": "/uploads/large_pizza_5275191_1920_00d531d4b4.jpg"
                            },
                            "medium": {
                                "name": "medium_pizza-5275191_1920.jpg",
                                "hash": "medium_pizza_5275191_1920_00d531d4b4",
                                "ext": ".jpg",
                                "mime": "image/jpeg",
                                "path": null,
                                "width": 750,
                                "height": 501,
                                "size": 79.1,
                                "sizeInBytes": 79099,
                                "url": "/uploads/medium_pizza_5275191_1920_00d531d4b4.jpg"
                            }
                        },
                        "hash": "pizza_5275191_1920_00d531d4b4",
                        "ext": ".jpg",
                        "mime": "image/jpeg",
                        "size": 333.61,
                        "url": "/uploads/pizza_5275191_1920_00d531d4b4.jpg",
                        "previewUrl": null,
                        "provider": "local",
                        "provider_metadata": null,
                        "createdAt": "2024-07-10T19:18:03.664Z",
                        "updatedAt": "2024-07-10T20:37:48.434Z"
                    }
                }
            }
        },
        "quantity": 3
    }
]

问题原因及修复方案

核心问题

你在购物车组件中直接访问信号的.value属性,破坏了Signals的响应式追踪机制:

  • useComputed返回的是信号对象,但你每次都通过.value获取当前值,导致React无法感知信号的后续变化。
  • 总价能正常更新是因为你在JSX中直接使用了total信号对象(Signals会自动解包),而商品列表用的是cartItems.value,相当于只取了一次静态值。

修复方式(二选一)

方式1:移除useComputed,直接使用原始信号

直接在组件中引用cart.value和totalAmount.value,确保组件能追踪信号变化:

'use client';

import React from 'react';
import { Container, Typography, List, ListItem, ListItemText, IconButton, Button } from '@mui/material';
import { cart, totalAmount, removeFromCart, updateQuantity, clearCart } from '../../signals/cart';

const Cart = () => {
  const handleIncreaseQuantity = (id) => {
    const item = cart.value.find(item => item.id === id);
    if (item) {
      updateQuantity(id, item.quantity + 1);
    }
  };

  const handleDecreaseQuantity = (id) => {
    const item = cart.value.find(item => item.id === id);
    if (item && item.quantity > 1) {
      updateQuantity(id, item.quantity - 1);
    }
  };

  return (
    <Container maxWidth="sm">
      <Typography variant="h4" gutterBottom>Shopping Cart</Typography>
      <List>
        {cart.value.length > 0 ? (
          cart.value.map((item) => (
            <ListItem key={item.id} divider>
              <ListItemText
                primary={item.attributes.name}
                secondary={`Price: $${item.attributes.price} x Quantity: ${item.quantity}`}
              />
              <div>
                <IconButton edge="start" aria-label="decrease" onClick={() => handleDecreaseQuantity(item.id)}>
                  ➖
                </IconButton>
                <IconButton edge="end" aria-label="increase" onClick={() => handleIncreaseQuantity(item.id)}>
                  ➕
                </IconButton>
                <IconButton edge="end" aria-label="delete" onClick={() => removeFromCart(item.id)}>
                  🗑️
                </IconButton>
              </div>
            </ListItem>
          ))
        ) : (
          <Typography variant="h6">Your cart is empty.</Typography>
        )}
      </List>
      <Typography variant="h6">Total: ${totalAmount.value}</Typography>
      <Button variant="contained" color="primary" href="/checkout">Proceed to Checkout</Button>
      <Button variant="contained" color="secondary" onClick={clearCart}>Clear Cart</Button>
    </Container>
  );
};

export default Cart;
方式2:保留useComputed,直接使用信号对象而非.value

Signals在React中会自动解包信号对象,所以在JSX中直接使用cartItems和total即可:

const Cart = () => {
  const cartItems = useComputed(() => cart.value);
  const total = useComputed(() => totalAmount.value);

  // ... 其他逻辑不变

  return (
    <Container maxWidth="sm">
      <Typography variant="h4" gutterBottom>Shopping Cart</Typography>
      <List>
        {cartItems.length > 0 ? (
          cartItems.map((item) => (
            // ... 商品条目渲染逻辑
          ))
        ) : (
          <Typography variant="h6">Your cart is empty.</Typography>
        )}
      </List>
      <Typography variant="h6">Total: ${total}</Typography>
      // ... 其他按钮
    </Container>
  );
};

另外,建议检查@preact/signals-react版本,确保使用最新版,旧版本可能存在自动解包的兼容性问题。


内容的提问来源于stack exchange,提问作者majordomo

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.21 05:28:11