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 防护和重复支付。账单支付还应由服务端保证幂等性,不能只靠前端禁用按钮来防止重复扣款。
备注:内容仅供参考。
