如何用 JavaScript 模拟隐藏表单提交并跳转支付网关?
结论
如果支付网关要求浏览器通过 POST 提交字段并跳转到网关页面,标准做法就是提交 <form>。JavaScript、jQuery、fetch() 和 XMLHttpRequest 都没有能够在不创建表单的情况下,同时完成跨域 POST 和页面跳转的等价 API。
动态创建表单是浏览器原生支持的跨域导航方式,并不算“脏”。把表单的创建、提交和清理封装成函数后,业务代码依然可以保持简洁。
如果支付网关支持由服务端先创建支付会话,再返回一个跳转 URL,则更适合采用这种模式。前端拿到 URL 后,使用 location.assign() 跳转即可。
为什么不能直接模拟 POST 跳转
浏览器中的几种常见请求方式各有用途和限制:
window.location、location.assign()和普通链接只能发起GET导航。fetch()和XMLHttpRequest可以发送POST,但不会让当前页面导航到响应地址,而且跨域读取响应会受到 CORS 限制。- HTML 表单可以向其他域提交数据,并让浏览器进入支付网关页面。这类顶层页面导航通常不受 Ajax CORS 规则限制。
- jQuery 并没有额外的底层导航能力,最终还是要创建并提交原生表单。
所以,只要网关要求浏览器跨域 POST 后进入网关页面,表单就是合适的实现方式。
封装动态表单提交
可以把创建表单的过程封装在工具函数中:
function postRedirect(url, data) {
const form = document.createElement('form');
form.method = 'POST';
form.action = url;
form.target = '_self';
form.style.display = 'none';
for (const [name, value] of Object.entries(data)) {
if (value === undefined || value === null) {
continue;
}
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = String(value);
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
}
调用示例:
postRedirect('https://payment.example.com/checkout', {
merchant_id: 'merchant-123',
order_id: 'order-20260901-001',
amount: '99.00',
currency: 'CNY',
return_url: 'https://shop.example.com/payment/return',
signature: 'signature-generated-by-server'
});
提交前需要先把表单加入文档,以兼容浏览器对未连接表单的处理。成功提交后,当前页面通常会离开,因此一般不用主动删除表单。如果提交可能被阻止,可以在提交后安排清理:
function postRedirect(url, data) {
const form = document.createElement('form');
Object.assign(form, {
method: 'POST',
action: url,
target: '_self'
});
form.hidden = true;
for (const [name, value] of Object.entries(data)) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = String(value ?? '');
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
setTimeout(() => form.remove(), 1000);
}
不要通过字符串拼接生成 <input>。如果字段名或字段值处理不当,这种写法可能引入 HTML 注入问题。使用 DOM API 设置 name 和 value 更稳妥。
网关支持 GET 跳转时
如果支付网关明确允许把参数放在查询字符串中,可以直接跳转:
const params = new URLSearchParams({
order_id: 'order-20260901-001',
session_id: 'payment-session-id'
});
window.location.assign(
`https://payment.example.com/checkout?${params.toString()}`
);
不过,不要擅自把网关要求使用 POST 的支付参数改成 GET。查询参数可能被记录在浏览器历史、访问日志、代理日志和监控系统中,不适合传递签名密钥、银行卡信息或其他敏感数据。
更推荐的支付会话模式
如果网关提供服务端 API,通常可以按下面的流程处理:
- SPA 请求自己的后端创建订单。
- 后端验证金额、商品和用户身份。
- 后端使用商户密钥调用支付网关,创建支付会话。
- 后端只把网关提供的支付 URL 返回给 SPA。
- SPA 跳转到该 URL。
async function startPayment(orderId) {
const response = await fetch('/api/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({ orderId })
});
if (!response.ok) {
throw new Error('Unable to create payment session');
}
const { redirectUrl } = await response.json();
window.location.assign(redirectUrl);
}
能否采用这种方式,要看支付网关是否提供创建会话后跳转的接口。具体的请求方式、签名算法和字段要求都应以网关文档为准。
安全注意事项
- 商户密钥、签名私钥和网关 API 密钥必须保存在服务端,不能写入 SPA。
- 金额、币种、商品和订单状态必须由服务端校验,不能信任前端提交的数据。
- 支付结果应通过网关的服务端通知或 webhook 确认,不能只根据用户是否返回页面来判断支付成功。
return_url主要用于用户跳转,不能作为最终入账依据。- 订单号、支付会话或签名应设置有效期,并加入防重放设计。
- 网关地址最好采用固定白名单。不要把未经验证的 URL 直接传给
form.action,否则可能造成开放重定向或数据泄露。 - 如果站点配置了 CSP,需要确保
form-action允许目标支付域,例如:
Content-Security-Policy: form-action 'self' https://payment.example.com
网关要求跨域 POST 导航时,动态表单就是正确的实现方式。只有当网关提供了可以直接跳转的支付会话 URL 时,前端才可以省去表单。
备注:内容仅供参考。
