Old-School Front-End / 2026 ・ No.08
いにしえのフロントエンド入門
第8回 JavaScript ② ── DOM とイベント:明細の行を足し、消し、合計をその場で出す
公開
AI に頼まず、一行ずつ手で書く「いにしえの」フロントエンド入門。第8回は、いよいよ画面が動き出す回です。
JavaScript から見た HTML の姿(DOM)と、「押された」「打たれた」を受け取る仕組み(イベント)。
第3回で「行を増やすのは第8回」と約束した明細を、追加・削除できるようにし、
第7回の計算の関数につないで、入力したそばから合計が変わるフォームにします。
今回のゴール
新井先輩、福岡出張の明細が5行あるんですけど、フォームには3行しか入りません……。
古井第3回で「行を増やすのは第8回」と言ったな。今日がその日だ。
新井AI に頼んだら、画面ごと innerHTML で作り直すコードが出てきて、打ったそばから入力が消えるんです。
古井家具を1つ動かすたびに家を建て直すようなものだな。今日は、部品を1つずつ動かす作法を覚えよう。
いまここ:第8回
第6回の Bootstrap 版の申請フォームに、次の動きを付けます。
- 行を追加・削除 ── 「+ 行を追加」で明細の行が増え、各行の「削除」で消える(最大10行、最低1行)
- 合計をその場で ── 金額を打つたびに合計が変わる。全角やカンマ入りでも読める(第7回の toAmount)
- お知らせ ── 合計が3万円以上なら「領収書が必要」、読めない金額や円以外の行があればその旨を出す
新しく作るのは js/form.js。第7回の js/app.js は1行も変えません。
DOM:JavaScript から見た HTML
ブラウザは HTML を読むと、第2回で見た「木」の形に組み立てます。この木を JavaScript から触れるようにしたものが DOM(Document Object Model)です。form.js は最初に、触る予定の部品を探して変数に入れておきます。
明細まわりの DOM と、form.js の変数
const tbody = document.querySelector('#items'); // id が items の要素を1つ 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 の関数を使う) -->
新井全部1つのファイルに書いたほうが楽じゃないですか?
古井最初はな。だが合計の計算が間違っていたら、どこを見る? 画面の都合と計算の都合が混ざっていると、探す場所が倍になる。分けておけば、計算は第7回のテストで、画面はブラウザで、別々に確かめられる。
行を足す:template と複製
明細の1行は、入力欄が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)に1つだけ聞き耳を立てておけば、後から足した行のボタンも拾えます。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');
/** 行を1つ足す */
function addRow(moveFocus = true) {
const row = template.content.firstElementChild.cloneNode(true);
tbody.append(row);
renumber();
if (moveFocus) row.querySelector('input').focus(); // 足した行の最初の欄へ(最初の1行のときは動かさない)
}
/** 行を消す */
function removeRow(row) {
row.remove();
if (tbody.rows.length === 0) addRow(false); // 最低1行は残す
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());
// 最初の1行を作り、合計を表示する
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)で撮影したものです。撮影環境には Hiragino Sans がないため、日本語は Noto Sans CJK JP で表示されています。行数・合計・お知らせ・フォーカスの移り先・読み上げ用の名前、イベントの委任の実験、input と change の回数、number 欄の値、innerHTML の実験は、同じ Chromium を Playwright で操作して記録した実際の値です。form.html は Nu Html Checker(vnu)でエラーがないことを確かめています。登場人物は架空の人物です。
いにしえのフロントエンド入門 第8回 — JavaScript ②:DOM とイベント / 2026.10