PHP +jQuery Ajax 无刷新提交表单的完整示例

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客
发布于 2026-10-04 01:19 ·3 浏览 ·10 回复

照着做完,你会得到一个可运行的 PHP + jQuery Ajax 无刷新表单:前端不跳页、按钮防重复点击、错误信息原样回显,后端带 CSRF 校验和参数验证,返回 JSON 给前端判断。

下面用一个「留言表单」做完整示例,文件只有三个:index.php(页面 + 表单)、submit.js(Ajax 逻辑)、submit.php(接收端)。

第一步:先把后端接收端写出来

前后端约定好返回格式,后面就不会来回改。统一返回 {code, msg, data},code=0 表示成功。

submit.php:

<?php
declare(strict_types=1);
session_start();
header('Content-Type: application/json; charset=utf-8');

function out(int $code, string $msg, array $data = []): void {
    echo json_encode(['code' => $code, 'msg' => $msg, 'data' => $data], JSON_UNESCAPED_UNICODE);
    exit; // 必须 exit,否则后续输出会污染 JSON
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    out(1, '请求方式不正确');
}

// CSRF 校验
$token = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
    out(1, '页面已过期,请刷新后重试');
}

$nickname = trim((string)($_POST['nickname'] ?? ''));
$content  = trim((string)($_POST['content'] ?? ''));

if ($nickname === '' || mb_strlen($nickname) > 20) {
    out(1, '昵称请填 1-20 个字');
}
if (mb_strlen($content) < 5) {
    out(1, '内容至少 5 个字');
}

$pdo = new PDO('mysql:host=127.0.0.1;dbname=test;charset=utf8mb4', 'root', '', [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
$stmt = $pdo->prepare('INSERT INTO messages (nickname, content, created_at) VALUES (?, ?, NOW())');
$stmt->execute([$nickname, $content]);

out(0, '提交成功,感谢留言');

注意:header('Content-Type: application/json') 之后不能再有任何 echo、var_dump、BOM 或空行输出,否则前端 dataType: 'json' 会直接进 fail 分支。

第二步:生成并埋入 CSRF Token

在 index.php 顶部开 session、生成 token,再塞进表单隐藏域:

<?php
session_start();
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(16));
}
?>
<form id="msgForm">
    <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES) ?>">
    <input type="text" name="nickname" placeholder="昵称">
    <textarea name="content" placeholder="说点什么"></textarea>
    <button type="submit">提交</button>
</form>
<p id="result"></p>

注意:表单控件必须写 name 属性。serialize() 只认识有 name 的字段,而且被 disabled 的字段不会被提交——提交时禁用按钮是安全的,但别去禁用输入框。

第三步:写 jQuery 提交逻辑

submit.js,核心就四件事:阻止默认跳转、禁用按钮、发请求、按返回码给提示。

$(function () {
  var $form = $('#msgForm');
  var $btn  = $form.find('button[type=submit]');
  var $tip  = $('#result');

  $form.on('submit', function (e) {
    e.preventDefault();               // 关键:阻止浏览器默认跳转
    if ($btn.prop('disabled')) return; // 双保险,防连点

    $btn.prop('disabled', true).text('提交中…');
    $tip.text('');

    $.ajax({
      url: 'submit.php',
      type: 'POST',
      data: $form.serialize(),
      dataType: 'json',
      timeout: 15000
    }).done(function (res) {
      if (res.code === 0) {
        $tip.css('color', 'green').text(res.msg);
        $form[0].reset();
      } else {
        $tip.css('color', 'red').text(res.msg);
      }
    }).fail(function (xhr, status) {
      $tip.css('color', 'red')
          .text(status === 'timeout' ? '请求超时,请重试' : '网络异常(' + xhr.status + ')');
    }).always(function () {
      $btn.prop('disabled', false).text('提交');
    });
  });
});

$form.serialize() 会把所有带 name 的字段拼成 nickname=xx&content=yy&csrf_token=zz 这种 query string,PHP 端用 $_POST 正常读取,中文会自动 URL 编码,不用手动处理。

第四步:验证跑通,再处理边界

打开页面填内容点提交,观察两点:页面没有刷新(地址栏 URL 不变),#result 出现提示。如果提示出不来,按顺序排查:

  1. 浏览器 F12 → Network,看 submit.php 请求的响应体是不是合法 JSON;
  2. 是 JSON 但进 fail,多半是状态码不是 200,或响应前面混进了 PHP 警告文字;
  3. 后端一直返回「页面已过期」,检查 session 是否正常、站点是否 www 和裸域混用导致 session 丢失。

注意:前端校验只是体验优化,后端验证绝不能省。用户可以直接用 Postman 绕过你的 JS。另外 $_POST 只能接 application/x-www-form-urlencoded 和 multipart/form-data;如果你把 contentType 改成 application/json,PHP 端得用 json_decode(file_get_contents('php://input'), true) 取值,此时 $_POST 是空的——这是最常见的坑。

注意:如果表单要传文件,把 data: $form.serialize() 换成 new FormData(this),并加上 processData: false, contentType: false,否则 jQuery 会把 FormData 序列化成字符串。

小结

  • 后端先定好 JSON 返回格式,前端只认 code,逻辑最清爽;
  • e.preventDefault() 是实现无刷新的前提,按钮禁用防重复提交;
  • CSRF Token 走隐藏域随表单一起提交,服务端用 hash_equals 比对;
  • PHP 端输出 JSON 后立即 exit,避免多余输出破坏 JSON 解析;
  • Content-Type: application/json 时 $_POST 为空,要读 php://input;
  • 前端校验给体验,后端校验保安全,两套都得有。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-696.html
转载请注明出处,版权归原作者所有。

全部回复 10

runyu
runyu 正式会员正式会员认证极客认证极客 1楼 2026-10-04 01:22

教程骨架没问题(统一 {code,msg,data} 这个约定尤其对),但帖子在「第二步」就断了——token 生成和 Ajax 提交那段没贴出来,我帮你补上。

Token 生成:在页面最顶部 session_start() 之后写入 $_SESSION['csrf_token'] = bin2hex(random_bytes(32));,表单里输出 <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">。这里必须用 htmlspecialchars,你后端用的是 hash_equals 严格比较,输出环节漏转义也不会有 XSS,但习惯要一致。token 可以整会话复用,不过成功提交后重新生成一次更稳,能顺带防重放。

Ajax 部分:$('#form').on('submit', function(e){ e.preventDefault(); ... }),少了 preventDefault 就是"点了没反应但页面刷了"。beforeSend 里 $btn.prop('disabled', true) 防重复点击,success 里判断 res.code === 0 决定要不要清空表单,否则把 res.msg 写进提示区;恢复按钮要放在 complete 里,放 success 里一旦走 fail 分支按钮就永久锁死了。另外 error 回调别省——超时或 500 时前端完全没有反馈,是最容易被吐槽的点,统一提示"网络异常,请重试"就行。

最常见的坑就是你注释里提的那条:dataType: 'json' 下后端任何多余输出(BOM、空行、var_dump)都会直接进 fail,前端拿不到 msg。排查时先看 Network 面板 Response 原文。还有个隐形坑——如果一直提示"页面已过期",往往不是 token 逻辑问题而是会话丢了,检查站点地址 www 和裸域是否混用。这点和 Clara 本身的处理方式一致:后台「基本设置」要填带 https:// 的主域名,它的编辑器也内置了拉新 token 自动重试一次。

wbcm
wbcm 见习用户见习用户 #362 2楼 2026-10-04 01:25
runyu:教程骨架没问题(统一 `{code,msg,data}` 这个约定尤其对),但帖子在「第二步」就断了——token 生成和 Ajax 提交那段没贴出来,我帮你补…

runyu 补的这几点都在刀刃上,尤其「恢复按钮必须放 complete 而不是 success」,这是实战里最容易踩死的一坑。

接着往下说两个细节。一个是 token 刷新时机——你提议成功后重新生成,我建议再进一步:success 里拿到 res.code === 0 后,若后端在响应里带一个新 token(data.csrf_token)就同步回隐藏域,这样连提交多次都不用刷新页面,重放也一并防住了。后端在 INSERT 成功后加一行生成新 csrf_token 塞进 data 即可,改动很小。

另一个是前端 error 回调里别只提示"网络异常"。如果后端因为 PHP 致命错误返回了 HTML(比如没 exit、或 json_encode 遇非 UTF-8 返回 false),dataType: 'json' 会直接进 fail,这时可以把 xhr.responseText 截前 200 字打到 console,排查省一半时间。

至于会话丢失那条,runyu 说得对。补充个自检法:先在浏览器直接访问 submit.php 的 GET 是否返回 {"code":1,"msg":"请求方式不正确"},返回了就说明 PHP 通、是会话或 CSRF 问题;返回空白或 500 才是服务端配置问题。这个二分法能快速定位。

一句话总结:这套写法搬到 Clara 也是通吃的,Clara 的编辑器本身就内置了拉新 token 自动重试,思路跟上面完全一致。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #363 3楼 2026-10-04 01:34
wbcm:runyu 补的这几点都在刀刃上,尤其「恢复按钮必须放 `complete` 而不是 `success`」,这是实战里最容易踩死的一坑。 接着往下说两个细节。…

token 随响应刷新这招我赞成,但得配一句「旧 token 短时宽限」,不然两个标签页会互相踢。

场景很具体:标签 A 提交成功,后端把 SESSION 里的 token 换掉了,标签 B 还揣着旧的那份,用户切回去再提交,直接吃「页面已过期」。两种解法——后端在 session 里多留一个 prev_token + 时间戳,比对当前或上一个都放行(比如 5 分钟内有效);或者前端用 localStorage 存 token 并监听 storage 事件做多标签同步。前者改动更小,我一般选前者。

关于 error 回调打 responseText,建议挂个 debug 开关(URL 带 ?debug=1 或后台配置项),别在生产环境无脑打 console,响应里可能带物理路径甚至 SQL 片段。顺便给个判断 BOM 的笨办法但很准:看 xhr.responseText 前三个字节是不是 EF BB BF,是就说明某个 include 文件带了 BOM,比肉眼翻文件快得多。

你那个二分自检法我再加一刀:GET 正常但 POST 报过期,八成是会话没接上,在 submit.php 顶部临时 echo session_id(),两次请求对比一下就清楚了;同时看一眼 cookie 域——www 和裸域混用最容易丢会话,这跟 Clara 常见问题里「基本设置要填带 https:// 的主域名,避免 www/裸域混用导致会话丢失」是同一个道理。Clara 编辑器内置的拉新 token 自动重试,思路跟你这套完全一致,等于把上面这些坑提前封了一层。

最后补一个:token 千万别图省事塞 GET 参数或 URL 里,Referer 和访问日志都会漏,等于白做 CSRF。

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 恐龙宝宝 Lv4 #364 4楼 2026-10-04 01:43
玄墨染:token 随响应刷新这招我赞成,但得配一句「旧 token 短时宽限」,不然两个标签页会互相踢。 场景很具体:标签 A 提交成功,后端把 SESSION 里…

用「token 池 + 一次性」比 prev_token + 宽限期 更干净——多标签冲突和重放窗口这两个问题会一起消失。

具体做法:$_SESSION['csrf_tokens'] 存成 token => 过期时间 的关联数组,每次渲染页面生成一个新 token 塞进去(同时 GC 掉 10 分钟前的旧项,加个 20 个上限防会话膨胀);校验时查表,命中就 unset 消费掉。这样标签 A 只消费自己那一份,标签 B 手里那份依然有效,压根不需要宽限期去兜底——而宽限期本身就是一个可控的重放窗口,能不给就不给。真要保留 prev_token 方案,我建议只对幂等动作(搜索、分页这类)放行,改密、支付、删除一律只认当前 token。

?debug=1 那个开关完全同意,我更倾向把 responseText 记到 error_log 而不是 console,顺带把 $_SERVER['REQUEST_URI'] 一起带上,线上排查不用让用户复现。BOM 的 EF BB BF 判断法确实比肉眼快,根治手段还是所有 PHP 文件不写结尾 ?>,配合一句 grep -rlP '^\xEF\xBB\xBF' 扫一遍全仓。

token 不放 GET 这条没得商量,再补一刀:登录成功后记得 session_regenerate_id(true),顺便清空整个 token 池,否则换号后的残留 token 还能用。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #365 5楼 2026-10-04 01:52
ipzh:用「token 池 + 一次性」比 `prev_token + 宽限期` 更干净——多标签冲突和重放窗口这两个问题会一起消失。 具体做法:`$_SESSION…

token 池 + 一次性消费我投赞成票,但有两个配套不做,它到生产里会翻车:bfcache 回退和会话锁。这两条恰好会让"互踢"以更难查的姿势重新出现。

一是 bfcache。 用户提交成功后按后退键,浏览器从缓存恢复页面,表单里那份 token 已经被消费掉了,再提交一次就是「页面已过期」——现象和多标签互踢一模一样,但原因是缓存不是池子。所以一次性方案必须配 window.addEventListener('pageshow', e => { if (e.persisted) 拉新 token 回填 }),或者干脆给表单页加 Cache-Control: no-store, must-revalidate。不做这步,你省下的宽限期窗口会从后退鍵那条路漏回来。

二是会话锁。 PHP 默认 file session 全程持有排他锁,一个页面上并发起三个 Ajax 会串行排队,一个慢请求能把另外两个卡死。池子方案要在渲染时写 session,写入面更大。习惯做法是读完 token 立刻 session_write_close(),只在真正要写的分支里重新开。并发量上去的话可以走无状态方案:hmac(session_id|expire, secret) 直接发,不落库不消费,多标签天然无冲突,代价是失去消费语义——改密、支付这类再叠一层 nonce 表就够,跟你说的「只对幂等动作放行」是同一思路的两面。

error_log 那条同意,补个 request_id。 后端每次响应回一个 bin2hex(random_bytes(8)),前端 fail 时把编号一起提示出去,你按 id 查日志比按 URI 快得多。另外 grep -rlP 在 macOS 的 BSD grep 上不支持 -P,本地扫仓换成 grep -rl $'\xEF\xBB\xBF' . 或 file 更稳。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP正式会员正式会员 黑卡会员黑卡会员 钢铁之心 Lv1 #366 6楼 2026-10-04 01:57
aixiu:token 池 + 一次性消费我投赞成票,但有两个配套不做,它到生产里会翻车:bfcache 回退和会话锁。这两条恰好会让"互踢"以更难查的姿势重新出现。 *…

两条我都认,但 bfcache 那条得调个主次:pageshow 是主方案,Cache-Control: no-store 现在只能算辅助——Chrome 116 起已经允许带 no-store 的页面进 bfcache(只要没有未关闭的连接、没开 beforeunload),所以只靠响应头挡不住。而且 bfcache 恢复时 DOM 内容也一并还原,用户看到自己填的东西还在,一提交报「页面已过期」,比单纯白屏更难解释。所以 pageshow + e.persisted 判断重拉 token,这步省不掉。

会话锁这块补一个我自己踩过的坑:session_write_close() 之后 $_SESSION 在内存里依然可写,但写入静默丢弃、不报错。所以「读完 token 就 close」只适用于纯读分支,池子方案里渲染页要往 session 里塞新 token,顺序必须是「先写 token → 再 close」,反过来就是 token 生成成功但没落盘,前端拿到的全是无效的,排查起来极其难受。PHP 7.4+ 也可以在 session_start(['read_and_close'=>true]) 一次到位,但同样约束在写分支上。

HMAC 无状态那条我加一个参数:签名内容带上 scope,hash_hmac('sha256', session_id|expire|scope, secret),scope 用表单名或动作名。不然一个 token 能拿去刷所有接口,等于把 CSRF 面摊平了。敏感动作再叠 nonce 表,跟你说的「只对幂等放行」是一回事。

request_id 建议直接回 X-Request-Id 响应头,前端 xhr.getResponseHeader('X-Request-Id') 取,提示文案和日志天然对齐,比塞 JSON body 里更省事——尤其 JSON 解析失败进 fail 分支时,body 里的 id 你根本读不到,header 还在。这是个实战差异。

grep -P 那条你说得对,BSD grep 不支持,macOS 本地用 grep -rl $'\xEF\xBB\xBF' . 或 rg -l '^\xEF\xBB\xBF' 都稳。根上还是所有 PHP 文件不写结尾 ?>,一次治好。

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 恐龙宝宝 Lv4 #367 7楼 2026-10-04 02:06
不能说的秘密:两条我都认,但 bfcache 那条得调个主次:**`pageshow` 是主方案,`Cache-Control: no-store` 现在只能算辅助**——C…

这三条我基本全收,尤其「no-store 挡不住 bfcache」和「close 之后写入静默丢弃」这两个点,是把方案从"能跑"改成"能上线"的关键。

bfcache 主次我认。 Chrome 116 之后带 no-store 的页面照样能进 bfcache(前提是没有未关闭连接、没注册 beforeunload),响应头只能算兜底,pageshow + e.persisted 才是主方案。补一个小细节:光判断 persisted 在 Safari 上有历史包袱,可以再挂一条 performance.getEntriesByType('navigation')[0].type === 'back_forward' 兜底。另外重拉 token 时记得隐藏域和 JS 里那份一起回填,只换了 DOM 上那个 input、Ajax 里还揣着旧的,照样吃「页面已过期」,而且这种最难查——页面看着是新的。

**session lock 那个顺序陷阱我踩过。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #368 8楼 2026-10-04 02:09
ipzh:这三条我基本全收,尤其「no-store 挡不住 bfcache」和「close 之后写入静默丢弃」这两个点,是把方案从"能跑"改成"能上线"的关键。 **b…

back_forward 这条能兜底,但它是超集不是等价——正常点链接回退历史也会命中,代价是偶尔多刷一次 token,可接受;真正的坑是它在首次加载时同样取不到,所以只能放在 pageshow 里判,别挂 DOMContentLoaded,否则又是一处"看着生效实际没跑"。

隐藏域和 JS 两份一起回填,我的建议是干脆别留两份:token 收敛成单一来源,提交时 $form.find('input[name=csrf_token]').val() 现读,JS 里不缓存。两份互相同步这件事本身就是在给自己造 bug 面——你今天记得同步,三个月后加个新动作就忘了。只有一处例外的场景:一个页面上有多个动作(点赞/回复/删除)各自复用一个缓存变量,那你就必须有 refreshToken(),并且每个动作成功后都得调。

顺带把这块和一次性消费的冲突点挑明:同一页多动作 + 一次性 token 是天然打架的,第一个 Ajax 消费掉,第二个立刻吃「页面已过期」,现象跟多标签互踢一模一样,查起来又得绕一圈。解法要么 scope 化(每个动作一个 token),要么开个按需取 token 的轻量端点。这个端点如果走无状态 HMAC、不写 session,就顺带绕开了你正要说的 session lock——多 Ajax 并发根本不排队。

所以顺序上是「先想清楚哪些动作需要消费语义 → 再决定 token 从哪来」,反过来先写业务后补 token 池,基本都要推翻重来。

最后补一刀你那句被截断的 session lock:如果你图省事在 index.php 用了 session_start(['read_and_close'=>true]),那渲染时塞 token 就是纯粹静默失败——连"到底写没写"都分不出来,比你自己踩的顺序错更难查。写分支老老实实开写锁,只在纯读的地方 close。

wbcm
wbcm 见习用户见习用户 #369 9楼 2026-10-04 02:16
itjianghu:`back_forward` 这条能兜底,但它是超集不是等价——正常点链接回退历史也会命中,代价是偶尔多刷一次 token,可接受;真正的坑是它在首次加载时同样…

全收,尤其「token 收敛成单一来源」和「先定消费语义、再定 token 从哪来」这两句,把前面几楼散着的补丁收敛成了一条主线。

顺着你的思路补三点落地细节。一是 scope 可以交给 DOM 承担。 你说「一页多动作 + 一次性 token 天然打架」,其实只要每个 form 各埋一份、提交时 $(this).closest('form').find('input[name=csrf_token]').val() 现读,scope 天然就等于「哪个表单」,不用额外维护一张 action→token 映射表,也就没有「三个月后加新动作忘了同步」的 bug 面。

二是轻量端点建议做成预取队列,别每次现取。 无状态 HMAC 端点每次提交多一个 RTT,页面加载时先预取 3-5 个进数组,用掉一个补一个,队列见底再拉。多 Ajax 并发不排队(本来就不写 session),也省掉往返。这比纯 no-store 或纯消费都稳。

三是判断标准落成一句可执行的:动作可重放且后果不同(改密、支付、删除、下单)→ 要消费语义;只读或天然幂等(点赞、拉列表、加载更多)→ 不消费。写业务前先把这张表列出来,token 方案基本就定死了。

顺一句坑:refreshToken() 必须挂在 always 而不是 done。失败路径(超时、500)里 token 往往已经被消费了,只在成功回调刷新的话,用户重试照样吃「页面已过期」——而且比首次更难复现。

runyu
runyu 正式会员正式会员认证极客认证极客 #370 10楼 2026-10-04 02:24
wbcm:全收,尤其「token 收敛成单一来源」和「先定消费语义、再定 token 从哪来」这两句,把前面几楼散着的补丁收敛成了一条主线。 顺着你的思路补三点落地细节…

三条我都认,但第二条(预取队列)跟第一条的 scope 化其实有张力——预取本身就意味着 token 是「限时多次」而非「一次性」,这个前提不定死,队列就是埋雷。

DOM 承担 scope 我完全同意,closest('form') 现读是成本最低的路子。补两个边界:一是同一个 form 挂多个按钮(保存草稿 / 发布),closest 分不出来,得靠 event.originalEvent.submitter 或按钮自带的 name=value 区分;二是服务端要能拿到 scope 才谈得上校验,所以 scope 最好签进 token 本身(就是你上一楼 HMAC 那个 session_id|expire|scope),别靠前端传一个表单名字段——前端传的东西是可以改的。

预取队列要落地,先回答一个问题:token 是一次性还是限时多次?一次性的话,队列里每个 token 后端都得配 nonce 表标记已用,否则「预取 5 个」等于「5 个同时有效」,抗重放还不如单个;限时多次(无状态 HMAC 天然如此)才是真省 session。另外队列得带 exp,本地发之前先判过期,别拿半小时前预取的去换 401;队列初始化必须挂 pageshow,挂 DOMContentLoaded 的话从历史回退进来队列是空的或旧的,又回到你上面那层说的问题。

判断表可以直接当规范用,只补一句:幂等动作「不消费」≠「不校验」,点赞照样验签,只是允许重复用,别顺手把 token 也省了。

always 那条认,但失败时建议整体 reset 队列而不是只补当前那个——单个 token 失效往往是 session 层面的(重登、会话过期),只换一个大概率还是错。reset 里若带异步预取,记得加 in-flight 标记,别让重试和预取互相触发。