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

古法前端入门

第8讲 JavaScript ② ── DOM 与事件:添加、删除明细行,当场算出合计

发布

Bootstrap 的申请表单。明细表里有 3 行,每行都有删除按钮。表格下面是“+ 添加一行”按钮和“合计:42,240 日元”,再下面是黄色提示条:合计在 3 万日元以上,需要附上收据
本讲的目标。可以添加行,金额一输入合计就变,超过 3 万日元就出现提示(笔者的演示页面,在云端的 Chromium 中截图,是用 Playwright 实际输入和点击的画面。2026年10月17日。浏览器边框是为了说明而补画的)

不靠 AI,一行一行亲手写的“古法”前端入门。第8讲,页面终于要动起来了。
从 JavaScript 看到的 HTML(DOM),以及接收“被点击了”“被输入了”的机制(事件)。
第3讲约好“增加行在第8讲”的明细,这次让它可以添加和删除,
再接上第7讲的计算函数,做成一边输入一边更新合计的表单。

一

本讲的目标

新井前辈,福冈出差的明细有 5 行,可表单只能填 3 行……

古井第3讲说过“增加行在第8讲”。就是今天。

新井我让 AI 写了,结果它用 innerHTML 把整个页面重做一遍,刚输入的内容就没了。

古井这就像每挪一件家具就把房子重盖一遍。今天学的是一件一件挪部件的规矩。

当前位置:第8讲

1 准备 2 HTML 骨架 3 表单 4 CSS 基础 5 布局 6 Bootstrap 7 JS 基础 8 DOM 与事件 9 输入校验 10 通信 11 登录 12 审批流程 13 统计与打印 14 jQuery 15 收尾 当前位置 准备 HTML CSS JavaScript 实战 收尾

给第6讲 Bootstrap 版的申请表单加上下面这些动作:

  • 添加・删除行 ── 用“+ 添加一行”增加明细行,用每行的“删除”去掉(最多 10 行,至少 1 行)
  • 当场算出合计 ── 每输入一次金额,合计就变。全角和带逗号的也能读(第7讲的 toAmount)
  • 提示 ── 合计在 3 万日元以上时显示“需要收据”,有读不出的金额或非日元的行时也会说明

新写的是 js/form.js。第7讲的 js/app.js 一行都不改。

二

DOM:从 JavaScript 看 HTML

浏览器读了 HTML 后,会把它搭成第2讲看到的“树”。让 JavaScript 能操作这棵树的,就是 DOM(Document Object Model)。form.js 一开始先找到要操作的部件,放进变量里。

明细部分的 DOM 与 form.js 的变量

form fieldset table tbody#items tbody tr input[name=amount] button[data-action=remove] template#row-template template button#add-row addButton output#total totalOutput p#receipt-note receiptNote form.js 的变量 像 document.querySelector('#items') 这样,用和 CSS 相同的选择器找部件
const tbody = document.querySelector('#items'); // id 为 items 的一个元素
const rows = document.querySelectorAll('#items tr'); // 所有符合的元素
totalOutput.textContent = '42,240 日元'; // 改写里面的文字
receiptNote.hidden = false; // 去掉 hidden 属性,显示出来

找部件的方法,和第4讲的 CSS 选择器完全一样。#items 是 id,[name="amount"] 是属性。CSS 里学到的东西,在这里直接派上用场。

三

事件:接收“被点击了”

按钮被点击、文字被输入这类发生的事情,就是事件。用 addEventListener 事先拜托:“发生这件事时,请调用这个函数”。

addButton.addEventListener('click', () => addRow());
//          ↑ 事件的种类     ↑ 发生时调用的函数

输入框有两个名字相似的事件。我测了一下它们的区别:

input 与 change(实测)

输入 14720,再点击别处(实测) 1 4 7 2 0 离开输入框 input × 5 change × 1 input 每输入一个字符就发生,change 在离开输入框时发生一次 合计要边输入边变化,所以用 input 接收

合计要边输入边变化,所以用 input。被调用的函数会收到事件的信息(event),用 event.target 就知道“是在哪个部件上发生的”。

四

把计算和页面分开

第7讲特意把计算函数和页面分开,就是为了这一讲。

从输入金额到合计改变

form.js:读写页面 app.js:只负责计算 input 输入了金额 1 读取 各行金额・币种 2 toAmount 文字 → 金额 3 calcTotal needsReceipt 4 写入 合计・提示 5 把页面的工作和计算的工作,按文件分开 app.js 一行都没改,第7讲的测试照样通过

在 head 里,两个文件都加 defer 读入。defer 的 script 按书写顺序执行,所以先写负责计算的 app.js,再写使用它的 form.js。

<script src="js/app.js" defer></script>   <!-- 计算 -->
<script src="js/form.js" defer></script>  <!-- 页面(使用 app.js 的函数) -->

新井全写在一个文件里不是更省事吗?

古井一开始是。可合计算错了,你去哪儿找?页面的事和计算的事搅在一起,要找的地方就翻倍。分开了,计算用第7讲的测试、页面用浏览器,各自确认。

五

添加行:template 与复制

明细的一行有 5 个输入框和 1 个按钮,是相当大的一段 HTML。用 JavaScript 的字符串拼出来,既难读又容易错。所以把行的模板写在 HTML 的 template 元素里,JavaScript 只负责复制。

    <tbody id="items"></tbody>
  </table>
</div>
<template id="row-template">
  <tr>
    <td><input type="date" class="form-control form-control-sm" name="date" data-label="日期" required></td>
    <td><select class="form-select form-select-sm" name="category" data-label="类别" required>…</select></td>
    <td><input type="text" class="form-control form-control-sm" name="item" data-label="内容" required></td>
    <td><input type="text" class="form-control form-control-sm text-end" name="amount" data-label="金额" inputmode="numeric" autocomplete="off" required></td>
    <td><select class="form-select form-select-sm" name="currency" data-label="币种">…</select></td>
    <td><button type="button" class="btn btn-outline-danger btn-sm" data-action="remove">删除</button></td>
  </tr>
</template>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mt-3">
  <button type="button" class="btn btn-outline-primary btn-sm" id="add-row">+ 添加一行</button>
  <p class="mb-0 fs-5">合计:<output id="total" class="fw-bold">0</output></p>
</div>
<p class="alert alert-warning py-2 mt-3 mb-0" id="receipt-note" hidden>合计在 3 万日元以上,需要附上收据。</p>
<p class="small text-body-secondary mt-2 mb-0" id="skip-note" hidden></p>

复制模板,加进表格

<template> 模板(不显示) cloneNode <tr> 复制出的一行 append tbody#items 添加后重新设置读屏用的名字(实测) "第 1 行的金额" "第 2 行的金额" "第 3 行的金额" HTML 的结构写在 HTML 里,JS 只负责复制和编号 删除行时也重新编号,所以序号不会断
  • template 里的内容在“沉睡” ── 不显示,也不会作为表单的值发送。复制进表格后,才成为真正的行
  • 读屏用的名字由 JS 设置 ── 表格里的输入框旁边放不了 label,所以和第3讲一样用 aria-label。第几行要加了才知道,所以每次添加、删除都重新设置
  • 加完就跳到那一行 ── 把焦点移到新行的第一栏,用键盘操作的人可以直接继续输入
六

删除行:事件委托

“删除”按钮每行都有,而行是后来增加的。打开页面时给每个按钮 addEventListener,后加的行的按钮上就没有。这时要用到事件的冒泡。

事件会传给父元素

tbody#items tr td button「删除」 向父元素传递(冒泡) tbody.addEventListener( 'click', …) 只在 tbody 上监听一次 event.target.closest('tr') 找到被点击的行 点击后加的行的“删除”会怎样?(实测) 一开始给每个按钮绑定 → 还是 3 行(没反应) 在 tbody 上接收 → 减为 2 行
tbody.addEventListener('click', (event) => {
  const button = event.target.closest('[data-action="remove"]');
  if (button) removeRow(button.closest('tr'));
});

click 会从被点击的部件一层层传给父元素。所以只要在装行的盒子(tbody)上监听一次,后加的行的按钮也能接住。closest() 返回“从自己往父元素找,第一个符合的元素”,被点击的是哪一行马上就知道。这种写法叫事件委托。计算合计的 input 也同样在 tbody 上接收。

七

显示合计

合计栏用了 output 元素。它是表示“计算结果”的 HTML 元素,对读屏软件来说具有“状态提示”(status)的角色。

另外,金额栏从第3讲的 type="number" 改成了 type="text"(用 inputmode="numeric" 弹出数字键盘)。原因在 Chromium 里实际输入一下就明白:

# 在 type="number" 输入框里键入时的 value(Chromium 实测)
'14720' → value = '14720'
'14,720' → value = '14720'
'14720' → value = '14720'
'abc' → value = '' 与空栏无法区分

Chromium 的 number 输入框会擅自修改或丢掉输入的字符,再交出 value(其他浏览器不一定相同)。尤其是“abc”会变成和空栏一样的 '',“没填”和“填了非数字”就区分不开了。所以我们原样接收输入的文字,用自己的 toAmount 来读。空栏不计,读不出的行和非日元的行不计入合计,并提示有几行。

八

textContent 与 innerHTML

往页面上写文字时,form.js 始终用 textContent。类似的工具还有 innerHTML,但它会把字符串当作 HTML 来解析。把用户输入的文字放进 innerHTML 会怎样?试一下:

两列的表格。左列是放进 innerHTML 的结果,显示了一个破损图片的图标和“酒店 1 晚”。右列是放进 textContent 的结果,img 元素的标签原样显示为文字
把同一个字符串用 innerHTML 和 textContent 放到页面上的结果。左边埋进去的处理真的运行了(笔者的演示页面,在云端的 Chromium 中截图,是用 Playwright 实际输入和点击的画面。2026年10月17日。浏览器边框是为了说明而补画的)
const typed = '<img src="x" onerror="window.__xss = 1">酒店 1 晚';
a.innerHTML = typed;     // 生成了 img 元素,onerror 的处理被执行(__xss 变成 1)
b.textContent = typed;   // 作为普通文字显示

这次只是立了一个标记(window.__xss),如果是真正的攻击,这里就会运行盗取他人登录信息之类的处理(跨站脚本,XSS)。报销的“内容”栏是申请人可以随意填写的地方。把别人输入的文字显示到页面上,用 textContent。这是古往今来不变的铁律。

POINT

AI 写的页面代码里,常有用 innerHTML 整行拼出来的写法。开头新井说的“刚输入就没了”,原因也是每次显示合计都用 innerHTML 重做整张表(一重做,输入到一半的栏也被换成新的)。看到 innerHTML,就确认那里会不会放进别人输入的文字、是否真的需要重做。光这一点就能避免很多事故。

九

动手试试

用 Playwright 实际输入和点击,每一步都记录行数、合计、提示和焦点的位置。

明细表的 3 个场景。1 在第 1 行输入 14720,合计 14,720 日元。2 加到 3 行,输入全角的“12,800”和带逗号的“14,720”,合计 42,240 日元,出现收据提示。3 删除第 2 行,剩 2 行,合计 29,440 日元,提示消失
①输入,②添加行,③删除。每个场景里,合计和提示都会当场跟上(笔者的演示页面,在云端的 Chromium 中截图,是用 Playwright 实际输入和点击的画面。2026年10月17日。浏览器边框是为了说明而补画的)
# 实际操作并记录的值(Chromium)
操作 行数 合计 收据 焦点
刚打开时 1 0 日元 ― ―
在第 1 行输入(14720) 1 14,720 日元 ― 第 1 行的金额
“+ 添加一行” 2 14,720 日元 ― 第 2 行的日期
第 2 行输入“12,800” 2 27,520 日元 ― 第 2 行的金额
加第 3 行并输入“14,720” 3 42,240 日元 需要 第 3 行的金额
第 3 行币种改为美元 3 27,520 日元 ― ―
 └ 有 1 行不是日元(换算在第10讲,未计入合计)
第 2 行改为“约1万” 3 29,440 日元 ― 第 2 行的金额
 └ 有 1 行的金额无法读取(未计入合计)
第 2 行改回“12800” 3 42,240 日元 需要 第 2 行的金额
“删除”第 2 行 2 29,440 日元 ― #add-row

请注意焦点那一列。添加行后移到新行的“日期”,删除行后移到“+ 添加一行”按钮。被删除的行上的焦点会无处可去,所以事先决定把它放回哪里,也是页面的工作。最后是 form.js 的全貌:

// 报销系统:申请表单的明细(第8讲)
// 计算交给 app.js 的函数,这里只负责“读页面・写页面・响应操作”

const MAX_ROWS = 10; // 明细最多 10 行

// 页面上的部件,一开始只找一次
const tbody = document.querySelector('#items');
const template = document.querySelector('#row-template');
const addButton = document.querySelector('#add-row');
const totalOutput = document.querySelector('#total');
const receiptNote = document.querySelector('#receipt-note');
const skipNote = document.querySelector('#skip-note');

/** 添加一行 */
function addRow(moveFocus = true) {
  const row = template.content.firstElementChild.cloneNode(true);
  tbody.append(row);
  renumber();
  if (moveFocus) row.querySelector('input').focus(); // 移到新行的第一栏(第一行时不移动)
}

/** 删除一行 */
function removeRow(row) {
  row.remove();
  if (tbody.rows.length === 0) addRow(false); // 至少保留一行
  renumber();
  updateTotal();
  addButton.focus(); // 删除后回到“添加一行”按钮
}

/** 按行号重新设置读屏用的名字 */
function renumber() {
  [...tbody.rows].forEach((row, i) => {
    const n = i + 1;
    for (const field of row.querySelectorAll('[data-label]')) {
      const label = field.dataset.label;
      field.setAttribute('aria-label', `第 ${n} 行的${label}`);
    }
    row.querySelector('[data-action="remove"]').setAttribute('aria-label', `删除第 ${n} 行`);
  });
  addButton.disabled = tbody.rows.length >= MAX_ROWS; // 满 10 行后不能再添加
}

/** 从页面读取明细,显示合计和提示 */
function updateTotal() {
  const items = [];
  let unreadable = 0;
  let foreign = 0;
  for (const row of tbody.rows) {
    const amount = toAmount(row.querySelector('[name="amount"]').value);
    const currency = row.querySelector('[name="currency"]').value;
    if (amount === null) continue;                          // 空栏不计
    if (Number.isNaN(amount)) { unreadable++; continue; }  // 无法读成金额
    if (currency !== 'JPY') { foreign++; continue; }       // 非日元暂不相加
    items.push({ amount });
  }
  const total = calcTotal(items);
  totalOutput.textContent = formatYen(total);
  receiptNote.hidden = !needsReceipt(total);

  const notes = [];
  if (unreadable > 0) notes.push(`有 ${unreadable} 行的金额无法读取(未计入合计)`);
  if (foreign > 0) notes.push(`有 ${foreign} 行不是日元(换算在第10讲,未计入合计)`);
  skipNote.textContent = notes.join(' / ');
  skipNote.hidden = notes.length === 0;
}

// 每次输入都重新计算合计(为了对后加的行也有效,在 tbody 上接收)
tbody.addEventListener('input', (event) => {
  const name = event.target.name;
  if (name === 'amount' || name === 'currency') updateTotal();
});

// “删除”按钮(同样在 tbody 上接收)
tbody.addEventListener('click', (event) => {
  const button = event.target.closest('[data-action="remove"]');
  if (button) removeRow(button.closest('tr'));
});

addButton.addEventListener('click', () => addRow());

// 先做出第一行,并显示合计
addRow(false);
updateTotal();

结 — 一页总结与下讲预告

  1. DOM 是从 JavaScript 看到的 HTML 树。用 querySelector 找,用 textContent 或 hidden 改写。找法和 CSS 选择器相同。
  2. 事件用 addEventListener 接收。想边输入边响应用 input,离开输入框时响应用 change。
  3. 行的模板写在 template 里,JS 只负责复制添加。添加后重新设置读屏用的名字,并移动焦点。
  4. 后来增加的部件的事件,在父元素上接收,用 closest() 分辨(事件委托)。
  5. 别人输入的文字用 textContent 显示,innerHTML 会被当作 HTML 运行。计算放 app.js,页面放 form.js。

新井福冈出差的 5 行全都填进去了!……啊,不过事由空着点“提交申请”,好像也能直接发出去。

古井注意到点子上了。下一讲是输入校验。用 3 万日元的规定把收据设为必填,并用易懂的提示告诉用户“哪里要改”。

参考

页面截图是笔者制作的演示页面,于2026年10月17日在云端的 Chromium(Playwright)中拍摄。截图环境里没有 PingFang SC,中文以 Noto Sans CJK SC 显示。行数・合计・提示・焦点的去向・读屏用的名字、事件委托的实验、input 与 change 的次数、number 输入框的值、innerHTML 的实验,都是用 Playwright 操作同一个 Chromium 记录的实际值。form.html 已用 Nu Html Checker(vnu)确认没有错误。登场人物均为虚构。

古法前端入门 第8讲 — JavaScript ②:DOM 与事件 / 2026.10

← 返回文章列表