事件与受控表单:让输入值只有一个所有者
问题:输入框、预览和提交记录为什么会不一致
如果 DOM 自己保存输入值,而 React 又保存一份“名称”,两份数据可能分叉。受控输入把 value 交给 React state;浏览器事件只报告用户意图,state 更新后,新值再作为 prop 回到输入和预览。
第一次使用 state:值由 React 在两次渲染之间保存
普通局部变量每次调用组件都会重新声明;改它也不会通知 React 重画页面。useState 则让 React 为当前组件保存一个值,并返回一对成员:当前渲染读到的值,以及请求更新的 setter。先独立运行这个 App.jsx:
import { useState } from 'react'
export default function App() {
const [name, setName] = useState('茶')
return (
<label>
名称
<input value={name} onChange={event => setName(event.target.value)} />
<span>当前名称:{name}</span>
</label>
)
}输入“茶杯”后,输入框和旁边文本一起变化。React 再次调用 App,这次 useState 返回保存的新值,而不是重新初始化为“茶”。setter 发起更新,不会把已经在运行的函数里的 name 原地改掉;第二章会用连续点击和延迟回调观察这个区别。
useState 是 Hook。像 useState、后面会遇到的 useReducer、useMemo,都应在组件或自定义 Hook 的顶层、提前返回之前调用,不能放进条件、循环、事件处理器或普通辅助函数中。React 依靠稳定的调用顺序,把多次 Hook 调用与各自的数据对应起来。
例如有 readOnly prop 时,先写 const [name, setName] = useState(''),再写 if (readOnly) return <p>{name}</p>。不要把这次 Hook 调用放到 if (!readOnly) 内。事件处理器调用的是 setName,不是再调用一次 useState。本书后面的 use(resource) 是有单独条件调用规则的 API,不能把它的例外套到这些 state Hook 上。
完整实验:商品登记表
import { useState } from 'react'
function Preview({ name, category }) {
return (
<section aria-label="预览">
<h2>{name.trim() || '未命名商品'}</h2>
<p>分类:{category}</p>
</section>
)
}
export default function App() {
const [name, setName] = useState('')
const [category, setCategory] = useState('drink')
const [records, setRecords] = useState([])
const normalizedName = name.trim()
function handleSubmit(event) {
event.preventDefault()
if (!normalizedName) return
setRecords(current => [
...current,
{ id: crypto.randomUUID(), name: normalizedName, category },
])
setName('')
}
return (
<main>
<h1>登记商品</h1>
<form onSubmit={handleSubmit}>
<label>
名称
<input
value={name}
onChange={event => setName(event.target.value)}
/>
</label>
<label>
分类
<select
value={category}
onChange={event => setCategory(event.target.value)}
>
<option value="drink">饮品</option>
<option value="tool">器具</option>
</select>
</label>
<button type="submit" disabled={!normalizedName}>添加</button>
</form>
<Preview name={name} category={category} />
<h2>已登记</h2>
{records.length === 0 ? <p>暂无记录</p> : (
<ul>
{records.map(record => (
<li key={record.id}>{record.name} / {record.category}</li>
))}
</ul>
)}
</main>
)
}输入名称时,onChange 读取事件目标值并请求新渲染;新渲染把同一个 name 同时交给 <input value> 与 Preview。提交后,记录增加,名称输入被 state 清空。
setRecords(current => [...current, newRecord]) 使用函数式更新:React 把待处理的当前记录交给函数,函数返回一个新数组。展开保留已有条目,再追加新记录;不要用 records.push(...) 修改原数组。此处先会用“基于旧值生成新值”的模式,第二章再推演多个更新一起排队的顺序。id 在提交事件创建记录时生成一次,随后作为列表 key 使用。
事件处理器是稍后调用的函数
onSubmit={handleSubmit} 传递函数引用。若写成 onSubmit={handleSubmit()},函数会在渲染时立刻执行,传给事件属性的是其返回值。这通常会导致提前副作用,若函数又设置 state,还可能造成重复渲染。
内联箭头函数 onClick={() => remove(id)} 也是传递函数:渲染时只创建它,点击时才调用 remove(id)。自定义组件的事件 prop 通常命名为 onSave,组件内部的处理函数命名为 handleSave,名称可以表达业务事件,不必局限于 DOM 事件名。
event.preventDefault() 阻止表单的浏览器默认提交导航;它不阻止事件冒泡。event.stopPropagation() 才阻止事件继续传到祖先。优先让表单使用 onSubmit,这样鼠标点击和键盘回车走同一条提交路径。
受控输入的数据流
键盘输入
→ onChange(event)
→ setName(event.target.value)
→ React 再次调用 App
→ input.value 与 Preview 都读取新 namestate 放在同时需要它的最近共同祖先 App,这叫状态提升。Preview 不再保存一份重复名称,只从 props 计算展示。对每一项 state,都应有一个明确的所有者,而不是每个消费者各存一份副本。
改一个条件:把 value 去掉
若改成 <input defaultValue={name}>,它只用初始值设置 DOM,之后 DOM 自己持有编辑值。提交后 setName('') 不会可靠清空该输入,因为它已经不再由 name 控制。
反过来,只写 value={name} 而没有 onChange,用户输入无法改变 state,输入看起来只读。受控输入的闭环必须同时有当前值和更新路径。
边界
- 文本输入的受控
value应始终是字符串;不要在生命周期中从undefined切到字符串。初始化为''。 - checkbox 使用
checked与event.target.checked,不是value。 - 文件输入由浏览器管理文件选择,不能像文本值一样用字符串完全控制。
- 简单、只在提交时读取的表单可以使用非受控输入;需要即时校验、联动预览或程序化重置时,受控输入更直接。
- 客户端禁用按钮改善交互,但不构成服务端校验或权限控制。
练习:带确认的通知设置
实现一个完整 App.jsx,包含邮箱文本框、是否接收周报的 checkbox、预览和提交历史。
契约:
- email 与 subscribed 都由
Appstate 控制;checkbox 必须使用checked; - email 去除首尾空白后必须包含
@,否则显示错误并禁止提交; - 提交阻止页面刷新,把当时的
{ id, email, subscribed }追加到历史,再清空 email,但保留 subscribed; - 历史使用稳定 id 作为 key;初始空输入不能在控制/非控制模式间切换。
完成后对照答案 03。