Old-School Front-End / 2026 ・ No.10

古法前端入门

第10讲 JavaScript ④ ── 异步通信:用 fetch,不切换页面就和服务器对话

发布

Bootstrap 的申请表单。明细有 3 行:第 1 行成田特快 3070 日元,第 2 行酒店 1 晚 189.50 美元,第 3 行会议参加费 100 美元。合计 46,851 日元,下面写着“美元按 1 美元 = 151.23 日元换算(2026-10-17 的汇率)”。最下方是绿色提示条“申请已受理。申请编号是 EXP-2026-0012。”
本讲的目标。从服务器取得汇率,把美元换算成日元,发送申请并收到申请编号(笔者的演示页面,经由练习用服务器,在云端的 Chromium 中截图,是用 Playwright 实际输入和点击的画面。2026年10月17日。浏览器边框是为了说明而补画的)

不靠 AI,一行一行亲手写的“古法”前端入门。第10讲是异步通信。
从第8讲起一直说“美元换算在第10讲”,第9讲又说“发送的机制在第10讲”,一拖再拖。
这次把两件事一起解决:用 fetch 从服务器取得汇率,发送申请并收到申请编号。
通信总是要等、会失败,怎么对付这两点,才是本讲的正题。

一

本讲的目标

新井前辈,一按“提交申请”就显示“发送的机制在第10讲制作”。终于到第10讲了!

古井是啊。美元换算也一直写着“第10讲”。今天是还账的日子。

新井我让 AI “把表单发出去”,结果一发送页面就变成一片白。出错的时候,输入的内容也全没了……

古井那是整个页面跳转的老式发法。今天让页面保持原样,在后台和服务器对话。

当前位置:第10讲

1 准备 2 HTML 骨架 3 表单 4 CSS 基础 5 布局 6 Bootstrap 7 JS 基础 8 DOM 与事件 9 输入校验 10 通信 11 登录 12 审批流程 13 统计与打印 14 jQuery 15 收尾 当前位置 准备 HTML CSS JavaScript 实战 收尾
  • 取得汇率 ── 打开页面就问服务器“1 美元合多少日元?”,把美元的行换算成日元计入合计
  • 发送申请 ── 按“提交申请”,连同收据一起发送,显示返回的申请编号
  • 不重复发送 ── 发送期间按钮不能按
  • 失败了也不清空 ── 服务器出故障也好,网络断了也好,输入的内容都原样保留

对方的服务器是为本讲准备的练习用小服务器(用 Node.js 写了大约 60 行。内容不是正题,只要掌握地址和回复的形式就够了)。汇率 151.23 也是这台服务器返回的虚构数值。

二

等,还是不等

服务器的回复不会马上到。这次的练习用服务器大约要 0.6 秒,遇到远的服务器或拥挤的时候要好几秒。这期间页面要是一直卡住,可就麻烦了。

是在电话里干等,还是交了点菜单就回座位

等待(想象图)  电话里干等 回复到来之前,页面卡住 A C B 不等待(fetch) 交了点菜单就回座 等待期间也能输入、滚动 A B C 0 ms 200 ms 400 ms 600 ms 800 ms A=调用 fetch 之前 B=fetch 的下一行 C=回复到达时(then 里面) fetch 只是发出请求,马上进入下一行(实测:B 是 0 ms,C 是 605 ms) 所以“回复之后要做的事”,要写在 then 里或 await 的后面

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 /api/rates 200 OK {"rate": 151.23, …} POST /api/expenses 正文:FormData(multipart) 201 Created {"id": "EXP-2026-0012"} 要汇率 交申请 用 JSON 回 返回编号 请求=方法+地址(+正文)。响应=状态码+正文 正文常用 JSON。用 res.json() 变回对象

请求由方法和地址组成。只是拿东西用 GET,要交东西用 POST。回复(响应)带着一个表示成败的 3 位状态码。

状态码意思本讲什么时候返回
200OK返回汇率
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 结果的岔路

fetch(…) 发请求 送到了? 没送到・超时 catch 没能发送 有回复 res.ok ? true 2xx 成功 false 400 请用户修改 404・500 服务器的问题 throw 交给 catch fetch 只在“没有回复”时报错(实测:404 和 500 都不报错) 所以要看 res.ok,不行就自己 throw

新井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 日元为单位的整数,再相乘。

先变成整数再乘,最后只除一次

美元 → 日元,先变成整数再乘 $189.50 美元 18950 美分 151.23 1 美元合多少日元 15123 1/100 日元 18950 × 15123 ÷ 10000 → 28658 直接用小数相乘的话(实测) 100 * 151.23 → 15122.999999999998 Math.floor(…) → 15122 ← 少了 1 日元 和第7讲 0.1 + 0.2 是同一个坑。钱要用整数来算
// ---- 第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讲把它写成了“不碰页面的函数”,在服务器上也能直接用(真正的服务器常用别的语言写,那时就把同样的规则照着写一遍)。假装用开发者工具关掉页面上的检查,事由空着发送——

表单上方是红色汇总“请检查输入内容(1 处)”和“请填写事由。”的链接。下面的事由栏是空的,带着红框
就算连同 JavaScript 一起关掉页面上的检查再发送,服务器也会用同一个 validate.js 拦下,并按它的回复显示红框(笔者的演示页面,经由练习用服务器,在云端的 Chromium 中截图,是用 Playwright 实际输入和点击的画面。2026年10月17日。浏览器边框是为了说明而补画的)

服务器返回了 400,以及和第9讲相同形式的 { errors: [{ field, message }] }。因为统一了回复的形式,页面只要直接调用 showErrors 就行。

八

显示发送中・成功・失败

提交按钮的一生

提交申请 可以按 发送中… disabled(两个都是) 提交申请 在 finally 里恢复 提交按钮的一生 双击的话(实测) 不禁用按钮的版本 POST POST 发送中设为 disabled 的版本 POST 成功和失败都要恢复,所以恢复的处理只在 finally 里写一次

发送中按钮如果还能按,等不及的人就会按两次,同一个申请就到了两份。实际上,用不禁用按钮的版本双击,服务器收到了 2 次。setSending 在发送期间把两个按钮设为 disabled,并把按下的按钮的文字改成“发送中…”。

恢复原样的处理写在 finally 里。不管成功、失败,还是遇到 400 中途 return,finally 一定会执行,所以不会忘记恢复。

3 个场景。1 发送中,“保存草稿”和“发送中…”都不能按。2 回复到了,绿色提示条显示申请编号。3 服务器返回 500 时,红色提示条显示“没能发送”。三个场景里收据一栏都还留着 receipt.pdf
①发送中,②已受理,③服务器故障。失败了输入也不会丢(笔者的演示页面,经由练习用服务器,在云端的 Chromium 中截图,是用 Playwright 实际输入和点击的画面。2026年10月17日。浏览器边框是为了说明而补画的)

失败时的提示要传达两件事:“该怎么办”和“输入没有丢失”。整页跳转的老式发法,在这里有可能把输入弄丢。用 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 时,事由、明细和收据都原样保留
POINT

写通信的代码时,要当它会延迟、会失败、会被按两次。顺利的路只有一条,不顺利的路却有好几条。不管走哪条路,都要让用户知道“现在发生了什么、下一步该做什么”。这就是页面这一侧的工作。

结 — 一页总结与下讲预告

  1. fetch 不等待。返回的是取货凭证(Promise),后续写在 await 的后面。
  2. 遇到 404 或 500,fetch 不会报错。看 res.ok,自己 throw。超时用 AbortSignal.timeout 来定。
  3. 钱要用整数算。美元变成美分,汇率变成 1/100 日元后再乘,最后只除一次。
  4. 发送的是 FormData(form, button)。地址用 getAttribute('action'),不写 Content-Type。
  5. 发送中禁用按钮,在 finally 里恢复。失败也不清空输入。服务器上也用同样的规则检查。

新井出现申请编号了!……可是前辈,这个申请算是谁提交的呢?

古井注意到点子上了。现在只是页面上写着“新井 太郎”而已。下一讲是登录和页面切换。确认是谁在用,并且能在申请列表・审批页面之间来回切换。

参考

页面截图是笔者制作的演示页面,经由练习用服务器(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

← 返回文章列表