Old-School Front-End · Frameworks / 2026 ・ No.05
古法前端入门 框架篇
第5讲 状态与重绘 ── useState、单向的环、key 的错误
发布
第 4 讲做的申请表单只有外观,“添加一行”不能用。第 5 讲用状态(useState)让添加・删除行和输入动起来。
然后数一数每输入 1 个字重绘了什么,并故意制造状态的 3 种常见错误(直接改写数组、连续改变 2 次、key 用第几个)来测量。
按了也没反应的原因
新井“添加一行”时,往数组里 push 1 行就行了吧?
古井画面会不会变,第六节来试。先要知道 React 什么时候绘制。
全 15 讲的路线图(当前位置:第 5 讲)
第 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 份(“状态提升”)。
单向的环
值用 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;
}
# 各组件函数被调用的次数(用 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 内容栏✓(需确认)栏里的文字在第 1 行输入 row.id value ✓ 在酒店上正确能输入 i value ✓ 跑到午餐上(错位)正确能输入 i defaultValue ✓ 在第 2 行(错位)删掉的“新干线”还在能输入(但是别的行) Math.random() value ✓ 消失了正确第 1 个字时栏就没了 Math.random() defaultValue ✓ 消失了正确能输入(因为不重绘) # ✓ 是行组件自己持有的小状态(useState)。key 相同,React 就认为是同一个组件并保留状态
用 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 行,就是③的画面。试完请务必改回来。
结 — 一页总结与下一讲
- React 再次绘制组件,是在状态变化时。普通变量不会触发重绘。
- 状态只放 1 份在使用它的组件的共同父组件里。值往下,“请修改”往上。
- 1 个字让 6 个组件全部重绘(开发用因 StrictMode 是 2 倍),画面变化的只有 2 处。
- 状态的数组要新建。基于之前的值改变时,给 setRows 传函数。
- key 用行的 id。用第几个状态会错位,每次不同的 key 会让栏消失。
新井行能动了!那就加上提交按钮……
古井事由空着就提交呢?金额里输入“abc”呢?
古井下一讲是表单与输入检查。用 React 19 的 Actions 写提交,并与后端篇第 6 讲的检查对齐。
参考
- React — State: A Component's Memory
- React — Render and Commit
- React — State as a Snapshot
- React — Queueing a Series of State Updates
- React — Updating Arrays in State
- React — Sharing State Between Components
- React — Preserving and Resetting State
- React — StrictMode
记录是笔者于 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