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

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客
发布于 2026-10-04 01:19 ·0 浏览 ·9 回复
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-696.html
转载请注明出处,版权归原作者所有。

全部回复 9

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 往往已经被消费了,只在成功回调刷新的话,用户重试照样吃「页面已过期」——而且比首次更难复现。