支付宝扫码打开 H5 支付后返回页面定时器失效
结论
问题通常不在定时器本身。页面进入支付宝支付流程后,可能被切到后台、冻结、放入页面缓存,甚至被 WebView 销毁。用户返回时,原来的 JavaScript 运行环境可能还没有恢复,也可能已经重新创建。因此,setInterval 和 setTimeout 无法保证按离开前的状态继续运行。
不要依靠定时器持续计时。更可靠的做法是记录关键时间点,并监听 visibilitychange、pageshow 等页面生命周期事件。页面恢复后,再重新计算状态、启动定时器,并主动查询支付结果。
原因分析
使用下面的方式跳转支付时:
window.location.href = payUrl;
当前 H5 页面会离开前台。支付宝客户端及其内置 WebView 可能会:
- 暂停后台页面的 JavaScript,导致定时器停止执行。
- 对后台定时器进行节流,使回调时间变得不确定。
- 将页面放入往返缓存(Back/Forward Cache,简称 BFCache)。用户返回时,页面不会完整执行常规的初始化流程。
- 在内存紧张时销毁原页面或 WebView。用户返回后,页面实际上会重新加载。
- 根据支付页的返回方式触发
pageshow,但不一定重新执行预期的初始化代码。
所以,即使设置了:
setInterval(() => {
// ...
}, 1000);
也不能认为它会在支付期间每秒执行一次。浏览器定时器只保证至少等待指定时间后再执行,并不保证准时,也不保证页面进入后台后仍会继续运行。
推荐解决方案
1. 不用定时器累计时间
业务中如果有倒计时,不要在每次回调时简单减一。应保存倒计时的结束时间,再用当前时间计算剩余时长。
const END_TIME_KEY = 'payment_expire_at';
function createCountdown(duration) {
const expireAt = Date.now() + duration;
sessionStorage.setItem(END_TIME_KEY, String(expireAt));
}
function getRemainingTime() {
const expireAt = Number(sessionStorage.getItem(END_TIME_KEY));
if (!expireAt) {
return 0;
}
return Math.max(0, expireAt - Date.now());
}
即使页面在后台停留了几分钟,返回后仍能算出正确的剩余时间。
2. 页面恢复时重新启动定时器
同时监听 visibilitychange 和 pageshow。visibilitychange 用于处理前后台切换,pageshow 则可以覆盖普通返回和 BFCache 恢复。
let timerId = null;
function stopTimer() {
if (timerId !== null) {
clearInterval(timerId);
timerId = null;
}
}
function updateCountdown() {
const remaining = getRemainingTime();
if (remaining <= 0) {
stopTimer();
renderCountdown(0);
return;
}
renderCountdown(remaining);
}
function startTimer() {
stopTimer();
updateCountdown();
timerId = window.setInterval(updateCountdown, 1000);
}
function renderCountdown(remaining) {
const totalSeconds = Math.ceil(remaining / 1000);
const element = document.querySelector('#countdown');
if (element) {
element.textContent = String(totalSeconds);
}
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
startTimer();
refreshPaymentStatus();
} else {
stopTimer();
}
});
window.addEventListener('pageshow', () => {
startTimer();
refreshPaymentStatus();
});
window.addEventListener('pagehide', () => {
stopTimer();
});
startTimer() 会先调用 stopTimer(),这样页面多次恢复时不会重复创建定时器。
3. 返回页面后主动查询支付状态
支付是否成功,不能由前端定时器判断,也不能只看用户是否返回了页面。用户可能取消支付或支付失败,也可能已经支付成功,但异步通知还没有到达业务服务器。
页面恢复时,可以向服务端查询订单状态:
async function refreshPaymentStatus() {
try {
const response = await fetch(
`/api/orders/${encodeURIComponent(orderId)}/status`,
{
method: 'GET',
cache: 'no-store',
credentials: 'include'
}
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (result.status === 'PAID') {
stopTimer();
showPaymentSuccess();
} else if (result.status === 'CLOSED') {
stopTimer();
showPaymentClosed();
}
} catch (error) {
console.error('查询支付状态失败:', error);
}
}
完整的处理流程如下:
- 发起支付前,保存订单号、倒计时截止时间等必要状态。
- 使用
window.location.href进入支付流程。 - 页面重新可见或从历史记录恢复时,重新启动界面定时器。
- 根据绝对时间计算倒计时。
- 向服务端查询订单状态。
- 支付结果最终以服务端验签后的异步通知或服务端主动查询结果为准。
一个较完整的示例
const orderId = 'ORDER_ID';
const expireAt = Date.now() + 15 * 60 * 1000;
sessionStorage.setItem('order_id', orderId);
sessionStorage.setItem('payment_expire_at', String(expireAt));
let timerId = null;
let querying = false;
function stopTimer() {
if (timerId !== null) {
clearInterval(timerId);
timerId = null;
}
}
function getRemainingTime() {
const value = Number(sessionStorage.getItem('payment_expire_at'));
if (!Number.isFinite(value)) {
return 0;
}
return Math.max(0, value - Date.now());
}
function updateView() {
const remaining = getRemainingTime();
const countdown = document.querySelector('#countdown');
if (countdown) {
countdown.textContent = String(Math.ceil(remaining / 1000));
}
if (remaining <= 0) {
stopTimer();
}
}
function startTimer() {
stopTimer();
updateView();
if (getRemainingTime() > 0) {
timerId = window.setInterval(updateView, 1000);
}
}
async function refreshPaymentStatus() {
if (querying) {
return;
}
const savedOrderId = sessionStorage.getItem('order_id');
if (!savedOrderId) {
return;
}
querying = true;
try {
const response = await fetch(
`/api/orders/${encodeURIComponent(savedOrderId)}/status`,
{
cache: 'no-store',
credentials: 'include'
}
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (result.status === 'PAID') {
stopTimer();
showPaymentSuccess();
}
} catch (error) {
console.error('恢复页面后查询支付状态失败:', error);
} finally {
querying = false;
}
}
function resumePage() {
startTimer();
refreshPaymentStatus();
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
resumePage();
} else {
stopTimer();
}
});
window.addEventListener('pageshow', resumePage);
window.addEventListener('pagehide', stopTimer);
resumePage();
注意事项
不要只监听 load
页面从 BFCache 恢复时,load 通常不会再次触发。支付返回场景更适合监听 pageshow,同时建议配合 visibilitychange 使用。
如果需要判断页面是否可能从 BFCache 恢复,可以读取 pageshow 事件的 persisted 属性:
window.addEventListener('pageshow', event => {
if (event.persisted) {
console.log('页面可能从 BFCache 恢复');
}
resumePage();
});
不要让业务逻辑完全依赖 persisted,不同 WebView 的实现可能并不一致。
不要假设返回后仍是原来的 JavaScript 上下文
WebView 一旦被销毁,内存中的变量也会全部丢失。订单号、截止时间等页面恢复时需要的数据,应保存在合适的位置,例如:
- URL 查询参数
sessionStoragelocalStorage- 服务端会话或订单记录
敏感数据、支付凭证和可以伪造的支付结果不能只保存在前端。
不要用前端回跳判断支付成功
进入支付完成页、页面重新可见,或者 URL 中出现某个参数,都不能单独作为支付成功的依据。应由服务端接收并验证支付宝的异步通知,必要时再由服务端主动查询订单状态。
避免重复轮询
pageshow 和 visibilitychange 可能连续触发。启动定时器或查询订单前需要去重,避免重复请求、重复提示,以及多个 setInterval 同时运行。
如果需要持续轮询订单状态,应设置合理的轮询间隔、超时时间和停止条件:
let pollTimer = null;
function startPolling() {
stopPolling();
pollTimer = window.setInterval(async () => {
const finished = await refreshPaymentStatus();
if (finished) {
stopPolling();
}
}, 3000);
}
function stopPolling() {
if (pollTimer !== null) {
clearInterval(pollTimer);
pollTimer = null;
}
}
支付宝客户端版本、手机系统和 WebView 内核都会影响页面生命周期的具体表现,所以很难仅凭“返回后定时器失效”判断是某个固定版本的问题。实现时应把页面暂停、恢复或重载都视为正常情况,并确保业务状态在这些情况下可以恢复。
备注:内容仅供参考。
