模块一 · 起步与渲染
JSX、状态与事件:先把交互跑通
JSX 只是语法糖,状态是快照,事件是闭包。这一节把这三件事拆开讲,并给出受控组件、列表 key 与批处理的正确用法。
读完这一节,你能做到
- 说清 JSX 编译后是什么,以及为什么不能写语句。
- 用快照解释"连续 setState 只加一次"的现象。
- 写出受控组件,并知道什么时候该用非受控。
- 用 key 控制列表节点的复用。
一、JSX 编译成什么
JSX 不是模板,也不是字符串,而是函数调用的语法糖:
const el = <button className="btn">点击</button>;
// 编译后(自动运行时)
const el = jsx("button", { className: "btn", children: "点击" });
三个直接结论:
- JSX 里只能写表达式,不能写
if/for语句,因为它是函数参数。 - 组件名必须大写,编译后用来区分"原生标签"与"组件引用"。
className、htmlFor这些是 React 的属性名,不是 HTML 属性名。
二、状态是快照
useState 返回的 state 是"这一次渲染的值",不是实时变量:
function Counter() {
const [count, setCount] = useState(0);
return (
<button
onClick={() => {
setCount(count + 1);
setCount(count + 1);
}}
>
{count}
</button>
);
}
点击一次只加 1,因为两次 setCount 读到的都是同一个 count。要基于上一次值更新,用函数式写法:
setCount((c) => c + 1);
setCount((c) => c + 1); // 加 2
三、批处理:多次 set 只渲染一次
React 会把同一事件循环里的多次状态更新合并,只渲染一次。这是性能优化,不是 bug。
| 场景 | 是否批处理 |
|---|---|
| 事件回调内多次 set | 是 |
| Promise/定时器里多次 set | React 18 起默认是 |
| 原生事件监听里 | React 18 起默认是 |
如果需要"改完立刻读 DOM",用 flushSync,但它会牺牲批处理收益,非必要不用。
四、事件:合成事件与闭包
React 的事件是合成事件,挂载在根容器上统一分发,而不是每个节点绑一个监听。带来两个影响:
- 事件对象会被复用(旧版本),异步访问要先
e.persist();新版本已不再复用,但习惯上仍建议立即取值。 - 事件回调是闭包,捕获的是"那次渲染"的 state。要读最新值,用函数式更新或
useRef。
const [text, setText] = useState('')
<input value={text} onChange={e => setText(e.target.value)} />
五、受控与非受控
| 类型 | 值由谁维护 | 适用 |
|---|---|---|
| 受控 | React state | 需要校验、联动、格式化 |
| 非受控 | DOM 自身 | 简单表单、文件上传、性能敏感 |
受控组件的关键是"值 + onChange"成对出现:
<input value={value} onChange={(e) => setValue(e.target.value)} />
只传 value 不传 onChange,输入框会变只读——这是最常见的"输入不了"原因。
六、列表与 key
key 不是给 v-for 用的装饰,而是 React 判断"这个节点是不是同一个"的依据。
- 用稳定 id,不要用数组下标。
- 用下标时,插入/删除会导致状态错位(输入框内容跑到别的行)。
- key 只在同级兄弟之间唯一,不需要全局唯一。
{
items.map((item) => <Row key={item.id} item={item} />);
}
七、常见坑
- 在 JSX 里写
if语句,编译报错。 - 连续
setState期望加多次,忘了函数式更新。 - 受控组件只传 value,不传 onChange。
- 列表 key 用下标,删除后状态错乱。
- 在事件里直接改 state 对象,引用没变,不触发更新。
八、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 写一个受控输入框,实时显示字数,并限制最大长度。
- 写一个待办列表,支持新增、删除、勾选,key 用 id。
- 用函数式更新实现"一次点击加 3"。
② 让 AI 写同一题
把第 2 题(待办列表)交给 AI,补一句 只用原生 Hooks,不要直接修改入参数组。
③ 对比要盯的点
key它用的是item.id还是数组下标?下标删掉第三条就会露馅。- 它有没有直接
push/splice原数组?引用没变,界面就不更新。 - 列表为空时它渲染空态了吗?
- 受控输入它有没有漏掉
onChange,让输入框变成一个只读框?
④ 一句话取舍
三处差异里,哪一处会让用户真的看到错误数据,而不只是“代码不够漂亮”?
九、自测
- JSX 编译后是什么?
- 为什么连续两次 setCount 只加 1?
- React 18 的批处理范围是什么?
- 受控组件的最小实现是什么?
- key 用下标会有什么问题?
参考答案要点:jsx() 调用;快照;事件/Promise/原生事件;value + onChange;复用错节点导致状态错位。
附:受控组件的性能与边界
受控输入每次按键都会触发组件渲染。对普通表单没问题,但在大表单里可能让整页重渲染。三种处理:
- 把输入框拆成独立组件,状态下放。
- 用非受控 +
defaultValue,提交时统一取值。 - 用
useDeferredValue延迟依赖该值的重计算。
边界:需要实时校验、联动、格式化时,受控更合适;只需要提交时取值、或需要接文件/富文本编辑器时,非受控更省心。
附:事件处理的三种写法
| 写法 | 例子 | 特点 |
|---|---|---|
| 内联箭头 | onClick={() => setX(1)} | 简单,但每次渲染新建函数 |
| 具名函数 | onClick={handleClick} | 可复用、可测试 |
| 柯里化 | onClick={handleClick(id)} | 传参方便,同样每次新建 |
性能敏感的子组件可以配合 memo + useCallback,但不要为了“看起来优化”给所有事件包 useCallback。
附:一个表单案例
登录表单包含手机号、验证码、同意协议。做法:手机号与验证码用受控,便于格式校验;协议勾选用受控;提交时统一校验一次,错误信息放在字段下方而不是弹窗。
关键点:不要在每次输入时请求验证码,用按钮触发;验证码倒计时用 useEffect + 清理;提交中禁用按钮,避免重复提交。
附:列表渲染的四个细节
- key 只在同级唯一,不需要全局唯一;但同一列表内必须稳定。
- 不要用
index作为 key 的同时又做排序/插入,状态会错位。 - 列表项是组件时,key 放在组件上,不要放在内部的 div 上。
- 空列表要渲染空状态,不要让用户看到空白页。
附:合成事件与原生事件的区别
| 维度 | 合成事件 | 原生事件 |
|---|---|---|
| 绑定 | 根容器统一分发 | 每个节点单独绑定 |
| 兼容 | 抹平浏览器差异 | 需要自己处理 |
| 阻止冒泡 | e.stopPropagation() 同时阻断合成与原生 | 只影响原生 |
React 17 起事件挂到根容器而不是 document,多版本共存时不会再互相干扰。
附:表单校验的三个层次
- 即时校验:输入时校验格式(手机号、邮箱),错误提示在字段旁。
- 提交校验:提交前统一校验必填与业务规则,避免遗漏。
- 服务端校验:接口返回的错误映射到字段,不能只依赖前端。
三层都要有。只做即时校验,用户可能绕过;只做服务端校验,体验差、请求多。
附录:高频追问
问:为什么 JSX 里不能写 if 语句? 因为 JSX 是表达式,会被编译成函数参数;语句不能作为参数。用三元或提前返回。
问:setState 之后能立刻拿到新值吗? 不能,state 是渲染快照;需要新值用函数式更新或在 effect 里读。
问:受控组件一定比非受控好吗? 不一定。需要校验、联动时用受控;只需要提交取值时非受控更简单。
问:key 能解决什么问题? 节点身份。列表插入/删除时决定复用还是重建,避免状态错位。
问:事件为什么要用箭头函数包一层? 直接传函数会丢 this(类组件)或误传事件对象;包一层可以传参。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解