
说实话学完前两天的内容我已经能搭出一个能看的页面骨架了——组件嵌套、props传参、state存储这些概念都在脑子里打转。但有个问题很扎心页面上不管点什么都毫无反应。一个能渲染的React应用和真实产品之间隔着的就是“交互”两个字。Day3要解决的就是这个问题。今天这章我会把React的事件处理、条件渲染与列表渲染、表单控制、Hooks基础全部串起来最后带大家写一个真正能用的待办清单。这不是一篇干巴巴的API说明书而是把我自己从“会写组件”到“能写页面”的阶段踩过的坑、绕过的弯都摊开给你看。本篇的内容量比较大建议配合电脑实操阅读光看不敲等于白看。1. Day3的定位为什么静态页面离真实开发还隔着“交互”这道鸿沟先快速对齐一下前两天的进度。Day1搭好了开发环境搞定了JSX语法你会把一个简单的组件渲染到页面上Day2开始接触组件的核心概念知道了props是父组件往子组件传数据的通道state是组件内部自己维护的数据。但这两天的内容有一个共同的特点所有数据都是写死的页面只有“展示”功能。真实的前端产品里用户会点击按钮、输入文字、下拉选择、勾选选项这些行为触发的就是事件处理行为发生后页面要跟着变化这是状态管理的活而数据一变界面局部或整体需要重新渲染条件渲染和列表渲染就派上了用场。我在带新人时发现一个规律到了这一步大家的水平开始明显分层。一部分人停留在“能照着文档写组件”的状态遇到写事件、写表单就发懵另一部分人虽然也会写但写出来的代码充满了坏味道——比如在渲染函数里搞一堆诡异的嵌套逻辑、给列表随便用index当key、表单组件没有受控导致数据流混乱。今天这篇的内容基本就是把这些分水岭上的问题一一拆解掉。一个建议是学今天的内容时把心态从“了解概念”切换到“掌握机制”。比如不要只记住“setState是异步的”这句话而是要通过实际代码去感受异步更新到底怎么影响你的业务逻辑。这些细节才是面试和工作里真正卡人的地方。2. 事件处理React交互的神经末梢与绑定潜规则2.1 从三种写法看懂事件绑定的演进React事件绑定的写法简直是前端框架迭代史的微缩样本。我最早接触React时还是class组件时代写一个点击事件要处理this指向问题那叫一个痛苦。看下这段代码你就明白当年的坑在哪class Button extends React.Component { constructor(props) { super(props); this.handleClick this.handleClick.bind(this); } handleClick() { console.log(this); // 不bind的话这里是undefined } render() { return button onClick{this.handleClick}点击/button; } }JavaScript的class方法默认不绑定this如果你直接把this.handleClick作为事件处理器传进去方法里的this就是undefined。所以要么在constructor里手动bind要么用箭头函数写法绑定。后来class fields语法流行起来可以写成handleClick () {}才省掉了constructor里那一堆bind语句。到了函数组件时代事情就简单了function Button() { const handleClick () { console.log(clicked); }; return button onClick{handleClick}点击/button; }箭头函数天然不产生自己的this往上一找就是组件作用域完全没有绑定的烦恼。这也是为什么函数组件能成为主流——心智负担确实低太多了。2.2 合成事件React事件系统的护城河很多新手没意识到你在JSX里写的onClick、onChange并不是直接把事件绑到你写的那个DOM元素上。React的事件系统是一层“合成事件”SyntheticEvent封装事件被统一挂载到了应用根容器上通过事件委托的方式管理。这套机制带来的好处很实在跨浏览器兼容性和性能优化。React帮你抹平了IE和现代浏览器之间事件对象和行为差异也在底层用统一的监听器管理事件避免了给每个元素单独挂监听导致的内存浪费。你在事件处理函数里拿到的event其实是React包装过的SyntheticEvent但它和原生事件的接口长得几乎一样。不过这里有个早期版本非常坑的细节React 17之前合成事件对象会“池化复用”意思是事件处理函数执行完事件对象的属性就会被清空重置。如果你想在setTimeout里访问事件对象会发现拿到的是一堆null。React 17之后去掉了池化机制这个问题就不复存在了。如果你看一些老教程提到这个坑别慌新版已经改了。实际开发中你还可能遇到NativeEvent和SyntheticEvent混用的情况。比如用原生addEventListener绑定的事件和React的合成事件在冒泡阶段的行为是有微妙差异的。一般不建议在同一个元素上混合使用两种事件体系容易出莫名其妙的bug。2.3 传参的正确姿势事件处理往往需要带参数。最常见的场景是列表项点击你得知道点的是哪一条数据。很多人会直接写button onClick{this.handleDelete(item.id)}删除/button这是经典的错误——它会在渲染阶段立刻执行handleDelete根本等不到点击。正确做法是包一层箭头函数button onClick{() this.handleDelete(item.id)}删除/button或者利用事件对象作为最后一个参数的特性button onClick{this.handleDelete}删除/button // 这样就能在函数里通过event.currentTarget拿到触发的元素 handleDelete(event, id) { // ... }这里要聊一个性能细节每次渲染时箭头函数都会创建一个新函数引用。在React的diff机制里这种变化可能会导致子组件不必要的重渲染尤其当子组件被React.memo包裹时props比较发现函数引用变了memo就失效了。所以对于性能敏感的的列表渲染更稳妥的方式是不传内联箭头函数而是把事件处理函数定义为稳定引用通过data属性或事件委托再取数据。一个更现代化的做法是用React的useCallback配合柯里化但这对新手来说可以先放一放。我的建议是先确保逻辑正确再操心性能优化。等你真正遇到渲染卡顿的场景再去细调这一类问题。3. 条件渲染与列表渲染动态界面的两条腿3.1 条件渲染的四种套路业务开发里你几乎每一行渲染逻辑都在跟“什么条件下显示什么内容”打交道。React里的条件渲染思路很灵活但正因为灵活新手容易写出拧巴的代码。最常用的是三元运算符和逻辑与短路function UserCard({ user }) { return ( div {user ? ( span{user.name}/span ) : ( button登录/button )} {user.isVip Badge colorgoldVIP/Badge} /div ); }但这里有个极其常见的坑当 左侧不是boolean而是数字时会渲染出意外的内容。比如data.length List data{data} /如果data是个空数组结果页面上会渲染出一个“0”。这个我在实际团队里不止一次看到新人踩到排查半天找不到原因最后发现页面上多了个光秃秃的0。正确的习惯是把左侧显式转成boolean比如data.length 0 List data{data} /。另外两种条件渲染是if/else和立即执行函数表达式。if/else适合逻辑较多的场景你可以在return之前写好分支逻辑IIFE则是一种相对老派的写法在JSX中段做临时逻辑处理。不过IIFE可读性真的不怎么样我一般不建议在现代代码里过度使用能用变量提前计算就提前算好。还有一部分开发者喜欢把这部分逻辑抽成小组件function Loading({ loading, children }) { if (loading) { return div加载中.../div; } return children; }这种“渲染逻辑组件化”的做法很值得学习它把判断逻辑从业务组件里剥离出来代码会清爽很多。3.2 key不是随便写的列表渲染的深层逻辑列表渲染是React开发的高频动作十行代码里有五行是map{todos.map(todo ( TodoItem key{todo.id} todo{todo} / ))}很多新手把key当成“为了不报错而必须写的属性”但其实key是React进行列表差异对比的基石。React的diff算法在执行时会用key来判定两个节点是否为同一个节点。如果key没有稳定对应关系React就只能靠位置来推测一旦列表项发生增删或排序它就不知道哪一项是“之前那个”导致组件状态错乱、DOM被误删重建。最经典的坑是用数组index当key。我给你复述一个真实场景你有一个待办列表每一项内部有输入框用户在第一项输入了文字。这时往列表头部插入一条新数据因为新数据占据了index 0原来的第一项变成了index 1React拿着新的key对照旧的key发现对不上了结果输入框的state被串到了错误的位置。用户看到的是自己输入的文字莫名其妙跑到别的项上。这个问题的本质是key的语义应该是“数据的身份标识”而不是“数据的位置序号”。只要你确定列表是静态的、不增删、不排序用index勉强能用但在真实业务里这种确定几乎不存在。所以我的建议很简单能用唯一id就用idid必须稳定且在列表中唯一。数据里没有id字段就在拉取数据时额外生成一个稳定标识。顺带提一句注意点不要用Math.random()生成key甚至不要用new Date().getTime()因为每次渲染时key的值都会变导致React每次diff都是全新列表性能崩坏不说组件状态也全丢了。面试里问到“key的作用”时回答应该落到diff算法和组件状态复用这两个点上光说“用于唯一标识”是不够的得分率不高。4. 表单处理受控与非受控和用户对话的正确姿势4.1 受控组件到底“受什么控”React的表单处理是新手最容易懵的一块。原生HTML里表单元素有自己的一套状态用户在输入框里敲字输入框的内容自动更新但在React里如果只给输入框写value属性不写onChange更新逻辑那么这个输入框就永远显示了那个固定值用户输入啥都变不了function App() { const [name, setName] useState(); return input value{name} /; // 页面能显示但永远打不了字 }这就是“受控组件”的含义——输入框的显示值由React的数据状态驱动变化由onChange里更新state来触发。数据是唯一的事实来源输入框只是个被控的展示层。受控组件的标准写法是function App() { const [name, setName] useState(); const handleChange (event) { setName(event.target.value); }; return input value{name} onChange{handleChange} /; }这样做的好处你知道吗因为输入内容同步进了state你可以在onChange里做实时校验、格式转换、联动处理也能在其他任何地方拿到当前输入值。这就是受控组件成为主流实践的原因——一切都可预测、可测试。4.2 多字段表单的状态管理单字段的表单还好说真实业务里的表单动不动就是十几个字段。一个字段一个useState那代码就没法看了。更合理的做法是用对象作为state配合表单元素的name属性做通用处理function RegistrationForm() { const [form, setForm] useState({ username: , email: , password: }); const handleChange (event) { const { name, value } event.target; setForm(prev ({ ...prev, [name]: value })); }; return ( input nameusername value{form.username} onChange{handleChange} / input nameemail value{form.email} onChange{handleChange} / input namepassword typepassword value{form.password} onChange{handleChange} / / ); }利用计算属性名语法和name字段一个handleChange通吃所有字段新增字段时只需要在state里追加一条并加一个input非常省事。这里提醒一句setForm(prev ({...prev, [name]: value}))要使用函数式更新尤其是在循环和批量操作时依赖当前state的更新逻辑必须这样写否则可能拿到旧值。select、checkbox、radio这几种控件也有它们各自的受控方式。select用value加onChangecheckbox用checked加onChangeradio是按name分组的。细节虽然不同核心思路一脉相承状态驱动显示变化回调改写状态。4.3 非受控组件和ref什么时候该“放手”非受控组件用的是DOM自身来管理输入状态React不参与其中。获取值靠reffunction FileUpload() { const fileInputRef useRef(null); const handleSubmit () { console.log(fileInputRef.current.files[0]); }; return input typefile ref{fileInputRef} /; }非受控组件在什么场景有价值文件上传是典型场景因为把文件内容同步进state既麻烦又无必要另外是第三方库集成的场景库需要直接操作DOM元素受控反而不方便。除此之外日常表单我强烈建议默认走受控组件路线。非受控方式的数据源在各个DOM节点里后续要做联动、校验、跨字段逻辑都得一遍遍地通过ref取值代码很快就烂了。还有个实际现象要注意受控组件加ref拿到的是组件实例或者绑定的真实DOM元素而非受控组件加ref直接拿DOM。理解这个差别有助于排查一些诡异的bug我曾经因为在一个受控input上期望ref.current.value能取到新值结果费了半天劲才发现当前渲染还没提交value还是旧的。4.4 表单验证与提交的常见错题本表单提交的主流程一般是submit按钮触发验证有错误就展示错误信息全部通过才执行提交逻辑。新手常见的错误有两个方向一是在handleChange里就做全部校验用户在输入过程中就被大量错误提醒轰炸二是完全不管等提交时憋了一个error弹出来但没有任何字段级定位。我的经验是字段级校验适合在失焦事件onBlur时触发给用户及时反馈整体校验在提交时统一跑一遍错误信息就近放到对应字段下方。提交期间要注意防重复提交用一个isSubmitting状态关闭按钮同时禁用表单元素。5. Hooks初体验useState与useEffect的日常与暗坑5.1 useState函数组件有了记忆class组件时代state只能在class里用函数组件曾经只是个“纯渲染函数”。useState的出现之所以是革命性的是因为它让函数组件有了内部记忆能力。每次组件渲染时React都按hooks的调用顺序记住每个state的当前值顺序一旦变了就会出大问题这也是为什么Hooks的调用规则是“不能在条件或循环里调用”。useState的更新是异步的这是新手踩坑重灾区function Counter() { const [count, setCount] useState(0); const handleClick () { setCount(count 1); setCount(count 1); console.log(count); // 还是0 }; return button onClick{handleClick}{count}/button; }这个例子里连续两次setCount(count 1)点击后count只会增加1。因为两次更新拿到的都是同一个旧count。正确的累加姿势是函数式更新setCount(prev prev 1); setCount(prev prev 1); // 这次执行时prev已经变成1了结果1变成2React的自动批处理Automatic Batching是React 18之后引入的默认行为它会把同一事件里的多次state更新合并到一次渲染里。理解了批处理你才能解释为什么连续set多次只触发一次渲染、以及为什么在setTimeout或Promise回调里set也会被批处理这点在18之前是分裂的17及更早版本在异步代码里不会批处理。闭包问题是另一个隐藏坑。看这段经典代码function DelayedAlert() { const [msg, setMsg] useState(); const handleChange (e) { setMsg(e.target.value); setTimeout(() { alert(这里显示的是 msg); // 永远显示更新前的msg }, 3000); }; return input onChange{handleChange} /; }因为setTimeout里的闭包捕获的是本次渲染的msg值而不是最新的。要拿到新值应该在依赖数组或callback引用层面解决或者直接在事件里读取e.target.value。这类问题面试里会以“useEffect闭包陷阱”的形式出现。5.2 useEffect副作用处理的正确姿势useEffect是给函数组件执行副作用的入口。数据请求、手动修改DOM、设置定时器、订阅事件都属于React渲染流程之外的“副作用”。它的核心是依赖数组。三种形态对应三种行为// 1. 每次渲染后都执行 useEffect(() { console.log(每次都执行); }); // 2. 仅挂载后执行一次 useEffect(() { console.log(挂载后执行一次); }, []); // 3. 依赖变化后才执行 useEffect(() { console.log(count变化后执行); }, [count]);空依赖数组“仅执行一次”是面试高频点但很多人没说出深层机制React会对比两次渲染的依赖项若引用没变就不触发effect。空数组意味着“没有任何依赖”所以只在首次挂载后执行。所以如果你在空数组里用了某个stateeslint-plugin-react-hooks会警告你缺少依赖因为它其实是个过时的闭包。有个容易忽略但极其重要的点是清理函数。设置定时器、添加事件监听这种副作用如果不清理会在组件卸载后继续执行导致内存泄漏和“在卸载组件上调用setState”的警告useEffect(() { const timer setInterval(() { console.log(tick); }, 1000); // 清理函数在组件卸载或依赖变化前调用 return () { clearInterval(timer); }; }, []);React 18还有一个关于effect执行次数的变化开发模式下组件会挂载、卸载、再挂载一次用来暴露出清理逻辑缺失的问题。很多用空数组写定时器的同学开发环境里一看控制台怎么打印了两份日志慌得不行。其实这是React故意“折腾”你逼着你把清理函数写对。生产环境不会有这个现象但不代表你可以偷懒不写清理。请求竞态问题也值得单独说。比如你根据一个keyword发请求用户输入很快前一个请求比后一个慢结果先发出去的请求后返回把旧数据覆盖了新数据。传统解决方案是用一个flag标记当前组件是否过期useEffect(() { let isActive true; fetchData(keyword).then(data { if (isActive) { setData(data); } }); return () { isActive false; }; }, [keyword]);新版浏览器可以直接用AbortController取消请求但思路是同一个effect的清理环节不只是清理定时器也要负责清理可能产生“过期赋值”的行为。这个点虽然对新手有点超前但既然已经聊到useEffect的边界问题早接触比晚踩坑好。6. 实战收尾写一个真正能用的待办清单6.1 需求拆解与组件设计理论聊了一大堆是时候落地了。我选待办清单Todo List作为实战项目是因为它能自然覆盖今天讲的所有知识点输入框和表单交互要用受控组件任务列表要用地道的条件渲染和列表渲染新增、删除、切换完成状态靠事件处理既要本地管理还要做个简单的持久化。拆解下来这个应用需要三个逻辑块输入区一个输入框加一个新增按钮列表区展示所有任务带删除按钮和完成状态切换统计区显示未完成任务数量组件划分上新手最容易犯的错误是什么都塞进一个巨大组件里。考虑到这个应用体积不大我先把它们集中写在一个App组件里展示核心逻辑等你熟练后自然会把输入区抽成TodoForm、任务项抽成TodoItem——组件拆分的边界由“复用性和可维护性”决定而不是“反正能跑就行”。6.2 核心代码与关键说明先写基础部分import { useState } from react; function App() { const [inputValue, setInputValue] useState(); const [todos, setTodos] useState([]); const handleAdd () { if (inputValue.trim() ) return; const newTodo { id: Date.now(), text: inputValue.trim(), done: false }; setTodos(prev [...prev, newTodo]); setInputValue(); }; const handleDelete (id) { setTodos(prev prev.filter(todo todo.id ! id)); }; const handleToggle (id) { setTodos(prev prev.map(todo todo.id id ? { ...todo, done: !todo.done } : todo ) ); };注意几个要点id用的是Date.now()而不是不稳定的Math.random()虽然极端情况下可能重复但作为本地数据够用了。所有set状态都走了函数式更新避免依赖旧的todos数组。新增任务时做了trim避免塞入一堆空格。输入框是受控组件value用inputValueonChange里setInputValue不允许用户输入空白提交return ( div h1待办清单/h1 div input value{inputValue} onChange{(e) setInputValue(e.target.value)} placeholder输入待办事项 onKeyDown{(e) { if (e.key Enter) handleAdd(); }} / button onClick{handleAdd}新增/button /div ul {todos.map(todo ( li key{todo.id} style{{ textDecoration: todo.done ? line-through : none }} input typecheckbox checked{todo.done} onChange{() handleToggle(todo.id)} / {todo.text} button onClick{() handleDelete(todo.id)}删除/button /li ))} /ul {todos.length 0 ( p未完成任务{todos.filter(todo !todo.done).length}/p )} /div ); }这一段里有一个今天讲过的细节todos.length 0而不是todos.length 就是为了避免空数组渲染出“0”的坑。统计未完成任务的数量用的是todos.filter(todo !todo.done).length每次渲染现算。数据量小没问题但如果业务复杂了你可能需要一个派生状态优化机制。React里统一用useMemo处理这类派生计算这是后话先知道有这个概念就好。6.3 我在写这个Demo时踩过的真实坑第一次写完这个Demo我的清空功能出了bug。我原本想把List组件单独抽出去props传了一个setTodos方法进去子组件里直接用setTodos([])清空。代码跑起来没问题但实际项目里这种“深层次直接改上层状态”的方式会让数据流非常难追踪。这是新手必经的成长点状态应该由拥有它的组件管理修改状态的函数通过props传给需要的子组件。我后续把状态操作方法都放在了App里子组件只负责触发事件调用传入的函数这个结构才比较接近推荐实践。另一个坑是键盘事件。我在input的onKeyDown里判断Enter键触发新增结果用户输入法选词按回车时也会触发导致选词选一半就新增了一条任务。这不是React的锅是原生键盘事件的固有行为。要规避得用compositionstart和compositionend检测输入法状态或者放弃键盘事件只走按钮点击。后来我妥协了按钮点击为主键盘快捷只是加分项别为了它引入无谓的复杂度。6.4 这些知识点如何演变成面试题我自己做技术面试官时特别喜欢拿这类Demo延展问题。今天这篇的每个知识点在真实面试里都能变成一套连招问一行受控组件怎么实现顺着问到为什么输入框锁死再问到setState异步批处理。问列表渲染只要你说出key立刻追问为什么不能用index然后考diff算法的基础策略。问Hooks必然有“useEffect空数组为什么只执行一次”和“清理函数什么时候触发”。问表单大概率是“受控和非受控的区别和各自适用场景”。这些题目都不偏、不怪但很考验你对“机制”的理解深度。如果你能把今天内容里的原理讲清楚——合成事件委托、更新批处理、effect依赖对比、key背后的diff策略——面试官基本能判定你是真的在用React而不是只写过几个Demo。我个人的体会是新手学React最忌讳“背面试题”。概念之间的因果关系一旦打通各种变体题目你都能现场拆解。就像你理解了key是为了让diff算法识别节点是否同一个就不会在主列表里用Math.random生成key因为你知道每次渲染key都不同等于强制React销毁重建全部节点——所有表现都从机制里来都能推出来。把今天这个待办清单多敲两遍一遍照着写熟悉语法一遍不看参考答案纯自己实现最后再尝试加一个“编辑任务”的功能。你会发现调通了之后你对React交互的恐惧感已经消失了大半。