PAYATHON 2026

如何用 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,通常可以按下面的流程处理:

  1. SPA 请求自己的后端创建订单。
  2. 后端验证金额、商品和用户身份。
  3. 后端使用商户密钥调用支付网关,创建支付会话。
  4. 后端只把网关提供的支付 URL 返回给 SPA。
  5. 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 时,前端才可以省去表单。

备注:内容仅供参考。