重新加载 Commerce.js Checkout 页面时出现错误
结论
报错的原因是加载顺序。
页面刷新后,App 首次渲染时,cart 还是空对象 {}。Checkout 此时已经挂载,并执行了依赖数组为空的 useEffect。由于 cart.line_items 尚不存在,读取 cart.line_items.length 就会报错。
需要修改两处:
- 先确认
cart已经加载,再安全地访问line_items。 - 生成 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。
备注:内容仅供参考。
