React UI 组件 和 交互
约 14000 字大约 47 分钟
React
2025-08-04
组件大写命名
React 的组件名称必须以大写字母开头,否则将无法运行。
在 JSX 的语法规则中,对于标签首字母的大小写有着明确且非常重要的区分。具体来说,当你在 JSX 中书写一个标签时,如果它的首字母是大写的,那么 JSX 的编译器会将其识别并“通常被认为”是一个自定义的 React 组件。这意味着它会期望在你的 JavaScript 代码中找到一个同名的、通过 import
导入或在当前作用域内定义的组件(例如,一个函数组件或类组件),并尝试渲染它所定义的 UI 逻辑。
相反地,如果一个标签的首字母是小写的,JSX 则会将其视作一个标准的、原生的 HTML 标签,例如 <h1>
、<p>
、<section>
等等。在这种情况下,JSX 会直接将其转换为对应的 HTML 元素,最终由浏览器进行渲染。
这种区分机制是 JSX 能够理解并处理自定义组件和原生 DOM 元素之间关系的关键所在。它确保了 React 能够正确地解析你的代码,并将你定义的组件树转化为浏览器可以理解的 DOM 结构。因此,严格遵循这一命名约定对于编写可运行且易于理解的 React 应用至关重要,混淆大小写将导致渲染错误或意想不到的行为。
组件定义位置
组件可以渲染其他组件,但不应在组件内部定义组件。
JSX 三大规则
JSX 规则
1. 只能返回一个根元素
2. 标签必须闭合
像 <img>
这样的自闭合标签必须书写成 <img />
。
3. 用驼峰式命名法给大部分属性命名
由于 JSX 属性会转化为 JavaScript 对象键值对,且 JavaScript 变量名不能含 -
或为 class
等保留字,React 将大部分 HTML 和 SVG 属性以驼峰式命名,如 strokeWidth
代替 stroke-width
。
class
因是保留字而需用 className
代替,这也符合 DOM 属性命名。
然而,aria-*
和 data-*
属性因历史原因仍沿用带 -
的 HTML 格式。
Props 的转发
function Profile(props) {
return (
<div className="card">
<Avatar {...props} />
</div>
);
}
这段代码的意思是将 Profile
的所有 props 转发到 Avatar
。
Children Prop
<Card>
<Avatar />
</Card>
Card
组件将接收一个被设为 <Avatar />
的 children
prop。
function Card({ children }) {
return (
<div className="card">
{children}
</div>
);
}
Fragment Key
Fragment 语法的简写形式 <> </>
无法接受 key
值。因此,你只能选择以下两种方式:要么将生成的节点用一个 <div>
标签包裹起来,要么使用虽然稍长但更明确的 Fragment
完整写法:
import { Fragment } from 'react';
// ...
const listItems = people.map(person =>
<Fragment key={person.id}>
<h1>{person.name}</h1>
<p>{person.bio}</p>
</Fragment>
);
这里的 Fragment
标签本身并不会出现在 DOM 上,这段代码最终会转换成 <h1>
、<p>
、<h1>
、<p>
…… 的列表。
纯函数与严格模式
纯函数指的是一种特殊的函数,它满足两个核心条件:首先,对于相同的输入参数,它总是返回完全相同的输出结果,不随外部状态的变化而改变;其次,纯函数不会产生任何可观察的副作用(Side Effects),这意味着它不会修改函数外部的任何状态,例如不修改传入的参数、不修改全局变量、不执行网络请求,也不进行控制台输出等操作。在 React 等函数式编程范式中,纯函数是构建可预测、易于测试和理解组件的基础。
React 提供了 “严格模式”,在严格模式下开发时,它将会调用每个组件函数两次。通过重复调用组件函数,严格模式有助于找到违反这些规则的组件。
严格模式在生产环境下不生效,因此它不会降低应用程序的速度。如需引入严格模式,你可以用 <React.StrictMode>
包裹根组件。一些框架会默认这样做。
局部 Mutation
局部 mutation
在渲染过程中,组件修改了 预先存在 的变量值。为了更精确地描述,我们将这种现象称之为 突变(mutation)。纯函数不会修改函数作用域外的变量,也不会修改在函数调用前(组件渲染前)就已存在的对象——这样做会使函数变得不纯粹!
然而,你完全可以在渲染时修改你刚刚创建的变量和对象。在本示例中,你创建了一个 []
数组,并将其赋值给 cups
变量,然后 push
进去 100 个 Cup
组件:
function Cup({ guest }) {
return Tea cup for guest #{guest};
}
export default function TeaGathering() {
const cups = [];
for (let i = 1; i <= 100; i++) {
cups.push();
}
return cups;
}
如果这个 cups
数组是在 TeaGathering
函数之外创建的,那这将是一个很大的问题!因为那样做会导致你调用数组的 push
方法时,修改了 预先存在 的对象。
然而,在这里则不会有问题,因为每次渲染时,cups
数组都是在 TeaGathering
函数内部创建的。TeaGathering
函数之外的代码不会感知到这一变化。这被称之为 局部突变(local mutation)——如同藏在组件内部的小秘密。
渲染需纯函数
React 只希望你在“渲染阶段”写纯函数,其他逻辑自由发挥。
纯函数的好处
React 为何强调纯函数?
编写纯函数需要遵循一些习惯与规范,但它也为你开启了巨大的可能性:
- 组件可在不同环境中安全运行 由于纯函数在相同输入下总是返回相同结果,React 组件可以无缝运行在浏览器、服务器,甚至其他平台上。比如,在服务端渲染(SSR)时,一个组件可以安全地响应多个用户请求。
- 渲染可以安全地跳过 如果组件的输入没有发生变化,React 可以跳过重新渲染。这是安全的优化,因为纯函数的返回值总是确定的,因此可以放心地缓存结果,显著提升性能。
- 渲染过程可随时中断和重启 在渲染深层组件树的过程中,若某些数据发生变化,React 可以中断当前的渲染并从头开始,无需担心副作用或状态污染。纯函数的确定性保证了这种“随时中止、随时重来”的能力。
- React 的核心能力依赖于纯函数设计 我们正在构建的每一个新特性——从数据请求、动画处理到性能优化,都在利用组件的纯粹性。保持组件纯净,是充分释放 React 编程范式潜力的关键。
渲染副作用
如果一个组件在渲染阶段产生副作用,那么当 React 因数据更新而中断并重启渲染时,这些副作用可能已经影响了外部状态,导致最终渲染结果不可预测,甚至产生 bug。
如果渲染过程有副作用,就无法安全地中断和恢复
状态的定义
随着时间推移而不断变化和更新的数据,通常被称作“状态”(state)。这种状态反映了系统或程序在特定时刻的即时情况或条件,是其当前动态属性的体现。
事件处理函数
事件处理函数 的特点是
- 通常在你的组件 内部 定义。
- 名称以
handle
开头,后跟事件名称。
按照惯例,通常将事件处理程序命名为 handle
,后接事件名。你会经常看到 onClick={handleClick}
,onMouseEnter={handleMouseEnter}
等。
传递函数引用
在 React 中,向事件处理函数(如 onClick
)传递函数时,应该直接传递函数的引用,而不是调用函数。这意味着你需要将函数本身作为属性值传递,而不是执行该函数并传递其返回值。
正确方式:
传递已定义的函数:
<button onClick={handleClick}>
- 这里
handleClick
是一个函数的引用。React 会记住这个函数,只有当用户点击按钮时才会执行它。
- 这里
传递内联匿名函数:
<button onClick={() => alert('...')}>
- 这里
() => alert('...')
是一个匿名函数的引用。这个匿名函数会在用户点击按钮时被调用,从而执行alert
。
- 这里
错误方式:
直接调用已定义的函数:
<button onClick={handleClick()}>
handleClick()
中的()
会导致函数在组件 渲染时立即执行,而不是在点击时执行。这是因为 JSX{}
内部的 JavaScript 会立即求值。你得到的是handleClick
的返回值,而不是函数本身。
直接调用内联函数:
<button onClick={alert('你点击了我!')}>
alert('你点击了我!')
会在组件 渲染时立即执行,而不是在点击时执行。同样,JSX{}
内部的代码会被立即执行。你传递给onClick
的是alert
函数的返回值(在浏览器环境中通常是undefined
),而不是一个函数。
关键区别在于:
当你使用 ()
调用一个函数时,你传递的是该函数执行后的结果。而当你不使用 ()
直接传递函数名或匿名函数时,你传递的是函数的引用,React 会在适当的时候(例如用户点击时)去调用这个引用所指向的函数。
阻止事件冒泡
阻止事件冒泡的写法
function Button({ onClick, children }) {
return (
<button onClick={e => {
e.stopPropagation();
onClick();
}}>
{children}
</button>
);
}
export default function Toolbar() {
return (
<div className="Toolbar" onClick={() => {
alert('你点击了 toolbar !');
}}>
<Button onClick={() => alert('正在播放!')}>
播放电影
</Button>
<Button onClick={() => alert('正在上传!')}>
上传图片
</Button>
</div>
);
}
事件捕获阶段
捕获阶段事件
在极少数情况下,你可能需要 在事件到达其目标元素之前捕获并处理它,即使目标元素上的事件处理函数会阻止事件继续传播。这就像一个“事件拦截器”——在事件向下传递的过程中,我们有机会先处理它。
举个例子,假设你想对页面上的每一次点击都进行数据统计(比如“埋点”),而不管点击的具体按钮是否阻止了事件冒泡(stopPropagation()
)。这时,传统的 onClick
事件就无法满足需求了,因为如果子元素阻止了传播,父元素的 onClick
就接收不到了。
为了实现这种“提前拦截”的能力,React 提供了 捕获阶段事件。你只需要在事件名称后面加上 Capture
:
<div onClickCapture={() => { /* 这会首先执行 */ }}>
<button onClick={e => e.stopPropagation()} /> {/* 这个按钮会阻止事件冒泡 */}
<button onClick={e => e.stopPropagation()} /> {/* 这个按钮也会阻止事件冒泡 */}
</div>
在这个例子中:
- 当用户点击任意一个
<button>
时,onClickCapture
处理函数会在事件到达按钮并执行按钮上的onClick
之前被调用。 - 即使按钮上的
onClick
调用了e.stopPropagation()
来阻止事件冒泡到div
的onClick
处理函数,onClickCapture
也已经执行过了。
事件传播的三个阶段(简化理解):
一个事件从触发到完成,通常会经历以下三个阶段:
- 捕获阶段(Capture Phase): 事件从文档根部开始,层层向下传播,直到到达目标元素。在此过程中,所有带有
Capture
后缀的事件处理函数(如onClickCapture
)都会被触发。 - 目标阶段(Target Phase): 事件到达被点击的实际元素。此时,该元素上绑定的普通事件处理函数(如
onClick
)会被触发。 - 冒泡阶段(Bubbling Phase): 事件从目标元素开始,层层向上冒泡,直到文档根部。在此过程中,所有未带有
Capture
后缀的事件处理函数(如父元素上的onClick
)都会被触发,除非在某个阶段被stopPropagation()
阻止。
何时使用捕获事件?
捕获事件主要用于实现一些“全局性”或“基础设施”级别的功能,例如:
- 路由: 在事件到达链接等元素之前,进行路由跳转的拦截和处理。
- 数据分析 / 埋点: 记录页面上所有用户交互行为,而无需关心具体元素的事件处理逻辑。
- 特定场景的事件委托: 在父级捕获所有子级的特定事件,进行统一处理。
总而言之,捕获事件是处理事件的“提前批”。它们确保了即使子元素阻止了事件冒泡,你仍然有机会在事件到达目标元素之前进行处理。在多数普通的应用程序业务逻辑中,你可能不常直接使用它们,但它们在构建复杂系统或基础架构时非常有用。
阻止默认行为
export default function Signup() {
return (
<form onSubmit={e => {
e.preventDefault();
alert('提交表单!');
}}>
<input />
<button>发送</button>
</form>
);
}
e.stopPropagation()
阻止触发绑定在外层标签上的事件处理函数。e.preventDefault()
阻止少数事件的默认浏览器行为。
副作用的位置
事件处理函数是执行副作用的最佳位置,和渲染函数不同,事件处理函数可以执行任何操作,包括修改 DOM、进行网络请求、设置状态等,所以它不需要是纯函数。
useState Hook
useState
Hook 提供了两个功能:
- State 变量 用于保存渲染间的数据。
- State setter 函数 更新变量并触发 React 再次渲染组件。
Hook 的规则
在 React 中,useState
以及任何其他以 use
开头的函数都被称为 Hook,只能在组件或 自定义 Hook 的最顶层调用,不能在条件语句、循环语句或其他嵌套函数内调用 Hook。
useState 流程
当你调用 useState
时,你是在告诉 React 你想让这个组件记住一些东西:
const [index, setIndex] = useState(0); // 你希望 React 记住 index。
useState
的唯一参数是 state 变量的 初始值。在例子中,index
的初始值被 useState(0)
设置为 0
。
每次你的组件渲染时,useState
都会给你一个包含两个值的数组:
- state 变量 (
index
) 会保存上次渲染的值。 - state setter 函数 (
setIndex
) 可以更新 state 变量并触发 React 重新渲染组件。
useState
的工作流程:
- 初次渲染: 当组件进行 第一次渲染 时,如果你像这样调用
useState(0)
,它会返回一个数组[0, setIndex]
。此时,React 会 记住:这个组件的index
状态的初始值是0
。 - 更新状态: 假设用户点击了一个按钮,触发表单之类的交互,并且事件处理函数调用了
setIndex(index + 1)
。在这次特定的调用中,index
的当前值是0
,所以它实际上是setIndex(0 + 1)
,也就是setIndex(1)
。这个操作会 通知 React:请记住,index
的最新值现在应该是1
。更重要的是,这个通知会 触发一次新的渲染。 - 后续渲染: 组件现在进行 第二次渲染。尽管你组件代码中仍然是
useState(0)
,但 React 并不会简单地再次将index
设置为0
。相反,因为 React 已经记住了 你之前通过setIndex(1)
更新了index
的值,所以它会聪明地返回[1, setIndex]
。此时,组件内部使用的index
变量就是最新的1
。 - 以此类推,每次渲染时,
useState
的第一个值都会返回你之前通过set
函数更新后的最新值。
核心要点:
useState(初始值)
只是在 组件第一次渲染时 用来设定状态的初始默认值。- 一旦状态被更新(通过
set
函数),React 会在幕后 “记住” 这个更新后的最新值。 - 在随后的每次渲染中,即使再次执行
useState(初始值)
,React 也不会使用初始值,而是会 返回它所记住的最新状态值。 set
函数不仅更新状态,还会 触发组件的重新渲染,确保 UI 反映最新的数据。
State 独立性
state 是组件实例内部的状态,所以即使重复使用一个组件,每个组件实例的 state 都是独立的,互不干扰,这也是 state 和 普通变量的区别,普通变量是全局变量,所有组件都可以访问和修改,导致数据混乱。
组件渲染时机
有两种原因会导致组件的渲染:组件的 初次渲染,组件(或者其祖先之一)的 状态发生了改变。
初次渲染一般出现在应用启动时,调用 createRoot
方法时传入根节点元素,再调用 render
方法渲染组件,render
方法的参数是根组件,一般是 App
组件。
在初次渲染后,就可以通过 setXXX
函数更新状态,从而触发后续的渲染,更新组件的状态后会把一次渲染加入到队列中。
渲染中即 React 正常调用你写的组件,初次渲染会调用根组件,对于后续的渲染, React 会调用内部状态更新触发了渲染的函数组件
当一个组件的状态更新并触发重新渲染时:
- 组件返回新的 JSX: React 会调用该组件的函数体,并获取它返回的新的 JSX 结构。
- 递归地渲染子组件: 如果这个新的 JSX 中包含了其他组件(即嵌套的子组件),那么 React 会 继续渲染 这些子组件。它会依次调用每个子组件的函数体,获取它们返回的 JSX。
- 层层深入直到叶子节点: 这个过程会 持续地、递归地 深入下去,直到所有嵌套的组件都被处理完毕,最终到达那些只返回 HTML 元素(如
<div>
、<span>
等)而不再包含其他 React 组件的“叶子”节点。
渲染必须是一次纯计算,即渲染组件组件必须是一个纯函数,不能有副作用,否则会导致渲染结果不可预测。
渲染性能问题
如果更新的组件在树中的位置非常高,渲染更新后的组件内部所有嵌套组件的默认行为将不会获得最佳性能。
在 React 组件树中,组件之间存在父子关系。一个“高位置”的组件通常意味着它是根组件、顶层布局组件或某个大型容器组件。当这样一个高层组件的 自身状态或接收到的 props 发生变化 时,React 会认为它需要重新渲染。
React 的 默认渲染行为是递归的。这意味着,当一个父组件被触发重新渲染时,React 会默认重新渲染它的所有子组件,以及子组件的子组件,以此类推,直到组件树的最深层。即使某些子组件的 props 实际上并没有发生变化,它们也会被重新渲染。
function App() { // 高层组件
const [count, setCount] = useState(0);
return (
<div>
<Header /> {/* 即使 Header 无需更新,也会被重新渲染 */}
<Sidebar /> {/* 即使 Sidebar 无需更新,也会被重新渲染 */}
<MainContent count={count} /> {/* MainContent 确实需要更新 */}
<Footer /> {/* 即使 Footer 无需更新,也会被重新渲染 */}
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
在这个 App
组件中,每次 count
变化时,App
会重新渲染。它的默认行为是:Header
、Sidebar
、MainContent
和 Footer
都会被 重新渲染,即使 Header
、Sidebar
和 Footer
的 JSX 输出根本不会改变。
“最佳性能”在这里意味着 只进行必要的渲染工作。如果一个组件的 JSX 输出(它所渲染的 UI)在当前渲染周期中没有任何变化,那么重新渲染它就是一种 不必要的性能开销(re-rendering overhead)。
解决方案:
React.memo
(用于函数组件): 对函数组件进行包裹,使其只有在 props 发生变化时才重新渲染。PureComponent
(用于类组件): 类似于React.memo
,通过浅比较 props 和 state 来决定是否重新渲染。shouldComponentUpdate
(用于类组件): 开发者可以手动实现这个生命周期方法,自定义渲染条件。
DOM 更新机制
在组件渲染(函数调用)之后,将开始修改 DOM,对于初次渲染会调用 appendChild()
方法将创建的所有 DOM 节点放在屏幕上,对于重渲染,将应用最少的必要操作(在渲染时计算,diff 算法),以使得 DOM 与最新的渲染输出相互匹配。
只会在渲染之间存在差异时才会更新 DOM。
假设有一个 Clock
组件,它接收一个 time
属性,并且这个 time
值每秒都会从父组件传递下来并触发 Clock
组件的重新渲染:
export default function Clock({ time }) {
return (
<>
<h1>{time}</h1>
<input />
</>
);
}
值得注意的是,在这个组件中,即便 Clock
组件每秒都在重新渲染,你仍然可以 在 <input>
标签中输入文本,并且你输入的文本(value
)不会在每次组件重渲染时消失。
这完美展示了 React 协调(Reconciliation) 机制的精妙之处。在每次 Clock
组件因 time
属性更新而重新渲染时,React 并不会粗暴地销毁旧的 DOM 元素并重新创建所有新的 DOM 元素。相反,它执行以下操作:
- 比较新旧 JSX 树: React 会将本次渲染生成的虚拟 DOM 树(新的 JSX 结构)与上一次渲染的虚拟 DOM 树进行对比。
- 更新
<h1>
标签: 对于<h1>
标签,React 检测到其内部的文本内容({time}
)发生了变化。因此,它会 精准地更新 真实 DOM 中<h1>
标签的textContent
,使其显示最新的time
值。 - 处理
<input>
标签: 这是关键点。React 发现<input>
标签在新的 JSX 中出现的位置与上一次渲染完全一致,并且它的“类型”也没有改变。由于<input>
标签是一个受用户交互影响的 DOM 元素(它管理着自己的内部状态,比如用户输入的value
),React 会智能地判断 没有必要 去修改这个<input>
标签本身,甚至不会去触碰它的value
属性,除非你显式地通过value
props 绑定或key
属性来要求它这样做。
因此,React 的这种优化行为确保了:它只更新实际发生变化的部分,同时 保留了那些没有实质性变化的 DOM 元素(如本例中的 <input>
)的内部状态,从而避免了不必要的 DOM 操作,提升了渲染效率,并极大地改善了用户体验。用户不会因为组件的重新渲染而丢失输入焦点或已输入的内容。
屏幕更新过程
在渲染完成并且 React 更新了 DOM 之后,浏览器就开始重新绘制屏幕(浏览器渲染)
一个 React 应用屏幕更新(state 更新)过程:触发 -> 渲染 -> 提交
如果渲染结果与上次一样,React 将不会修改 DOM
State 是快照
state 变量和一般的 JS 变量类似,但是每次修改 state 变量并不是修改它原始的值,而是一个快照,React 会记住这个快照,并在下一次渲染时使用这个快照。
React 渲染的本质就是 生成 UI 的快照,渲染的本质是在调用你的组件函数的过程,当你的组件被调用时,它会返回一个 JSX 结构,这就是在某个特定的时间点,你的用户界面的 UI 的一个快照。
这个“快照”是高度精确和当时有效的:
- 组件的 props:是其父组件在当前渲染周期传入的值。
- event handlers(事件处理函数):是根据当前渲染的 state 和 props 定义的函数实例。
- 内部变量:所有在组件函数作用域内定义的变量,其值都将根据当前渲染时的 state 和 props 进行计算并锁定。它们构成了本次渲染的上下文。
与静态的照片或电影画面不同,React 生成的 UI “快照”是 动态且可交互的。它不仅描绘了用户的视觉界面,还 内置了交互逻辑,最典型的就是事件处理函数。这些逻辑清晰地定义了 UI 如何响应用户的输入(例如点击、输入等)。
当 React 接收到这个新的 UI 快照后,它会承担起以下职责:
- 更新屏幕: React 会智能地对比新的快照与上一次的屏幕状态(通过其高效的协调算法——
reconciliation
),并 只更新真实 DOM 中必要的部分,以确保屏幕上的 UI 与最新的快照精确匹配。 - 绑定事件: 同时,React 会在对应的 DOM 元素上 绑定(或更新)事件处理函数。因此,当用户点击一个按钮时,屏幕上的操作会准确无误地触发你在该次渲染中为该按钮定义的点击事件处理函数。
React 重新渲染的流程:
当应用程序的 state
或 props
发生变化,触发组件重新渲染时,React 会遵循一个清晰的循环:
- 再次调用你的组件函数: React 再次执行你的组件成为一个纯函数。
- 生成新的 JSX 快照: 函数基于当前最新的
state
和props
,计算并返回一个新的 JSX 结构,这便是应用程序 UI 的新快照。 - 更新用户界面: React 负责将这个新的 UI 快照高效地反映到用户的屏幕上,确保界面始终与最新的数据同步。
State 的“记忆”特性:
区分 state
和普通变量至关重要。组件内部的普通变量,在函数执行完毕并返回 JSX 后,它们的生命周期即告结束,下次渲染时将重新初始化。然而,state
则不同,它是组件的“记忆”:
state
的值 并非“活”在你的组件函数内部。相反,它实际上是由 React 运行时自身进行管理和“存放” 的——你可以想象它像被放置在一个 React 内部的“架子”上。
当 React 调用你的组件函数进行某次渲染时,它会从那个“架子”上取出 state
的 当前值(一个快照),并将其作为这次渲染的上下文提供给你的组件。因此,你的组件在本次渲染中获取的 state
值是固定的。它会基于这些值,在 JSX 中返回一个全新的 UI 快照,其中包含一套全新的 props
和事件处理函数,而这些值都是 根据本次渲染中 state
的特定值进行计算和确定的!这种机制确保了每次渲染的隔离性和一致性,让组件能够基于其当前状态可靠地构建和呈现 UI。
更多关于 State:React 中的 State 执行机制与渲染过程全解析
当前快照值
“当前的快照值”是指 React 在上一次渲染完成后,保存在内部的最新状态值(即通过 useXXX
Hook 管理和更新的状态)。除首次渲染外,每次渲染时,Hook 返回的状态即为该状态快照。
一句话说白了:“当前的快照值”就是上一次渲染中 useXXX
这个 Hook 返回的那个状态值,简单来说,就是上一次 Hook 执行的结果。
异步操作旧值
在 React 中,如果在渲染过程中定义了异步操作,该异步操作中使用的变量会被“固定”在当时的值。即使异步操作执行时组件的 state 已经更新,它仍然访问的是定义时的旧值。
State 批量更新
设置组件的 state 会把一次重新渲染加入到队列中,React 会对 State 更新进行批处理,这意味着在同一个事件循环中,多个 State 更新会被合并为一次渲染。
React 会在事件处理函数中的所有代码执行完毕后,才会统一处理你对 state 的更新。这意味着,即使你在函数里调用了多次 setNumber()
,React 也不会立即触发多次重新渲染,而是会等所有 setNumber()
调用完成后,一次性合并这些更新,然后才进行渲染。
function Counter() {
const [number, setNumber] = useState(0);
function handleClick() {
setNumber(prev => prev + 1);
setNumber(prev => prev + 1);
setNumber(prev => prev + 1);
console.log("点击事件处理完毕");
}
console.log("组件渲染", number);
return <button onClick={handleClick}>点击我</button>;
}
在这个例子里,虽然 handleClick
里调用了三次 setNumber
,但 React 会等 handleClick
中的所有代码执行完毕后,再合并这三次更新,最终只触发一次重新渲染,且 number
会增加 3。这样做可以避免不必要的多次渲染,提高性能。
总结:React 通过批量更新机制,保证了在事件处理函数里多次调用状态更新时,页面不会被重复渲染,而是高效地在最后统一更新。
事件函数旧值
function App() {
const [number, setNumber] = useState(0);
function handleClick() {
setNumber(number + 1);
console.log("事件处理函数里的 number:", number); // 打印旧值
}
console.log("组件函数体执行时 number:", number); // 打印当前渲染的状态值(当再次执行到此说明触发了重渲染,状态是更新后的值)
return <button onClick={handleClick}>点我</button>;
}
函数体内打印的是“当前渲染周期的旧状态”
这里的 console.log
在事件函数内打印时,打印的永远是旧值,因为事件函数闭包捕获了 当前渲染的状态快照。
事件函数执行时,状态更新请求已发出,此时 React 还没重新渲染组件,当开始重渲染,console.log
打印的则是已经状态更新后的值了
事件函数内访问的状态是“旧值”,组件函数顶层访问的是“最新值”。
闭包捕获旧值
关于函数内访问到的是旧值:
这个现象是 React 组件和 JavaScript 闭包(closure)机制 共同作用的经典表现
详细解释:
- 闭包“捕获”当时的变量快照
- 在 JavaScript 中,函数内部访问外部变量时,会形成一个闭包,捕获 定义时刻 的变量状态。
- 事件处理函数里的
number
变量就是这样被捕获的,锁定了 当前渲染时的那个状态值。 - 所以即使状态后来变了,闭包里的
number
仍然是旧值。
- React 的渲染是基于快照的
- React 渲染一次组件,整个组件函数就是一次“快照”。
- 在这次快照内,所有函数和事件处理函数使用的状态都是这次渲染时的值。
- 事件处理函数即使在后续异步调用或事件触发时执行,也只能访问当时捕获的快照。
- 异步操作里的变量值也是旧的
- 比如你用
setTimeout
或 Promise 异步执行事件处理函数里的代码,这段异步代码访问的状态变量仍然是定义时的旧值。 - 这是 JS 闭包和异步执行的通用规律,不是 React 特有的。
function App() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setTimeout(() => {
console.log("异步里 count:", count); // 这里仍然打印旧值,因为闭包捕获了旧值
}, 1000);
}
console.log("渲染时 count:", count);
return <button onClick={handleClick}>点我</button>;
}
这里 setTimeout
里访问的 count
是闭包捕获的旧值。
总结:在 React 中,事件处理函数和异步回调里访问的 state,都是在定义时“快照”下的旧状态。这是 JavaScript 闭包机制和 React 渲染机制共同决定的。
更新批处理
React 会等到事件处理函数里的所有代码执行完毕之后,才会真正处理你的 state 更新。
这正是为什么在一个事件中连续调用多次 setNumber()
,React 依然只会在最后统一触发一次重新渲染的原因。
可以把它想象成餐厅里为你点餐的服务员。服务员不会在你说出第一道菜时就立刻跑向厨房; 相反,他们会耐心地等你把所有菜品都点完,允许你修改之前的选择,甚至顺便帮同桌的其他人也记录好订单。
在这个场景中,React 就像那位优雅的服务员——即使你多次调用 setState()
,它也会等到你全部“下单”完成后,才将最终版本的订单送去“厨房”处理。 这样,你就可以一次性更新多个 state 变量,甚至是来自不同组件的 state,而不会触发一连串多余的渲染。
这种行为叫做 批处理(Batching)。 它不仅让 React 应用运行得更高效,还能避免出现那种只更新了部分 state、导致 UI 半成品渲染的尴尬情况。
不过要注意,React 并不会 跨越多个需要单独触发的事件(例如多次点击)来进行批处理。 每一次点击,都是一次独立的“下单”。 这样可以确保,如果第一次点击按钮就会禁用表单,那么第二次点击就不会在不该提交的时候再触发提交。
更新队列机制
在执行渲染函数的过程中,如果调用了 setState
(或其他触发状态更新的 Hook,例如 useReducer
),React 并不会立刻修改 state 并重新渲染。 相反,它会将 状态更新任务 加入到一个内部的“更新队列”中,并安排一次 重新渲染任务 等待当前渲染完成后执行。
- 如果在同一次渲染流程中多次更新 state(无论是同一个还是不同的 state 变量),React 会将它们合并起来,在下一次渲染时 统一计算并应用,而不是触发多次渲染。这就是 批处理(Batching)。
- 具体合并方式取决于
setState
的调用形式:- 传入值(
setCount(1)
):后一次调用会覆盖前一次对同一 state 的更新。 - 传入函数(
setCount(prev => prev + 1)
):React 会把每个更新函数按顺序依次应用到最新的 state 上,避免覆盖,确保计算结果正确。
- 传入值(
对于 由同一次事件触发的多次更新(例如一次点击触发多个 setState
),React 会批处理它们,只在最后进行一次重新渲染,从而提升性能。
对于用户多次点击按钮等 由独立事件触发的状态更新,React 并不会进行批处理,而是会分别处理每一次事件的更新并立即渲染。 这样可以保证每一次点击都立即生效,例如第一次点击按钮后立刻将其禁用,从而避免出现第二次点击仍能触发操作的情况。
多次更新State
如果你希望 在下一次渲染前多次更新同一个 state,可以传入一个 根据当前 state 计算新值的函数,而不是直接传入一个新值。
setNumber(n => n + 1); // 基于当前 state 计算
setNumber(number + 1); // 直接替换成某个值
这样做的意思是告诉 React:“请用当前的 state 值来计算下一个 state”,而不仅仅是“把 state 替换成这个值”。
在这个例子中,n => n + 1
就是一个 更新函数(updater function)。 当你将它传递给 state 设置函数时,React 会这样处理:
- 事件执行阶段:React 会把这个更新函数加入内部队列,而不是立即计算结果。它会等到事件处理函数里的所有代码都执行完毕后再处理这些队列中的更新。
- 下一次渲染阶段:React 会按顺序遍历这个队列,并依次把上一次更新的结果作为参数传给下一个更新函数,最终计算出新的 state 值。
例如,执行下面的代码:
setNumber(n => n + 1);
setNumber(n => n + 1);
setNumber(n => n + 1);
React 在事件处理函数中只是把三次 n => n + 1
全部放进队列:
更新队列 | n | 返回值 |
---|---|---|
n => n + 1 | 0 | 0 + 1 = 1 |
n => n + 1 | 1 | 1 + 1 = 2 |
n => n + 1 | 2 | 2 + 1 = 3 |
初始 number
是 0
,第一次调用得到 1
,第二次调用得到 2
,第三次调用得到 3
。 最终,React 会把 3
保存为新的 state,并在渲染中返回这个值。
因此,在这个例子中,点击一次“+3”按钮确实会让数值增加 3,而不会因为多次更新覆盖而丢失中间的计算结果。
混合更新State
如果你在替换 state 后又更新 state,会发生什么?
<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
}}>
假设当前 number = 0
,这段代码在事件阶段会告诉 React:
setNumber(number + 5)
:此时number
是0
,所以这是一次 替换操作,等价于“把 state 设置为5
”。React 会将“替换为 5”加入更新队列。setNumber(n => n + 1)
:这是一个 基于当前 state 计算新值的更新函数。React 会将这个函数加入更新队列。
到了下一次渲染阶段,React 会按顺序处理队列中的更新:
更新类型 | n(传入值) | 返回值 |
---|---|---|
替换为 5 | 0(未使用) | 5 |
n => n + 1 | 5 | 6 |
处理结果是:先替换为 5,再在 5 的基础上加 1,最终得到的 state 是 6
。
注意setState(x)
实际上可以看作是 setState(() => x)
,只是这里的函数参数(n
)不会被用到,因此直接传值会覆盖之前对同一 state 的赋值更新。
替换覆盖更新
如果你在更新 state 后又替换 state,会发生什么?
<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
setNumber(42);
}}>
假设当前 number = 0
,React 在 事件处理阶段 会这样处理:
setNumber(number + 5)
- 事件开始时
number = 0
→ 计算得到5
- 这是一次 替换操作,React 将“替换为 5”加入更新队列。
- 事件开始时
setNumber(n => n + 1)
- 这是一个 更新函数,React 将它加入更新队列。
setNumber(42)
- 直接替换为
42
,React 将“替换为 42”加入更新队列。
- 直接替换为
下一次渲染阶段,React 会按照队列顺序依次处理:
更新类型 | n(传入值) | 返回值 |
---|---|---|
替换为 5 | 0(未使用) | 5 |
n => n + 1 | 5 | 6 |
替换为 42 | 6(未使用) | 42 |
最终结果:42。 原因很简单——后面的“替换操作”会直接覆盖之前的更新结果。
总结:你可以向 setNumber
传递两种类型的值:
- 更新函数(例如
n => n + 1
)- 会被加入队列,并在渲染阶段依次执行。
- 计算时会用到前一个 state 结果。
- 必须是 纯函数,只能返回新值,不要在里面修改 state 或产生副作用。
- 直接值(例如
42
)- 会作为“替换操作”加入队列。
- 后续的替换操作会覆盖之前的计算结果。
在严格模式下,React 会执行每个更新函数 两次(第二次的结果会被丢弃),以帮助你发现非纯函数带来的问题。
更新阶段总结
事件处理阶段(或渲染阶段):只是把更新请求加入队列,不会立刻改 state。
下一次重新渲染阶段:React 会依次取出队列中的更新,按顺序执行(函数更新依赖上一个结果,值更新直接覆盖),最终计算出新的 state,并用它渲染 UI。
所以:上一次添加的操作状态的队列,会在下一次渲染时 一次性全部处理完。
更新函数命名
命名建议:
在为 setState
传递 更新函数 时,你需要为参数(旧的 state 值)取一个名字。 常见的写法是直接用对应 state 变量的 首字母,既简短又直观:
如果你更喜欢代码可读性高一些,可以选择 完整变量名,甚至加上 prev
前缀,让读者一眼就能明白这是“上一次的值”
更新函数应纯净
在 React 状态更新里,函数式更新(functional update)传递的是一个 函数,这个函数确实可以做很多事——理论上你可以在里面调用别的函数、打印日志、甚至发请求。 但是 React 的设计初衷 是希望这个函数是 纯函数,只根据传入的上一个 state(和必要的 props)返回一个新的 state,而不产生任何副作用。
如果你发现更新函数里需要访问外部可变数据或执行副作用,那很可能它应该放到 useEffect
、事件处理函数或者自定义 Hook 中,而不是放在 setState
的函数式更新里。
State 对象不可变
React 的 state 可以保存任意类型的 JavaScript 值,包括对象。 但需要注意的是,不要直接修改存储在 state 中的对象。
当你想要更新对象时,应当创建一个新的对象(或者对原对象进行浅拷贝,创建一个副本),然后将这个新对象作为新的 state 进行更新(替换)。
数据可变性
Mutation 指的是直接修改(变更)已有数据的行为,比如改变对象的属性、数组元素等。
React state 中的数据类型与可变性:
你可以把任意类型的 JavaScript 值存入 React 的 state,比如数字、字符串、布尔值,甚至对象和数组。
- 数字、字符串、布尔值属于原始类型(primitive),它们在 JavaScript 中是 不可变的(immutable)。
- 这意味着这些值本身不能被修改。
- 例如,数字
0
本身不能被改变成别的数字。
const [x, setX] = useState(0);
setX(5);
这里,虽然 x
的值从 0
变成了 5
,但数字 0
本身并没有被修改,而是直接用一个新的值 5
替换了它。
React 依赖 不可变数据 的特性来判断是否需要重新渲染组件。
- 对于原始类型来说,直接替换值是没问题的,因为它们本身不可变。
- 但对于 对象和数组,如果直接修改它们的属性(即发生 mutation),React 很难检测到变化,可能导致 UI 不更新。
所以,避免对存储在 state 里的对象或数组直接修改(mutation)非常重要,应该使用不可变的更新方式,创建新对象或新数组来触发更新。
不可变性定义
什么是“不变”(immutable)?
“不变”指的是 数据本身的值不能被改变。
- 例如,数字
5
是一个值。你不能“改”这个数字本身,比如让数字 5 变成 6。 - 你只能用另一个数字(比如 6)来 替换 原来的值。
换句话说,数字、字符串、布尔这些原始类型的值是 固定不变的,不能被修改,只能被替换。
let a = 5;
// 你不能在 a 的值上做修改让它变成 6
// 只能用赋值语句把 a 替换成 6
a = 6;
- 这里不是“修改”5 这个值本身,而是把变量
a
指向了一个新的值6
。
和对象对比
对象是“可变”的,因为你可以直接修改对象内部的属性:
let obj = { name: 'Alice' };
obj.name = 'Bob'; // 这是修改,不是替换
但数字、字符串不能这样修改——你只能替换它们。
State 对象视为不变
const [position, setPosition] = useState({ x: 0, y: 0 });
// 从技术角度来说,你确实可以直接修改对象的属性,例如:
position.x = 5;
这就是对对象本身进行的 突变(mutation)。
然而,虽然 React state 中存放的对象本身是可变的,你仍然应该像对待数字、布尔值和字符串那样,将它们视为不可变的。
也就是说,不要直接修改对象的属性,而是创建一个新的对象,替换原有的 state,这样做可以确保 React 正确检测到状态的变化,从而触发相应的重新渲染。
局部突变允许
局部突变(局部 mutation)是可以接受的
像下面这样的代码是有问题的,因为它直接修改了存储在 state 中的已有对象:
position.x = e.clientX;
position.y = e.clientY;
然而,下面这种写法就没有任何问题,因为你修改的是一个 新创建的对象:
const nextPosition = {};
nextPosition.x = e.clientX;
nextPosition.y = e.clientY;
setPosition(nextPosition);
事实上,它等同于更简洁的写法:
setPosition({
x: e.clientX,
y: e.clientY
});
只有当你直接修改了已经存储在 state 中的 现有对象 时,突变才会导致问题。 而修改一个新创建的对象则不会产生副作用,因为此对象还没有被其他代码引用,改变它不会意外影响任何依赖它的地方。
这就是所谓的 局部突变(local mutation)。 你甚至可以在组件渲染过程中进行局部突变,这样的操作既方便又安全,不会带来任何问题。
更新部分字段
在前面的例子中,我们每次都会基于当前指针位置创建一个新的 position
对象。但在实际开发中,更常见的场景是 只更新对象中的部分字段,其他字段保持不变。
比如,在表单中,如果我们只想更新一个输入框的值,就需要在新对象中保留其他字段的旧值。
function handleFirstNameChange(e) {
person.firstName = e.target.value; // 直接突变了 state
}
这会修改上一次渲染的 state 对象(产生突变),导致 React 无法检测到状态的变化,从而可能不会触发重新渲染。
正确的写法:创建一个 新对象,将当前的值复制过来,只覆盖需要更新的字段:
setPerson({
firstName: e.target.value,
lastName: person.lastName,
email: person.email
});
这样每次都会生成一个全新的对象,React 就能识别到 引用发生了变化,从而触发渲染。
更简洁的写法:使用展开语法(Shallow Copy)
setPerson({
...person, // 复制原对象的所有字段(浅拷贝)
firstName: e.target.value // 覆盖需要修改的字段
});
这样就不需要一个个字段手动复制,非常方便。
注
浅拷贝 指的是只复制对象的第一层属性:
- 如果属性是 原始值(number、string、boolean 等),会直接复制值本身。
- 如果属性是 引用类型(对象、数组、函数等),会复制它的 引用地址,并不会创建新的对象。
结果: 浅拷贝后的对象和原对象在第一层是相互独立的,但 引用类型属性仍然指向同一块内存,修改其中一个的内部值会影响另一个。
const obj1 = { name: 'Alice', address: { city: 'Paris' } };
const obj2 = { ...obj1 };
obj2.name = 'Bob'; // 不影响 obj1.name
obj2.address.city = 'NY';// 会影响 obj1.address.city
深拷贝与浅拷贝的区别
浅拷贝(shallow copy) 只复制第一层属性的值,如果是引用类型,复制的是引用。 修改引用类型的内部值会影响到原对象。 常见方法:Object.assign()
、展开语法 ...
、Array.slice()
等。
深拷贝(deep copy) 会递归复制所有层级的值,引用类型也会重新开辟内存,完全独立。 修改拷贝对象的任何属性都不会影响原对象。 常见方法:
structuredClone(obj)
(现代浏览器支持)
JSON.parse(JSON.stringify(obj))
(有局限性,如丢失函数、undefined
、循环引用)
第三方库(lodash.cloneDeep
)
在 React 中的注意事项
对于 浅层对象更新(如更新表单字段),展开语法已经足够。
如果你的 state 中有 嵌套对象,需要更新深层属性时,必须逐层浅拷贝:
setPerson({
...person,
address: {
...person.address,
city: 'NY'
}
});
这样可以避免意外修改原对象内部的引用,保持 React 状态不可变的原则。
动态更新字段
小技巧:
在对象字面量中,你可以用方括号 [ ]
来包裹一个 表达式,它的计算结果将作为属性名:
const key = "age";
const person = {
[key]: 30
};
// 等同于 { age: 30 }
在 HTML <input>
里,可以用 name
属性标记它对应的 state 字段,比如:
<input name="firstName" />
事件触发时,e.target.name
就会告诉你这个输入框绑定的字段名,于是更新 state 时,可以直接这样写:
setPerson({
...person, // 先浅拷贝原对象
[e.target.name]: e.target.value // 用 name 做属性名,动态更新对应字段
});
这样就能避免为每个字段写一个单独的 handler。
对象嵌套是引用
JavaScript 对象“嵌套”并不是真的嵌套
很多人初学时会觉得,下面这个 artwork
对象是“住在” obj
里面的:
let obj = {
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
};
从视觉上看,这像是“嵌套对象”。
但 真相 是:JavaScript 对象属性并不存储另一个对象的副本,而是 存储引用(reference)。 引用就像一个“指针”,指向内存中某个对象的位置。
实际上,上面代码等价于:
let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};
let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1 // 引用 obj1
};
多个对象共享引用
let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};
let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};
let obj3 = {
name: 'Copycat',
artwork: obj1
};
// 修改 obj3.artwork.city
obj3.artwork.city = 'Berlin';
console.log(obj1.city); // "Berlin"
console.log(obj2.artwork.city); // "Berlin"
改 obj3.artwork.city
,obj1.city
和 obj2.artwork.city
都变了
因为它们指向同一块内存中的对象
obj1 ───▶ { title: 'Blue Nana', city: 'Hamburg', image: '...' }
▲
│
obj2.artwork ────┘
▲
│
obj3.artwork ────┘
结论:对象之间是通过引用连接的,而不是像数组嵌套那样真的物理包含。
使用 Immer 库
当你的 state 结构比较复杂、存在多层嵌套时,通常建议将其扁平化以简化更新操作。但如果你不想修改数据结构,也可以借助 Immer 这个流行库,来用更简洁、接近“直接修改”的写法管理嵌套 state。
比如,使用 Immer 你可以这样写:
npm install use-immer
updatePerson(draft => {
draft.artwork.city = 'Lagos';
});
看起来像是直接修改了对象,但 Immer 会帮你在幕后完成不可变数据的复制与更新,不会覆盖之前的 state,保持 React 状态管理的正确性。
Immer 的工作原理
Immer 通过创建一个特殊的 draft
对象(基于 JavaScript 的 Proxy
),记录你对它的所有操作。它能智能地识别哪些部分被修改,然后基于这些修改生成一个全新的、更新后的对象,免去了手动深度拷贝的繁琐。
例子:
import { useImmer } from 'use-immer';
const [person, updatePerson] = useImmer({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});
function handleNameChange(e) {
updatePerson(draft => {
draft.name = e.target.value;
});
}
function handleTitleChange(e) {
updatePerson(draft => {
draft.artwork.title = e.target.value;
});
}
// ...
扁平化 State
扁平化(Flattening)定义
扁平化 是指将嵌套的、层级结构的数据转换成更简单、更少层级的结构。 简单来说,就是把多层嵌套的对象或数组,拆解成“平铺开”的形式,减少访问和更新时的复杂度。
嵌套结构(未扁平化)
const state = {
user: {
name: 'Alice',
address: {
city: 'Paris',
zip: '75001'
}
}
};
访问 city
需要写 state.user.address.city
,更新也比较繁琐。
扁平化结构
const state = {
userName: 'Alice',
userAddressCity: 'Paris',
userAddressZip: '75001'
};
现在访问和更新就简单很多,直接用 state.userAddressCity
。
为何要不可变
为什么在 React 中不推荐直接修改 state?
直接修改 React 的 state 会带来一系列问题,因此我们建议始终保持 state 的不可变性,主要原因包括:
- 调试更清晰 不直接修改 state,可以保证
console.log
输出的旧 state 不会被后续修改影响,方便你准确追踪状态变化和渲染过程。 - 性能优化 React 通过浅比较(
prevState === nextState
)判断是否需要重新渲染。如果直接修改了 state 对象,引用不变,React 会误以为状态没变,跳过更新,导致 UI 不同步。 - 支持新功能 未来 React 的许多新特性依赖于将 state 视作“快照”,直接修改历史 state 会破坏这种机制,影响功能的正确实现。
- 实现复杂需求更容易 比如撤销/重做、展示历史记录、状态回滚等功能,都依赖于保存之前的 state 副本。直接修改 state 会让这类需求实现异常困难。
- 实现简单且性能无忧 React 本身并不依赖对对象的代理或拦截机制,因此无需担心性能或复杂性。你可以自由存放大型对象而不会产生额外开销。
数组更新原则
React 中的数组更新原则
数组虽然是可变对象,但在 React state 中要视为 不可变。
不要 直接修改 state 中的数组(如 arr[0] = 'bird'
、push()
、pop()
)。
要 通过生成新数组并传入 setState
来更新。
常用生成新数组的方法:
添加:concat()
、[...arr, newItem]
删除:filter()
、slice()
替换:map()
排序:[...arr].sort()
、[...arr].reverse()
可用 Immer 简化操作,允许用“可变写法”生成不可变结果。
核心思路:更新数组时,永远用“新数组”替换旧数组,避免直接修改原始 state。
slice vs splice
React 中 slice vs splice 的陷阱
slice()
:返回数组的一个 拷贝(全部或部分),不会 修改原数组。splice()
:直接修改 原数组(插入或删除元素)。- 在 React state 更新中,应优先使用
slice()
(无 p!),避免splice()
引发的 mutation 问题。
immer 实践
use-immer
实践举例
import { useImmer } from "use-immer"; // 注意引入库
原始类型:
const [count, updateCount] = useImmer(0);
// 增加 1
updateCount(draft => {
draft += 1; // 直接改,看似 mutable
});
特点:对原始值,draft
就是当前值,不是引用。
对象:
const [user, updateUser] = useImmer({ name: "Tom", age: 20 });
// 修改 age
updateUser(draft => {
draft.age += 1;
});
特点:对象可以直接改属性,不用复制。
数组:
const [list, updateList] = useImmer(["apple", "banana"]);
// 添加元素
updateList(draft => {
draft.push("orange"); // 不需要 concat
});
特点:直接用 push
、splice
等会改原数组的方法没问题,因为 Immer 会生成新副本。
嵌套对象:
const [state, updateState] = useImmer({
user1: { name: "Tom", address: { city: "NY" } },
user2: { name: "Jerry", address: { city: "LA" } },
user3: { name: "Spike", address: { city: "SF" } },
});
// 改 city
updateState(draft => {
draft.user1.address.city = "LA";
});
特点:嵌套层可以直接改,不用一层层 spread。
嵌套数组:
const [matrix, updateMatrix] = useImmer([
[1, 2],
[3, 4]
]);
// 改第二行第二列
updateMatrix(draft => {
draft[1][1] = 99;
});
特点:数组嵌套数组依然可直接访问修改。
数组嵌套对象:
const [todos, updateTodos] = useImmer([
{ id: 1, text: "Learn React", done: false },
{ id: 2, text: "Learn Immer", done: false }
]);
// 将 id=2 的 done 设为 true
updateTodos(draft => {
const todo = draft.find(t => t.id === 2);
todo.done = true;
});
// 也可以写成
updateTodos(draft => {
draft[1].done = true;
});
方法区别
- 直接索引访问:更快、更简洁,但依赖数组顺序固定。
find
查找:更安全、可读性更好,即使数组顺序改变,也能正确找到目标。
特点:查到对象直接改属性,Immer 会帮你生成不可变结果。
深层嵌套(数组 + 对象 + 数组):
const [data, updateData] = useImmer({
users: [
{
id: 1,
profile: {
name: "Tom",
tags: ["dev", "blogger"]
}
}
]
});
// 改第一个用户第二个 tag
updateData(draft => {
draft.users[0].profile.tags[1] = "writer";
});
特点:不管嵌套多少层,都能像普通 JS 对象那样直接改。
总结规律
use-immer
的updateFn
里,draft
是一个 代理对象,可以用原生 JS 的可变写法去改。- 改完后 Immer 会帮你生成 全新的不可变数据 传给 state。
- 适合 深嵌套数据 更新,省去层层
...spread
的麻烦。
贡献者
更新日志
04d70
-doc update于6e699
-doc update于7eec2
-Document organization于9de0b
-全局优化于b1c4a
-文档迁移于d5191
-first-commit于