【react】useState是什么,怎么用

175 阅读16分钟

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) ,处理步骤:

  1. 调用 setState → React 不立即修改 state,而是把更新任务加入队列。

  2. 批量处理队列

    • React 会按顺序处理队列里的任务。
    • 对于普通值更新,后面的更新可能覆盖前面,导致丢失变化。
    • 对于函数式更新,每个 updater 都能拿到最新值计算。
  3. 队列处理完毕 → React 决定是否触发重新渲染。


3️⃣ 渲染时机

  • 批量更新(Batching) :在 React 的事件回调中(例如 onClick),多次 setState 会被合并成一次渲染。

  • 队列执行完毕后

    1. React 会拿最新的 state 计算组件要渲染的虚拟 DOM。
    2. 与上一次虚拟 DOM 做 diff。
    3. 最后把差异更新到真实 DOM。
  • 所以,你可以理解为:

只有队列里的所有更新都执行完,React 才会触发组件重新渲染。

React 事件处理函数 中:

即使你连续调用多次 setState(包括函数式更新),React 只会触发一次重新渲染


🔍 一、为什么只渲染一次?

因为 React 有一个「批量更新机制(Batching) 」。

当你在事件处理函数中写:

function handleClick() {
  setCount(prev => prev + 1)
  setCount(prev => prev + 1)
  setCount(prev => prev + 1)
}

React 内部流程如下👇:

  1. 进入事件回调 → React 开启批量更新模式;
  2. 每次 setCount 不会立即触发渲染,只是往队列里加更新;
  3. 当事件函数执行完毕后,React 才合并所有更新;
  4. 执行完所有 updater(函数式更新),得到最终的 state;
  5. 最后只 重新 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)
}

执行过程是:

  1. 你点击按钮 → React 调用 handleClick
  2. setTimeout 注册了一个浏览器原生异步任务
  3. handleClick 执行完 → React 的批量模式关闭;
  4. 浏览器主线程空闲时,执行 setTimeout 回调;
  5. 此时 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 扩展了批量更新范围:

即使在 setTimeoutPromise.thenfetch 等异步回调中调用 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 扩展了批量机制:

即使是在 setTimeoutPromise.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
  • 批量模式扩展到 几乎所有异步场景,包括 setTimeoutPromise.thenfetch
  • 示例:
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.memoshouldComponentUpdate)。

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.count
  • state.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。只有当 countmessage 都更新在内存里准备好了,React 才会进行一次原子级的渲染。

这对开发者意味着:你永远不会看到“更新了一半”的视图。


补充知识点:React 18 的自动批处理 (Automatic Batching)

既然你是高级开发,这点必须知道。

在 React 18 之前(React 16/17): React 的批量更新是有条件的。它只在 React 合成事件(Synthetic Events)(如 onClick, onChange)中生效。 如果在 setTimeoutPromise 或原生 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);

总结

你的理解非常到位。总结一下:

  1. 性能(主要原因): 避免在单个事件循环中进行多次昂贵的 DOM 更新和组件树递归计算。
  2. 一致性(架构原因): 确保 propsstate 之间的关系始终保持同步,避免在只有部分状态更新时渲染出“破坏的 UI”。
  3. 实现方式: 就像你说的,它不是真正的异步(Event Loop 层面),而是 React 内部通过标志位控制的“延迟执行”。