Old-School Front-End · Frameworks / 2026 ・ No.01
古法前端入门 框架篇
第1讲 为什么会诞生框架 ── 把同一张明细表写成 3 种并实测
发布
前端篇里,我们用原生 JavaScript 和 jQuery 做了费用报销的申请表单。后端篇里,做了接收申请的服务器。新连载回到页面这一侧,主题是框架。全 15 讲,先回顾历史与流派的变迁,再详细介绍 React 和 Next.js。
第 1 讲讲“为什么需要”。把申请表单的明细表用 jQuery、手写、React 三种方式来写,做同样的操作,测一测页面在哪里对不上、在哪里变慢。
再看一次前端篇的页面
新井今天开始学 React 了!jQuery 已经过时了,赶紧改写吧。
古井过不过时,放到后面再说。先测一测 jQuery 让人为难在哪里,React 又是为此而生的什么。不为难就换工具,只会多出新工具的麻烦。
新井……上一个连载里,好像也被这么说过。
全 15 讲的路线图(当前位置:第 1 讲)
材料是前端篇第 14 讲 jQuery 版的申请表单(form-jq.js)。从中只取出明细表(添加・删除行、金额、币种、合计),把同一个页面写成 3 种。
- A jQuery ── 原样使用 form-jq.js 的明细部分(前端篇的做法)
- B 手写 ── 把值放在一个变量里,从它把整张表重建(不用工具)
- C React ── 和 B 同样的思路,交给 React 19.3
计算(日元和美元的合计、需要收据的金额)三种都共用前端篇的 app.js。不同的只是修改页面的方式。用驱动浏览器的工具(Playwright)对这 3 个页面做同样的 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') … # 明细的行・金额・合计・红框・错误件数在哪里? → 只在画面里。想要合计,就把表的行一行行重新读
放在 JavaScript 变量里的值,只有汇率 1 个。明细的行、金额、合计、哪个栏是红框,都只在页面(DOM)里。想要合计时,就把表格的行一行一行重新读(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');
}
只删一行,就做了 5 件善后的事:重新设置按行号朗读的名称,重新算合计,重新设置红框,决定光标去哪里。把每种操作调用了哪些函数做成了表。
每种操作调用的、修改页面的函数(来自 form-jq.js)
这张表在源码里哪儿都没写。它在写代码的人的脑子里。最右边 ★ 的一列,是下一节要加的“件数”。
只是多显示一个件数
古井财务那边来了个请求:“请在明细的标题上显示有几件”。
新井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 次,件数都还是旧的。看第三节的表就明白了。“添加一行”不调用 updateTotal。加一行空行合计不变,所以本来不需要调用。“删除一行”调用了,所以不会对不上。
# rows-jq2.js:加行时也调用 updateTotal()(8 个操作中,不对 0 次)
/** 添加一行 */
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 个。页面上显示的东西每多一样,就多一列。
新井而且就算对不上,谁也注意不到。因为合计是对的。
古井对。不报错,只是旧了。这是最难发现的一类 bug。
从状态生成页面
于是把思路反过来。真正的值放在JavaScript 的一个变量里(这叫状态)。操作只改状态。页面每次都从状态全部生成。不管哪个操作之后都调用同一个 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 行的表里,一个字一个字地输入。
# 在有 3 行的画面,往第 1 行金额逐字输入 1・4・7・2・0,每次用 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 的数字下一节再看。
另一个问题是速度。把行数设成 10・100・1000,测量从一次输入到下一次绘制完成的时间。
从一次输入到下一次绘制完成(实测中位数)
# 把行数设为 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・这台电脑)
浏览器每秒大约绘制 60 次,所以光是等待下一次绘制就约 16.7 ms。A 几乎不再增加。B 随行数成比例变重,1000 行时每个字 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。
三种写法:从操作到页面
不过,并不是全部。添加行之后,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.js58 3,496 B 1,582 B
C React 19.3.1 + rows.jsx59 226,513 B 70,113 B
(gzip 是逐个压缩后的大小相加)
# C 的内容(esbuild 的 metafile)
react-dom 209,163 B react 8,408 B scheduler 3,689 B rows.jsx 3,135 B
浏览器不能直接读 JSX,所以用 esbuild 转成 JavaScript 再分发(一次 73 ms)
自己写的行数,三种几乎一样(58〜73 行)。不同的是送到浏览器的大小。C 要把 React 本体(大半是负责写页面的 react-dom)一起送去,226 KB,压缩后也有 70 KB,约是 A 的 2.8 倍(压缩后的大小是 2.4 倍)。还有一样,构建步骤。浏览器不能直接读 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。
在自己的电脑上
这次的测量方法在任何页面上都能试。打开浏览器的开发者工具(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 处)。
- 所以每种操作“改哪里”都要靠人记住。只是多显示一个件数,8 个操作中就对不上 3 次。
- 把状态放在一处,从状态全部生成页面,就不会对不上。但正在输入的栏会消失,1000 行时每个字 0.3 秒。
- React 用同样的思路,只写变化的地方。正确,1000 行 30 ms。光标的去处仍要自己管。
- 代价是送达的大小 226 KB(gzip 70 KB),还有多出来的构建步骤。
新井原来 React 是把 jQuery 的痛点一个个消灭才诞生的。
古井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)
记录是笔者于 2026 年 11 月 1 日在云端 Linux 上取得的。页面由练习用服务器(Node.js 22)提供,用 Playwright 驱动的 Chromium 153.0.8010.12 进行操作和测量。写法 A 使用 jQuery 4.0.0,C 是用 esbuild 0.28.2 打包的 React 19.3.1(react-dom 19.3.1),外观都用 Bootstrap 5.3.8。时间是在同一个栏里放入值、发送 input 事件、直到下一次绘制之后,测 25 次,去掉前 5 次,取 20 次的中位数。在其他电脑上数字会不同。
古法前端入门 框架篇 第1讲 — 为什么会诞生框架 / 2026.11