useState的用法
useState 创建组件的状态
useState 的settter 修改组件的状态
useState 的settter
可以触发组件函数的重渲染(react使用
Object.is方法来判断之前的状态和现在的设置的状态是否相同,如果相同则不会重新渲染。不同则渲染。)
React 不会因为对象内部变化而重新渲染,必须传入新对象引用 才能让 React 感知到变化。
Object.is的特点:基本类型比较值,引用类型比较引用地址。
这也就是react强调 数据的 不可变性。 只有整体替换才会让两次状态的引用地址不同,从而触发重渲染。
整体替换太麻烦,每次都要写很多属性,可以使用
useImmer,useImmerReducer
useState 的 setter 触发更新的逻辑
setState 进入队列 异步更新
useState值改变之后,组件会重新渲染,这个过程是怎样的!
🧩 一、React 内部的更新队列机制示意
当你调用 setState 时,React 并不会马上修改 state,而是像这样往队列里加“更新任务”:
🔴 情况一:普通写法(值更新)(三个setCount中的count都是闭包里的旧值)
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
假设当前 count = 0。
React 收到的其实是:
更新队列:
[ { value: 1 }, { value: 1 }, { value: 1 } ]
然后 React 最终会这样处理:
初始 state: 0
处理更新 1 → newState = 1
处理更新 2 → newState = 1(没变化)
处理更新 3 → newState = 1(没变化)
🧨 所以最后只会变成 1。
因为这三个更新都基于同一个旧值(0)计算出来。
🟢 情况二:函数式更新(函数更新)
setCount(prev => prev + 1)
setCount(prev => prev + 1)
setCount(prev => prev + 1)
React 收到的更新队列变成这样:
更新队列:
[ { updater: (prev) => prev + 1 },
{ updater: (prev) => prev + 1 },
{ updater: (prev) => prev + 1 } ]
React 在真正更新时会顺序执行每个函数:
初始 state: 0
执行第一个 updater → prev = 0 → newState = 1
执行第二个 updater → prev = 1 → newState = 2
执行第三个 updater → prev = 2 → newState = 3
✅ 最终结果:count = 3
⚙️ 三、这说明了什么?
| 对比点 | 普通更新 | 函数式更新 |
|---|---|---|
| 更新类型 | 写死的值 | 延迟执行的函数 |
| React 处理顺序 | 只保留最后一个值 | 顺序执行每个函数 |
| 最终结果 | 只更新一次 | 连续叠加 |
💡 类比理解
可以把 setCount(xxx) 想象成往“任务列表”中丢任务。
- 普通写法:丢进去的是「我想把值改成 1」
- 函数式写法:丢进去的是「等会执行我,传给我最新值,我来算新值」
第二种方式才真正能保证逻辑正确。
看看下面最终的结果是啥?
function App() {
const [user, setUser] = useState({name: '张三', age: 18})
const handleClick = () => {
setUser({...user, name: '李四'})
setUser({...user, age: 20})
}
return (
<div className="app">
<FileUpload />
<button onClick={handleClick}>click</button>
<p>{user.name}</p>
<p>{user.age}</p>
</div>
)
}
点击click之后的结果是张三,20.
原因:setState不是修改 闭包中的老值,而是根据这个老值生成新的state,所以老的state不会被修改
函数式更新是 把 生成的新state传递下去,让下面的set函数,根据自己新state来操作。
(不是函数式更新,只看最后一次setState即可)
setSate是异步的,只有队列执行完之后才会 重新渲染组件
1️⃣ setState 的本质
-
普通写法(直接值)
setCount(count + 1)- 把计算好的“值”放入更新队列。
- 如果多次调用,每次都是基于调用时的旧值计算的。
-
函数式写法(函数)
setCount(prev => prev + 1)- 把函数放入更新队列,等队列执行时会拿到“最新的 state”作为参数。
- 可以连续、正确地叠加更新。
2️⃣ 更新队列处理流程
React 内部维护一个 更新队列(update queue) ,处理步骤:
-
调用 setState → React 不立即修改 state,而是把更新任务加入队列。
-
批量处理队列:
- React 会按顺序处理队列里的任务。
- 对于普通值更新,后面的更新可能覆盖前面,导致丢失变化。
- 对于函数式更新,每个 updater 都能拿到最新值计算。
-
队列处理完毕→ React 决定是否触发重新渲染。
3️⃣ 渲染时机
-
批量更新(Batching) :在 React 的事件回调中(例如
onClick),多次setState会被合并成一次渲染。 -
队列执行完毕后:
- React 会拿最新的 state 计算组件要渲染的虚拟 DOM。
- 与上一次虚拟 DOM 做 diff。
- 最后把差异更新到真实 DOM。
-
所以,你可以理解为:
只有队列里的所有更新都执行完,React 才会触发组件重新渲染。
在 React 事件处理函数 中:
即使你连续调用多次
setState(包括函数式更新),React 只会触发一次重新渲染。
🔍 一、为什么只渲染一次?
因为 React 有一个「批量更新机制(Batching) 」。
当你在事件处理函数中写:
function handleClick() {
setCount(prev => prev + 1)
setCount(prev => prev + 1)
setCount(prev => prev + 1)
}
React 内部流程如下👇:
- 进入事件回调 → React 开启批量更新模式;
- 每次
setCount不会立即触发渲染,只是往队列里加更新; - 当事件函数执行完毕后,React 才合并所有更新;
- 执行完所有 updater(函数式更新),得到最终的 state;
- 最后只 重新 render 一次。
🧩 二、具体过程模拟
const [count, setCount] = useState(0)
function handleClick() {
setCount(prev => prev + 1) // 加入队列
setCount(prev => prev + 1) // 加入队列
setCount(prev => prev + 1) // 加入队列
console.log('after set:', count)
}
输出过程大致如下:
点击前:count = 0
点击时:三个 setCount 都加入队列,但组件还没重新渲染
console.log 打印:0 (旧值)
React 合并更新,计算最终 state = 3
React 重新 render 一次(只一次!)
⚙️ 三、那什么时候会 render 多次?
如果你把这些更新放到不同的异步回调里,比如:
setTimeout(() => setCount(prev => prev + 1), 0)
setTimeout(() => setCount(prev => prev + 1), 0)
setTimeout(() => setCount(prev => prev + 1), 0)
此时每个 setTimeout 都是独立的事件上下文,React 不会把它们批量合并。
于是:
- 每次
setCount都会单独触发一次渲染; - 最终渲染了 3 次。
🧩 四、总结
| 场景 | 是否批量更新 | 渲染次数 |
|---|---|---|
| 同一个事件回调里多次 setState | ✅ 是 | 1 次 |
| 不同异步回调(setTimeout、Promise.then) | ❌ 否 | 多次 |
| 使用 React 18 自动批量(启用 Concurrent Mode) | ✅ 是(几乎所有情况) | 1 次 |
✅ 一句话总结:
React 会在一个
同步事件中收集所有setState,等函数执行完再合并,
所以你即使执行三次更新,也只会 render 一次。
setTimeout(() => setCount(prev => prev + 1), 0) 为什么setTimeout有独立的事件上下文
🧠 一、JavaScript 事件循环与调用栈
JS 是单线程执行的。浏览器维护一个事件循环(event loop):
主线程执行:
同步代码 -> 执行完 -> 检查任务队列 -> 执行下一个宏任务 / 微任务
所以:
- 同步任务(比如普通的点击事件函数)都在同一个调用栈中;
- 异步任务(比如
setTimeout回调)会被放到任务队列中,等同步任务执行完之后再执行。
⚙️ 二、React 的批量更新机制(Batching)
React 内部有一个“批量更新模式”的开关,伪代码如下:
ReactDOM.flushSync(() => {
// 批量更新开启
setCount(1)
setName('foo')
// ...
// 批量更新关闭
})
这个“开关”只在某些 React 管控的上下文中 打开,比如:
- 组件生命周期(render、effect、事件回调);
- React 事件系统触发的事件(如 onClick、onChange)。
当批量更新模式开启时:
- 所有
setState不会立即触发渲染; - React 会收集所有更新,事件结束后统一执行一次。
🧩 三、为什么 setTimeout 不在 React 的上下文里?
当你这样写:
function handleClick() {
setTimeout(() => setCount(c => c + 1), 0)
}
执行过程是:
- 你点击按钮 → React 调用
handleClick; setTimeout注册了一个浏览器原生异步任务;handleClick执行完 → React 的批量模式关闭;- 浏览器主线程空闲时,执行
setTimeout回调; - 此时 React 不再处于“批量模式”,所以
setState立刻触发一次渲染。
⚠️ 因此:
每个
setTimeout的回调都在「React 批量更新范围外」,
所以每次都单独触发 render。
🧩 四、例子对比一下
// ✅ 批量更新(1次渲染)
function handleClick() {
setCount(prev => prev + 1)
setCount(prev => prev + 1)
setCount(prev => prev + 1)
}
// ❌ 非批量更新(3次渲染)
function handleClick() {
setTimeout(() => setCount(prev => prev + 1), 0)
setTimeout(() => setCount(prev => prev + 1), 0)
setTimeout(() => setCount(prev => prev + 1), 0)
}
第一个例子里,所有更新都在 React 控制的事件回调内 → React 自动合并。
第二个例子里,setTimeout 回调是原生异步上下文 → React 不知道你要合并。
🧩 五、React 18 之后的变化:自动批量(Automatic Batching)
在 React 18(启用 concurrent 模式)中,React 扩展了批量更新范围:
即使在
setTimeout、Promise.then、fetch等异步回调中调用setState,
React 也会自动合并更新 🎉。
例如:
setTimeout(() => {
setCount(c => c + 1)
setName('Alice')
}, 0)
在 React 18 中,这两个更新会被合并成一次渲染。
(以前的版本是两次)
🧭 六、总结表格
| 场景 | React 17及以前 | React 18(自动批量) |
|---|---|---|
同一个事件回调中多次 setState | ✅ 合并更新 | ✅ 合并更新 |
不同 setTimeout 回调中多次 setState | ❌ 各自渲染 | ✅ 自动合并 |
| Promise.then / fetch / async 回调 | ❌ 各自渲染 | ✅ 自动合并 |
🎬 一、先认识两个关键角色
1️⃣ React 的批量更新开关(Batch Mode)
React 在某些场景会打开“批量模式”:
“所有
setState我先记着,不急着更新,等函数结束我一起更新。”
比如:
- React 事件回调(
onClick,onChange) - 生命周期函数(
useEffect,useLayoutEffect)
当事件结束后,React 会自动关闭批量模式,然后统一触发一次重新渲染。
2️⃣ 浏览器的事件循环机制
浏览器的事件循环分两种任务:
- 同步任务(主线程执行)
- 异步任务(放入任务队列,稍后执行)
setTimeout 的回调就是被放进“任务队列”的异步任务。
它不会立刻执行,要等主线程的同步代码都跑完再轮到它。
🧩 二、我们用时间轴看整个过程
来看代码 👇
function handleClick() {
setTimeout(() => setCount(prev => prev + 1), 0)
}
然后点击按钮。
🕒 Step 1:React 调用事件回调
React 捕获到点击事件 → 调用 handleClick()。
此时 React 打开了“批量模式”:
ReactBatching = true
🕒 Step 2:执行 setTimeout 注册异步任务
执行到:
setTimeout(() => setCount(...), 0)
这里其实什么都没更新,只是:
“告诉浏览器:我有个任务,等同步任务都执行完再来执行它。”
于是浏览器把回调放到任务队列:
TaskQueue = [() => setCount(...)]
🕒 Step 3:handleClick 执行结束
handleClick() 执行完毕。
React 认为:“当前事件结束了,我可以把批量模式关掉。”
ReactBatching = false
🕒 Step 4:浏览器执行 setTimeout 回调
主线程空闲后,浏览器取出任务队列里的回调执行:
setCount(prev => prev + 1)
但此时 React 并不知道这是“同一个事件”中的更新了,
因为 React 的批量模式已经关了!
于是 React 直接:
“好,我立刻更新 state 并重新渲染一次组件。”
✅ 最终结果
每次进入 setTimeout 的回调,都独立触发一次渲染。
因为这些回调是在 React 的“批量模式”之外执行的。
🧠 三、用动画类比理解
可以想象成:
| 阶段 | React 批量模式 | 状态 |
|---|---|---|
| 点击按钮 → 进入 handleClick | ✅ 打开批量模式 | “收集更新” |
| handleClick 结束 | ❌ 关闭批量模式 | “该渲染了” |
| 浏览器稍后执行 setTimeout 回调 | 🚫 React 已不知情 | “立刻渲染一次” |
🧩 四、如果想让它在异步回调里也批量更新怎么办?
在 React 18 之后(Concurrent Mode),React 扩展了批量机制:
即使是在
setTimeout、Promise.then等异步中,
React 也会自动帮你批量。
所以在 React 18 中:
setTimeout(() => {
setCount(c => c + 1)
setName('Alice')
}, 0)
只会触发 1 次渲染 ✅。
哪些操作会触发批量更新
1️⃣ 什么是批量更新(Batching)
React 的 批量更新模式,本质上是一个开关,控制 同一次事件中多次 setState 是否合并渲染。
- 批量模式开着:多次
setState只触发 一次 render - 批量模式关掉:每次
setState都会单独触发渲染
伪代码示意:
ReactBatching = true // 开启批量
setCount(1)
setName('foo')
// 事件结束,React 会合并更新
render()
ReactBatching = false // 关闭批量
2️⃣ 触发批量更新的上下文
React 并不是所有 setState 都批量,而是只在 React 控制的上下文 才批量:
(1) React 事件系统触发的事件
- 如
onClick,onChange,onInput - React 内部会把事件回调包装成 SyntheticEvent
- 批量模式在回调执行前自动打开,回调执行完毕后自动关闭
- 示例:
<button onClick={() => {
setCount(c => c + 1)
setName('Alice')
}}>Click</button>
✅ 同一事件回调里的两个 setState 会被批量,最终只 render 一次。
(2) 组件生命周期
render内部不会调用setState(会报错),但useEffect/useLayoutEffect可以- React 会在这些钩子回调中 默认开启批量模式
- 例如:
useEffect(() => {
setCount(c => c + 1)
setName('Bob')
}, [])
✅ 两次更新也只触发一次 render。
(3) React 18 扩展后的异步上下文
- React 18 引入 Automatic Batching
- 批量模式扩展到 几乎所有异步场景,包括
setTimeout、Promise.then、fetch - 示例:
setTimeout(() => {
setCount(c => c + 1)
setName('Charlie')
}, 0)
在 React 18 下,也会合并成 一次 render
注意:如果是 React 17,setTimeout 内的更新不会被合并。
3️⃣ React 内部是如何实现的
核心就是一个 批量标志 + 更新队列:
let isBatching = false
let updateQueue = []
function setState(update) {
if (isBatching) {
updateQueue.push(update) // 记录更新
} else {
// 立即更新
state = typeof update === 'function' ? update(state) : update
render()
}
}
function flushUpdates() {
for (const u of updateQueue) {
state = typeof u === 'function' ? u(state) : u
}
render()
updateQueue = []
}
React 事件回调执行时:
isBatching = true
callback()
flushUpdates()
isBatching = false
flushUpdates()在事件结束时一次性把队列里的更新合并- 批量模式自动控制渲染次数
4️⃣ 总结理解
| 方面 | 描述 |
|---|---|
| 批量更新 | 同一事件或同一上下文内,多次 setState 只渲染一次 |
| React 17 默认 | 仅事件回调 & 生命周期钩子中批量,异步回调不批量 |
| React 18 自动批量 | 批量范围扩展到几乎所有异步回调(setTimeout、Promise 等) |
| 原理 | isBatching 标志 + 更新队列,事件结束 flush 一次 |
简单理解:
批量更新模式就是 React 的开关,控制 setState 是否“收集起来一次渲染”。
事件回调、effect、生命周期里开,其他普通异步可能不开(React 17),React 18 自动扩展。
总结
react中的setState是 异步 批量更新的。
因为react的响应式策略是,递归更新当前组件和及其所有子组件。
如果一个父组件中的state是同步更新的话,父组件的handerclick事件中多次修改了state,那么整个组件树在每一次state更新时,都会更新,非常影响效率。
我们可以把 setState 设计成异步 + 批量更新的原因归纳为两大支柱:
原因一:性能优化 (Performance) —— 正如你所说
你的推导完全正确。
1. 渲染昂贵
在 React 中,re-render(重渲染)是一个昂贵的过程。
- 调用组件函数(运行 JS)。
- 生成新的 Virtual DOM 树。
- Diff 算法对比新旧树。
- Commit 阶段更新真实 DOM。
- 执行 Layout 和 Paint(浏览器渲染)。
2. 牵一发而动全身
React 的更新机制确实是递归的。一旦父组件更新,默认情况下,它的所有子组件都会递归地进行重渲染(除非你用了 React.memo 或 shouldComponentUpdate)。
3. 场景模拟
如果 setState 是同步的:
handleClick = () => {
// 假设这是同步的
this.setState({ count: this.state.count + 1 }); // 立即触发父组件+子组件重渲染
this.setState({ name: 'React' }); // 再次触发父组件+子组件重渲染
this.setState({ isLoaded: true }); // 第三次触发...
}
在一个点击事件里,浏览器就要重绘 3 次。这不仅仅是慢,简直是灾难性的浪费。
4. 批量更新 (Batching) React 的策略就像去餐厅点菜:服务员(React)不会你说一个菜他就跑去厨房(渲染)一次。他会拿个小本本记下来(Batching),等你把这顿饭的菜都点完了(Event Handler 执行结束),他再一次性把单子送到厨房。
原因二:数据一致性 (Internal Consistency) —— 被忽略的关键
除了性能,还有一个更底层的原因,是 React 团队(Dan Abramov 等人)多次强调的:保证 Props 和 State 的一致性。
场景假设: 假设你有两个状态,父组件把它们作为 props 传给子组件。
state.countstate.message
如果 setState 是同步的:
handleClick = () => {
// 1. 同步修改 count
this.setState({ count: 1 });
// 此时,React 立即重渲染。
// 子组件收到的 props 是:{ count: 1, message: "旧消息" }
// 危险!此时子组件可能根据 count=1 去处理旧消息,导致逻辑错误或报错。
// 2. 同步修改 message
this.setState({ message: "Hello" });
// 再次重渲染。
// 子组件收到:{ count: 1, message: "Hello" }
}
问题在于“中间状态”:
在第 1 步和第 2 步之间,UI 处于一个不一致的状态(半个新数据,半个旧数据)。如果子组件依赖这两个 props 的配合(比如 message 是一个数组,count 是索引),那么在第 1 步渲染时,子组件可能会因为索引越界而崩溃。
React 的做法:
通过异步和批量更新,React 保证了在 handleClick 执行完之前,不会触碰 DOM。只有当 count 和 message 都更新在内存里准备好了,React 才会进行一次原子级的渲染。
这对开发者意味着:你永远不会看到“更新了一半”的视图。
补充知识点:React 18 的自动批处理 (Automatic Batching)
既然你是高级开发,这点必须知道。
在 React 18 之前(React 16/17):
React 的批量更新是有条件的。它只在 React 合成事件(Synthetic Events)(如 onClick, onChange)中生效。
如果在 setTimeout、Promise 或原生 addEventListener 中,setState 曾是同步的(不会批量)!
// React 17
setTimeout(() => {
setCount(c => c + 1); // 触发一次渲染
setFlag(f => !f); // 又触发一次渲染
}, 1000);
在 React 18 之后:
React 引入了 Automatic Batching。无论你在哪里调用 setState(定时器、Promise、原生事件),React 都会自动合并更新。
// React 18
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// 只触发一次渲染!
}, 1000);
总结
你的理解非常到位。总结一下:
- 性能(主要原因): 避免在单个事件循环中进行多次昂贵的 DOM 更新和组件树递归计算。
- 一致性(架构原因): 确保
props和state之间的关系始终保持同步,避免在只有部分状态更新时渲染出“破坏的 UI”。 - 实现方式: 就像你说的,它不是真正的异步(Event Loop 层面),而是 React 内部通过标志位控制的“延迟执行”。