Old-School Front-End · Frameworks / 2026 ・ No.01

古法前端入门 框架篇

第1讲 为什么会诞生框架 ── 把同一张明细表写成 3 种并实测

发布

3 个浏览器画面。都是“费用明细”表格,做完同样 8 个操作之后。左上写法 A(jQuery)有 3 行,标题却是“明细 2 条”,合计 29,843 日元。右上写法 B(整张表重建)是明细 3 条,但金额栏是 1 和 1,合计 152 日元。下方写法 C(React 19.3)是明细 3 条,金额 14720 和 100,合计 29,843 日元
把同一张明细表写成 3 种,做同样的操作。A 件数对不上,B 金额只能输入 1 个字,只有 C 两样都正确(笔者于 2026 年 11 月 1 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)

前端篇里,我们用原生 JavaScript 和 jQuery 做了费用报销的申请表单。后端篇里,做了接收申请的服务器。新连载回到页面这一侧,主题是框架。全 15 讲,先回顾历史与流派的变迁,再详细介绍 React 和 Next.js。
第 1 讲讲“为什么需要”。把申请表单的明细表用 jQuery、手写、React 三种方式来写,做同样的操作,测一测页面在哪里对不上、在哪里变慢。

一

再看一次前端篇的页面

新井今天开始学 React 了!jQuery 已经过时了,赶紧改写吧。

古井过不过时,放到后面再说。先测一测 jQuery 让人为难在哪里,React 又是为此而生的什么。不为难就换工具,只会多出新工具的麻烦。

新井……上一个连载里,好像也被这么说过。

全 15 讲的路线图(当前位置:第 1 讲)

1 为什么要框架 2 jQuery→MVC 3 React 以后 4 React 第一步 5 状态与重绘 6 表单 7 获取数据 8 重绘的代价 9 状态放哪里 10 页面切换 11 乐观更新 12 Server Comp. 13 缓存 14 测试 15 发布与选择 当前位置 缘由与历史 React 基础 速度与位置 页面与服务器 Next.js 收尾

材料是前端篇第 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)

每种操作调用的、修改页面的函数(来自 form-jq.js) addRow renumber updateTotal recheck focus 提交的显示 ★件数 添加一行 删除一行 输入金额・币种 输入其他栏 汇率到达 提交 页面上多显示一样东西,就多一列。要在哪几行打标记,得自己找 漏打一个标记,就只在那个操作之后,页面变旧

这张表在源码里哪儿都没写。它在写代码的人的脑子里。最右边 ★ 的一列,是下一节要加的“件数”。

四

只是多显示一个件数

古井财务那边来了个请求:“请在明细的标题上显示有几件”。

新井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 日元。

写法 B 的页面拍了 3 次。①点击金额栏,栏上出现蓝框。②输入“1”,栏里是 1,合计变成 1 日元,但蓝框消失了。③接着输入“4720”,栏里还是 1,合计也还是 1 日元
用写法 B 输入 14720。按下 1 的瞬间表格被重建,正在输入的栏整个消失。剩下的 4720 哪里也没进去(笔者于 2026 年 11 月 1 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)

按下 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,测量从一次输入到下一次绘制完成的时间。

从一次输入到下一次绘制完成(实测中位数)

从一次输入到下一次绘制完成(ms,实测中位数) 0 100 200 300 16.6 16.5 16.7 10 行 16.7 29.7 16.8 100 行 20.8 326.8 30.3 1000 行 A jQuery B 全部重建 C React 光是等待下一次绘制就约 16.7 ms(什么都不做也要) B 随行数成比例变慢。1000 行时,每个字 0.3 秒
# 把行数设为 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 jQuery(前端篇) 操作 点击・输入 挑出要改的地方 自己调用函数 改写页面 .text() 等 快 漏挑就变旧 B 从状态全部生成(手写) 操作 点击・输入 改状态 只改 state 全部重建 innerHTML 总是正确 正在输入的栏也没了 C React 操作 点击・输入 改状态 setRows 与上次比较 只改变化处 正确又快 代价是需要工具 B 和 C 都是“状态 → 页面”单向。C 把比较的工作交给工具 A 要由人记住每种操作“该改哪里”

不过,并不是全部。添加行之后,A 的光标会移到新行的“内容”栏。这是前端篇第 8 讲为了用键盘操作的人手写的行为。C 停留在“添加一行”按钮上,删除行之后光标没有了去处。React 会帮你对齐“页面的样子”,但光标该移到哪里,仍是写代码的人的工作(本连载中会修好)。

新井React 也不是什么都替你做啊。

古井它承担的是“对齐样子”。其他的体贴,和 jQuery 时代一样多,都还在。

八

作为交换而增加的东西

不会对不上,又快。代价是,有东西要付出。

终端画面。用 esbuild rows.jsx --bundle --minify --jsx=automatic 生成 rows-react.js,显示 built。jquery.min.js 是 78748 B(gzip 27392 B),rows-react.js 是 226513 B(gzip 70113 B)。grep -c . rows.jsx 的结果是 59
React 版要先用 esbuild 把 JSX 转成 JavaScript 再发布。自己写的是 59 行,送到浏览器的是 226 KB(笔者于 2026 年 11 月 1 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)
# 送到浏览器的 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 到 MVCBackbone・Knockout・AngularJS
3历史②:React 之后Vue・Angular・Svelte・Solid、SSR 与 Server Components
4React 的第一步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
12Next.js①:Server Components'use client' 的边界、Server Actions
13Next.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 讲(获取数据)开始使用。手头还没有的人,在那之前也能用练习用的小服务器继续学。

结 — 一页总结与下一讲

  1. 前端篇用 jQuery 写的页面,真正的值只放在页面里(变量 1 个,写 34 处,读 22 处)。
  2. 所以每种操作“改哪里”都要靠人记住。只是多显示一个件数,8 个操作中就对不上 3 次。
  3. 把状态放在一处,从状态全部生成页面,就不会对不上。但正在输入的栏会消失,1000 行时每个字 0.3 秒。
  4. React 用同样的思路,只写变化的地方。正确,1000 行 30 ms。光标的去处仍要自己管。
  5. 代价是送达的大小 226 KB(gzip 70 KB),还有多出来的构建步骤。

新井原来 React 是把 jQuery 的痛点一个个消灭才诞生的。

古井React 不是突然冒出来的。在那之间,有很多人对同样的烦恼给出了别的答案。

新井别的答案?

古井下一讲讲历史。从 jQuery 的时代,到 Backbone、Knockout、AngularJS。围绕页面和数据怎么连接,流派开始分化。

参考

记录是笔者于 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

← 返回文章列表