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

古法前端入门 框架篇

第5讲 状态与重绘 ── useState、单向的环、key 的错误

发布

浏览器画面。React 的申请表单添加了 1 行,共 4 行。第 4 行是 2026/10/15、交通费、出租车 新大阪→客户、3000 日元。标题显示明细 4 条,合计 45,643 日元,并显示收据提示
“添加一行”能用了。在新加的行里输入,件数和合计马上就变(笔者于 2026 年 11 月 1 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)

第 4 讲做的申请表单只有外观,“添加一行”不能用。第 5 讲用状态(useState)让添加・删除行和输入动起来。
然后数一数每输入 1 个字重绘了什么,并故意制造状态的 3 种常见错误(直接改写数组、连续改变 2 次、key 用第几个)来测量。

一

按了也没反应的原因

新井“添加一行”时,往数组里 push 1 行就行了吧?

古井画面会不会变,第六节来试。先要知道 React 什么时候绘制。

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

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

第 4 讲的组件只是调用 1 次函数、返回画面的样子。React 再次调用组件函数,只在状态变化时(以及父组件重绘时)。改写普通变量,React 不会察觉。于是使用 useState,这是请 React “这个值一变就重绘”的容器。

二

把状态放在一处

import { useState } from 'react';
import { T } from './text.js';
import { SAMPLE_ROWS, RATE, newRow } from './sample.js';
import { countRender } from './renderCount.js';
import AboutSection from './components/AboutSection.jsx';
import ItemsSection from './components/ItemsSection.jsx';

/** 申请表单的画面。明细的状态(rows)只在这里保存 1 份 */
export default function App() {
  countRender('App');
  const [rows, setRows] = useState(SAMPLE_ROWS);

  // 改变状态的入口只有这 3 个。都是做出“新的数组”再传入
  const change = (id, name, value) => setRows(rows.map((r) => (r.id === id ? { ...r, [name]: value } : r)));
  const add = () => setRows([...rows, newRow()]);
  const remove = (id) => {
    const rest = rows.filter((r) => r.id !== id);
    setRows(rest.length > 0 ? rest : [newRow()]); // 至少保留 1 行
  };

  return ( … <ItemsSection rows={rows} rate={RATE} onChange={change} onAdd={add} onRemove={remove} /> … );
}

useState(SAMPLE_ROWS) 返回当前值 rows 和改变值的函数 setRows 这一对。状态放在最上层的 App。件数(ItemsSection)、合计(TotalLine)以及下一讲的提交都用同一个 rows,所以只在它们的共同父组件里放 1 份(“状态提升”)。

单向的环

状态 App 的 rows 绘制组件 再次调用函数 画面 变化的只有 2 处 人输入 调用 onChange 用 props 往下 比较后写入 在栏里输入 setRows(新数组) 值从上往下,“请修改”从下往上。改变状态的入口只有 setRows 重绘就是调用函数。画面本身只在不同的地方变化

值用 props 从上往下。下面的组件不改写值,而是用 onChange 等函数往上通知“请修改”。改变状态的入口只有 change・add・remove 3 个,都是做出新的数组再传给 setRows。

三

把栏连到状态

import { T } from '../text.js';
import { countRender } from '../renderCount.js';

/** 明细的 1 行。值从 row 读取,输入时只用 onChange 通知父组件(自己不改写) */
export default function ItemRow({ row, n, onChange, onRemove }) {
  countRender('ItemRow');
  const edit = (event) => onChange(row.id, event.target.name, event.target.value);
  return (
    <tr>
      <td><input type="date" name="date" value={row.date} onChange={edit} /></td>
      <td><select … name="category" value={row.category} onChange={edit}>…</select></td>
      <td><input type="text" name="item" value={row.item} onChange={edit} /></td>
      <td><input type="text" name="amount" value={row.amount} onChange={edit} /></td>
      <td><select … name="currency" value={row.currency} onChange={edit}>…</select></td>
      <td><button type="button" data-action="remove" onClick={() => onRemove(row.id)}>{T.remove}</button></td>
    </tr>
  );
}

把第 4 讲的 defaultValue 改成 value={row.amount},并加上 onChange。第 4 讲第六节里“只有 value 没有 onChange”变成了只读,现在栏的值和 React 的状态是同一个了。这种连接方式叫作受控组件。输入 1 个字,就沿着 onChange → setRows → 重绘 → 栏里出现新值 转一圈回来。

四

用同样的操作确认

# 改为由状态驱动的申请表单,每次操作都查看画面(ja 页面)
  操作行件数显示合计显示
  打开3     明細 3 件42,643 円
  第 1 行金额加 03     明細 3 件175,123 円
  用 Backspace 删回3     明細 3 件42,643 円
  添加一行4     明細 4 件42,643 円
  第 4 行输入 30004     明細 4 件45,643 円
  第 3 行美元→日元4     明細 4 件30,620 円
  删除第 2 行3     明細 3 件17,820 円
  事由输入“大阪”3     明細 3 件17,820 円
# 合计都与手算的值相同(美元按 151.23 日元换算)。开发用和生产用都一样

件数和合计每次都正确变化。合计和第 4 讲一样只是从 rows 计算,所以无论加行还是删行,都没有会对不上的地方。第 1 讲 jQuery 版里对不上 3 次的件数,这里什么都不做也一直正确。

五

重绘了几次

在每个组件开头加了 1 行计数(只为测量)。

// 只为测量的 1 行:数组件函数被调用(绘制)了几次。平时的应用不需要
export function countRender(name) {
  const c = (globalThis.__renders ??= {});
  c[name] = (c[name] ?? 0) + 1;
}
终端画面。node render5.js 的结果。输入 1 个字时,App・AboutSection・ItemsSection・ItemTable・TotalLine 开发用 2 次、生产用 1 次,ItemRow 6 次和 3 次。添加一行时 ItemRow 8 次和 4 次。输入事由时全部 0 次
数了组件函数被调用的次数。开发用正好是 2 倍(笔者于 2026 年 11 月 1 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)
# 各组件函数被调用的次数(用 countRender 计数)
  组件输入 1 个字添加一行输入事由
                  开发 / 生产开发 / 生产开发 / 生产
  App             2 / 1             2 / 1             0 / 0
  AboutSection    2 / 1             2 / 1             0 / 0
  ItemsSection    2 / 1             2 / 1             0 / 0
  ItemTable       2 / 1             2 / 1             0 / 0
  ItemRow         6 / 3             8 / 4             0 / 0
  TotalLine       2 / 1             2 / 1             0 / 0
# 开发用时 StrictMode 会调用组件函数 2 次,所以正好 2 倍。事由栏没有连到状态,所以什么都不重绘

输入 1 个字,6 个组件全部重绘了。和金额无关的 AboutSection(事由和支付方式),以及没有输入的第 2・3 行的 ItemRow 也一样。持有状态的 App 一重绘,它下面的全部都会被调用,这是 React 的规则。开发用正好是 2 倍,是因为 <StrictMode> 故意调用 2 次,检查函数是否每次都返回相同结果。

# 同样 1 个字,画面(DOM)实际变了多少(MutationObserver,开发用和生产用相同)
  记录 42 条。其中内容真正变化的只有 2 处(输入栏的 value 和合计的文字)
  其余 40 条是 React 用同样的值重设输入栏 name・type 等的内部步骤(和第 1 讲的 21 条一样)

不过,调用函数和改写画面是两回事。React 把调用的结果与上次比较,真正改写的只有输入的栏和合计 2 处。这么大的表,函数调用多少次人都察觉不到。行多时怎么办,第 8 讲来测。

六

状态是“下一次绘制”的值

照第一节新井说的写,会怎样?

  const addWrong = () => {
    rows.push({ id: rows.length + 1, item: '(新しい行)' }); // 改写了内容
    setRows(rows);                                          // 传入了同一个数组
  };
  const addRight = () => setRows([...rows, { id: rows.length + 1, item: '(新しい行)' }]); // 传入新的数组

  const addTwoWrong = () => {
    setRows([...rows, newRow()]);
    setRows([...rows, newRow()]); // 这个 rows 还是 1 行
    window.__seen = rows.length;  // 刚调用完 setRows 就读取的 rows 长度
  };
  const addTwoRight = () => {
    setRows((prev) => [...prev, newRow()]); // 接收“当前的值”,返回下一个值
    setRows((prev) => [...prev, newRow()]);
  };
# lab.html?lab=push 和 ?lab=twice
  操作件数
  打开1
  ① push 后把同一个数组传给 setRows1(画面不变)
  ② 接着用新数组添加 1 行3(①的行突然出现了)

  打开1
  ③ setRows([...rows, 新行]) 调用 2 次2(只增加 1 行)
     紧接着读取的 rows.length1(还是旧的)
  ④ setRows((prev) => [...prev, 新行]) 调用 2 次4(增加 2 行)

① push 后传入同一个数组,画面没有变。React 用“是不是和之前同一个数组”来判断是否变化,所以改写了内容的同一个数组被判为“没变”。麻烦的是②:接着用别的方法加 1 行时,①里悄悄加进去的行也一起出现了。状态的数组不改写,而是新建,这是规则。

③想“一次添加 2 行”,调用了 2 次 setRows。结果只增加了 1 行。rows 是这次绘制时的值(快照),调用 setRows 也不会马上变(紧接着读取还是 1)。2 次都传了“1 行的数组 + 1 行”,结果是 2 行。要基于之前的值改变时,像④那样传入函数。React 会依次传入“当前的值”。

七

用 key 区分行

第 4 讲即使出现“没有 key”的警告,画面也能显示。没有 key 真正麻烦,是在删除或重排行的时候。我们给行组件加上只属于它自己的小状态(“需确认”的 ✓)来试。

3 个小浏览器画面。都是给酒店打 ✓ 后删除第 1 行新干线之后。① key = row.id 时 ✓ 还在酒店上。② key = i 时 ✓ 移到了午餐上。③ key = i 加 defaultValue 时,第 1 行的栏里留着本应删掉的“新干线 东京→大阪”,右侧数据是 2:酒店 1 晚;第 2 行的栏是“酒店 1 晚”,数据是 3:与客户午餐,✓ 在第 2 行
做同样的操作,只改变 key。右边的“2:酒店 1 晚”是那一行真正的数据(笔者于 2026 年 11 月 1 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)
# 在 3 行(新干线・酒店・午餐)中,给酒店打 ✓ 后,删除第 1 行的新干线
  key             内容栏✓(需确认)栏里的文字在第 1 行输入
  row.id          value         ✓ 在酒店上正确能输入
  i               value         ✓ 跑到午餐上(错位)正确能输入
  i               defaultValue  ✓ 在第 2 行(错位)删掉的“新干线”还在能输入(但是别的行)
  Math.random()   value         ✓ 消失了正确第 1 个字时栏就没了
  Math.random()   defaultValue  ✓ 消失了正确能输入(因为不重绘)
# ✓ 是行组件自己持有的小状态(useState)。key 相同,React 就认为是同一个组件并保留状态

用 key 决定哪个组件连到哪一行

key = row.id 删除前 删除第 1 行后 key=1 新干线 key=2 酒店 ✓ key=3 午餐 key=2 酒店 ✓ key=3 午餐 key=1 的组件消失 key = i(第几个) 删除前 删除第 1 行后 key=0 新干线 key=1 酒店 ✓ key=2 午餐 key=0 酒店 key=1 午餐 ✓ key=2 的组件消失 组件随被删除的行一起消失 组件保留,只有内容被替换 React 把 key 相同的视为“同一个组件”,保留组件的状态(✓) 用第几个作为 key,删除或重排时状态会移到相邻的行

React 重绘时,把 key 相同的视为“同一个组件”,沿用该组件的状态和画面元素。key 用第几个(i)时,一删除第 1 行,0 号组件(原为新干线)就负责酒店,1 号组件(原为带 ✓ 的酒店)负责午餐。✓ 属于组件,所以移到了午餐上。

③更糟,内容栏改用交给浏览器的 defaultValue 后,栏里的文字也随组件保留,本应删掉的“新干线”留在了画面上。而数据明明是酒店。key 不要用“第几个”,而要用行本身的 id。

八

1 个字栏就消失的 key

表的第 4・5 行是把 key 设为 Math.random() 的情况。每次绘制 key 都不同,React 每次都认为“之前的组件全没了,来了新组件”。

连到状态的栏(value)在输入第 1 个字的瞬间发生重绘,栏本身被重建,光标消失了。和第 1 讲写法 B(用 innerHTML 重建整张表)完全一样的事,在 React 里发生了。key 不正确,React 的比较机制就不起作用。

九

不做成状态的东西

第五节表的右列,输入事由时,哪个组件都没有重绘。因为事由栏没有连到状态。这次做成状态的只有明细的 rows。

  • 合计・件数・收据提示 ── 能从 rows 计算,所以不做成状态(重复保存会像第 1 讲那样对不上)
  • 事由・备注 ── 提交时读取就行,现在交给浏览器(第 6 讲和提交一起考虑)
  • 汇率 ── 固定值。改为从服务器获取时再做成状态(第 7 讲)

状态越多,重绘的契机和会对不上的地方也越多。“能从别处算出来的,就不做成状态”是第一条规则。

十

在自己的电脑上

在 React Developer Tools 的设置(Components 标签的齿轮)里启用“Highlight updates when components render.”,重绘的组件会以边框闪烁。输入 1 个金额字符,确认连事由栏也会闪。输入事由时,哪里都不闪。

key 的实验,只要把 ItemTable.jsx 的 key={row.id} 改成 key={i} 就能做。把第 2 行的内容栏改回 defaultValue 再删除第 1 行,就是③的画面。试完请务必改回来。

结 — 一页总结与下一讲

  1. React 再次绘制组件,是在状态变化时。普通变量不会触发重绘。
  2. 状态只放 1 份在使用它的组件的共同父组件里。值往下,“请修改”往上。
  3. 1 个字让 6 个组件全部重绘(开发用因 StrictMode 是 2 倍),画面变化的只有 2 处。
  4. 状态的数组要新建。基于之前的值改变时,给 setRows 传函数。
  5. key 用行的 id。用第几个状态会错位,每次不同的 key 会让栏消失。

新井行能动了!那就加上提交按钮……

古井事由空着就提交呢?金额里输入“abc”呢?

古井下一讲是表单与输入检查。用 React 19 的 Actions 写提交,并与后端篇第 6 讲的检查对齐。

参考

记录是笔者于 2026 年 11 月 1 日在云端 Linux 上取得的。在第 4 讲的项目(Vite 8.3.5、React 19.3.1)上加入状态,用 Playwright 驱动的 Chromium 153.0.8010.12 打开开发用(npm run dev,含 StrictMode)和生产用(npm run build 后用 npm run preview 提供)。重绘次数用加在每个组件开头的 countRender 计数,画面变化用 MutationObserver 计数。key 等实验在 lab.html 的另一个画面里进行。

古法前端入门 框架篇 第5讲 — 状态与重绘 / 2026.11

← 返回文章列表