Old-School Front-End / 2026 ・ No.08
古法前端入门
第8讲 JavaScript ② ── DOM 与事件:添加、删除明细行,当场算出合计
发布
不靠 AI,一行一行亲手写的“古法”前端入门。第8讲,页面终于要动起来了。
从 JavaScript 看到的 HTML(DOM),以及接收“被点击了”“被输入了”的机制(事件)。
第3讲约好“增加行在第8讲”的明细,这次让它可以添加和删除,
再接上第7讲的计算函数,做成一边输入一边更新合计的表单。
本讲的目标
新井前辈,福冈出差的明细有 5 行,可表单只能填 3 行……
古井第3讲说过“增加行在第8讲”。就是今天。
新井我让 AI 写了,结果它用 innerHTML 把整个页面重做一遍,刚输入的内容就没了。
古井这就像每挪一件家具就把房子重盖一遍。今天学的是一件一件挪部件的规矩。
当前位置:第8讲
给第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 的变量
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(实测)
合计要边输入边变化,所以用 input。被调用的函数会收到事件的信息(event),用 event.target 就知道“是在哪个部件上发生的”。
把计算和页面分开
第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 里的内容在“沉睡” ── 不显示,也不会作为表单的值发送。复制进表格后,才成为真正的行
- 读屏用的名字由 JS 设置 ── 表格里的输入框旁边放不了 label,所以和第3讲一样用 aria-label。第几行要加了才知道,所以每次添加、删除都重新设置
- 加完就跳到那一行 ── 把焦点移到新行的第一栏,用键盘操作的人可以直接继续输入
删除行:事件委托
“删除”按钮每行都有,而行是后来增加的。打开页面时给每个按钮 addEventListener,后加的行的按钮上就没有。这时要用到事件的冒泡。
事件会传给父元素
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 会怎样?试一下:
const typed = '<img src="x" onerror="window.__xss = 1">酒店 1 晚'; a.innerHTML = typed; // 生成了 img 元素,onerror 的处理被执行(__xss 变成 1) b.textContent = typed; // 作为普通文字显示
这次只是立了一个标记(window.__xss),如果是真正的攻击,这里就会运行盗取他人登录信息之类的处理(跨站脚本,XSS)。报销的“内容”栏是申请人可以随意填写的地方。把别人输入的文字显示到页面上,用 textContent。这是古往今来不变的铁律。
AI 写的页面代码里,常有用 innerHTML 整行拼出来的写法。开头新井说的“刚输入就没了”,原因也是每次显示合计都用 innerHTML 重做整张表(一重做,输入到一半的栏也被换成新的)。看到 innerHTML,就确认那里会不会放进别人输入的文字、是否真的需要重做。光这一点就能避免很多事故。
动手试试
用 Playwright 实际输入和点击,每一步都记录行数、合计、提示和焦点的位置。
# 实际操作并记录的值(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();
结 — 一页总结与下讲预告
- DOM 是从 JavaScript 看到的 HTML 树。用 querySelector 找,用 textContent 或 hidden 改写。找法和 CSS 选择器相同。
- 事件用 addEventListener 接收。想边输入边响应用 input,离开输入框时响应用 change。
- 行的模板写在 template 里,JS 只负责复制添加。添加后重新设置读屏用的名字,并移动焦点。
- 后来增加的部件的事件,在父元素上接收,用 closest() 分辨(事件委托)。
- 别人输入的文字用 textContent 显示,innerHTML 会被当作 HTML 运行。计算放 app.js,页面放 form.js。
新井福冈出差的 5 行全都填进去了!……啊,不过事由空着点“提交申请”,好像也能直接发出去。
古井注意到点子上了。下一讲是输入校验。用 3 万日元的规定把收据设为必填,并用易懂的提示告诉用户“哪里要改”。
参考
- MDN — Document: querySelector() method
- MDN — EventTarget: addEventListener() method
- MDN — Element: input event
- MDN — HTMLElement: change event
- MDN — Event bubbling
- MDN — <template>: The Content Template element
- MDN — Node: cloneNode() method
- MDN — Element: closest() method
- MDN — Node: textContent property
- MDN — Element: innerHTML property
- MDN — <output>: The Output element
- MDN — inputmode
- MDN — Cross-site scripting (XSS)
- OWASP — DOM based XSS Prevention Cheat Sheet
页面截图是笔者制作的演示页面,于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