如何在 React 组件中集成 MercadoPago Payment SDK
结论
MercadoPago.js V2 应作为外部 SDK 异步加载。只有满足以下两个条件,才能初始化 checkout:
- 后端已经返回有效的
preferenceId。 - SDK 已加载完成,并且
window.MercadoPago可用。
支付按钮应挂载在 <div> 容器中,不要使用 <form>。否则,浏览器可能触发表单的原生提交行为,向当前页面的来源发送额外请求,例如 localhost:3001。
推荐实现
先封装一个函数,确保 SDK 只加载一次:
let mercadoPagoScriptPromise;
function loadMercadoPagoScript() {
if (window.MercadoPago) {
return Promise.resolve();
}
if (mercadoPagoScriptPromise) {
return mercadoPagoScriptPromise;
}
mercadoPagoScriptPromise = new Promise((resolve, reject) => {
const existingScript = document.querySelector(
'script[src="https://sdk.mercadopago.com/js/v2"]'
);
if (existingScript) {
existingScript.addEventListener("load", resolve, { once: true });
existingScript.addEventListener("error", reject, { once: true });
return;
}
const script = document.createElement("script");
script.src = "https://sdk.mercadopago.com/js/v2";
script.async = true;
script.onload = () => resolve();
script.onerror = () => {
mercadoPagoScriptPromise = undefined;
reject(new Error("MercadoPago SDK 加载失败"));
};
document.head.appendChild(script);
});
return mercadoPagoScriptPromise;
}
组件可以这样写:
import { useEffect, useState } from "react";
const FORM_ID = "mercado-pago-checkout";
const PUBLIC_KEY_VENDEDOR_PRUEBA =
"TEST-001debb2-d8d5-40a4-953f-8ca65aaa0fa0";
export default function Suscripcion() {
const productId = 1122;
const [preferenceId, setPreferenceId] = useState(null);
const [error, setError] = useState(null);
// 向后端请求创建 preference
useEffect(() => {
const controller = new AbortController();
async function createPreference() {
try {
const response = await fetch("http://localhost:3000/api/orders", {
method: "POST",
mode: "cors",
signal: controller.signal,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
productId,
name: "agustin",
lastname: "miotti",
email: "amiotti@secco.com.ar",
}),
});
if (!response.ok) {
throw new Error(`创建 preference 失败:HTTP ${response.status}`);
}
const data = await response.json();
if (!data.id) {
throw new Error("后端响应中缺少 preference id");
}
setPreferenceId(data.id);
} catch (err) {
if (err.name !== "AbortError") {
console.error(err);
setError(err.message);
}
}
}
createPreference();
return () => controller.abort();
}, [productId]);
// preferenceId 就绪后加载 SDK 并初始化 checkout
useEffect(() => {
if (!preferenceId) {
return;
}
let cancelled = false;
async function initializeCheckout() {
try {
await loadMercadoPagoScript();
if (cancelled) {
return;
}
const container = document.getElementById(FORM_ID);
if (!container) {
throw new Error("找不到 MercadoPago checkout 容器");
}
// 避免开发模式下重复渲染按钮
container.replaceChildren();
const mp = new window.MercadoPago(
PUBLIC_KEY_VENDEDOR_PRUEBA,
{
locale: "es-AR",
}
);
mp.checkout({
preference: {
id: preferenceId,
},
render: {
container: `#${FORM_ID}`,
label: "Pagar",
},
});
} catch (err) {
console.error(err);
setError(err.message);
}
}
initializeCheckout();
return () => {
cancelled = true;
const container = document.getElementById(FORM_ID);
if (container) {
container.replaceChildren();
}
};
}, [preferenceId]);
return (
<section>
{error && <p role="alert">{error}</p>}
{!preferenceId && !error && <p>正在准备支付信息……</p>}
<div id={FORM_ID} />
</section>
);
}
原实现中的几个问题
不要将 useEffect 回调直接声明为 async
不建议这样写:
useEffect(async () => {
// ...
}, []);
useEffect 的返回值只能是清理函数或 undefined,但 async 函数始终会返回 Promise。可以在 effect 内定义异步函数,再调用它:
useEffect(() => {
async function run() {
// ...
}
run();
}, []);
made 是拼写错误
原代码写成了:
made: "cors"
应改为:
mode: "cors"
不过,请求能否跨域成功,最终仍取决于后端返回的 CORS 响应头。
不要在请求中设置 Access-Control-Allow-Origin
浏览器请求端不应设置下面这个请求头:
"Access-Control-Allow-Origin": "*"
Access-Control-Allow-Origin 是服务器返回的响应头。后端需要允许 React 开发服务器的来源,例如:
Access-Control-Allow-Origin: http://localhost:3001
如果请求使用 Cookie 或其他凭据,就不能同时使用通配符 *,还要正确配置 credentials。
setPreferenceId 不需要 await
状态更新函数不会返回 Promise,因此:
setPreferenceId(await data.id);
应改为:
setPreferenceId(data.id);
不要把支付按钮挂载到空的 <form>
原代码返回的是:
<form id={FORM_ID} method="GET" />
SDK 创建的按钮如果位于表单内,点击时可能触发表单提交。表单没有设置 action 时,浏览器会默认提交到当前页面地址,也就是 React 应用所在的来源,例如 http://localhost:3001。
这里使用普通的渲染容器即可:
<div id={FORM_ID} />
为什么会请求 localhost:3001
示例中的 fetch 明确请求:
http://localhost:3000/api/orders
仅看这段 fetch 代码,请求目标不会自动变成 localhost:3001。出现对 localhost:3001 的请求,通常有以下原因:
- 支付按钮位于
<form>内,点击后触发了原生表单提交。 - 后端返回了重定向响应,把请求重定向到
localhost:3001。 - 项目中还有使用相对地址的请求,例如
fetch("/api/orders")。这类请求会发送到当前页面所在的端口。 - 开发服务器的代理、Service Worker 或其他代码改写了请求。
- 初始化执行了多次,页面中还留有旧按钮或事件监听器。
可以在浏览器开发者工具的 Network 面板中检查该请求的 Initiator 和重定向链。如果请求确实是 POST,原示例中的 <form method="GET"> 就不足以解释它。此时应重点排查请求的发起方、后端重定向,以及项目中的其他网络请求代码。
还需要注意
PUBLIC_KEY 可以放在前端,但创建支付 preference 所需的访问令牌或私钥必须保存在后端。正常流程如下:
React 前端
→ 请求自己的后端创建 preference
→ 后端使用私密凭据调用 MercadoPago
→ 后端只把 preferenceId 返回前端
→ 前端使用 PUBLIC_KEY 和 preferenceId 初始化 checkout
原代码的公钥字符串后面还有一个独立的 7:
const PUBLIC_KEY_VENDEDOR_PRUEBA = "...";7
在某些情况下,它会被解析成一个没有实际作用的数字表达式。应将其删除,以免引起混淆。
备注:内容仅供参考。
