文章目录
- Hook简介
- 概述
- class组件的不足
- 什么是 Hook?
- Hook 使用规则
- state的研究(useState)
- State:组件的记忆(响应式数据)
- 当普通的变量无法满足时
- 添加一个 state 变量
- 遇见你的第一个 Hook
- 剖析 `useState`
- 赋予一个组件多个 state 变量
- State 是隔离且私有的
- 渲染和提交
- 步骤 1: 触发一次渲染
- 初次渲染
- 状态更新时重新渲染
- 步骤 2: React 渲染您的组件
- 步骤 3: React 把更改提交到 DOM 上
- 尾声:浏览器绘制
- state 如同一张快照
- 设置 state 会触发渲染
- 渲染会及时生成一张快照
- 随时间变化的 state
- 把一系列 state 更新加入队列
- React 会对 state 更新进行批处理
- 在下次渲染前多次更新同一个 state
- 如果你在替换 state 后==更新== state 会发生什么
- 如果你在更新 state 后==替换== state 会发生什么
- 命名惯例
- 更新 state 中的对象
- 什么是 mutation?
- 将状态视为只读
- 使用扩展语法复制对象
- 更新嵌套对象
- 使用 Immer 编写简洁的更新逻辑
- Immer 介绍
- 更新 state 中的数组
- 在没有 mutation 的前提下更新数组
- 向数组中添加元素
- 从数组中删除元素
- 转换数组
- 替换数组中的元素
- 向数组中插入元素
- 其他改变数组的情况
- 更新数组内部的对象
- 使用 Immer 编写简洁的更新逻辑
Hook简介
概述
Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。
在我们继续之前,请记住 Hook 是:
- 完全可选的。 你无需重写任何已有代码就可以在一些组件中尝试 Hook。但是如果你不想,你不必现在就去学习或使用 Hook。
- 100% 向后兼容的。 Hook 不包含任何破坏性改动。
- 现在可用。 Hook 已发布于 v16.8.0。
没有计划从 React 中移除 class。
class组件的不足
- 组件之间难以复用状态逻辑
- 复杂组件难以理解
- 使用class导致学习成本变高
Hook文档:https://zh-hans.legacy.reactjs.org/docs/hooks-intro.html
什么是 Hook?
Hook 是一些可以让你在函数组件里“钩入” React state 及生命周期等特性的函数。Hook 不能在 class 组件中使用 —— 这使得你不使用 class 也能使用 React。
什么时候我会用 Hook? 如果你在编写函数组件并意识到需要向其添加一些 state,以前的做法是必须将其它转化为 class。现在你可以在现有的函数组件中使用 Hook。
Hook 使用规则
Hook 本质就是 JavaScript 函数,但是使用它们会有额外的规则:
- Hook不能在class 组件中使用
- 只能在函数组件最外层调用 Hook,不要在循环、条件判断或者子函数中调用
- 只能在React的函数组件中调用Hook,不要在其他JavaScript函数中调用
- 在 React 的函数组件中调用 Hook
- 在自定义 Hook 中调用其他 Hook
state的研究(useState)
State:组件的记忆(响应式数据)
组件通常需要根据交互更改屏幕上显示的内容。输入表单应该更新输入字段,单击轮播图上的“下一个”应该更改显示的图片,单击“购买”应该将商品放入购物车。组件需要“记住”某些东西:当前输入值、当前图片、购物车。在 React 中,这种组件特有的记忆被称为 state。
当普通的变量无法满足时
以下是一个渲染点击次数的组件。点击按钮应该显示点击次数并将 count
更改为 1
,再次点击又更改为 2
,以此类推。但这个组件现在不起作用(你可以试一试!):
export default function App () {
let count = 0;
function handleClick() {
count++;
console.log('count: ', count);
}
return (
<div>
<h1>App 点击次数:{count}</h1>
<button onClick={handleClick}>按钮</button>
</div>
);
}
handleClick()
事件处理函数正在更新局部变量 count
。但存在两个原因使得变化不可见:
- 局部变量无法在多次渲染中持久保存。 当 React 再次渲染这个组件时,它会从头开始渲染——不会考虑之前对局部变量的任何更改。
- 更改局部变量不会触发渲染。 React 没有意识到它需要使用新数据再次渲染组件。
要使用新数据更新组件,需要做两件事:
- 保留 渲染之间的数据。
- 触发 React 使用新数据渲染组件(重新渲染)。
useState
Hook 提供了这两个功能:
- State 变量 用于保存渲染间的数据。
- State setter 函数 更新变量并触发 React 再次渲染组件。
添加一个 state 变量
要添加 state 变量,先从文件顶部的 React 中导入 useState
:
import { useState } from 'react';
然后,替换这一行:
let count = 0;
将其修改为一个 state 变量:
const [count, setCount] = useState(0);
这里的
[
和]
语法称为数组解构,它允许你从数组中读取值。useState
返回的数组总是正好有两项。
你将从 useState
中获得两样东西:当前的 state(count
)变量,以及用于更新它的setter函数(setCount
)。你可以给它们起任何名字,但按照惯例,需要像这样 [something, setSomething]
为它们命名。
import { useState } from 'react';
export default function App () {
const [count, setCount] = useState(0);
function handleClick() {
// 改变count
setCount(count + 1);
}
return (
<div>
<h1>App 点击次数:{count}</h1>
<button onClick={handleClick}>按钮</button>
</div>
);
}
第一次显示时,count
的值为 0
,因为你把 0
传给了 useState()
。当你想改变 state 时,调用 setCount()
并将新的值传递给它。点击该按钮计数器将递增。React 将再次调用你的组件函数。这次,count
会变成 1
。接着,变成 2
。以此类推。
遇见你的第一个 Hook
在 React 中,以 use
开头的函数被称为 Hook。useState
是 React 提供的一个内置 Hook。你可以在 React API 参考 中找到其他内置的 Hook。你也可以通过组合现有的 Hook 来编写属于你自己的 Hook。
Hook 是特殊的函数,只在 React 渲染时有效。它们能让你 “hook” 到不同的 React 特性中去。
Hook 比普通函数更为严格。你只能在你的组件(或其他 Hook)的 顶层 调用 Hook。如果你想在一个条件或循环中使用 useState
,请提取一个新的组件并在组件内部使用它。
陷阱
Hooks ——以
use
开头的函数——只能在组件或自定义 Hook 的最顶层调用。 你不能在条件语句、循环语句或其他嵌套函数内调用 Hook。Hook 是函数,但将它们视为关于组件需求的无条件声明会很有帮助。在组件顶部 “use” React 特性,类似于在文件顶部“导入”模块。
剖析 useState
当你调用 useState
时,你是在告诉 React 你想让这个组件记住一些东西:
const [count, setCount] = useState(0);
在这个例子里,你希望 React 记住 count
。
注意
惯例是将这对返回值命名为
const [thing, setThing]
。你也可以将其命名为任何你喜欢的名称,但遵照约定俗成能使跨项目合作更易理解。
useState
的唯一参数是 state 变量的初始值。在这个例子中,count
的初始值被useState(0)
设置为 0
。
每次你的组件渲染时,useState
都会给你一个包含两个值的数组:
- state 变量 (
count
) 会保存上次渲染的值。 - state setter 函数 (
setCount
) 可以更新 state 变量并触发React 重新渲染组件。
执行
setThing
函数会重新渲染组件,就相当于组件函数重新被调用一次
以下是实际发生的情况:
const [count, setCount] = useState(0);
- 组件进行第一次渲染。 因为你将
0
作为count
的初始值传递给useState
,它将返回[0, setCount]
。 React 记住0
是最新的 state 值。 - 你更新了 state。当用户点击按钮时,它会调用
setCount(count+ 1)
。count
是0
,所以它是setCount(1)
。这告诉 React 现在记住count
是1
并触发下一次渲染。 - 组件进行第二次渲染。React 仍然看到
useState(0)
,但是因为 React 记住 了你将count
设置为了1
,它将返回[1, setCount]
。 - 以此类推!
赋予一个组件多个 state 变量
你可以在一个组件中拥有任意多种类型的 state 变量。该组件有两个 state 变量,一个数字 count
和一个布尔值 showMore
,点击 “Show Details” 会改变 showMore
的值:
import { useState } from 'react';
export default function App() {
const [count, setCount] = useState(0);
const [showMore, setShowMore] = useState(false);
function handleClick() {
setCount(count + 1);
console.log('count: ', count);
}
function handleMoreClick() {
setShowMore(!showMore);
}
return (
<div>
<h1>App 点击次数:{count}</h1>
{showMore && <p>记录次数使用的是React的响应式状态</p>}
<button onClick={handleClick}>按钮</button>
<button onClick={handleMoreClick}>{showMore ? 'Hide' : 'Show'} details</button>
</div>
);
}
如果它们不相关,那么存在多个 state 变量是一个好主意,例如本例中的 count
和 showMore
。但是,如果你发现经常同时更改两个 state 变量,那么最好将它们合并为一个。例如,如果你有一个包含多个字段的表单,那么有一个值为对象的 state 变量比每个字段对应一个 state 变量更方便。 选择 state 结构在这方面有更多提示。
State 是隔离且私有的
State 是屏幕上组件实例内部的状态。换句话说,如果你渲染同一个组件两次,每个副本都会有完全隔离的 state!改变其中一个不会影响另一个。
在这个例子中, Count
组件以同样的逻辑被渲染了两次。试着点击每个Count
组件的按钮。你会注意到它们的 state 是相互独立的:
import { useState } from 'react';
export default function App() {
return (
<div>
<h1>App </h1>
<Count></Count>
<Count></Count>
</div>
);
}
function Count() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>点击次数:{count}</button>
</div>
);
}
这就是 state 与声明在模块顶部的普通变量不同的原因。 State 不依赖于特定的函数调用或在代码中的位置,它的作用域“只限于”屏幕上的某块特定区域。你渲染了两个 <Count/>
组件,所以它们的 state 是分别存储的。
还要注意 App
组件“不知道”关于 Count
state 的任何信息,甚至不知道它是否有任何 state。与 props 不同,state 完全私有于声明它的组件。父组件无法更改它。这使你可以向任何组件添加或删除 state,而不会影响其他组件。
如果你希望两个Count
保持其 states 同步怎么办?在 React 中执行此操作的正确方法是从子组件中删除 state 并将其添加到离它们最近的共享父组件中。
渲染和提交
组件显示到屏幕之前,其必须被 React 渲染。理解这些处理步骤将帮助您思考代码的执行过程并能解释其行为。
想象一下,您的组件是厨房里的厨师,把食材烹制成美味的菜肴。在这种场景下,React 就是一名服务员,他会帮客户们下单并为他们送来所点的菜品。这种请求和提供 UI 的过程总共包括三个步骤:
- 触发 一次渲染(把客人的点单分发到厨房)
- 渲染 组件(在厨房准备订单)
- 提交 到 DOM(将菜品放在桌子上)
触发 | 渲染 | 提交 |
步骤 1: 触发一次渲染
有两种原因会导致组件的渲染:
- 组件的 初次渲染。
- 组件(或者其祖先之一)的 状态发生了改变。
初次渲染
当应用启动时,会触发初次渲染。框架和沙箱有时会隐藏这部分代码,但它是通过调用目标 DOM 节点的 createRoot
,然后用你的组件调用 render
函数完成的:
// Image.jsx
export default function Image() {
return (
<img
src="https://i.imgur.com/ZF6s192.jpg"
alt="'Floralis Genérica' by Eduardo Catalano: a gigantic metallic flower sculpture with reflective petals"
/>
);
}
// App.jsx
import Image from './Image.js';
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'))
root.render(<Image />);
试着注释掉 root.render()
,然后您将会看到组件消失。
状态更新时重新渲染
一旦组件被初次渲染,您就可以通过使用 set
函数 更新其状态来触发之后的渲染。更新组件的状态会自动将一次渲染送入队列。(您可以想象这种情况成餐厅客人在第一次下单之后又点了茶、点心和各种东西,具体取决于他们的胃口。)
状态更新… | …触发… | …渲染 |
步骤 2: React 渲染您的组件
在您触发渲染后,React 会调用您的组件来确定要在屏幕上显示的内容。“渲染中” 即 React 在调用您的组件。
- 在进行初次渲染时, React 会调用根组件。
- 对于后续的渲染, React 会调用内部状态更新触发了渲染的函数组件。
这个过程是递归的:如果更新后的组件会返回某个另外的组件,那么 React 接下来就会渲染 那个 组件,而如果那个组件又返回了某个组件,那么 React 接下来就会渲染 那个 组件,以此类推。这个过程会持续下去,直到没有更多的嵌套组件并且 React 确切知道哪些东西应该显示到屏幕上为止。
在接下来的例子中,React 将会调用 Gallery()
和 Image()
若干次:
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'))
root.render(<Gallery />);
function Gallery() {
return (
<section>
<h1>鼓舞人心的雕塑</h1>
<Image />
<Image />
<Image />
</section>
);
}
function Image() {
return (
<img
src="https://i.imgur.com/ZF6s192.jpg"
alt="'Floralis Genérica' by Eduardo Catalano: a gigantic metallic flower sculpture with reflective petals"
/>
);
}
- 在初次渲染中, React 将会为
<section>
、<h1>
和三个<img>
标签 创建 DOM 节点。 - 在一次重渲染过程中, React 将计算它们的哪些属性(如果有的话)自上次渲染以来已更改。在下一步(提交阶段)之前,它不会对这些信息执行任何操作
陷阱
渲染必须始终是一次 纯计算:
- 输入相同,输出相同。 给定相同的输入,组件应始终返回相同的 JSX。(当有人点了西红柿沙拉时,他们不应该收到洋葱沙拉!)
- 只做它自己的事情。 它不应更改任何存在于渲染之前的对象或变量。(一个订单不应更改其他任何人的订单。)
否则,随着代码库复杂性的增加,您可能会遇到令人困惑的错误和不可预测的行为。在 “严格模式” 下开发时,React 会调用每个组件的函数两次,这可以帮助发现由不纯函数引起的错误。
步骤 3: React 把更改提交到 DOM 上
在渲染(调用)您的组件之后,React 将会修改 DOM。
- 对于初次渲染, React 会使用
appendChild()
DOM API 将其创建的所有 DOM 节点放在屏幕上。 - 对于重渲染, React 将应用最少的必要操作(在渲染时计算!),以使得 DOM 与最新的渲染输出相互匹配。
React 仅在渲染之间存在差异时才会更改 DOM 节点。 例如,有一个组件,它每秒使用从父组件传递下来的不同属性重新渲染一次。注意,您可以添加一些文本到 标签,更新它的 ,但是文本不会在组件重渲染时消失:``
export default function Clock({ time }) {
return (
<>
<h1>{time}</h1>
<input />
</>
);
}
这个例子之所以会正常运行,是因为在最后一步中,React 只会使用最新的 time
更新 <h1>
标签的内容。它看到 <input>
标签出现在 JSX 中与上次相同的位置,因此 React 不会修改 <input>
标签或它的 value
!
尾声:浏览器绘制
在渲染完成并且 React 更新 DOM 之后,浏览器就会重新绘制屏幕。尽管这个过程被称为“浏览器渲染”(“browser rendering”),但我们还是将它称为“绘制”(“painting”),以避免在这些文档的其余部分中出现混淆。
state 如同一张快照
也许 state 变量看起来和一般的可读写的 JavaScript 变量类似。但 state 在其表现出的特性上更像是一张快照。设置它不会更改你已有的 state 变量,但会触发重新渲染。
设置 state 会触发渲染
你可能会认为你的用户界面会直接对点击之类的用户输入做出响应并发生变化。在 React 中,它的工作方式与这种思维模型略有不同。在上一页中,你看到了来自 React 的设置 state 请求重新渲染。这意味着要使界面对输入做出反应,你需要设置其 state。
在这个例子中,当你按下 “send” 时,setIsSent(true)
会通知 React 重新渲染 UI:
import { useState } from 'react';
export default function Form() {
const [isSent, setIsSent] = useState(false);
const [message, setMessage] = useState('Hi!');
if (isSent) {
return <h1>Your message is on its way!</h1>
}
return (
<form onSubmit={(e) => {
e.preventDefault();
setIsSent(true);
sendMessage(message);
}}>
<textarea
placeholder="Message"
value={message}
onChange={e => setMessage(e.target.value)}
/>
<button type="submit">Send</button>
</form>
);
}
function sendMessage(message) {
// ...
}
当你单击按钮时会发生以下情况:
- 执行
onSubmit
事件处理函数。 setIsSent(true)
将isSent
设置为true
并排列一个新的渲染。- React 根据新的
isSent
值重新渲染组件。
让我们仔细看看 state 和渲染之间的关系。
渲染会及时生成一张快照
“正在渲染” 就意味着 React 正在调用你的组件——一个函数。你从该函数返回的 JSX 就像是 UI 的一张及时的快照。它的 props、事件处理函数和内部变量都是 根据当前渲染时的 state 被计算出来的。
与照片或电影画面不同,你返回的 UI “快照”是可交互的。它其中包括类似事件处理函数的逻辑,这些逻辑用于指定如何对输入作出响应。React 随后会更新屏幕来匹配这张快照,并绑定事件处理函数。因此,按下按钮就会触发你 JSX 中的点击事件处理函数。
当 React 重新渲染一个组件时:
- React 会再次调用你的函数
- 你的函数会返回新的 JSX 快照
- React 会更新界面来匹配你返回的快照
React 执行函数 | 计算快照 | 更新 DOM 树 |
作为一个组件的记忆,state 不同于在你的函数返回之后就会消失的普通变量。state 实际上“活”在 React 本身中——就像被摆在一个架子上!——位于你的函数之外。当 React 调用你的组件时,它会为特定的那一次渲染提供一张 state 快照。你的组件会在其 JSX 中返回一张包含一整套新的 props 和事件处理函数的 UI 快照 ,其中所有的值都是 根据那一次渲染中 state 的值 被计算出来的!
React 收到 setUpdate 通知 | React 更新 state 的值 | React 向组件内传入一张 state 的快照 |
作为一个组件的记忆,state 不同于在你的函数返回之后就会消失的普通变量。state 实际上“活”在 React 本身中——就像被摆在一个架子上!——位于你的函数之外。当 React 调用你的组件时,它会为特定的那一次渲染提供一张 state 快照。你的组件会在其 JSX 中返回一张包含一整套新的 props 和事件处理函数的 UI 快照 ,其中所有的值都是 根据那一次渲染中 state 的值 被计算出来的!
这里有个向你展示其运行原理的小例子。在这个例子中,你可能会以为点击“+3”按钮会调用 setNumber(number + 1)
三次从而使计数器递增三次。
看看你点击“+3”按钮时会发生什么:
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}>+3</button>
</>
)
}
请注意,每次点击只会让 number
递增一次!
设置 state 只会为 下一次 渲染变更 state 的值。在第一次渲染期间,number
为 0
。这也就解释了为什么在 那次渲染中的 onClick
处理函数中,即便在调用了 setNumber(number + 1)
之后,number
的值也仍然是 0
:
<button onClick={() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}>+3</button>
以下是这个按钮的点击事件处理函数通知 React 要做的事情:
-
setNumber(number + 1)
:number
是 0 所以setNumber(0 + 1)
。- React 准备在下一次渲染时将
number
更改为 1。
- React 准备在下一次渲染时将
-
setNumber(number + 1)
:number
是0 所以setNumber(0 + 1)
。- React 准备在下一次渲染时将
number
更改为 1。
- React 准备在下一次渲染时将
-
setNumber(number + 1)
:number
是0 所以setNumber(0 + 1)
。- React 准备在下一次渲染时将
number
更改为 1。
- React 准备在下一次渲染时将
尽管你调用了三次 setNumber(number + 1)
,但在 这次渲染的 事件处理函数中 number
会一直是 0
,所以你会三次将 state 设置成 1
。这就是为什么在你的事件处理函数执行完以后,React 重新渲染的组件中的 number
等于 1
而不是 3
。
你还可以通过在心里把 state 变量替换成它们在你代码中的值来想象这个过程。由于 这次渲染 中的 state 变量 number
是 0
,其事件处理函数看起来会像这样:
<button onClick={() => {
setNumber(0 + 1);
setNumber(0 + 1);
setNumber(0 + 1);
}}>+3</button>
对于下一次渲染来说,number
是 1
,因此 那次渲染中的 点击事件处理函数看起来会像这样:
<button onClick={() => {
setNumber(1 + 1);
setNumber(1 + 1);
setNumber(1 + 1);
}}>+3</button>
这就是为什么再次点击按钮会将计数器设置为 2
,下次点击时会设为 3
,依此类推。
随时间变化的 state
好的,刚才那些很有意思。试着猜猜点击这个按钮会弹出什么:
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 5);
alert(number);
}}>+5</button>
</>
)
}
如果你使用之前替换的方法,你就能猜到这个提示框将会显示 “0”:
setNumber(0 + 5);
alert(0);
但如果你在这个提示框上加上一个定时器, 使得它在组件重新渲染 之后 才触发,又会怎样呢?是会显示 “0” 还是 “5” ?猜一猜!
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 5);
setTimeout(() => {
alert(number);
}, 3000);
}}>+5</button>
</>
)
}
惊讶吗?你如果使用替代法,就能看到被传入提示框的 state “快照”。
setNumber(0 + 5);
setTimeout(() => {
alert(0);
}, 3000);
到提示框运行时,React 中存储的 state 可能已经发生了更改,但是这次更改是根据用户与其交互时的 state 快照来安排的!
一个 state 变量的值永远不会在一次渲染的内部发生变化, 即使其事件处理函数的代码是异步的。在 那次渲染的 onClick
内部,number
的值即使在调用 setNumber(number + 5)
之后也还是 0
。它的值在 React 通过调用你的组件“获取 UI 的快照”时就被“固定”了。
这里有个示例能够说明上述特性会使你的事件处理函数更不容易出现计时错误。下面是一个会在五秒延迟之后发送一条消息的表单。想象以下场景:
- 你按下“发送”按钮,向 Alice 发送“你好”。
- 在五秒延迟结束之前,将“To”字段的值更改为“Bob”。
你觉得 alert
会显示什么?它是会显示“你向 Alice 说了你好“还是会显示“你向 Tom 说了你好”?根据你已经学到的知识猜一猜,然后动手试一试:
import { useState } from 'react';
export default function Form() {
const [to, setTo] = useState('Alice');
const [message, setMessage] = useState('Hello');
function handleSubmit(e) {
e.preventDefault();
setTimeout(() => {
alert(`You said ${message} to ${to}`);
}, 5000);
}
return (
<form onSubmit={handleSubmit}>
<label>
To:
<select value={to} onChange={e => setTo(e.target.value)}>
<option value='Alice'>Alice</option>
<option value='Bob'>Bob</option>
</select>
</label>
<textarea placeholder='Message' value={message} onChange={e => setMessage(e.target.value)} />
<button type='submit'>Send</button>
</form>
);
}
React 会使 state 的值始终”固定“在一次渲染的各个事件处理函数内部。 你无需担心代码运行时 state 是否发生了变化。
但是,万一你想在重新渲染之前读取最新的 state 怎么办?你应该使用 状态更新函数,后面将会介绍!
把一系列 state 更新加入队列
设置组件 state 会把一次重新渲染加入队列。但有时你可能会希望在下次渲染加入队列之前对 state 的值执行多次操作。为此,了解 React 如何批量更新 state 会很有帮助。
React 会对 state 更新进行批处理
在下面的示例中,你可能会认为点击 “+3” 按钮会使计数器递增三次,因为它调用了 setNumber(number + 1)
三次:
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}>+3</button>
</>
)
}
但是,你可能还记得上一节中的内容,每一次渲染的 state 值都是固定的,因此无论你调用多少次 setNumber(1)
,在第一次渲染的事件处理函数内部的 number
值总是 0
:
setNumber(0 + 1);
setNumber(0 + 1);
setNumber(0 + 1);
但是这里还有另外一个影响因素需要讨论。React 会等到事件处理函数中的 *所有* 代码都运行完毕再处理你的 state 更新。 这就是为什么重新渲染只会发生在所有这些 setNumber()
调用 之后 的原因。
这可能会让你想起餐厅里帮你点菜的服务员。服务员不会在你说第一道菜的时候就跑到厨房!相反,他们会让你把菜点完,让你修改菜品,甚至会帮桌上的其他人点菜。
这让你可以更新多个 state 变量——甚至来自多个组件的 state 变量——而不会触发太多的 重新渲染。但这也意味着只有在你的事件处理函数及其中任何代码执行完成 之后,UI 才会更新。这种特性也就是 批处理,它会使你的 React 应用运行得更快。它还会帮你避免处理只更新了一部分 state 变量的令人困惑的“半成品”渲染。
React 不会跨 *多个* 需要刻意触发的事件(如点击)进行批处理——每次点击都是单独处理的。请放心,React 只会在一般来说安全的情况下才进行批处理。这可以确保,例如,如果第一次点击按钮会禁用表单,那么第二次点击就不会再次提交它。
在下次渲染前多次更新同一个 state
这是一个不常见的用例,但是如果你想在下次渲染之前多次更新同一个 state,你可以像 setNumber(n => n + 1)
这样传入一个根据队列中的前一个 state 计算下一个 state 的 函数,而不是像 setNumber(number + 1)
这样传入 下一个 state 值。这是一种告诉 React “用 state 值做某事”而不是仅仅替换它的方法。
现在尝试递增计数器:
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(n => n + 1);
setNumber(n => n + 1);
setNumber(n => n + 1);
}}>+3</button>
</>
)
}
在这里,n => n + 1
被称为 更新函数。当你将它传递给一个 state 设置函数时:
- React 会将此函数加入队列,以便在事件处理函数中的所有其他代码运行后进行处理。
- 在下一次渲染期间,React 会遍历队列并给你更新之后的最终 state。
setNumber(n => n + 1);
setNumber(n => n + 1);
setNumber(n => n + 1);
下面是 React 在执行事件处理函数时处理这几行代码的过程:
setNumber(n => n + 1)
:n => n + 1
是一个函数。React 将它加入队列。setNumber(n => n + 1)
:n => n + 1
是一个函数。React 将它加入队列。setNumber(n => n + 1)
:n => n + 1
是一个函数。React 将它加入队列。
当你在下次渲染期间调用 useState
时,React 会遍历队列。之前的 number
state 的值是 0
,所以这就是 React 作为参数 n
传递给第一个更新函数的值。然后 React 会获取你上一个更新函数的返回值,并将其作为 n
传递给下一个更新函数,以此类推:
更新队列 | n | 返回值 |
---|---|---|
n => n + 1 | 0 | 0 + 1 = 1 |
n => n + 1 | 1 | 1 + 1 = 2 |
n => n + 1 | 2 | 2 + 1 = 3 |
React 会保存 3
为最终结果并从 useState
中返回。
这就是为什么在上面的示例中点击“+3”正确地将值增加“+3”。
如果你在替换 state 后更新 state 会发生什么
这个事件处理函数会怎么样?你认为 number
在下一次渲染中的值是什么?
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
}}>增加数字</button>
</>
)
}
这是事件处理函数告诉 React 要做的事情:
setNumber(number + 5)
:number
为0
,所以setNumber(0 + 5)
。React 将 “替换为5
” 添加到其队列中。setNumber(n => n + 1)
:n => n + 1
是一个更新函数。 React 将 该函数 添加到其队列中。
在下一次渲染期间,React 会遍历 state 队列:
更新队列 | n | 返回值 |
---|---|---|
“替换为 5 ” | 0 (未使用) | 5 |
n => n + 1 | 5 | 5 + 1 = 6 |
React 会保存 6
为最终结果并从 useState
中返回。
注意
你可能已经注意到,
setState(x)
实际上会像setState(n => x)
一样运行,只是没有使用n
!
如果你在更新 state 后替换 state 会发生什么
让我们再看一个例子。你认为 number
在下一次渲染中的值是什么?
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
setNumber(42);
}}>增加数字</button>
</>
)
}
以下是 React 在执行事件处理函数时处理这几行代码的过程:
setNumber(number + 5)
:number
为0
,所以setNumber(0 + 5)
。React 将 “替换为5
” 添加到其队列中。setNumber(n => n + 1)
:n => n + 1
是一个更新函数。React 将该函数添加到其队列中。setNumber(42)
:React 将 “替换为42
” 添加到其队列中。
在下一次渲染期间,React 会遍历 state 队列:
更新队列 | n | 返回值 |
---|---|---|
“替换为 5 ” | 0 (未使用) | 5 |
n => n + 1 | 5 | 5 + 1 = 6 |
“替换为 42 ” | 6 (未使用) | 42 |
然后 React 会保存 42
为最终结果并从 useState
中返回。
总而言之,以下是你可以考虑传递给 setNumber
state 设置函数的内容:
- 一个更新函数(例如:
n => n + 1
)会被添加到队列中。 - 任何其他的值(例如:数字
5
)会导致“替换为5
”被添加到队列中,已经在队列中的内容会被忽略。
事件处理函数执行完成后,React 将触发重新渲染。在重新渲染期间,React 将处理队列。更新函数会在渲染期间执行,因此 更新函数必须是 纯函数 并且只 返回 结果。不要尝试从它们内部设置 state 或者执行其他副作用。在严格模式下,React 会执行每个更新函数两次(但是丢弃第二个结果)以便帮助你发现错误。
命名惯例
通常可以通过相应 state 变量的第一个字母来命名更新函数的参数:
setEnabled(e => !e);
setLastName(ln => ln.reverse());
setFriendCount(fc => fc * 2);
如果你喜欢更冗长的代码,另一个常见的惯例是重复使用完整的 state 变量名称,如 setEnabled(enabled => !enabled)
,或使用前缀,如 setEnabled(prevEnabled => !prevEnabled)
。
更新 state 中的对象
State 可以保存任何类型的 JavaScript 值,包括对象。但是你不应该直接更改你持有的处于 React 状态的对象。相反,当您想要更新对象时,您需要创建一个新对象(或创建现有对象的副本),然后将状态设置为使用该副本。
什么是 mutation?
您可以在state中存储任何类型的 JavaScript 值。
const [x, setX] = useState(0);
到目前为止,您一直在使用数字、字符串和布尔值。这些类型的JavaScript值是“不可变的”,这意味着不可更改或“只读”。您可以触发重新渲染以替换值:
setX(5);
state从 0
更改为 5
,但数字 0
本身没有改变。无法对 JavaScript 中的内置原始值(如数字、字符串和布尔值)进行任何更改。
对于对象类型的state:
const [position, setPosition] = useState({ x: 0, y: 0 });
从技术上讲,可以更改对象本身的内容。这称为mutation:
position.x = 5;
但是,尽管处于 React 状态的对象在技术上是可变的,但您应该将它们视为不可变的,就像数字、布尔值和字符串一样。与其改变它们,不如始终替换它们。
将状态视为只读
换句话说,您应该将放入state的任何 JavaScript 对象视为只读。
本示例使一个对象处于状态以表示当前指针位置。当您在预览区域上触摸或移动光标时,红点应该会移动。但是点停留在初始位置:
import { useState } from 'react';
export default function MovingDot() {
const [position, setPosition] = useState({
x: 0,
y: 0,
});
return (
<div
onPointerMove={e => {
position.x = e.clientX;
position.y = e.clientY;
}}
style={{
position: 'relative',
width: '100vw',
height: '100vh',
}}>
<div
style={{
position: 'absolute',
backgroundColor: 'red',
borderRadius: '50%',
transform: `translate(${position.x}px, ${position.y}px)`,
left: -10,
top: -10,
width: 20,
height: 20,
}}
/>
</div>
);
}
问题出在这段代码上。
onPointerMove={e => {
position.x = e.clientX;
position.y = e.clientY;
}}
这段代码修改了从之前的渲染中分配给位置的对象。但是如果不使用状态设置函数,React就不知道对象发生了变化。所以React不做任何响应。这就像你已经吃过饭了还想换菜一样。虽然在某些情况下可以改变状态,但我们不建议这样做。您应该将呈现中可以访问的状态值视为只读。
React中默认浅监听,当State值为对象({}、[])时,栈中存的是对象的引用(地址),setTing 改变的是堆中的数据,直接往
position
对象中添加数据,栈中的地址还是原地址,React浅监听到地址没变,故会认为State并未改变,故没有重渲染页面,想要从新渲染页面,需要把position
对象的指向地址的改变,数组也是同理
在这种情况下,要实际触发重新渲染,请创建一个*新*对象并将其传递给状态设置函数:
onPointerMove={e => {
setPosition({
x: e.clientX,
y: e.clientY
});
}}
使用 setPosition
,你告诉React:
- 替换为此新对象
position
- 并再次渲染此组件
注意,当你触摸或悬停在预览区域上时,红点现在是如何跟随你的指针的:
import { useState } from 'react';
export default function MovingDot() {
const [position, setPosition] = useState({
x: 0,
y: 0,
});
return (
<div
onPointerMove={e => {
setPosition({
x: e.clientX,
y: e.clientY,
});
}}
style={{
position: 'relative',
width: '100vw',
height: '100vh',
}}>
<div
style={{
position: 'absolute',
backgroundColor: 'red',
borderRadius: '50%',
transform: `translate(${position.x}px, ${position.y}px)`,
left: -10,
top: -10,
width: 20,
height: 20,
}}
/>
</div>
);
}
使用扩展语法复制对象
在前面的例子中,position
对象总是从当前光标位置重新创建。但是,您通常希望将现有数据作为创建的新对象的一部分包含进去。例如,您可能希望仅更新表单中的一个字段,但保留所有其他字段的先前值。
这些输入字段不起作用,因为 onChange
处理程序会改变状态:
import { useState } from 'react';
export default function Form() {
const [person, setPerson] = useState({
firstName: 'Barbara',
lastName: 'Hepworth',
email: 'bhepworth@sculpture.com',
});
function handleFirstNameChange(e) {
person.firstName = e.target.value;
}
function handleLastNameChange(e) {
person.lastName = e.target.value;
}
function handleEmailChange(e) {
person.email = e.target.value;
}
return (
<>
<label>
First name:
<input value={person.firstName} onChange={handleFirstNameChange} />
</label>
<label>
Last name:
<input value={person.lastName} onChange={handleLastNameChange} />
</label>
<label>
Email:
<input value={person.email} onChange={handleEmailChange} />
</label>
<p>
{person.firstName} {person.lastName} ({person.email})
</p>
</>
);
}
例如,此行从过去的渲染中改变状态:
person.firstName = e.target.value;
获得你正在寻找的行为的可靠方法是创建一个新对象并将它传递给 setPerson
。但是在这里,你还想复制现有的数据到它,因为只有一个字段改变了:
setPerson({
firstName: e.target.value, // 改变 firstName 的值为输入框的值
lastName: person.lastName,
email: person.email
});
你可以用…对象扩展语法,这样就不需要单独复制每个属性。
setPerson({
...person, // 复制其他字段
firstName: e.target.value // 只给 firstName 重新赋值
});
现在表格有效!
注意,您没有为每个输入字段声明单独的状态变量。对于大型表单,将所有数据分组在一个对象中非常方便——只要您正确地更新它!
import { useState } from 'react';
export default function Form() {
const [person, setPerson] = useState({
firstName: 'Barbara',
lastName: 'Hepworth',
email: 'bhepworth@sculpture.com',
});
function handleFirstNameChange(e) {
setPerson({
...person,
firstName: e.target.value,
});
}
function handleLastNameChange(e) {
setPerson({
...person,
lastName: e.target.value,
});
}
function handleEmailChange(e) {
setPerson({
...person,
email: e.target.value,
});
}
return (
<>
<label>
First name:
<input value={person.firstName} onChange={handleFirstNameChange} />
</label>
<label>
Last name:
<input value={person.lastName} onChange={handleLastNameChange} />
</label>
<label>
Email:
<input value={person.email} onChange={handleEmailChange} />
</label>
<p>
{person.firstName} {person.lastName} ({person.email})
</p>
</>
);
}
注意 ...扩展语法
是“浅复制的”——它只复制一层深的东西。这使得它更快,但这也意味着如果您想要更新一个嵌套属性,您将不得不多次使用它。
更新嵌套对象
考虑一个嵌套的对象结构,如下所示:
const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});
如果你想更新person.artwork.City
,很明显如何利用mutation
来做到这一点:
person.artwork.city = 'New Delhi';
但在React中,你将状态视为不可变的!为了改变 city
,你首先需要生成新的 artwork
对象(预先填充来自前一个artwork
的数据),然后生成指向新artwork
的新person
对象:
const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);
或者,编写为单个函数调用:
setPerson({
...person, // 复制其他字段
artwork: { //但是替换 artwork 字段
...person.artwork, // 复制 artwork 其他字段
city: 'New Delhi' // 更新 city 字段的值
}
});
这有点罗嗦,但在许多情况下都很好用:
import { useState } from 'react';
export default function Form() {
const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
},
});
function handleNameChange(e) {
setPerson({
...person,
name: e.target.value,
});
}
function handleTitleChange(e) {
setPerson({
...person,
artwork: {
...person.artwork,
title: e.target.value,
},
});
}
function handleCityChange(e) {
setPerson({
...person,
artwork: {
...person.artwork,
city: e.target.value,
},
});
}
function handleImageChange(e) {
setPerson({
...person,
artwork: {
...person.artwork,
image: e.target.value,
},
});
}
return (
<>
<label>
Name:
<input value={person.name} onChange={handleNameChange} />
</label>
<label>
Title:
<input value={person.artwork.title} onChange={handleTitleChange} />
</label>
<label>
City:
<input value={person.artwork.city} onChange={handleCityChange} />
</label>
<label>
Image:
<input value={person.artwork.image} onChange={handleImageChange} />
</label>
<p>
<i>{person.artwork.title}</i>
{' by '}
{person.name}
<br />
(located in {person.artwork.city})
</p>
<img src={person.artwork.image} alt={person.artwork.title} />
</>
);
}
使用 Immer 编写简洁的更新逻辑
如果状态嵌套较深,则可能需要考虑将其平展。但是,如果您不想更改状态结构,则可能更喜欢嵌套跨页的快捷方式。Immer 是一个流行的库,它允许您使用方便但不断变化的语法进行编写,并负责为您生成副本。使用 Immer,您编写的代码看起来像是在“违反规则”并改变对象:
updatePerson(draft => {
draft.artwork.city = 'Lagos';
});
但与常规突变不同的是,它不会覆盖过去的状态!
要试用 Immer
:
- 运行以将
Immer
添加为依赖项npm install use-immer
- 然后使用
import { useImmer } from 'use-immer'
替换为import { useState } from 'react'
下面是上面转换为 Immmer 的示例:
import { useImmer } from 'use-immer';
export default function Form() {
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;
});
}
function handleCityChange(e) {
updatePerson(draft => {
draft.artwork.city = e.target.value;
});
}
function handleImageChange(e) {
updatePerson(draft => {
draft.artwork.image = e.target.value;
});
}
return (
<>
<label>
Name:
<input value={person.name} onChange={handleNameChange} />
</label>
<label>
Title:
<input value={person.artwork.title} onChange={handleTitleChange} />
</label>
<label>
City:
<input value={person.artwork.city} onChange={handleCityChange} />
</label>
<label>
Image:
<input value={person.artwork.image} onChange={handleImageChange} />
</label>
<p>
<i>{person.artwork.title}</i>
{' by '}
{person.name}
<br />
(located in {person.artwork.city})
</p>
<img src={person.artwork.image} alt={person.artwork.title} />
</>
);
}
注意事件处理程序变得多么简洁。您可以在单个组件中随意混合和匹配useState
和useImmer
。Immer
是一种保持更新处理程序简洁的好方法,特别是在您的状态中存在嵌套,并且复制对象会导致重复代码的情况下。
Immer 介绍
Immer(德语为:always)是一个小型包,可让您以更方便的方式使用不可变状态。
文档:https://immerjs.github.io/immer/zh-CN/
更新 state 中的数组
数组是另外一种可以存储在 state 中的 JavaScript 对象,它虽然是可变的,但是却应该被视为不可变。同对象一样,当你想要更新存储于 state 中的数组时,你需要创建一个新的数组(或者创建一份已有数组的拷贝值),并使用新数组设置 state。
在没有 mutation 的前提下更新数组
在 JavaScript 中,数组只是另一种对象。同对象一样,你需要将 React state 中的数组视为只读的。这意味着你不应该使用类似于 arr[0] = 'bird'
这样的方式来重新分配数组中的元素,也不应该使用会直接修改原始数组的方法,例如 push()
和 pop()
。
相反,每次要更新一个数组时,你需要把一个新的数组传入 state 的 setter 方法中。为此,你可以通过使用像 filter()
和 map()
这样不会直接修改原始值的方法,从原始数组生成一个新的数组。然后你就可以将 state 设置为这个新生成的数组。
下面是常见数组操作的参考表。当你操作 React state 中的数组时,你需要避免使用左列的方法,而首选右列的方法:
避免使用 (会改变原始数组) | 推荐使用 (会返回一个新数组) | |
---|---|---|
添加元素 | push ,unshift | concat ,[...arr] 展开语法(例子) |
删除元素 | pop ,shift ,splice | filter ,slice (例子) |
替换元素 | splice ,arr[i] = ... 赋值 | map (例子) |
排序 | reverse ,sort | 先将数组复制一份(例子) |
或者,你可以使用 Immer ,这样你便可以使用表格中的所有方法了。
陷阱
不幸的是,虽然
slice
和splice
的名字相似,但作用却迥然不同:
slice
让你可以拷贝数组或是数组的一部分。splice
会直接修改 原始数组(插入或者删除元素)。在 React 中,更多情况下你会使用
slice
(没有p
!),因为你不想改变 state 中的对象或数组。更新对象这一章节解释了什么是 mutation,以及为什么不推荐在 state 里这样做。
向数组中添加元素
push()
会直接修改原始数组,而你不希望这样:
import { useState } from 'react';
let nextId = 0;
export default function List() {
const [name, setName] = useState('');
const [artists, setArtists] = useState([]);
return (
<>
<h1>振奋人心的雕塑家们:</h1>
<input
value={name}
onChange={e => setName(e.target.value)}
/>
<button onClick={() => {
artists.push({
id: nextId++,
name: name,
});
}}>添加</button>
<ul>
{artists.map(artist => (
<li key={artist.id}>{artist.name}</li>
))}
</ul>
</>
);
}
相反,你应该创建一个 新 数组,其包含了原始数组的所有元素 以及 一个在末尾的新元素。这可以通过很多种方法实现,最简单的一种就是使用 ...
数组展开 语法:
setArtists( // 替换 state
[ // 是通过传入一个新数组实现的
...artists, // 新数组包含原数组的所有元素
{ id: nextId++, name: name } // 并在末尾添加了一个新的元素
]
);
现在代码可以正常运行了。
从数组中删除元素
从数组中删除一个元素最简单的方法就是将它过滤出去。换句话说,你需要生成一个不包含该元素的新数组。这可以通过 filter
方法实现,例如:
import { useState } from 'react';
let initialArtists = [
{ id: 0, name: 'Marta Colvin Andrade' },
{ id: 1, name: 'Lamidi Olonade Fakeye' },
{ id: 2, name: 'Louise Nevelson' },
];
export default function List() {
const [artists, setArtists] = useState(initialArtists);
return (
<>
<h1>振奋人心的雕塑家们:</h1>
<ul>
{artists.map(artist => (
<li key={artist.id}>
{artist.name}{' '}
<button
onClick={() => {
setArtists(artists.filter(a => a.id !== artist.id));
}}>
删除
</button>
</li>
))}
</ul>
</>
);
}
点击“删除”按钮几次,并且查看按钮处理点击事件的代码。
setArtists(
artists.filter(a => a.id !== artist.id)
);
这里,artists.filter(s => s.id !== artist.id)
表示“创建一个新的数组,该数组由那些 ID 与 artists.id
不同的 artists
组成”。换句话说,每个 artist 的“删除”按钮会把 那一个 artist 从原始数组中过滤掉,并使用过滤后的数组再次进行渲染。注意,filter
并不会改变原始数组。
转换数组
如果你想改变数组中的某些或全部元素,你可以用 map()
创建一个新数组。你传入 map
的函数决定了要根据每个元素的值或索引(或二者都要)对元素做何处理。
在下面的例子中,一个数组记录了两个圆形和一个正方形的坐标。当你点击按钮时,仅有两个圆形会向下移动 100 像素。这是通过使用 map()
生成一个新数组实现的。
import { useState } from 'react';
let initialShapes = [
{ id: 0, type: 'circle', x: 50, y: 100 },
{ id: 1, type: 'square', x: 150, y: 100 },
{ id: 2, type: 'circle', x: 250, y: 100 },
];
export default function ShapeEditor() {
const [shapes, setShapes] = useState(
initialShapes
);
function handleClick() {
const nextShapes = shapes.map(shape => {
if (shape.type === 'square') {
// 不作改变
return shape;
} else {
// 返回一个新的圆形,位置在下方 50px 处
return {
...shape,
y: shape.y + 50,
};
}
});
// 使用新的数组进行重渲染
setShapes(nextShapes);
}
return (
<>
<button onClick={handleClick}>
所有圆形向下移动!
</button>
{shapes.map(shape => (
<div
key={shape.id}
style={{
background: 'purple',
position: 'absolute',
left: shape.x,
top: shape.y,
borderRadius:
shape.type === 'circle'
? '50%' : '',
width: 20,
height: 20,
}} />
))}
</>
);
}
替换数组中的元素
想要替换数组中一个或多个元素是非常常见的。类似 arr[0] = 'bird'
这样的赋值语句会直接修改原始数组,所以在这种情况下,你也应该使用 map
。
要替换一个元素,请使用 map
创建一个新数组。在你的 map
回调里,第二个参数是元素的索引。使用索引来判断最终是返回原始的元素(即回调的第一个参数)还是替换成其他值:
import { useState } from 'react';
let initialCounters = [0, 0, 0];
export default function CounterList() {
const [counters, setCounters] = useState(initialCounters);
function handleIncrementClick(index) {
const nextCounters = counters.map((c, i) => {
if (i === index) {
// 递增被点击的计数器数值
return c + 1;
} else {
// 其余部分不发生变化
return c;
}
});
setCounters(nextCounters);
}
return (
<ul>
{counters.map((counter, i) => (
<li key={i}>
{counter}
<button onClick={() => handleIncrementClick(i)}>+1</button>
</li>
))}
</ul>
);
}
向数组中插入元素
有时,你也许想向数组特定位置插入一个元素,这个位置既不在数组开头,也不在末尾。为此,你可以将数组展开运算符 ...
和 slice()
方法一起使用。slice()
方法让你从数组中切出“一片”。为了将元素插入数组,你需要先展开原数组在插入点之前的切片,然后插入新元素,最后展开原数组中剩下的部分。
下面的例子中,插入按钮总是会将元素插入到数组中索引为 1
的位置。
import { useState } from 'react';
let nextId = 3;
const initialArtists = [
{ id: 0, name: 'Marta Colvin Andrade' },
{ id: 1, name: 'Lamidi Olonade Fakeye' },
{ id: 2, name: 'Louise Nevelson' },
];
export default function List() {
const [name, setName] = useState('');
const [artists, setArtists] = useState(initialArtists);
function handleClick() {
// const insertAt = Math.floor(Math.random() * artists.length); // 可能是任何索引
const insertAt = 1; // 可能是任何索引
const nextArtists = [
// 插入点之前的元素:
...artists.slice(0, insertAt),
// 新的元素:
{ id: nextId++, name: name },
// 插入点之后的元素:
...artists.slice(insertAt),
];
setArtists(nextArtists);
setName('');
}
return (
<>
<h1>振奋人心的雕塑家们:</h1>
<input value={name} onChange={e => setName(e.target.value)} />
<button onClick={handleClick}>插入</button>
<ul>
{artists.map(artist => (
<li key={artist.id}>{artist.name}</li>
))}
</ul>
</>
);
}
其他改变数组的情况
总会有一些事,是你仅仅依靠展开运算符和 map()
或者 filter()
等不会直接修改原值的方法所无法做到的。例如,你可能想翻转数组,或是对数组排序。而 JavaScript 中的 reverse()
和 sort()
方法会改变原数组,所以你无法直接使用它们。
然而,你可以先拷贝这个数组,再改变这个拷贝后的值。
例如:
import { useState } from 'react';
let nextId = 3;
const initialList = [
{ id: 0, title: 'Big Bellies' },
{ id: 1, title: 'Lunar Landscape' },
{ id: 2, title: 'Terracotta Army' },
];
export default function List() {
const [list, setList] = useState(initialList);
function handleClick() {
const nextList = [...list];
nextList.reverse();
setList(nextList);
}
return (
<>
<button onClick={handleClick}>翻转</button>
<ul>
{list.map(artwork => (
<li key={artwork.id}>{artwork.title}</li>
))}
</ul>
</>
);
}
在这段代码中,你先使用 [...list]
展开运算符创建了一份数组的拷贝值。当你有了这个拷贝值后,你就可以使用像 nextList.reverse()
或 nextList.sort()
这样直接修改原数组的方法。你甚至可以通过 nextList[0] = "something"
这样的方式对数组中的特定元素进行赋值。
然而,即使你拷贝了数组,你还是不能直接修改其_内部_的元素。这是因为数组的拷贝是浅拷贝——新的数组中依然保留了与原始数组相同的元素。因此,如果你修改了拷贝数组内部的某个对象,其实你正在直接修改当前的 state。举个例子,像下面的代码就会带来问题。
const nextList = [...list];
nextList[0].seen = true; // 问题:直接修改了 list[0] 的值
setList(nextList);
虽然 nextList
和 list
是两个不同的数组,nextList[0]
和 list[0]
却指向了同一个对象。因此,通过改变 nextList[0].seen
,list[0].seen
的值也被改变了。这是一种 state 的 mutation 操作,你应该避免这么做!你可以用类似于 更新嵌套的 JavaScript 对象 的方式解决这个问题——拷贝想要修改的特定元素,而不是直接修改它。下面是具体的操作。
更新数组内部的对象
对象并不是_真的_位于数组“内部”。可能他们在代码中看起来像是在数组“内部”,但其实数组中的每个对象都是这个数组“指向”的一个存储于其它位置的值。这就是当你在处理类似 list[0]
这样的嵌套字段时需要格外小心的原因。其他人的艺术品清单可能指向了数组的同一个元素!
当你更新一个嵌套的 state 时,你需要从想要更新的地方创建拷贝值,一直这样,直到顶层。 让我们看一下这该怎么做。
在下面的例子中,两个不同的艺术品清单有着相同的初始 state。他们本应该互不影响,但是因为一次 mutation,他们的 state 被意外地共享了,勾选一个清单中的事项会影响另外一个清单:
import { useState } from 'react';
let nextId = 3;
const initialList = [
{ id: 0, title: 'Big Bellies', seen: false },
{ id: 1, title: 'Lunar Landscape', seen: false },
{ id: 2, title: 'Terracotta Army', seen: true },
];
export default function BucketList() {
const [myList, setMyList] = useState(initialList);
const [yourList, setYourList] = useState(initialList);
function handleToggleMyList(artworkId, nextSeen) {
const myNextList = [...myList];
const artwork = myNextList.find(a => a.id === artworkId);
artwork.seen = nextSeen;
setMyList(myNextList);
}
function handleToggleYourList(artworkId, nextSeen) {
const yourNextList = [...yourList];
const artwork = yourNextList.find(a => a.id === artworkId);
artwork.seen = nextSeen;
setYourList(yourNextList);
}
return (
<>
<h1>艺术愿望清单</h1>
<h2>我想看的艺术清单:</h2>
<ItemList artworks={myList} onToggle={handleToggleMyList} />
<h2>你想看的艺术清单:</h2>
<ItemList artworks={yourList} onToggle={handleToggleYourList} />
</>
);
}
function ItemList({ artworks, onToggle }) {
return (
<ul>
{artworks.map(artwork => (
<li key={artwork.id}>
<label>
<input type='checkbox' checked={artwork.seen} onChange={e => onToggle(artwork.id, e.target.checked)} />
{artwork.title}
</label>
</li>
))}
</ul>
);
}
问题出在下面这段代码中:
const myNextList = [...myList];
const artwork = myNextList.find(a => a.id === artworkId);
artwork.seen = nextSeen; // 问题:直接修改了已有的元素
setMyList(myNextList);
虽然 myNextList
这个数组是新的,但是其内部的元素本身与原数组 myList
是相同的。因此,修改 artwork.seen
,其实是在修改原始的 artwork 对象。而这个 artwork 对象也被 yourList
使用,这样就带来了 bug。这样的 bug 可能难以想到,但好在如果你避免直接修改 state,它们就会消失。
你可以使用 map
在没有 mutation 的前提下将一个旧的元素替换成更新的版本。
setMyList(myList.map(artwork => {
if (artwork.id === artworkId) {
// 创建包含变更的*新*对象
return { ...artwork, seen: nextSeen };
} else {
// 没有变更
return artwork;
}
}));
此处的 ...
是一个对象展开语法,被用来创建一个对象的拷贝.
通过这种方式,没有任何现有的 state 中的元素会被改变,bug 也就被修复了。
import { useState } from 'react';
let nextId = 3;
const initialList = [
{ id: 0, title: 'Big Bellies', seen: false },
{ id: 1, title: 'Lunar Landscape', seen: false },
{ id: 2, title: 'Terracotta Army', seen: true },
];
export default function BucketList() {
const [myList, setMyList] = useState(initialList);
const [yourList, setYourList] = useState(initialList);
function handleToggleMyList(artworkId, nextSeen) {
setMyList(
myList.map(artwork => {
if (artwork.id === artworkId) {
// 创建包含变更的*新*对象
return { ...artwork, seen: nextSeen };
} else {
// 没有变更
return artwork;
}
})
);
}
function handleToggleYourList(artworkId, nextSeen) {
setYourList(
yourList.map(artwork => {
if (artwork.id === artworkId) {
// 创建包含变更的*新*对象
return { ...artwork, seen: nextSeen };
} else {
// 没有变更
return artwork;
}
})
);
}
return (
<>
<h1>艺术愿望清单</h1>
<h2>我想看的艺术清单:</h2>
<ItemList artworks={myList} onToggle={handleToggleMyList} />
<h2>你想看的艺术清单:</h2>
<ItemList artworks={yourList} onToggle={handleToggleYourList} />
</>
);
}
function ItemList({ artworks, onToggle }) {
return (
<ul>
{artworks.map(artwork => (
<li key={artwork.id}>
<label>
<input type='checkbox' checked={artwork.seen} onChange={e => onToggle(artwork.id, e.target.checked)} />
{artwork.title}
</label>
</li>
))}
</ul>
);
}
通常来讲,你应该只直接修改你刚刚创建的对象。如果你正在插入一个新的 artwork,你可以修改它,但是如果你想要改变的是 state 中已经存在的东西,你就需要先拷贝一份了。
使用 Immer 编写简洁的更新逻辑
在没有 mutation 的前提下更新嵌套数组可能会变得有点重复。就像对对象一样:
- 通常情况下,你应该不需要更新处于非常深层级的 state 。如果你有此类需求,你或许需要调整一下数据的结构,让数据变得扁平一些。
- 如果你不想改变 state 的数据结构,你也许会更喜欢使用 Immer ,它让你可以继续使用方便的,但会直接修改原值的语法,并负责为你生成拷贝值。
下面是我们用 Immer 来重写的艺术愿望清单的例子:
import { useImmer } from 'use-immer';
let nextId = 3;
const initialList = [
{ id: 0, title: 'Big Bellies', seen: false },
{ id: 1, title: 'Lunar Landscape', seen: false },
{ id: 2, title: 'Terracotta Army', seen: true },
];
export default function BucketList() {
const [myList, updateMyList] = useImmer(initialList);
const [yourList, updateYourList] = useImmer(initialList);
function handleToggleMyList(id, nextSeen) {
updateMyList(draft => {
const artwork = draft.find(a => a.id === id);
artwork.seen = nextSeen;
});
}
function handleToggleYourList(artworkId, nextSeen) {
updateYourList(draft => {
const artwork = draft.find(a => a.id === artworkId);
artwork.seen = nextSeen;
});
}
return (
<>
<h1>艺术愿望清单</h1>
<h2>我想看的艺术清单:</h2>
<ItemList artworks={myList} onToggle={handleToggleMyList} />
<h2>你想看的艺术清单:</h2>
<ItemList artworks={yourList} onToggle={handleToggleYourList} />
</>
);
}
function ItemList({ artworks, onToggle }) {
return (
<ul>
{artworks.map(artwork => (
<li key={artwork.id}>
<label>
<input type='checkbox' checked={artwork.seen} onChange={e => onToggle(artwork.id, e.target.checked)} />
{artwork.title}
</label>
</li>
))}
</ul>
);
}
请注意当使用 Immer 时,类似 artwork.seen = nextSeen
这种会产生 mutation 的语法不会再有任何问题了:
updateMyTodos(draft => {
const artwork = draft.find(a => a.id === artworkId);
artwork.seen = nextSeen;
});
这是因为你并不是在直接修改_原始的_ state,而是在修改 Immer 提供的一个特殊的 draft
对象。同理,你也可以为 draft
的内容使用 push()
和 pop()
这些会直接修改原值的方法。
在幕后,Immer 总是会根据你对 draft
的修改来从头开始构建下一个 state。这使得你的事件处理程序非常的简洁,同时也不会直接修改 state。
下篇地址