h5支付页面如何自动调起支付弹窗并在放弃支付后重新调起?
结论
H5 付款页首次打开时,可以尝试自动调起支付弹窗。用户取消支付并返回页面后,记录取消时间,满 3 分钟再尝试调起。
不要只依赖 setTimeout(180000)。页面进入后台后,浏览器可能暂停定时器,也可能延迟执行。更可靠的做法是保存“下次调起时间”,并在 pageshow、visibilitychange 事件触发时重新计算剩余时间。
微信、支付宝和普通浏览器对自动弹窗及支付调用的限制并不相同。有些环境要求用户点击后才能发起支付,因此无法保证全程自动调起。页面必须保留“立即支付”按钮,以便自动调用失败时让用户手动操作。
实现思路
支付流程可以分为以下几个状态:
- 页面加载完成后,尝试调起支付。
- 调用支付 SDK,等待成功、失败或取消结果。
- 用户取消支付后,记录下一次调起时间:
nextRetryAt = 当前时间 + 3 分钟
- 如果页面仍在前台,使用定时器等待。
- 用户返回付款页,或页面从后台恢复到前台时,根据
nextRetryAt重新计算剩余时间。 - 到达重试时间后,先查询订单状态。确认订单仍未支付且没有过期,再重新创建支付参数并调起支付。
通用代码示例
以下示例不绑定具体的支付平台。createPayment、invokePayment 和 queryOrderStatus 需要替换为项目实际使用的接口和 SDK 调用。
<button id="payButton" type="button">立即支付</button>
<p id="paymentTip"></p>
const RETRY_INTERVAL = 3 * 60 * 1000;
const RETRY_STORAGE_KEY = "payment_next_retry_at";
let retryTimer = null;
let invoking = false;
const payButton = document.getElementById("payButton");
const paymentTip = document.getElementById("paymentTip");
function setTip(message) {
paymentTip.textContent = message;
}
function saveNextRetryTime() {
const nextRetryAt = Date.now() + RETRY_INTERVAL;
sessionStorage.setItem(RETRY_STORAGE_KEY, String(nextRetryAt));
return nextRetryAt;
}
function clearNextRetryTime() {
sessionStorage.removeItem(RETRY_STORAGE_KEY);
if (retryTimer !== null) {
clearTimeout(retryTimer);
retryTimer = null;
}
}
function scheduleRetry() {
if (retryTimer !== null) {
clearTimeout(retryTimer);
retryTimer = null;
}
const nextRetryAt = Number(
sessionStorage.getItem(RETRY_STORAGE_KEY)
);
if (!Number.isFinite(nextRetryAt)) {
return;
}
const remaining = nextRetryAt - Date.now();
if (remaining <= 0) {
retryPayment();
return;
}
setTip(`支付已取消,稍后将再次尝试调起支付。`);
retryTimer = window.setTimeout(() => {
retryTimer = null;
retryPayment();
}, remaining);
}
async function retryPayment() {
if (document.visibilityState !== "visible") {
return;
}
const nextRetryAt = Number(
sessionStorage.getItem(RETRY_STORAGE_KEY)
);
if (!Number.isFinite(nextRetryAt) || Date.now() < nextRetryAt) {
scheduleRetry();
return;
}
// 先清除本轮计划,避免多个生命周期事件重复触发。
clearNextRetryTime();
try {
const order = await queryOrderStatus();
if (order.status === "PAID") {
setTip("订单已支付,请勿重复付款。");
return;
}
if (order.status === "CLOSED" || order.status === "EXPIRED") {
setTip("订单已关闭或过期,请重新下单。");
return;
}
await startPayment();
} catch (error) {
console.error("重新调起支付失败:", error);
setTip("暂时无法自动调起支付,请点击“立即支付”。");
}
}
async function startPayment() {
if (invoking) {
return;
}
invoking = true;
payButton.disabled = true;
try {
// 建议每次调起前由服务端返回有效的支付参数。
const paymentParams = await createPayment();
const result = await invokePayment(paymentParams);
if (result.status === "success") {
clearNextRetryTime();
setTip("支付成功,正在确认订单状态……");
await confirmPaymentResult();
return;
}
if (result.status === "cancel") {
saveNextRetryTime();
setTip("你已取消支付,3 分钟后将再次尝试调起。");
scheduleRetry();
return;
}
setTip("支付未完成,请点击“立即支付”重试。");
} catch (error) {
console.error("调起支付失败:", error);
setTip("未能调起支付,请点击“立即支付”重试。");
} finally {
invoking = false;
payButton.disabled = false;
}
}
payButton.addEventListener("click", () => {
startPayment();
});
// 从支付应用或浏览器历史记录返回时触发。
window.addEventListener("pageshow", () => {
scheduleRetry();
});
// 页面从后台恢复到前台时重新校准时间。
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
scheduleRetry();
}
});
// 首次进入页面时尝试调起。
// 如果目标环境要求用户手势,此调用可能被拦截,按钮仍可正常使用。
window.addEventListener("DOMContentLoaded", () => {
startPayment().catch((error) => {
console.error(error);
});
});
接口函数可以这样封装:
async function createPayment() {
const response = await fetch("/api/payment/create", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
credentials: "include",
body: JSON.stringify({
orderId: getCurrentOrderId()
})
});
if (!response.ok) {
throw new Error("创建支付参数失败");
}
return response.json();
}
async function queryOrderStatus() {
const orderId = encodeURIComponent(getCurrentOrderId());
const response = await fetch(`/api/orders/${orderId}/status`, {
credentials: "include",
cache: "no-store"
});
if (!response.ok) {
throw new Error("查询订单状态失败");
}
return response.json();
}
invokePayment 需要根据实际平台实现。如果支付 SDK 支持取消回调,可以把回调转换成 Promise:
function invokePayment(paymentParams) {
return new Promise((resolve, reject) => {
paymentSdk.pay({
...paymentParams,
success() {
resolve({ status: "success" });
},
cancel() {
resolve({ status: "cancel" });
},
fail(error) {
reject(error);
}
});
});
}
其中,paymentSdk.pay 只是接口结构示例,并不是微信或支付宝的真实 API 名称。实际接入时,需要替换为对应平台提供的正式 SDK 调用方式。
自动调起受限时的处理
浏览器通常会限制没有用户操作的弹窗、跳转和应用唤起。如果支付 SDK 只能在点击事件中调用,3 分钟后直接执行 invokePayment() 可能失败。
遇到这种情况,可以在倒计时结束后显示确认层,让用户点击一次:
function requestUserToRetry() {
setTip("可以重新支付了,请点击“立即支付”。");
payButton.hidden = false;
payButton.focus();
}
另一种做法是先尝试自动调起,失败后立即显示按钮。不要连续循环调用,否则页面可能反复跳转、频繁弹窗,或重复创建订单。
注意事项
- 每次重新调起支付前,都要向服务端查询订单状态,避免用户已经支付成功却再次付款。
- 服务端创建支付单时,应使用订单号或幂等键控制重复请求,不能只靠前端变量防重。
- 不要重复使用已经过期的支付参数。签名、时间戳、支付链接和预支付凭证都可能存在有效期。
- 使用绝对时间
nextRetryAt,不要把定时器的实际执行时间当作真实经过时间。 sessionStorage适合处理当前标签页内的返回场景。如果刷新或关闭页面后仍需保留重试计划,可以改用localStorage,但键名必须按订单号区分。支付成功、订单关闭或过期后,要及时清除相应记录。visibilitychange、pageshow和支付 SDK 回调可能同时触发,因此需要通过invoking等状态避免重复调起。- 支付结果应以服务端查询或支付平台的异步通知为准,不能只依赖前端成功回调。
- 自动重试前要明确提示用户,并允许用户取消。反复强制弹出支付窗口会影响使用体验,也可能不符合支付平台或浏览器的交互规范。
备注:内容仅供参考。