按钮连点会提交两次吗:用本地请求计数验收表单
用虚构本地表单练习提交验收:先写四个场景的允许次数,再对照按钮状态与接收端真实请求记录。附完整 Node.js 示例和 Chrome 无头操作结果,区分等待期防重复与后端幂等。
执行信息
- 测试环境
- Node.js v22.22.0 / macOS / Chrome 153.0.8010.53 无头模式 / 127.0.0.1 虚构示例
- 输入
- 请查看正文中的输入说明
- 产出
- 请查看正文中的产出说明
- 实测结果
- 请以作者提供的实测记录为准
AgentField 编辑整理|输入和响应均为虚构演示,不是用户案例。只在本机计数请求,不连接账号、付款或外部业务接口。
按钮变灰,只能说明界面状态发生了变化。要回答“等待期间又点了两次,有没有多提交”,还需要看接收端到底收到了几次请求。本文给做小型网页表单的读者一份可独立运行的练习:先写允许次数,再对照操作、状态和接收记录。
一、先写四个期望,不从结果倒推
固定输入为 demo note alpha。每个场景使用独立计数,第一次运行从 0 开始:
- 单次提交:点一次,允许 1 次请求,最终显示成功。
- 慢响应连点:响应延迟 5 秒,在等待期间再点两次提交按钮,累计仍应为 1 次,最终成功。
- 等待时回车:点提交后,在输入框按 Enter,等待结束后累计仍应为 1 次,最终成功。
- 失败后显式重试:首次模拟 HTTP 503,累计 1 次;显示失败后按钮恢复可操作,输入保留。再主动点一次,累计 2 次,状态依次为 503、200,最终成功。
第四项的第二次请求是事先允许的显式重试,不是需要消除的重复。没有自动重试;若错误后不再操作,计数应停在 1。
二、运行这份完整本地示例
把下列完整代码保存为 form-demo.cjs,在专用练习目录运行 node form-demo.cjs。需要 Node.js;编辑使用 v22.22.0,无需安装依赖。打开 http://127.0.0.1:18766/?case=single,只保留一个演示页面进行操作。
服务仅监听本机回环地址。页面上有四个场景链接和接收记录入口。关闭终端服务会丢失内存日志;重跑同一场景前先停止并重启服务,刷新页面并不会清零接收端计数。不要改成真实个人资料或把这份练习服务部署到公网。
const http = require('node:http');
// Shared by the browser and the separate controller/HTTP checks.
function createController(send, render) {
let pending = false;
return async function submit(note) {
if (pending) return { ignored: true };
pending = true; // Set synchronously, before the first await.
render({ phase: 'pending', pending, message: '等待响应' });
let final;
try {
const response = await send(note);
if (!response.ok) throw new Error('HTTP ' + response.status);
const data = await response.json();
final = { phase: 'success', message: '成功;本次请求 #' + data.number };
} catch (error) {
final = { phase: 'error', message: '失败:' + error.message + ';可显式重试' };
} finally {
pending = false;
render({ ...final, pending });
}
return final;
};
}
const html = `<!doctype html><html lang="zh-CN"><meta charset="utf-8">
<title>本地表单请求计数演示</title>
<style>body{font:18px system-ui;max-width:720px;margin:40px auto;padding:20px}input,button{font:inherit;padding:10px}pre{white-space:pre-wrap;font-size:14px}nav a{margin-right:14px}</style>
<h1>本地表单请求计数演示</h1>
<p>仅虚构数据;每个场景从零开始,重跑同一场景请重启服务。</p>
<nav><a href="/?case=single">单次</a><a href="/?case=slow">慢响应连点</a><a href="/?case=keyboard">等待时回车</a><a href="/?case=retry">失败后重试</a></nav>
<p id="scenario"></p>
<form id="form"><label for="note">虚构备注</label>
<input id="note" name="note" value="demo note alpha" required>
<button id="submit" type="submit">提交</button></form>
<p id="status" role="status">就绪</p><pre id="events">尚无状态变化</pre>
<p><a href="/evidence" target="_blank">查看接收端记录(只读)</a></p>
<script>
const createController = ${createController.toString()};
const caseId = new URLSearchParams(location.search).get('case') || 'single';
const form = document.querySelector('#form');
const button = document.querySelector('#submit');
const events = [];
document.querySelector('#scenario').textContent = '当前场景:' + caseId;
const submit = createController(note => fetch('/submit?case=' + encodeURIComponent(caseId), {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ note })
}), state => {
button.disabled = state.pending;
button.textContent = state.pending ? '提交中' : '提交';
form.setAttribute('aria-busy', String(state.pending));
document.querySelector('#status').textContent = state.message;
events.push({ at: new Date().toISOString(), ...state, buttonDisabled: button.disabled });
document.querySelector('#events').textContent = JSON.stringify(events, null, 2);
});
form.addEventListener('submit', event => {
event.preventDefault();
void submit(document.querySelector('#note').value);
});
</script></html>`;
function createDemoServer() {
const events = [];
const delays = { single: 300, slow: 5000, keyboard: 5000, retry: 1500 };
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
res.setHeader('Cache-Control', 'no-store');
const json = (status, data) => {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(data, null, 2));
};
if (req.method === 'GET' && url.pathname === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(html);
}
if (req.method === 'GET' && url.pathname === '/evidence') return json(200, { events });
if (req.method !== 'POST' || url.pathname !== '/submit') return json(404, { error: 'NOT_FOUND' });
const caseId = url.searchParams.get('case');
if (!Object.hasOwn(delays, caseId)) return json(400, { error: 'UNKNOWN_CASE' });
// The receiver logs every request, including invalid bodies; no deduplication.
const entry = { caseId, number: events.filter(e => e.caseId === caseId).length + 1,
receivedAt: new Date().toISOString(), respondedAt: null, status: null, note: null };
events.push(entry);
let body = '';
for await (const chunk of req) body += chunk;
try {
const input = JSON.parse(body);
if (input.note !== 'demo note alpha') throw new Error('FICTIONAL_NOTE_ONLY');
entry.note = input.note;
} catch {
entry.status = 400;
entry.respondedAt = new Date().toISOString();
return json(400, { error: 'FICTIONAL_NOTE_ONLY' });
}
await new Promise(resolve => setTimeout(resolve, delays[caseId]));
entry.status = caseId === 'retry' && entry.number === 1 ? 503 : 200;
entry.respondedAt = new Date().toISOString();
json(entry.status, { number: entry.number, simulated: true });
});
return { server, events };
}
if (require.main === module) {
const { server } = createDemoServer();
server.listen(18766, '127.0.0.1', () => console.log('Open http://127.0.0.1:18766/?case=single'));
}
module.exports = { createController, createDemoServer };