PAYATHON 2026

h5支付页面如何自动调起支付弹窗并在放弃支付后重新调起?

支付小周

结论

H5 付款页首次打开时,可以尝试自动调起支付弹窗。用户取消支付并返回页面后,记录取消时间,满 3 分钟再尝试调起。

不要只依赖 setTimeout(180000)。页面进入后台后,浏览器可能暂停定时器,也可能延迟执行。更可靠的做法是保存“下次调起时间”,并在 pageshow、visibilitychange 事件触发时重新计算剩余时间。

微信、支付宝和普通浏览器对自动弹窗及支付调用的限制并不相同。有些环境要求用户点击后才能发起支付,因此无法保证全程自动调起。页面必须保留“立即支付”按钮,以便自动调用失败时让用户手动操作。

实现思路

支付流程可以分为以下几个状态:

  1. 页面加载完成后,尝试调起支付。
  2. 调用支付 SDK,等待成功、失败或取消结果。
  3. 用户取消支付后,记录下一次调起时间:
nextRetryAt = 当前时间 + 3 分钟
  1. 如果页面仍在前台,使用定时器等待。
  2. 用户返回付款页,或页面从后台恢复到前台时,根据 nextRetryAt 重新计算剩余时间。
  3. 到达重试时间后,先查询订单状态。确认订单仍未支付且没有过期,再重新创建支付参数并调起支付。

通用代码示例

以下示例不绑定具体的支付平台。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 等状态避免重复调起。
  • 支付结果应以服务端查询或支付平台的异步通知为准,不能只依赖前端成功回调。
  • 自动重试前要明确提示用户,并允许用户取消。反复强制弹出支付窗口会影响使用体验,也可能不符合支付平台或浏览器的交互规范。

备注:内容仅供参考。