知

模块一 · 起步与渲染

第 4 / 18 节17 分钟阅读型

JSX、状态与事件:先把交互跑通

JSX 只是语法糖,状态是快照,事件是闭包。这一节把这三件事拆开讲,并给出受控组件、列表 key 与批处理的正确用法。

读完这一节,你能做到

  1. 说清 JSX 编译后是什么,以及为什么不能写语句。
  2. 用快照解释"连续 setState 只加一次"的现象。
  3. 写出受控组件,并知道什么时候该用非受控。
  4. 用 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/定时器里多次 setReact 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} />);
}

七、常见坑

  1. 在 JSX 里写 if 语句,编译报错。
  2. 连续 setState 期望加多次,忘了函数式更新。
  3. 受控组件只传 value,不传 onChange。
  4. 列表 key 用下标,删除后状态错乱。
  5. 在事件里直接改 state 对象,引用没变,不触发更新。

八、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 写一个受控输入框,实时显示字数,并限制最大长度。
  2. 写一个待办列表,支持新增、删除、勾选,key 用 id。
  3. 用函数式更新实现"一次点击加 3"。

② 让 AI 写同一题

把第 2 题(待办列表)交给 AI,补一句 只用原生 Hooks,不要直接修改入参数组。

③ 对比要盯的点

  • key 它用的是 item.id 还是数组下标?下标删掉第三条就会露馅。
  • 它有没有直接 push / splice 原数组?引用没变,界面就不更新。
  • 列表为空时它渲染空态了吗?
  • 受控输入它有没有漏掉 onChange,让输入框变成一个只读框?

④ 一句话取舍

三处差异里,哪一处会让用户真的看到错误数据,而不只是“代码不够漂亮”?

九、自测

  1. JSX 编译后是什么?
  2. 为什么连续两次 setCount 只加 1?
  3. React 18 的批处理范围是什么?
  4. 受控组件的最小实现是什么?
  5. key 用下标会有什么问题?

参考答案要点:jsx() 调用;快照;事件/Promise/原生事件;value + onChange;复用错节点导致状态错位。

附:受控组件的性能与边界

受控输入每次按键都会触发组件渲染。对普通表单没问题,但在大表单里可能让整页重渲染。三种处理:

  • 把输入框拆成独立组件,状态下放。
  • 用非受控 + defaultValue,提交时统一取值。
  • 用 useDeferredValue 延迟依赖该值的重计算。

边界:需要实时校验、联动、格式化时,受控更合适;只需要提交时取值、或需要接文件/富文本编辑器时,非受控更省心。

附:事件处理的三种写法

写法例子特点
内联箭头onClick={() => setX(1)}简单,但每次渲染新建函数
具名函数onClick={handleClick}可复用、可测试
柯里化onClick={handleClick(id)}传参方便,同样每次新建

性能敏感的子组件可以配合 memo + useCallback,但不要为了“看起来优化”给所有事件包 useCallback。

附:一个表单案例

登录表单包含手机号、验证码、同意协议。做法:手机号与验证码用受控,便于格式校验;协议勾选用受控;提交时统一校验一次,错误信息放在字段下方而不是弹窗。

关键点:不要在每次输入时请求验证码,用按钮触发;验证码倒计时用 useEffect + 清理;提交中禁用按钮,避免重复提交。

附:列表渲染的四个细节

  1. key 只在同级唯一,不需要全局唯一;但同一列表内必须稳定。
  2. 不要用 index 作为 key 的同时又做排序/插入,状态会错位。
  3. 列表项是组件时,key 放在组件上,不要放在内部的 div 上。
  4. 空列表要渲染空状态,不要让用户看到空白页。

附:合成事件与原生事件的区别

维度合成事件原生事件
绑定根容器统一分发每个节点单独绑定
兼容抹平浏览器差异需要自己处理
阻止冒泡e.stopPropagation() 同时阻断合成与原生只影响原生

React 17 起事件挂到根容器而不是 document,多版本共存时不会再互相干扰。

附:表单校验的三个层次

  1. 即时校验:输入时校验格式(手机号、邮箱),错误提示在字段旁。
  2. 提交校验:提交前统一校验必填与业务规则,避免遗漏。
  3. 服务端校验:接口返回的错误映射到字段,不能只依赖前端。

三层都要有。只做即时校验,用户可能绕过;只做服务端校验,体验差、请求多。

附录:高频追问

问:为什么 JSX 里不能写 if 语句? 因为 JSX 是表达式,会被编译成函数参数;语句不能作为参数。用三元或提前返回。

问:setState 之后能立刻拿到新值吗? 不能,state 是渲染快照;需要新值用函数式更新或在 effect 里读。

问:受控组件一定比非受控好吗? 不一定。需要校验、联动时用受控;只需要提交取值时非受控更简单。

问:key 能解决什么问题? 节点身份。列表插入/删除时决定复用还是重建,避免状态错位。

问:事件为什么要用箭头函数包一层? 直接传函数会丢 this(类组件)或误传事件对象;包一层可以传参。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解