PAYATHON 2026

重新加载 Commerce.js Checkout 页面时出现错误

支付阿杰

结论

报错的原因是加载顺序。

页面刷新后,App 首次渲染时,cart 还是空对象 {}。Checkout 此时已经挂载,并执行了依赖数组为空的 useEffect。由于 cart.line_items 尚不存在,读取 cart.line_items.length 就会报错。

需要修改两处:

  1. 先确认 cart 已经加载,再安全地访问 line_items。
  2. 生成 token 的 useEffect 应依赖购物车数据,不能只在组件首次挂载时执行。

为什么点击跳转正常,刷新却报错

从购物车页面点击 Checkout 时,fetchCart() 通常已经执行完毕。传给 Checkout 的 cart 包含 id 和 line_items,因此可以正常生成 token。

直接刷新 Checkout 页面时,状态会重新初始化:

const [cart, setCart] = useState({});

首次渲染期间:

cart.line_items // undefined

因此,下面的判断并不安全:

if (cart.line_items.length) {

即使 fetchCart() 随后异步更新了 cart,Checkout 中的 effect 使用的是空依赖数组:

useEffect(() => {
    generateCheckoutToken();
}, []);

这个 effect 只会在组件挂载后运行一次。购物车加载完成后,它不会再次运行。

推荐修改方式

可以在 useEffect 中检查所需字段,等购物车数据就绪后再生成 token:

import React, { useEffect, useState } from 'react';
import { commerce } from '../../lib/commerce';
import './styles.css';

const Checkout = ({ cart }) => {
    const [checkoutToken, setCheckoutToken] = useState(null);
    const [tokenLoading, setTokenLoading] = useState(false);
    const [tokenError, setTokenError] = useState(null);

    useEffect(() => {
        if (!cart?.id || !cart?.line_items?.length) {
            return;
        }

        let cancelled = false;

        const generateCheckoutToken = async () => {
            try {
                setTokenLoading(true);
                setTokenError(null);

                const token = await commerce.checkout.generateToken(cart.id, {
                    type: 'cart',
                });

                if (!cancelled) {
                    setCheckoutToken(token);
                }
            } catch (error) {
                if (!cancelled) {
                    console.error(
                        'There was an error in generating a token',
                        error
                    );
                    setTokenError(error);
                }
            } finally {
                if (!cancelled) {
                    setTokenLoading(false);
                }
            }
        };

        generateCheckoutToken();

        return () => {
            cancelled = true;
        };
    }, [cart?.id, cart?.line_items?.length]);

    if (!cart?.id) {
        return <p>Loading cart...</p>;
    }

    if (!cart.line_items?.length) {
        return <p>Your cart is empty.</p>;
    }

    if (tokenLoading) {
        return <p>Preparing checkout...</p>;
    }

    if (tokenError) {
        return <p>Unable to prepare checkout. Please try again.</p>;
    }

    if (!checkoutToken) {
        return null;
    }

    return <div>Checkout</div>;
};

export default Checkout;

下面这段判断会在购物车尚未加载或购物车为空时直接退出,避免调用 API:

if (!cart?.id || !cart?.line_items?.length) {
    return;
}

依赖项则使用购物车中与生成 token 有关的字段:

[cart?.id, cart?.line_items?.length]

fetchCart() 完成并更新 cart 后,effect 会重新执行。与直接依赖整个 cart 对象相比,这种写法也能减少对象引用变化导致的重复 token 请求。

在 App 中明确记录购物车加载状态

只用 cart.id 判断购物车是否加载通常已经足够。不过,单独维护加载状态更清楚,也能区分加载中和加载失败。

function App() {
    const [products, setProducts] = useState([]);
    const [cart, setCart] = useState(null);
    const [productLoading, setProductLoading] = useState(false);
    const [cartLoading, setCartLoading] = useState(true);
    const [cartError, setCartError] = useState(null);

    const fetchCart = async () => {
        try {
            setCartLoading(true);
            setCartError(null);

            const retrievedCart = await commerce.cart.retrieve();
            setCart(retrievedCart);
        } catch (error) {
            console.error('There was an error fetching the cart', error);
            setCartError(error);
        } finally {
            setCartLoading(false);
        }
    };

    useEffect(() => {
        setProductLoading(true);
        fetchProducts();
        fetchCart();
    }, []);

    // ...
}

渲染 Checkout 路由时,把这些状态一并传入:

<Route
    path="/checkout"
    element={
        <Checkout
            cart={cart}
            cartLoading={cartLoading}
            cartError={cartError}
        />
    }
/>

Checkout 可以先处理购物车的不同状态:

const Checkout = ({ cart, cartLoading, cartError }) => {
    if (cartLoading) {
        return <p>Loading cart...</p>;
    }

    if (cartError) {
        return <p>Unable to load the cart.</p>;
    }

    if (!cart?.line_items?.length) {
        return <p>Your cart is empty.</p>;
    }

    // Checkout 内容
};

不要因为前面有条件 return,就把 useEffect 放在这些条件之后。React Hooks 必须在每次渲染时按相同顺序调用。更稳妥的做法还是在 effect 内判断条件。

console.log(checkoutToken) 为什么显示旧值

原代码还有一个容易造成误解的地方:

setCheckoutToken(token);
console.log(checkoutToken);

React 更新状态时,不会立即修改当前函数作用域中的变量。因此,紧接着读取 checkoutToken,通常得到的还是上一次渲染时的值。

如果只是要查看 API 返回的内容,直接输出 token 即可:

setCheckoutToken(token);
console.log(token);

如果需要在状态更新后执行逻辑,可以用另一个 effect 监听:

useEffect(() => {
    if (checkoutToken) {
        console.log(checkoutToken);
    }
}, [checkoutToken]);

还需要注意的情况

空购物车不能生成有效的结账流程

购物车加载完成后,line_items 仍可能是空数组。不能只检查 cart 是否存在,还要判断:

cart.line_items?.length > 0

购物车为空时,应显示提示或跳回购物车页面,不要调用 generateToken()。

避免把 checkoutToken 加入生成 token 的依赖项

下面的写法可能导致循环:

useEffect(() => {
    generateCheckoutToken();
}, [checkoutToken]);

生成 token 后会更新 checkoutToken。状态变化又会触发 effect,随后继续请求新的 token。生成逻辑应依赖购物车数据,而不是生成结果。

开发环境可能看到重复请求

如果项目使用 React 18 的 StrictMode,开发环境可能会额外执行一次 effect,以发现不安全的副作用。即使依赖项设置正确,也可能看到重复的 token 请求。

遇到这种情况,可以记录已经处理过的购物车 ID:

import React, { useEffect, useRef, useState } from 'react';

const Checkout = ({ cart }) => {
    const [checkoutToken, setCheckoutToken] = useState(null);
    const generatedForCart = useRef(null);

    useEffect(() => {
        if (!cart?.id || !cart?.line_items?.length) {
            return;
        }

        if (generatedForCart.current === cart.id) {
            return;
        }

        generatedForCart.current = cart.id;

        commerce.checkout
            .generateToken(cart.id, { type: 'cart' })
            .then(setCheckoutToken)
            .catch((error) => {
                generatedForCart.current = null;
                console.error(
                    'There was an error in generating a token',
                    error
                );
            });
    }, [cart?.id, cart?.line_items?.length]);

    return <div>Checkout</div>;
};

修复的重点是安全检查 cart.id 和 cart.line_items,并让 effect 在购物车异步加载完成后重新执行。这样,无论用户从购物车页面进入 Checkout,还是直接刷新 Checkout 页面,都不会再读取空对象上的 line_items.length。

备注:内容仅供参考。