PAYATHON 2026

Safari 导航到新 URL 时 JavaScript 弹窗不显示

支付小周

结论

不要在显示弹窗后马上执行 location.replace()。DOM 更新和 Bootstrap 弹窗显示只会在当前 JavaScript 任务中提交,浏览器通常要等代码执行完毕,才有机会重新布局并绘制页面。如果 Safari 发现页面即将跳转,可能会直接放弃尚未完成的绘制。

可以等待 Bootstrap 触发 shown.bs.modal 事件,再通过异步回调执行跳转:

function pay_now(event) {
    if (event) {
        event.preventDefault();
    }

    var button = document.getElementById('paynow_btn');
    button.disabled = true;
    button.style.opacity = 0.25;

    document.getElementById('staticModalLabel').textContent =
        'Please wait while the transaction is processed...';

    var modalBody = document.getElementById('staticModalBody');
    modalBody.innerHTML = '';

    var spinner = new Spinner().spin();
    modalBody.appendChild(spinner.el);

    $('#staticPopupModal')
        .one('shown.bs.modal', function () {
            window.setTimeout(function () {
                location.replace('/billingpage/pay/');
            }, 0);
        })
        .modal({
            backdrop: 'static',
            keyboard: false
        });
}

为什么原来的代码看不到弹窗

下面两条语句在同一个 JavaScript 任务中连续执行:

$('#staticPopupModal').modal({
    backdrop: 'static',
    keyboard: false
});

location.replace('/billingpage/pay/');

第一条语句修改 DOM,并启动弹窗的显示流程,但弹窗此时未必已经绘制到屏幕上。第二条语句紧接着开始导航,当前文档也将被卸载。Safari 可能因此跳过这次已经没有实际呈现意义的绘制。

Bootstrap 的弹窗方法已经被调用,只是页面在浏览器完成绘制前就开始跳转了。

setTimeout() 为什么没有生效

下面的写法不会延迟调用:

window.setTimeout(navigate(), 1000);

navigate() 后面的括号会让函数立刻执行,传给 setTimeout() 的其实是它的返回值。

正确的写法是传入函数本身:

window.setTimeout(navigate, 1000);

也可以传入回调函数:

window.setTimeout(function () {
    navigate();
}, 1000);

或者使用箭头函数:

window.setTimeout(() => navigate(), 1000);

不过,与固定延迟相比,监听 shown.bs.modal 更能准确表达意图。如果弹窗带有 CSS 过渡动画,该事件会在显示过程完成后触发,适合用来实现“先显示弹窗,再跳转”。

如果仍需确保至少完成一次绘制

如果页面没有弹窗过渡效果,或者其他代码改变了 Bootstrap 的事件行为,可以在 shown.bs.modal 触发后再等待一个绘制周期:

$('#staticPopupModal')
    .one('shown.bs.modal', function () {
        window.requestAnimationFrame(function () {
            window.setTimeout(function () {
                location.replace('/billingpage/pay/');
            }, 0);
        });
    })
    .modal({
        backdrop: 'static',
        keyboard: false
    });

如果在实际环境中仍来不及显示,可以加入很短的缓冲时间,例如:

$('#staticPopupModal')
    .one('shown.bs.modal', function () {
        window.setTimeout(function () {
            location.replace('/billingpage/pay/');
        }, 100);
    })
    .modal({
        backdrop: 'static',
        keyboard: false
    });

延迟时间并非越长越好,也不能严格保证浏览器一定完成绘制。它只是为事件循环留出处理当前页面绘制的机会。

防止按钮触发表单的默认提交

如果“支付”按钮位于 <form> 中,还需要阻止按钮默认提交表单,否则表单提交可能与 JavaScript 跳转同时发生。

可以将按钮声明为普通按钮:

<button id="paynow_btn" type="button" onclick="pay_now(event)">
    PAY
</button>

也可以在事件处理函数中调用:

event.preventDefault();

使用 addEventListener() 更便于管理事件:

document.getElementById('paynow_btn').addEventListener('click', function (event) {
    event.preventDefault();
    pay_now();
});

更可靠的交互方案

浏览器不保证在页面导航前绘制当前文档最后一次 DOM 更新。如果弹窗必须在整个支付处理期间保持显示,可以采用以下两种方案:

  • 在目标支付页面显示加载界面,再开始服务端处理。
  • 使用 fetch() 或 Ajax 在当前页面发起支付请求,保持弹窗显示,处理完成后再跳转到结果页。

例如:

async function pay_now(event) {
    event.preventDefault();

    var button = document.getElementById('paynow_btn');
    button.disabled = true;
    button.style.opacity = 0.25;

    $('#staticPopupModal').modal({
        backdrop: 'static',
        keyboard: false
    });

    try {
        var response = await fetch('/billingpage/pay/', {
            method: 'POST',
            credentials: 'same-origin'
        });

        if (!response.ok) {
            throw new Error('Payment request failed');
        }

        location.replace('/billingpage/result/');
    } catch (error) {
        $('#staticPopupModal').modal('hide');
        button.disabled = false;
        button.style.opacity = 1;

        console.error(error);
    }
}

这种方案要求支付接口支持对应的请求方式,并妥善处理 CSRF 防护和重复支付。账单支付还应由服务端保证幂等性,不能只靠前端禁用按钮来防止重复扣款。

备注:内容仅供参考。