Old-School Front-End / 2026 ・ No.10
古法前端入门
第10讲 JavaScript ④ ── 异步通信:用 fetch,不切换页面就和服务器对话
发布
不靠 AI,一行一行亲手写的“古法”前端入门。第10讲是异步通信。
从第8讲起一直说“美元换算在第10讲”,第9讲又说“发送的机制在第10讲”,一拖再拖。
这次把两件事一起解决:用 fetch 从服务器取得汇率,发送申请并收到申请编号。
通信总是要等、会失败,怎么对付这两点,才是本讲的正题。
本讲的目标
新井前辈,一按“提交申请”就显示“发送的机制在第10讲制作”。终于到第10讲了!
古井是啊。美元换算也一直写着“第10讲”。今天是还账的日子。
新井我让 AI “把表单发出去”,结果一发送页面就变成一片白。出错的时候,输入的内容也全没了……
古井那是整个页面跳转的老式发法。今天让页面保持原样,在后台和服务器对话。
当前位置:第10讲
- 取得汇率 ── 打开页面就问服务器“1 美元合多少日元?”,把美元的行换算成日元计入合计
- 发送申请 ── 按“提交申请”,连同收据一起发送,显示返回的申请编号
- 不重复发送 ── 发送期间按钮不能按
- 失败了也不清空 ── 服务器出故障也好,网络断了也好,输入的内容都原样保留
对方的服务器是为本讲准备的练习用小服务器(用 Node.js 写了大约 60 行。内容不是正题,只要掌握地址和回复的形式就够了)。汇率 151.23 也是这台服务器返回的虚构数值。
等,还是不等
服务器的回复不会马上到。这次的练习用服务器大约要 0.6 秒,遇到远的服务器或拥挤的时候要好几秒。这期间页面要是一直卡住,可就麻烦了。
是在电话里干等,还是交了点菜单就回座位
JavaScript 的 fetch 属于后者。发出请求就马上进入下一行,等回复到了再执行“后续”。真的是这样吗?我用 3 行试了一下。
console.log('A');
fetch('/api/rates').then(() => console.log('C'));
console.log('B');
# 执行的顺序,以及从 A 算起的时间(Chromium 实测)
A: 调用 fetch 之前 0 ms
B: fetch 的下一行 0 ms
C: 回复到了(then 里面) 605 ms
写的顺序是 A・C・B,执行的顺序却是 A・B・C。fetch 马上返回的不是回复本身,而是“回复到了就交给你”的取货凭证(Promise)。后续要么写在 then 里,要么在加了 async 的函数里用 await 写“在这里等凭证兑现”。本讲的代码都用好读的 await 来写。
新井“异步”说白了就是“不等”啊。
古井对。等的时候页面照样能动。代价是“用到回复的处理”要是写错了地方,就会用到还是空的值。
请求与回复:HTTP 和 JSON
浏览器和服务器的往来
请求由方法和地址组成。只是拿东西用 GET,要交东西用 POST。回复(响应)带着一个表示成败的 3 位状态码。
| 状态码 | 意思 | 本讲什么时候返回 |
|---|---|---|
| 200 | OK | 返回汇率 |
| 201 | 已创建 | 受理申请,返回编号 |
| 400 | 请求有问题 | 没通过服务器的检查 |
| 404 | 没有这个地址 | URL 写错了 |
| 500 | 服务器内部故障 | (服务器的问题,不怪用户) |
回复的正文通常是 JSON。它是写法很像 JavaScript 对象的“文字”,练习用服务器的汇率是这样返回的。
{"base":"USD","quote":"JPY","rate":151.23,"date":"2026-10-17"}
收到的终究是文字,所以要先用 res.json() 变回对象,再像 data.rate 这样使用。
去取汇率
这是加到 form.js 里的 loadRate。在页面的最后调用一次。
/** 从服务器取得汇率 */
async function loadRate() {
rateNote.textContent = '正在取得汇率…';
try {
const res = await fetch('/api/rates', { signal: AbortSignal.timeout(10000) }); // 10 秒后放弃
if (!res.ok) throw new Error(`HTTP ${res.status}`); // 遇到 404 或 500,fetch 也不会报错
const data = await res.json(); // 把正文的 JSON 变成对象
rate = data.rate;
rateNote.textContent = `美元按 1 美元 = ${rate} 日元换算(${data.date} 的汇率)`;
} catch (error) {
rateNote.textContent = '没能取得汇率。美元的行未计入合计。';
}
updateTotal();
}
- async 与 await ── await 的意思是“等凭证变成回复,再继续这个函数”。等的只是这个函数,页面不会停
- 一开始是 null ── 汇率到达之前 rate 是 null。这期间美元的行不计入合计,并提示“正在取得…”
- 调用的一方不等 ── 最后那句 loadRate(); 不加 await。不等回复,先把第一行的显示做完
- 定好超时 ── fetch 本来没有超时。用 AbortSignal.timeout(10000) 定下“10 秒就放弃”
像前几讲那样双击打开 form.html(地址以 file:// 开头)的话,取不到汇率。Chromium 的控制台显示“Cross origin requests are only supported for protocol schemes: … http, https …”,页面上则是“没能取得汇率”。试 fetch 的时候,要先启动服务器,用 http:// 打开,这是规矩。
fetch 不当成错误的情况
上面的代码里有一行 if (!res.ok) throw …。没有这一行会怎样?我向各种对象 fetch 了一下。
# fetch 的结果(Chromium 实测) 对象 会报错吗? 内容 存在的地址(/api/rates) 不会 ok: true / 200 不存在的地址(/api/nothing) 不会 ok: false / 404 ← 不看 ok 就会当成“成功” 服务器故障(500) 不会 ok: false / 500 ← 不看 ok 就会当成“成功” 服务器没有运行 会 TypeError: Failed to fetch 回复太慢(1 秒就放弃) 会 TimeoutError: signal timed out
地址不存在(404)也好,服务器故障(500)也好,对 fetch 来说都是“回复确实送到了”,所以算成功。会报错(跳到 catch)的,只有连不上服务器和超时这两种。所以要自己确认 res.ok(状态码在 200 段时为 true),不行就用 throw 交给 catch。
fetch 结果的岔路
新井AI 写的 fetch,根本没看 res.ok……
古井只在顺利的日子里试,当然发现不了。服务器停摆的日子,一定会来。
美元换日元:钱要用整数算
取到汇率就可以换算了。像酒店费 189.50 美元那样,美元有美分。很想直接写“美元 × 汇率”,可是一试……
# 把 $100.00 按 1 美元 = 151.23 日元换算(Chromium 实测) 100 * 151.23 → 15122.999999999998 // 直接用小数相乘 Math.floor(100 * 151.23) → 15122 ← 少了 1 日元 centsToYen(10000, 151.23) → 15123 // 用美分和 1/100 日元的整数(centsToYen)
100 美元少了 1 日元。和第7讲的 0.1 + 0.2 一样,计算机没法精确地存住 151.23 这样的小数。所以要把美元变成美分、汇率变成以 1/100 日元为单位的整数,再相乘。
先变成整数再乘,最后只除一次
// ---- 第10讲:美元换算 ----
/**
* 把美元输入框里的文字,转成美分(1/100 美元)的整数。'189.5' → 18950
* 空栏返回 null,读不出返回 NaN(和 toAmount 的约定相同)
*/
function toCents(text) {
const s = String(text).normalize('NFKC').replaceAll(',', '').trim();
if (s === '') return null;
if (!/^[0-9]+(\.[0-9]{1,2})?$/.test(s)) return NaN; // 小数点后最多 2 位
const [dollars, cents = ''] = s.split('.');
return Number(dollars) * 100 + Number(cents.padEnd(2, '0')); // '.5' 是 50 美分
}
/**
* 把美分换算成日元(不足 1 日元的部分舍去)
* rate 是“1 美元 = 151.23 日元”里的 151.23。直接用小数相乘会有误差,所以先变成整数再乘
*/
function centsToYen(cents, rate) {
const sen = Math.round(rate * 100); // 151.23 → 15123(以 1/100 日元为单位的整数)
return Math.floor(cents * sen / 10000); // 整数之间相乘,不会有误差
}
toCents 的正则表达式是第9讲 /^[0-9]+$/ 的延续。( … ) 是一组,? 是“可有可无”,{1,2} 是“1〜2 次”。也就是“数字后面可以跟一个小数点和 1〜2 位数字”。不足 1 日元的部分舍去,这由公司的规定决定,实际工作中请向财务确认。
计算合计的部分(第8讲的 updateTotal)改成这样处理美元的行。
for (const row of tbody.rows) {
const text = row.querySelector('[name="amount"]').value;
const usd = row.querySelector('[name="currency"]').value === 'USD';
const amount = usd ? toCents(text) : toAmount(text);
if (amount === null) continue; // 空栏不计
if (Number.isNaN(amount)) { unreadable++; continue; } // 无法读成金额
if (!usd) items.push({ amount });
else if (rate === null) waiting++; // 还没有汇率
else items.push({ amount: centsToYen(amount, rate) }); // 美元换算成日元
}
第9讲的 validateClaim 也改成用第 3 个参数传入汇率,把美元的行计入合计(3 万日元的收据规则)。手写测试增加了 4 项,共 18 项。
✓ 正确的申请可以通过
✓ 事由只有全角空格
✓ 明细全部为空
✓ 空行不计
✓ 金额“约1万”
✓ 金额“0”
✓ 明天的日期
✓ 今天的日期没问题
✓ 合计 29,999 日元・没有收据
✓ 合计 30,000 日元・没有收据
✓ 合计 30,000 日元・有收据
✓ 美元的行也换算后计入合计(3 万日元以上需要收据)
✓ 没有确认规定
✓ 只有第 2 行的内容为空
✓ 没有汇率时美元的行不能申请
✓ toCents('189.5') 是 18950
✓ toCents('1.234') 读不出
✓ centsToYen(10000, 151.23) 是 15123
输入校验与换算的测试: 18 项中 18 项通过
发送申请:POST 与 FormData
要发送的内容用 new FormData(form, button) 来做。它按 name 收集表单的各栏,连收据文件也一起装进去。和第3讲在 form 上写的 enctype="multipart/form-data" 是同一种形式。
/** 发送中让提交按钮不能再按(防止重复提交) */
function setSending(sending, button) {
button.dataset.label ??= button.textContent;
for (const b of form.querySelectorAll('button[type="submit"]')) b.disabled = sending;
button.textContent = sending ? '发送中…' : button.dataset.label;
form.setAttribute('aria-busy', String(sending));
}
/** 显示结果(kind 是 'success' 或 'danger') */
function showResult(kind, message) {
result.className = `alert alert-${kind}`;
result.textContent = message;
result.hidden = false;
}
/** 把申请发送到服务器 */
async function send(data, button) {
setSending(true, button);
try {
const url = form.getAttribute('action'); // 用 form.action 会拿到 name="action" 的按钮(实测)
const res = await fetch(url, { method: 'POST', body: data, signal: AbortSignal.timeout(15000) }); // 15 秒后放弃
if (res.status === 400) { // 没通过服务器的检查(以和第9讲相同的形式返回)
const body = await res.json();
showErrors(body.errors);
summary.focus();
return; // 即使 return,finally 也会执行
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
showResult('success', body.draft ? `草稿已保存(${body.id})。` : `申请已受理。申请编号是 ${body.id}。`);
} catch (error) {
showResult('danger', '没能发送。请确认网络状态后再按一次。输入的内容没有丢失。');
} finally {
setSending(false, button); // 不管成功还是失败,都把按钮恢复原样
}
}
form.addEventListener('submit', async (event) => {
event.preventDefault(); // 阻止页面跳转,改用 fetch 发送
result.hidden = true;
const button = event.submitter;
if (!button.formNoValidate) { // “保存草稿”不做检查(第9讲)
const errors = validateClaim(readClaim(), localDate(), rate);
showErrors(errors);
if (errors.length > 0) {
summary.focus();
return;
}
}
await send(new FormData(form, button), button); // 把表单内容连同文件一起装进去(也放进按下的按钮)
});
古井老实说,我一开始写的是 fetch(form.action, …),结果发到了“/[object RadioNodeList]”。
新井那是什么?
古井第3讲给按钮加了 name="action" 吧。表单会把带 name 的部件当成自己的属性,所以 form.action 指向的不是地址,而是那两个按钮。
用 fetch 发送表单时的其他坑,我也一并确认了。
# 用 fetch 发送表单时的坑(Chromium 实测) form.action → [object RadioNodeList](两个 name="action" 的按钮) form.getAttribute('action') → '/api/expenses' new FormData(form).get('action') → null(按下的按钮没有放进去) new FormData(form, button).get('action') → 'submit' 没有附件时的 receipt → 收到一个名字为 ''、0 字节的文件 自己在 headers 里写 'Content-Type' → 服务器返回 400(boundary 没了,读不出来)
- 地址用 getAttribute 取 ── form.action 会输给 name="action" 的部件。MDN 上也有同样的提醒
- 把按下的按钮也传进去 ── 给 FormData 的第 2 个参数传 submitter,就和普通提交一样带上 action=submit。服务器靠它区分草稿和申请
- 空的文件栏 ── 什么都没附也会收到一个“没有名字・0 字节”的文件。服务器不能数个数,要看内容大小
- 不写 Content-Type ── 用 FormData 时,浏览器会连同分隔标记(boundary)一起写好。自己写的话标记就没了,服务器读不出来
而服务器那边,用和页面相同的 validate.js 再检查一次。因为第9讲把它写成了“不碰页面的函数”,在服务器上也能直接用(真正的服务器常用别的语言写,那时就把同样的规则照着写一遍)。假装用开发者工具关掉页面上的检查,事由空着发送——
服务器返回了 400,以及和第9讲相同形式的 { errors: [{ field, message }] }。因为统一了回复的形式,页面只要直接调用 showErrors 就行。
显示发送中・成功・失败
提交按钮的一生
发送中按钮如果还能按,等不及的人就会按两次,同一个申请就到了两份。实际上,用不禁用按钮的版本双击,服务器收到了 2 次。setSending 在发送期间把两个按钮设为 disabled,并把按下的按钮的文字改成“发送中…”。
恢复原样的处理写在 finally 里。不管成功、失败,还是遇到 400 中途 return,finally 一定会执行,所以不会忘记恢复。
失败时的提示要传达两件事:“该怎么办”和“输入没有丢失”。整页跳转的老式发法,在这里有可能把输入弄丢。用 fetch 的话页面保持原样,只要再按一次就行。
动手试试
启动练习用服务器,用 Playwright 实际输入和点击。汇率的回复设成约 0.6 秒,申请的回复约 1.2 秒。浏览器的时钟固定在 2026年10月17日 10:00(东京)。
# 实际操作并记录的值(Chromium。服务器是练习用的) 操作 合计 按钮 显示 刚打开时 0 日元 保存草稿 提交申请 正在取得汇率… 约 0.6 秒后(汇率到了) 0 日元 保存草稿 提交申请 美元按 1 美元 = 151.23 日元换算(2026-10-17 的汇率) 填 3 行(其中 2 行美元) 46,851 日元 保存草稿 提交申请 ― “提交申请” 46,851 日元 保存草稿 提交申请 合计在 3 万日元以上,请附上收据。 附上收据再“提交申请” 46,851 日元 [×]保存草稿 [×]发送中… ― 约 1.2 秒后(201) 46,851 日元 保存草稿 提交申请 申请已受理。申请编号是 EXP-2026-0012。 服务器返回 500 时“提交申请” 46,851 日元 保存草稿 提交申请 没能发送。请确认网络状态后再按一次。输入的内容没有丢失。 关掉页面检查,事由空着发送(400) 46,851 日元 保存草稿 提交申请 请填写事由。
- 刚打开时显示“正在取得…”,约 0.6 秒后汇率到达,显示随之改变
- 换算 2 行美元后,合计是 3,070 + 28,658 + 15,123 = 46,851 日元。超过 3 万日元,按第9讲的规则要求附上收据
- 发送中两个按钮都不能按,约 1.2 秒后显示了申请编号
- 服务器返回 500 时,事由、明细和收据都原样保留
写通信的代码时,要当它会延迟、会失败、会被按两次。顺利的路只有一条,不顺利的路却有好几条。不管走哪条路,都要让用户知道“现在发生了什么、下一步该做什么”。这就是页面这一侧的工作。
结 — 一页总结与下讲预告
- fetch 不等待。返回的是取货凭证(Promise),后续写在 await 的后面。
- 遇到 404 或 500,fetch 不会报错。看 res.ok,自己 throw。超时用 AbortSignal.timeout 来定。
- 钱要用整数算。美元变成美分,汇率变成 1/100 日元后再乘,最后只除一次。
- 发送的是 FormData(form, button)。地址用 getAttribute('action'),不写 Content-Type。
- 发送中禁用按钮,在 finally 里恢复。失败也不清空输入。服务器上也用同样的规则检查。
新井出现申请编号了!……可是前辈,这个申请算是谁提交的呢?
古井注意到点子上了。现在只是页面上写着“新井 太郎”而已。下一讲是登录和页面切换。确认是谁在用,并且能在申请列表・审批页面之间来回切换。
参考
- MDN — Using the Fetch API
- MDN — Response: ok property
- MDN — async function
- MDN — try...catch
- MDN — AbortSignal: timeout() static method
- MDN — FormData: FormData() constructor
- MDN — HTMLFormElement (named controls and native properties)
- MDN — HTTP response status codes
- MDN — JSON
页面截图是笔者制作的演示页面,经由练习用服务器(Node.js),于2026年10月17日在云端的 Chromium(Playwright)中拍摄。截图环境里没有 PingFang SC,中文以 Noto Sans CJK SC 显示。汇率 151.23 和申请编号是练习用服务器返回的虚构数值。执行顺序和所用时间、fetch 的结果、小数的计算结果、FormData 与表单的坑、重复发送的次数、用 file:// 打开时的提示、页面显示的变化,以及 18 项手写测试,都是用 Playwright 操作和运行同一个 Chromium 记录的实际值。form.html 已用 Nu Html Checker(vnu)确认没有错误。登场人物均为虚构。
古法前端入门 第10讲 — JavaScript ④:异步通信 / 2026.10