Old-School Front-End · Frameworks / 2026 ・ No.01
いにしえのフロントエンド入門 フレームワーク編
第1回 なぜフレームワークが生まれたか ── 同じ明細の表を、3通りに書いて測る
公開
フロントエンド編では、経費精算の申請フォームを素の JavaScript と jQuery で作りました。バックエンド編では、その申請を受け取るサーバーを作りました。新しい連載は、画面の側にもどってフレームワークです。全15回で、歴史と流派の移り変わりをたどってから、React と Next.js をくわしく見ていきます。
第1回は「なぜ要るのか」です。申請フォームの明細の表を、jQuery・手作り・React の3通りで書き、同じ操作をして、画面がどこでずれるか、どこで遅くなるかを測ります。
フロントエンド編の画面をもう一度
新井今日から React ですね! jQuery はもう古いので、さっそく書きかえましょう。
古井古いかどうかは、あとでいい。先に、jQuery の何に困って React が生まれたのかを測ろう。困ってもいないのに道具を替えると、新しい道具の困りごとだけが増える。
新井……前の連載でも、似たことを言われた気がします。
全15回の路線図(いまここ:第1回)
材料は、フロントエンド編 第14回の jQuery 版の申請フォーム(form-jq.js)です。その中から明細の表(行の追加・削除、金額、通貨、合計)だけを取り出し、同じ画面を3通りに書きました。
- A jQuery ── form-jq.js の明細の部分そのまま(フロントエンド編のやり方)
- B 手作り ── 値を1か所の変数に持ち、そこから表を全部作りなおす(道具なし)
- C React ── B と同じ考え方を、React 19.3 に任せる
計算(円とドルの合計、領収書が要る金額)は、フロントエンド編の app.js を3つとも共通で使います。ちがうのは画面の直し方だけです。この3つに、ブラウザを動かす道具(Playwright)で同じ8つの操作をして、そのたびに画面を見ました。
本当の値はどこにあるか
まず、form-jq.js を数えました。
# フロントエンド編 第14回の申請フォーム(form-jq.js、空行を除いて 187 行)を数えた JavaScript の変数に持っている値1 (let rate) 画面(DOM)に書いている場所34 .text(…) .prop('hidden', …) .append(…) … 画面(DOM)から読んでいる場所22 .val() .children('tr') .prop('checked') … # 明細の行・金額・合計・赤い枠・エラーの件数は、どこにある? → 画面の中だけ。合計がほしければ、表の行を1つずつ読みなおす
JavaScript の変数に持っている値は、為替レート1つだけでした。明細の行も、金額も、合計も、どの欄が赤い枠かも、画面(DOM)の中だけにあります。合計を出したいときは、表の行を1つずつ読みなおします(22 か所)。結果は画面へ書きもどします(34 か所)。
これは jQuery が悪いのではありません。素の JavaScript で書いたフロントエンド編の form.js も、同じ形でした。画面そのものを、値の置き場所にしているのです。画面が小さいうちは、これがいちばん手っ取り早い書き方です。
操作ごとに呼ぶ関数
画面が値の置き場所なら、何かが変わるたびに、画面のどこを書きかえるかを人が決めて呼ぶことになります。たとえば「行を消す」です。
/** 行を消す */
function removeRow($row) {
$row.remove();
if ($tbody.children('tr').length === 0) addRow(false); // 最低1行は残す
renumber();
updateTotal();
recheck();
$addButton.trigger('focus');
}
1行消すだけで、5つの後始末をしています。行番号の読み上げ名をつけなおし、合計を出しなおし、赤い枠をつけなおし、カーソルの行き先を決める。操作ごとに、どの関数を呼んでいるかを表にしました。
操作ごとに、画面を直す関数(form-jq.js から)
この表は、ソースのどこにも書いてありません。書いた人の頭の中にあります。右端の ★ の列は、次の節で足す「件数」です。
件数を1つ出すだけで
古井経理から頼まれた。「明細の見出しに、何件あるかを出してほしい」。
新井1行で終わりますよ。合計を出すところで、ついでに件数も出せばいいんです。
# rows-jq.js:form-jq.js の明細の部分に、件数を出す2行を足した(★)
/** 画面から明細を読み、合計を表示する */
function updateTotal() {
const items = [];
$tbody.children('tr').each(function () {
const text = $(this).find('[name="amount"]').val();
const usd = $(this).find('[name="currency"]').val() === 'USD';
const amount = usd ? toCents(text) : toAmount(text);
if (amount === null || Number.isNaN(amount)) return;
if (!usd) items.push({ amount });
else if (rate !== null) items.push({ amount: centsToYen(amount, rate) });
});
const total = calcTotal(items);
$total.text(formatYen(total));
$receiptNote.prop('hidden', !needsReceipt(total));
$count.text(ROWS_TEXT.count($tbody.children('tr').length)); // ★ 件数も、合計といっしょに出す
}
合計を出す関数の最後に、件数を出す1行を足しました(書き方 A)。行を足したり消したりすれば合計も出しなおすはず、という読みです。8つの操作をして、そのたびに画面を見ました。
# 書き方 A:同じ8つの操作をして、そのたびに画面を見た 操作行件数の表示合計の表示 開いた1 明細 1 件0 円 1行目に 147201 明細 1 件14,720 円 行を追加2 明細 1 件14,720 円← ずれ 2行目に 128002 明細 2 件27,520 円 行を追加3 明細 2 件27,520 円← ずれ 3行目をドルで 1003 明細 3 件42,643 円 2行目を削除2 明細 2 件29,843 円 行を追加3 明細 2 件29,843 円← ずれ # 行を足したあとの3回、件数が1つ古い。合計はずっと正しい
行を足したあとの3回、件数が1つ古いままでした。三の表を見ると理由がわかります。「行を追加」は updateTotal を呼びません。空の行を足しても合計は変わらないので、呼ぶ必要がなかったのです。「行を削除」のほうは呼んでいるので、ずれません。
# rows-jq2.js:行を足したときも updateTotal() を呼ぶ(8つの操作で、ずれは 0 回)
/** 行を1つ足す */
function addRow(moveFocus = true) {
const $row = $($template.html().trim());
$tbody.append($row);
renumber();
updateTotal(); // ★ 行を足したときも、件数を出しなおす
if (moveFocus) $row.find('input').first().trigger('focus');
}
addRow にも1行足すと、ずれは 0 回になりました(中国語・英語のページでも同じ)。直すのは簡単です。むずかしいのは、どこを直せばいいかを、表の全部の行について確かめることです。三の表なら、★を入れて列が7つ、操作が6つで、気にするマスは 42。画面に出すものが増えるたび、列が増えます。
新井しかも、ずれても誰も気づかないですね。合計は合ってるので。
古井そう。エラーにならずに、ただ古い。いちばん見つけにくい種類のバグだ。
状態から画面を作る
そこで、考え方を逆にします。本当の値はJavaScript の変数1か所に持つ(これを状態と呼びます)。操作は状態を変えるだけ。そして画面は、毎回状態から全部作る。どの操作のあとも同じ render() を呼ぶので、「どこを直すか」を考える必要がなくなります(書き方 B、道具なし)。
// 状態:この画面の「本当の値」は、ここだけにある
const state = { rows: [newRow()], rate: null, rateDate: '' };
/** 状態から、画面を全部作る */
function render() {
const { rows, rate } = state;
tbody.innerHTML = rows.map((r, i) => `
<tr data-id="${r.id}">
<td><input … name="item" value="${esc(r.item)}" …></td>
<td><input … name="amount" value="${esc(r.amount)}" …></td>
…
</tr>`).join('');
const total = calcTotal(rows.map((r) => yenOf(r, rate)).filter((y) => y !== null && !Number.isNaN(y)).map((amount) => ({ amount })));
document.querySelector('#total').textContent = formatYen(total);
document.querySelector('#receipt-note').hidden = !needsReceipt(total);
document.querySelector('#count').textContent = ROWS_TEXT.count(rows.length);
document.querySelector('#add-row').disabled = rows.length >= MAX_ROWS;
document.querySelector('#rate-note').textContent = rate === null ? '' : ROWS_TEXT.rate(rate, state.rateDate);
}
// 操作は「状態を変えて、render() を呼ぶ」だけ
tbody.addEventListener('input', (event) => {
const row = state.rows.find((r) => r.id === Number(event.target.closest('tr').dataset.id));
row[event.target.name] = event.target.value;
render();
});
tbody.addEventListener('click', (event) => {
if (!event.target.matches('[data-action="remove"]')) return;
const id = Number(event.target.closest('tr').dataset.id);
state.rows = state.rows.filter((r) => r.id !== id);
if (state.rows.length === 0) state.rows.push(newRow());
render();
});
document.querySelector('#add-row').addEventListener('click', () => {
state.rows.push(newRow());
render();
});
件数も、合計も、ボタンの押せる・押せないも、render() の中で毎回出しなおします。同じ8つの操作をしました。
# 書き方 B:同じ8つの操作 操作行件数の表示合計の表示金額の欄 開いた1 明細 1 件0 円— 1行目に 147201 明細 1 件1 円1 行を追加2 明細 2 件1 円1 / — 2行目に 128002 明細 2 件2 円1 / 1 行を追加3 明細 3 件2 円1 / 1 / — 3行目をドルで 1003 明細 3 件153 円1 / 1 / 1 2行目を削除2 明細 2 件152 円1 / 1 行を追加3 明細 3 件152 円1 / 1 / — # 件数はずっと正しい。でも、どの欄も 1 文字しか入らない
件数は一度もずれませんでした。そのかわり、どの金額の欄にも 1 文字しか入りません。14720 と打ったのに、合計は 1 円です。
1 を打つと input のイベントが来て、状態を変え、render() が表の中身を innerHTML で作りなおします。いま打っていた欄も、新しい欄に入れかわります。カーソルは古い欄といっしょに消え、ページの本体(BODY)にもどります。残りの 4720 は、どの欄にも入りません。
全部作りなおすと
画面の中で何が起きているかを数えました。ブラウザの MutationObserver は、画面の部品(ノード)が足されたり消されたり、属性や文字が変わったりするたびに記録を残してくれます。3行ある表で、1文字ずつ打ちました。
# 3行ある画面で、1行目の金額に 1・4・7・2・0 と1文字ずつ打ち、そのたびに画面の変化を MutationObserver で数えた
打った字変化の記録消えた要素欄の値カーソル
A 1 5 2 1 1行目の金額
A 4 5 2 14 1行目の金額
A 7 5 2 147 1行目の金額
A 2 5 2 1472 1行目の金額
A 0 5 2 14720 1行目の金額
B 1 5 9 1 BODY
B 4 0 0 1 BODY
B 7 0 0 1 BODY
B 2 0 0 1 BODY
B 0 0 0 1 BODY
C 1 23 0 1 1行目の金額
C 4 23 0 14 1行目の金額
C 7 23 0 147 1行目の金額
C 2 23 0 1472 1行目の金額
C 0 23 0 14720 1行目の金額
# C の 23 件のうち 21 件は、React が入力欄の name・type を同じ値で付けなおす内部の手順。中身が変わったのは、入力欄の value と合計の文字の2か所
A は、合計と件数の文字を書きかえるだけ(消えたのは古い文字 2 つ)。B は 1 文字目で、表の行もふくめて 9 つの部品が消えて作りなおされ、そのあとは何も起きていません。打った字の行き先が、もうないからです。C の数字は次の節で見ます。
もう1つの問題は速さです。行の数を 10・100・1000 にして、1回の入力から次の描画が終わるまでを測りました。
1回の入力から、次の描画が終わるまで(実測の中央値)
# 行を 10・100・1000 にして、1行目の金額を変えてから次の描画が終わるまで(20 回の中央値、ms) 行A jQuery B 全部作りなおすC React 10 16.6 16.5 16.7 100 16.7 29.7 16.8 1000 20.8 326.8 30.3 (次の描画まで待つので、何もしなくても約 16.7 ms かかる。Chromium 153・この PC)
ブラウザは1秒に約 60 回画面を描くので、次の描画を待つだけで約 16.7 ms かかります。A はそこからほとんど増えません。B は行の数に比例して重くなり、1000 行では1文字ごとに 0.3 秒。経費の明細が 1000 行になることはまずありませんが、一覧の画面ならすぐです。
B の考え方(状態から全部作る)は、ずれをなくしました。でも、入力中の欄を壊し、遅い。考え方は B のまま、実際に書きかえるのは変わったところだけにできれば、両方を取れます。それを引き受けるのがフレームワークです。
React の答え
function Rows() {
const [rows, setRows] = useState(() => [newRow()]);
const [rate, setRate] = useState(null);
useEffect(() => { // 画面が出たあとで、一度だけレートを取る
fetch('/api/rates').then((res) => res.json()).then(setRate).catch(() => {});
}, []);
const change = (id, name, value) => setRows(rows.map((r) => (r.id === id ? { ...r, [name]: value } : r)));
const remove = (id) => {
const rest = rows.filter((r) => r.id !== id);
setRows(rest.length > 0 ? rest : [newRow()]);
};
const total = calcTotal(rows.map((r) => yenOf(r, rate)).filter((y) => y !== null && !Number.isNaN(y)).map((amount) => ({ amount })));
return (
<>
<h1 className="h4 mb-3">{ROWS_TEXT.heading} <span id="count" className="badge text-bg-secondary">{ROWS_TEXT.count(rows.length)}</span></h1>
<table className="table table-sm align-middle mb-0">
<thead>…</thead>
<tbody id="items">
{rows.map((r, i) => (
<tr key={r.id}>
<td><input … name="item" value={r.item} onChange={(e) => change(r.id, 'item', e.target.value)} /></td>
<td><input … name="amount" value={r.amount} onChange={(e) => change(r.id, 'amount', e.target.value)} /></td>
<td><select … value={r.currency} onChange={(e) => change(r.id, 'currency', e.target.value)}>…</select></td>
<td><button … onClick={() => remove(r.id)}>{ROWS_TEXT.remove}</button></td>
</tr>
))}
</tbody>
</table>
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mt-3">
<button type="button" className="btn btn-outline-primary btn-sm" id="add-row" disabled={rows.length >= MAX_ROWS}
onClick={() => setRows([...rows, newRow()])}>{ROWS_TEXT.add}</button>
<p className="mb-0 fs-5">{ROWS_TEXT.total}<output id="total" className="fw-bold">{formatYen(total)}</output></p>
</div>
<p … id="receipt-note" hidden={!needsReceipt(total)}>{ROWS_TEXT.receipt}</p>
<p … id="rate-note">{rate ? ROWS_TEXT.rate(rate.rate, rate.date) : ''}</p>
</>
);
}
書き方 C です。useState で状態を持ち、関数は「この状態なら、画面はこういう形」を返すだけ。HTML に似た部分は JSX という書き方です。操作は setRows で状態を変えます。B とちがうのは、画面を直接作らないところです。React は新しい形を前回の形と比べ、ちがうところだけを本物の画面に書きます。key={r.id} は、どの行が前回のどの行かを React に教える印です。
# 書き方 C:同じ8つの操作 操作行件数の表示合計の表示金額の欄 開いた1 明細 1 件0 円— 1行目に 147201 明細 1 件14,720 円14720 行を追加2 明細 2 件14,720 円14720 / — 2行目に 128002 明細 2 件27,520 円14720 / 12800 行を追加3 明細 3 件27,520 円14720 / 12800 / — 3行目をドルで 1003 明細 3 件42,643 円14720 / 12800 / 100 2行目を削除2 明細 2 件29,843 円14720 / 100 行を追加3 明細 3 件29,843 円14720 / 100 / — # 件数も合計も、ずっと正しい。ただし、行を足したり消したりしたあとのカーソルの行き先は、A とちがう A 行を追加 → 新しい行の「内容」、削除 → 「行を追加」ボタン(第8回で手で書いた) C 行を追加 → 「行を追加」ボタンのまま、削除 → どこにもない(BODY)
件数も合計も、8回とも正しく、14720 も最後まで入りました。六の表にもどると、C の 23 件の記録のうち 21 件は、React が入力欄の name と type を同じ値でつけなおしている内部の手順で、中身が変わったのは入力欄の値と合計の文字の2か所だけです。消えた部品は 0。だから、打っている欄もカーソルもそのまま残ります。1000 行でも 30 ms でした。
3つの書き方:操作から画面まで
ただし、全部ではありません。行を足したあと、A ではカーソルが新しい行の「内容」の欄へ移ります。フロントエンド編 第8回で、キーボードで使う人のために手で書いた動きです。C では「行を追加」のボタンにとどまり、行を消したあとは行き先がなくなりました。React は「画面の形」は合わせてくれますが、カーソルをどこへ動かすかは、いまも書く人の仕事です(この連載の中で直します)。
新井React が全部やってくれるわけじゃないんですね。
古井「形を合わせる」を引き受けてくれる。それ以外の気づかいは、jQuery のころと同じだけ残っている。
引きかえに増えるもの
ずれもなく、速い。そのかわり、払っているものがあります。
# ブラウザに届く JavaScript(app.js と文字の分は、どれも同じなので除いた)
自分で書いた行届く大きさgzip
A jQuery 4.0.0 + rows-jq.js73 81,575 B 28,787 B
B rows-render.js だけ58 3,496 B 1,582 B
C React 19.3.1 + rows.jsx59 226,513 B 70,113 B
(gzip は1つずつ縮めた大きさを足した)
# C の中身(esbuild の metafile)
react-dom 209,163 B react 8,408 B scheduler 3,689 B rows.jsx 3,135 B
JSX はブラウザがそのままでは読めないので、esbuild で JavaScript にしてから配る(1回 73 ms)
自分で書いた行は、3つともほとんど同じ(58〜73 行)でした。ちがうのはブラウザに届く大きさです。C は React 本体(大半は画面に書く係の react-dom)をいっしょに送るので 226 KB、縮めても 70 KB。A の約 2.8 倍(縮めた大きさで 2.4 倍)です。そしてもう1つ、ビルドの手順。JSX はブラウザがそのままでは読めないので、esbuild で JavaScript にしてから配ります。フロントエンド編の第15回で、Vue がビルドなしでは厳しい CSP の下で動かなかったのと同じ入口です。
まとめると、フレームワークは「状態から画面を作る」考え方を、遅くならずに使えるようにする道具です。払うのは、届く大きさ、ビルドの手順、そして道具そのものを学ぶ時間。小さな画面なら、A のままがいちばん安いこともあります。
この連載の地図
この「状態から画面を作る」という答えにたどりつくまでに、いくつもの流派が生まれては移り変わりました。第2・3回でその歴史をたどり、第4回から React で経費精算の画面を作りなおします。後半は Next.js で、サーバーの側で画面を作る話まで進みます。データは、バックエンド編で作った Spring Boot のサーバーからもらいます。
| 回 | 題 | 中身 |
|---|---|---|
| 1 | なぜフレームワークが生まれたか | この回 |
| 2 | 歴史①:jQuery から MVC へ | Backbone・Knockout・AngularJS |
| 3 | 歴史②:React 以後 | Vue・Angular・Svelte・Solid、SSR と Server Components |
| 4 | React の最初の一歩 | Vite、JSX、コンポーネント |
| 5 | 状態と再描画 | useState、一方向、key |
| 6 | フォームと入力チェック | Actions、バック第6回と |
| 7 | データ取得 | useEffect、Suspense、Spring Boot API |
| 8 | 再描画のコスト | Profiler、React Compiler |
| 9 | 状態の置き場所 | Context、useReducer、バック第10回 |
| 10 | 画面遷移 | React Router、バック第11回 |
| 11 | 楽観的更新と同時更新 | useOptimistic、409、View Transitions |
| 12 | Next.js①:Server Components | 'use client' の境界、Server Actions |
| 13 | Next.js②:描画とキャッシュ | 'use cache'、先読み |
| 14 | テスト | Vitest・Testing Library・Playwright |
| 15 | 公開と選び方 | Docker・nginx、Vue/Angular/Svelte と比べる |
使う版は、2026年11月1日の時点で React 19.3.1、Next.js 16.4.3 です。
自分の PC で
今回の測り方は、どのページでも試せます。ブラウザの開発者ツールを開き(F12)、コンソールに次を貼りつけてから、ページの入力欄に1文字打ってみてください。
// 画面の変化を数える(ページを読みこみなおすと消える)
new MutationObserver((list) => {
console.log(list.length, '件の変化', list.reduce((n, m) => n + m.removedNodes.length, 0), 'こ消えた');
}).observe(document.body, { subtree: true, childList: true, attributes: true, characterData: true });
次回からの準備として、次のものをそろえておきましょう。
| 用意するもの | 入れ方の例 | 確かめ方 |
|---|---|---|
| Node.js | 公式サイトの LTS 版をインストール | node -v |
| エディター | VS Code など | フォルダを開ける |
| ブラウザ | Chrome・Edge・Firefox のどれか | 開発者ツール(F12)が開く |
| React Developer Tools | ブラウザの拡張機能 | 開発者ツールに Components のタブが出る |
バックエンド編のサーバーは、第7回(データ取得)から使います。まだ手元にない人も、それまでは練習用の小さなサーバーで進められるようにします。
結 — 一枚まとめと、次回
- フロントエンド編の jQuery の画面は、本当の値を画面の中だけに持っていた(変数は1つ、書く 34 か所、読む 22 か所)。
- だから操作ごとに「どこを直すか」を人が覚えておく。件数を1つ出すだけで、8つの操作のうち3回ずれた。
- 状態を1か所に持ち、状態から画面を全部作ると、ずれはなくなる。でも打っている欄が消え、1000 行で 1 文字 0.3 秒。
- React は同じ考え方で、変わったところだけを書く。正しくて 1000 行で 30 ms。カーソルの行き先は、いまも自分で。
- そのかわり、届く大きさは 226 KB(gzip 70 KB)、そしてビルドの手順が増える。
新井React って、jQuery のつらさを1つずつつぶして生まれたんですね。
古井いきなり React が出てきたわけじゃない。そのあいだに、同じ悩みに別の答えを出した人たちがたくさんいる。
新井別の答え?
古井次回は歴史だ。jQuery の時代から、Backbone、Knockout、AngularJS へ。画面とデータをどうつなぐかで、流派が分かれていく。
参考
- React — Thinking in React
- React — Render and Commit
- React — Rendering Lists(key)
- React — Manipulating the DOM with Refs
- jQuery 4.0.0
- MDN — Element: innerHTML
- MDN — MutationObserver
- esbuild — API(bundle)
記録は、筆者がクラウド上の Linux で、2026年11月1日に取ったものです。ページは練習用のサーバー(Node.js 22)から配り、Playwright で動かした Chromium 153.0.8010.12 で操作と測定をしました。書き方 A は jQuery 4.0.0、C は React 19.3.1(react-dom 19.3.1)を esbuild 0.28.2 でまとめたもの、見た目はどれも Bootstrap 5.3.8 です。時間は、同じ欄に値を入れて input のイベントを送り、次の描画のあとまでを 25 回測り、最初の 5 回を除いた 20 回の中央値です。ほかの PC では数字が変わります。
いにしえのフロントエンド入門 フレームワーク編 第1回 — なぜフレームワークが生まれたか / 2026.11