React 14
state:组件的记忆案例练习
1 修复画廊翻页BUG代码详细解释
原
import { useState } from 'react';
import { sculptureList } from './data.js';export default function Gallery() {const [index, setIndex] = useState(0);const [showMore, setShowMore] = useState(false);function handleNextClick() {setIndex(index + 1);}function handleMoreClick() {setShowMore(!showMore);}let sculpture = sculptureList[index];return (<><button onClick={handleNextClick}>Next</button><h2><i>{sculpture.name} </i> by {sculpture.artist}</h2><h3> ({index + 1} of {sculptureList.length})</h3><button onClick={handleMoreClick}>{showMore ? 'Hide' : 'Show'} details</button>{showMore && <p>{sculpture.description}</p>}<img src={sculpture.url} alt={sculpture.alt}/></>);
}
改后
import { useState } from 'react';
import { sculptureList } from './data.js';export default function Gallery() {const [index, setIndex] = useState(0);const [showMore, setShowMore] = useState(false);let hasPrev = index > 0;let hasNext = index < sculptureList.length - 1;function handlePrevClick() {if (hasPrev) {setIndex(index - 1);}}function handleNextClick() {if (hasNext) {setIndex(index + 1);}}function handleMoreClick() {setShowMore(!showMore);}let sculpture = sculptureList[index];return (<><buttononClick={handlePrevClick}disabled={!hasPrev}>Previous</button><buttononClick={handleNextClick}disabled={!hasNext}>Next</button><h2><i>{sculpture.name} </i> by {sculpture.artist}</h2><h3> ({index + 1} of {sculptureList.length})</h3><button onClick={handleMoreClick}>{showMore ? 'Hide' : 'Show'} details</button>{showMore && <p>{sculpture.description}</p>}<img src={sculpture.url} alt={sculpture.alt}/></>);
}
我们来详细分析这个代码的变化,理解它修复了什么问题以及为什么这样修复。
先看第一个版本的问题
第一个版本的代码中,只有一个Next按钮,它的点击事件处理函数是这样的:
function handleNextClick() {setIndex(index + 1);
}
这个函数的逻辑很简单:每次点击就把index(当前显示的雕塑索引)加 1。
这里隐藏的 bug 是:当浏览到最后一个雕塑时,继续点击Next按钮会导致错误。
- 假设
sculptureList有 5 个元素(索引 0-4),当index已经是 4(最后一个)时,点击Next会让index变成 5 - 此时执行
let sculpture = sculptureList[index]时,sculptureList[5]是不存在的(数组越界) - 后续访问
sculpture.name、sculpture.url等属性时,就会报错(无法访问undefined的属性)
修复方案:添加边界判断
第二个版本的代码主要做了 3 件事来修复这个问题:
增加了 “上一个” 按钮和对应的逻辑不仅能往后翻,还能往前翻,提升了用户体验。
添加了边界状态判断定义了两个变量来判断当前是否能翻页:
let hasPrev = index > 0; // 是否有上一个(索引大于0时可以往前翻) let hasNext = index < sculptureList.length - 1; // 是否有下一个(索引小于最后一个索引时可以往后翻)- 比如数组长度为 5,最后一个元素的索引是 4,所以
index < 4时才有下一个
- 比如数组长度为 5,最后一个元素的索引是 4,所以
在翻页函数中添加判断,并且禁用无效按钮
- 翻页函数中先判断是否能翻页,只有能翻页时才修改
index:function handlePrevClick() {if (hasPrev) { // 只有有上一个时,才执行翻页setIndex(index - 1);} }function handleNextClick() {if (hasNext) { // 只有有下一个时,才执行翻页setIndex(index + 1);} } - 按钮添加
disabled属性,当不能翻页时按钮变灰且无法点击:<buttononClick={handlePrevClick}disabled={!hasPrev} // 没有上一个时禁用按钮 >Previous </button>
- 翻页函数中先判断是否能翻页,只有能翻页时才修改
总结修复的核心
- 防止数组越界:通过
hasPrev和hasNext判断边界,避免index超出数组的合法索引范围(0 到 数组长度 - 1)。 - 优化用户体验:禁用无效状态的按钮,让用户直观知道当前不能继续翻页,避免误操作。
这样修改后,无论怎么点击按钮,index都会保持在合法范围内,也就不会出现访问不存在的数组元素的错误了。
2 修复表单输入
原
export default function Form() {let firstName = '';let lastName = '';function handleFirstNameChange(e) {firstName = e.target.value;}function handleLastNameChange(e) {lastName = e.target.value;}function handleReset() {firstName = '';lastName = '';}return (<form onSubmit={e => e.preventDefault()}><inputplaceholder="First name"value={firstName}onChange={handleFirstNameChange}/><inputplaceholder="Last name"value={lastName}onChange={handleLastNameChange}/><h1>Hi, {firstName} {lastName}</h1><button onClick={handleReset}>Reset</button></form>);
}
改后
import { useState } from 'react';export default function Form() {const [firstName, setFirstName] = useState('');const [lastName, setLastName] = useState('');function handleFirstNameChange(e) {setFirstName(e.target.value);}function handleLastNameChange(e) {setLastName(e.target.value);}function handleReset() {setFirstName('');setLastName('');}return (<form onSubmit={e => e.preventDefault()}><inputplaceholder="First name"value={firstName}onChange={handleFirstNameChange}/><inputplaceholder="Last name"value={lastName}onChange={handleLastNameChange}/><h1>Hi, {firstName} {lastName}</h1><button onClick={handleReset}>Reset</button></form>);
}
第一个版本的核心问题:普通变量无法触发组件重新渲染
第一个版本中,使用了普通的let变量来存储表单输入值:
let firstName = '';
let lastName = '';
当用户在输入框中输入内容时,handleFirstNameChange和handleLastNameChange确实会修改这两个变量的值。但React 并不知道这些变量的变化,所以不会重新渲染组件。
具体表现为:
- 输入框中输入内容时,输入框会 “卡住”,无法显示用户输入的内容(因为
value={firstName}绑定的是不会被 React 追踪的普通变量)。 - 页面上的
<h1>Hi, {firstName} {lastName}</h1>也不会随着输入更新,始终显示Hi,。 - 点击 “Reset” 按钮时,虽然变量被重置为
'',但页面同样不会有任何变化。
这是因为React 组件的重新渲染只依赖于 “状态(state)” 或 “属性(props)” 的变化,普通变量的修改不会触发重新渲染。
第二个版本的修复:使用 React 状态(useState)
第二个版本引入了useState钩子来管理表单状态,这是修复问题的核心:
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
useState是 React 提供的用于管理组件内部状态的钩子,它的工作原理和作用如下:
- 状态变量(如 firstName):由
useState创建的变量,React 会追踪它的变化。 - 状态更新函数(如 setFirstName):用于修改状态变量的值。关键是:调用这个函数后,React 会知道状态发生了变化,并自动重新渲染组件。
修复后的具体变化分析
输入框与状态的双向绑定:
- 输入框的
value绑定到状态变量(value={firstName}),确保输入框显示的是当前状态值。 - 当用户输入时,
onChange触发handleFirstNameChange,通过setFirstName(e.target.value)更新状态。 - 状态更新后,React 重新渲染组件,输入框会显示新的状态值,实现了 “输入即显示” 的效果。
- 输入框的
页面内容实时更新:
- 由于
<h1>Hi, {firstName} {lastName}</h1>依赖状态变量,当状态更新并重新渲染时,这里的内容会自动更新为最新的输入值。
- 由于
Reset 按钮生效:
- 点击 “Reset” 时,
handleReset通过setFirstName('')和setLastName('')将状态重置为空。 - 状态变化触发重新渲染,输入框和
<h1>都会显示为空,实现了重置效果。
- 点击 “Reset” 时,
总结:核心修复逻辑
第一个版本的问题在于使用普通变量存储状态,变量变化无法被 React 感知,导致组件不重新渲染,页面无法更新。
第二个版本通过useState 钩子将普通变量转换为 React 状态,状态的每次更新(通过setXXX函数)都会触发组件重新渲染,从而让输入框、页面内容等依赖状态的部分实时更新,最终实现了表单的正常交互功能。
这也是 React 中 “状态驱动视图” 理念的体现:视图(UI)的变化应该由状态的变化来驱动,而状态必须通过 React 提供的机制(如useState)来管理,才能被 React 追踪和响应。
3 弹窗错误
原
import { useState } from 'react';export default function FeedbackForm() {const [isSent, setIsSent] = useState(false);if (isSent) {return <h1>Thank you!</h1>;} else {// eslint-disable-next-lineconst [message, setMessage] = useState('');return (<form onSubmit={e => {e.preventDefault();alert(`Sending: "${message}"`);setIsSent(true);}}><textareaplaceholder="Message"value={message}onChange={e => setMessage(e.target.value)}/><br /><button type="submit">Send</button></form>);}
}
改后
import { useState } from 'react';export default function FeedbackForm() {const [isSent, setIsSent] = useState(false);const [message, setMessage] = useState('');if (isSent) {return <h1>Thank you!</h1>;} else {return (<form onSubmit={e => {e.preventDefault();alert(`Sending: "${message}"`);setIsSent(true);}}><textareaplaceholder="Message"value={message}onChange={e => setMessage(e.target.value)}/><br /><button type="submit">Send</button></form>);}
}
我们来详细分析这两段代码的区别,以及为什么第二个版本修复了第一个版本的问题。
第一个版本的核心问题:条件渲染中声明状态(useState)
第一个版本的代码结构是这样的:
const [isSent, setIsSent] = useState(false);
if (isSent) {return <h1>Thank you!</h1>;
} else {// 在 else 分支中声明了另一个状态const [message, setMessage] = useState('');// ... 返回表单
}
这里的关键问题是:message状态被声明在了else条件分支内部。
React 对状态钩子(useState)有一个严格的规则:所有的useState调用必须在组件的顶层作用域(即不能在条件、循环、嵌套函数中)。
为什么会有这个规则?React 是通过状态声明的顺序来追踪每个状态的(而不是变量名)。如果在条件分支中声明状态,会导致每次渲染时useState的调用顺序不一致,React 就会混淆不同状态的值,最终引发错误。
第一个版本的具体问题表现
当isSent为false时(初始状态),组件会进入else分支,执行const [message, setMessage] = useState(''),此时 React 会记录 “第 2 个状态是message”。
但当用户点击 “Send” 按钮后,isSent会被设置为true,此时组件会进入if分支,直接返回<h1>Thank you!</h1>,不会执行else分支中的useState('')。
这时候问题就出现了:
- 第一次渲染时,React 记录了 “2 个状态”(
isSent和message)。 - 第二次渲染时(
isSent为true),React 只找到了 “1 个状态”(isSent),状态的数量和顺序发生了变化。
React 会因此抛出错误(类似 “Rendered fewer hooks than expected”),因为它无法正确匹配状态的顺序。
第二个版本的修复:将状态声明移到顶层
第二个版本的代码将message状态的声明移到了组件的顶层作用域, outside 条件判断:
// 所有状态都在组件顶层声明,顺序固定
const [isSent, setIsSent] = useState(false);
const [message, setMessage] = useState('');if (isSent) {return <h1>Thank you!</h1>;
} else {// 直接使用已声明的 message 状态// ... 返回表单
}
这样修改后,无论isSent是true还是false,每次渲染时:
useState的调用顺序都是固定的(先isSent,再message)。- 状态的数量也固定为 2 个,不会因为条件分支而变化。
React 就能始终正确追踪每个状态的值,避免了因状态顺序混乱导致的错误。
额外说明:未使用的状态是否有问题?
可能你会问:当isSent为true时,message状态虽然被声明了,但并没有被使用,这会不会有问题?答案是不会。React 允许声明状态但暂时不使用,只要useState的调用顺序和数量在每次渲染时保持一致即可。未使用的状态只是占用一点内存,不会影响组件的正常运行。
总结:核心修复逻辑
第一个版本的问题在于在条件分支中声明状态(message),导致 React 无法通过固定的顺序追踪状态,引发渲染错误。
第二个版本通过将所有useState调用移到组件顶层,保证了每次渲染时状态声明的顺序和数量一致,符合 React 对钩子的使用规则,从而修复了错误。
这也是 React 钩子(Hooks)的核心规则之一:只在组件顶层调用 Hooks,不要在条件、循环或嵌套函数中调用。
4 状态更新的 “异步性” 导致的预期不符
原
import { useState } from 'react';export default function FeedbackForm() {const [name, setName] = useState('');function handleClick() {setName(prompt('What is your name?'));alert(`Hello, ${name}!`);}return (<button onClick={handleClick}>Greet</button>);
}
改后
export default function FeedbackForm() {function handleClick() {const name = prompt('What is your name?');alert(`Hello, ${name}!`);}return (<button onClick={handleClick}>Greet</button>);
}
第一个版本的核心问题:状态更新的 “异步性” 导致的预期不符
第一个版本使用了useState来管理name状态,代码逻辑是:
- 点击按钮时,通过
prompt获取用户输入的名字。 - 调用
setName更新name状态。 - 立即用
alert显示Hello, ${name}!。
但这里存在一个关键问题:React 中状态更新是异步的。当你调用setName(newValue)时,React 并不会立即修改name变量的值,而是会安排一个状态更新任务,在合适的时机(通常是当前函数执行完成后)才会更新name并重新渲染组件。
这就导致了:
- 当执行
alert(Hello, ${name}!)时,name仍然是更新前的值(初始为空字符串'')。 - 所以无论用户在
prompt中输入什么,alert都会显示Hello, !(因为此时name还没被更新)。 - 虽然状态最终会被更新(组件重新渲染时
name会变为输入值),但alert的时机太早,无法获取到最新的状态。
第二个版本的修复:无需状态时避免使用状态
第二个版本删除了useState,直接在事件处理函数内部处理逻辑:
- 点击按钮时,通过
prompt获取用户输入的名字,用普通变量name存储。 - 直接用
alert显示Hello, ${name}!。
这个修复的核心思路是:当不需要在组件渲染中使用某个值时,没必要把它存在 React 状态里。
在这个例子中,name的值只在点击事件中使用一次(用于alert),后续组件渲染并不依赖这个值(没有在 JSX 中显示name)。所以:
- 不需要用
useState管理name,用普通变量存储临时值即可。 - 普通变量的赋值是同步的,
prompt获取值后,name会立即更新,alert能正确显示用户输入的名字。
总结:两个版本的对比和修复逻辑
| 第一个版本(有问题) | 第二个版本(修复后) |
|---|---|
使用useState管理name状态 | 不使用状态,用普通变量存储临时值 |
状态更新异步,alert无法获取最新值 | 普通变量同步赋值,alert能获取最新值 |
多余地使用了状态(因为name不影响渲染) | 按需使用变量,逻辑更直接 |
修复的核心:React 状态的作用是 “存储影响组件渲染的值”,当一个值仅用于事件处理的临时计算,不参与 UI 渲染时,无需用useState,直接用普通变量即可避免状态更新异步导致的问题。
如果后续需求变化,比如需要在页面上显示用户输入的名字(例如<p>Hello, {name}</p>),这时就必须用useState管理name,但需要注意状态更新的异步性 —— 如果要在更新后立即做某事,需要用状态更新的回调函数,例如:
setName(newName => {alert(`Hello, ${newName}!`); // 这里能获取到最新值return newName;
});
但当前例子中没有这个需求,所以第二个版本的简化方案更合适。
useState的异步问题
我们结合前面那个FeedbackForm的例子,再具体说一下useState的异步问题是如何体现的,以及修复后的逻辑为什么能正常工作。
回顾第一个版本的代码(有问题)
const [name, setName] = useState('');function handleClick() {setName(prompt('What is your name?')); alert(`Hello, ${name}!`); // 这里的 name 是旧值
}
问题的关键过程:
- 用户点击按钮,
handleClick函数执行。 - 首先调用
prompt获取用户输入的名字(比如 “Alice”),然后调用setName("Alice")。此时,React 只是 “计划” 更新name状态,并不会立即把name从''改成"Alice"(异步特性)。 - 紧接着执行
alert(Hello, ${name}!),这里的name仍然是更新前的旧值(''),所以弹窗会显示Hello, !。 handleClick函数执行完毕后,React 才会真正更新name为"Alice",并重新渲染组件。但此时alert已经执行完了,无法再获取到新值。
所以在这个例子中,“状态更新后立即读取” 的操作(alert)永远拿不到最新值,并不是 “有时能显示、有时不能”,而是一定不能显示最新值 —— 因为alert的执行时机在 React 处理状态更新之前。
第二个版本的代码(修复后)
function handleClick() {const name = prompt('What is your name?'); // 普通变量,同步赋值alert(`Hello, ${name}!`); // 直接用刚获取的变量
}
为什么能正常工作?
这里没有使用useState,而是用普通变量存储prompt的结果:
prompt获取到用户输入(比如 “Alice”)后,立即赋值给普通变量name(同步操作,赋值后name就是"Alice")。- 紧接着执行
alert时,直接使用这个普通变量,自然能拿到最新值,弹窗会显示Hello, Alice!。
因为这个场景中,name的值只需要在handleClick函数内部临时使用一次,不需要影响组件的渲染(JSX 中没有用到name),所以完全没必要用useState。用普通变量就能避开状态更新的异步问题,逻辑更直接。
延伸:如果需要在 UI 中显示name怎么办?
假设需求变了,不仅要弹窗,还要在页面上显示名字,这时候就必须用useState了。但要注意异步问题:
const [name, setName] = useState('');function handleClick() {const inputName = prompt('What is your name?');setName(inputName); // 更新状态// 此时 name 还是旧值,但没关系,因为 UI 会重新渲染
}// 组件重新渲染时,会使用更新后的 name
return (<div><button onClick={handleClick}>Greet</button>{name && <p>Hello, {name}!</p>} {/* 这里一定显示最新值 */}</div>
);
这种情况下:
handleClick中setName后,name虽然还是旧值,但p标签会在组件重新渲染后显示最新的name(因为 React 会在handleClick执行完后更新状态并重新渲染)。- 如果此时还需要在
setName后立即执行某些操作(比如基于新name发请求),可以用setName的回调函数:setName(inputName => {// 这里的 inputName 是最新值console.log('最新名字:', inputName);return inputName; });
总结
在前面的例子中,第一个版本的问题是错误地在状态更新后立即读取旧值(因为useState异步更新),导致alert失效;第二个版本通过用普通变量存储临时值,避开了状态更新的异步问题,从而正常工作。
核心规律:当一个值不需要参与组件渲染时,无需用useState;当需要参与渲染时,尽管setXxx后立即读取是旧值,但重新渲染后的 UI 会显示最新值,必要时可通过setXxx的回调或useEffect处理更新后的逻辑。
【为什么是一定不能?】
因为 React 中 useState 的状态更新是 “异步且批量处理” 的,这种机制决定了在同一个事件处理函数里,调用 setXxx 之后立即读取原状态变量,一定只能拿到旧值,不可能拿到新值。
具体来说,第一个版本的代码执行顺序是 “固定死” 的:
function handleClick() {// 步骤1:调用 setName,告诉 React“计划更新 name”setName(prompt('What is your name?')); // 步骤2:此时 React 还没真正更新 name,所以这里的 name 还是旧值(初始为空)alert(`Hello, ${name}!`);
}
步骤 1:
setName(...)只是给 React 发了一个 “更新请求”,但 React 不会立刻执行这个更新(比如不会马上把name从''改成用户输入的值)。这就像你在手机上发了一条消息,对方不会 “瞬间” 就看到并回复 —— 中间有一个 “传输和处理” 的过程。步骤 2:
alert执行时,React 还没处理完步骤 1 的 “更新请求”,name变量的值仍然是更新前的旧值(初始为空字符串'')。所以无论用户输入什么,alert里的name都是空,必然显示Hello, !。只有当
handleClick函数完全执行结束后,React 才会处理之前的 “更新请求”,把name改成新值,然后重新渲染组件。但此时alert已经执行完了,再也没机会用到新值了。
为什么说 “一定不能”?
因为 React 的状态更新机制是 **“先收集所有更新请求,等当前函数执行完再统一处理”,而不是 “执行一个更新就立即生效”。在这个机制下,同一个函数里 “更新状态后立即读原变量” 的操作,本质上是 “在 React 还没处理更新时就去读结果”,所以100% 会拿到旧值 **,不存在 “有时候能拿到新值” 的可能。
这就像你在银行柜台提交了取款申请,还没等柜员处理,就想立即从账户里取出现金 —— 显然是不可能的,必须等柜员操作完成(函数执行结束),账户余额(状态)才会更新。
总结
第一个版本中,alert 之所以 “一定不能” 显示新值,是因为:setName 只是 “预约” 了更新,而 alert 在 “预约” 还没被处理时就执行了,此时状态根本没变化。这种 “先预约、后处理” 的异步机制,决定了同一函数内 “更新后立即读原变量” 的操作必然拿到旧值。
