PAYATHON 2026

如何在 React 组件中集成 MercadoPago Payment SDK

支付小周

结论

MercadoPago.js V2 应作为外部 SDK 异步加载。只有满足以下两个条件,才能初始化 checkout:

  1. 后端已经返回有效的 preferenceId。
  2. 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

在某些情况下,它会被解析成一个没有实际作用的数字表达式。应将其删除,以免引起混淆。

备注:内容仅供参考。