PAYATHON 2026

使用 window.open 和 window.postMessage 完成支付

支付阿杰

结论

可以用 window.open 打开支付页面,并在支付成功页或取消页通过 window.postMessage 通知 React 应用。不过,这种方式只能改善前端交互,不能单独用来确认支付结果。

一个可靠的支付流程通常是:

  1. React 应用打开支付窗口。
  2. 支付处理器将用户重定向到你控制的 success_url 或 cancel_url。
  3. 回跳页面通过 window.opener.postMessage() 通知主窗口,然后关闭支付窗口。
  4. 主窗口收到消息后,向自己的后端查询订单状态。
  5. 后端通过支付处理器的 Webhook 或服务端查询接口确认最终支付结果。

浏览器访问了 success_url,只能说明页面完成了跳转,不能证明款项已经成功入账。

为什么不能只依赖 postMessage

postMessage 传递的是浏览器窗口之间的消息,并不是支付处理器提供的服务端支付凭证。前端状态可能因以下情况而失真:

  • 用户直接访问或伪造 success_url。
  • 恶意页面向主窗口发送伪造消息。
  • 用户关闭弹窗,消息未能发送。
  • 网络或浏览器策略阻止了重定向、弹窗或跨窗口通信。
  • 支付结果需要异步确认,页面跳转时订单仍是 pending 状态。
  • 支付已经成功,但支付处理器的 Webhook 还没有到达后端。

因此,前端消息可以用来触发订单状态刷新,但不能据此直接把订单标记为已支付。

推荐流程

1. 由后端创建支付会话

不要完全依赖浏览器拼接金额、订单号和回调地址。React 应用应先请求自己的后端,由后端创建支付会话并返回支付 URL。

POST /api/payments
Content-Type: application/json

{
  "orderId": "order_123"
}

后端创建支付会话时,可以设置类似下面的回调地址:

success_url=https://app.example.com/payment/return?result=success&orderId=order_123
cancel_url=https://app.example.com/payment/return?result=cancel&orderId=order_123

具体参数名称以及能否附加订单标识,取决于所使用的支付处理器。回调 URL 中不要放置密钥或其他敏感信息。

2. React 应用打开支付窗口

尽量在用户点击事件中直接调用 window.open,否则可能触发浏览器的弹窗拦截策略。

import { useEffect, useRef, useState } from "react";

const APP_ORIGIN = window.location.origin;

export default function PaymentButton({ orderId }) {
  const popupRef = useRef(null);
  const [status, setStatus] = useState("idle");

  useEffect(() => {
    async function refreshPaymentStatus(expectedOrderId) {
      setStatus("checking");

      const response = await fetch(
        `/api/payments/${encodeURIComponent(expectedOrderId)}/status`,
        {
          credentials: "include",
        }
      );

      if (!response.ok) {
        throw new Error("Unable to query payment status");
      }

      const payment = await response.json();
      setStatus(payment.status);
    }

    function handleMessage(event) {
      if (event.origin !== APP_ORIGIN) {
        return;
      }

      if (popupRef.current && event.source !== popupRef.current) {
        return;
      }

      const message = event.data;

      if (
        !message ||
        message.type !== "PAYMENT_RETURN" ||
        message.orderId !== orderId
      ) {
        return;
      }

      if (message.result === "cancel") {
        setStatus("cancelled");
        return;
      }

      refreshPaymentStatus(orderId).catch(() => {
        setStatus("check_failed");
      });
    }

    window.addEventListener("message", handleMessage);

    return () => {
      window.removeEventListener("message", handleMessage);

      if (popupRef.current && !popupRef.current.closed) {
        popupRef.current.close();
      }
    };
  }, [orderId]);

  async function startPayment() {
    setStatus("creating");

    // 先同步创建空窗口,降低异步请求后 window.open 被拦截的概率。
    const popup = window.open(
      "about:blank",
      "payment",
      "popup=yes,width=520,height=720"
    );

    if (!popup) {
      setStatus("popup_blocked");
      return;
    }

    popupRef.current = popup;

    try {
      const response = await fetch("/api/payments", {
        method: "POST",
        credentials: "include",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ orderId }),
      });

      if (!response.ok) {
        throw new Error("Unable to create payment");
      }

      const { paymentUrl } = await response.json();

      // paymentUrl 必须来自可信后端,并应使用 HTTPS。
      popup.location.replace(paymentUrl);
      setStatus("pending");
    } catch {
      popup.close();
      popupRef.current = null;
      setStatus("create_failed");
    }
  }

  return (
    <div>
      <button type="button" onClick={startPayment}>
        去支付
      </button>
      <p>支付状态:{status}</p>
    </div>
  );
}

代码先打开 about:blank,再异步获取支付 URL,这样可以让 window.open 留在用户点击事件的调用链中。各浏览器的弹窗策略可能不同,所以仍需准备弹窗被拦截后的提示,或者提供在当前窗口继续支付的方案。

3. 在回跳页面发送消息

success_url 和 cancel_url 应指向你控制的页面。页面读取回跳结果后,再将消息发送给打开它的主窗口。

import { useEffect } from "react";

const APP_ORIGIN = window.location.origin;

export default function PaymentReturnPage() {
  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    const result = params.get("result");
    const orderId = params.get("orderId");

    if (
      window.opener &&
      orderId &&
      (result === "success" || result === "cancel")
    ) {
      window.opener.postMessage(
        {
          type: "PAYMENT_RETURN",
          result,
          orderId,
        },
        APP_ORIGIN
      );

      window.close();
    }
  }, []);

  return <p>正在返回支付页面……</p>;
}

不要将 "*" 作为 postMessage 的 targetOrigin,而要指定主应用的准确源,例如:

window.opener.postMessage(message, "https://app.example.com");

如果回跳页面和主应用不在同一个源,需要单独配置准确的主应用源,不能直接使用回跳页面自身的 window.location.origin。

4. 后端确认支付状态

后端应保存支付会话与内部订单之间的关联,并验证支付处理器发送的 Webhook。主窗口收到 PAYMENT_RETURN 后,只需请求后端查询状态。

伪代码如下:

app.get("/api/payments/:orderId/status", requireUser, async (req, res) => {
  const payment = await paymentRepository.findByOrderId(req.params.orderId);

  if (!payment || payment.userId !== req.user.id) {
    return res.sendStatus(404);
  }

  res.json({
    status: payment.status,
  });
});

Webhook 处理必须按照支付处理器的要求验证签名,并保证幂等:

app.post("/api/payment-webhook", rawBodyMiddleware, async (req, res) => {
  const event = verifyWebhookSignature(req);

  await paymentRepository.applyEventIdempotently(event);

  res.sendStatus(200);
});

verifyWebhookSignature、原始请求体要求和事件字段都取决于具体的支付处理器,应以其官方文档为准。

必须处理的边界情况

用户手动关闭支付窗口

用户可能没有经过 cancel_url 就直接关闭弹窗。主窗口可以定时检查 popup.closed,但弹窗关闭并不代表支付已经取消。用户可能已经付款,只是很快关掉了窗口。因此,检测到窗口关闭后,仍然需要查询后端状态。

const timer = window.setInterval(() => {
  if (popupRef.current?.closed) {
    window.clearInterval(timer);
    // 查询后端,而不是直接认定为 cancelled。
  }
}, 500);

组件卸载或支付流程结束时,记得调用 clearInterval。

异步确认

收到 success 消息后,后端可能暂时仍返回 pending。此时可以进行短时间轮询,例如逐步增加请求间隔并设置超时,不要无限期地高频请求。

弹窗被拦截

如果 window.open 返回 null,应明确提示用户允许弹窗,或者提供“在当前页面继续支付”的降级入口。

window.opener 不可用

以下配置可能导致 window.opener 为 null:

  • 打开窗口时使用了 noopener。
  • 页面设置了不兼容的 Cross-Origin-Opener-Policy。
  • 浏览器或内嵌 WebView 隔离了窗口之间的关系。

这些情况需要在目标浏览器和 WebView 环境中实际验证。如果安全策略要求隔离 window.opener,可以改用后端状态轮询,不再依赖 postMessage。

安全校验

主窗口收到消息后,至少要检查:

  • event.origin 是否为预期源。
  • event.source 是否为当前支付窗口。
  • event.data 的结构和类型是否正确。
  • orderId 是否属于当前用户和当前支付流程。

即使这些检查全部通过,也只能把消息当作重新查询后端的触发信号。

是否一定要使用弹窗

不一定。如果支付处理器提供官方嵌入式组件、SDK 或前端确认流程,通常应优先使用其支持的集成方式。这些方案可能已经处理了身份验证、重定向、浏览器兼容性和支付状态同步。

如果支付处理器只提供托管支付 URL,那么“弹窗 + 回跳页面 + postMessage + 服务端确认”是可行的方案。postMessage 负责交互通知,Webhook 或服务端查询负责确认真实的支付结果。

备注:内容仅供参考。