使用 window.open 和 window.postMessage 完成支付
结论
可以用 window.open 打开支付页面,并在支付成功页或取消页通过 window.postMessage 通知 React 应用。不过,这种方式只能改善前端交互,不能单独用来确认支付结果。
一个可靠的支付流程通常是:
- React 应用打开支付窗口。
- 支付处理器将用户重定向到你控制的
success_url或cancel_url。 - 回跳页面通过
window.opener.postMessage()通知主窗口,然后关闭支付窗口。 - 主窗口收到消息后,向自己的后端查询订单状态。
- 后端通过支付处理器的 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 或服务端查询负责确认真实的支付结果。
备注:内容仅供参考。
