第二章 - 添加交互
State: 组件的记忆
组件通常需要根据交互更改屏幕上显示的内容。输入表单应该更新输入字段,单击轮播图上的“下一个”应该更改显示的图片,单击“购买”应该将商品放入购物车。组件需要“记住”某些东西:当前输入值、当前图片、购物车。在 React 中,这种组件特有的记忆被称为 state。
当普通变量无法满足时
以下是一个渲染雕塑图片的组件。点击 “Next” 按钮应该显示下一个雕塑并将 index
更改为 1
,再次点击又更改为 2
,以此类推。但这个组件现在不起作用(你可以试一试!):
import { sculptureList } from './data.js';export default function Gallery() {let index = 0;function handleClick() {index = index + 1;}let sculpture = sculptureList[index];return (<><button onClick={handleClick}>Next</button><h2><i>{sculpture.name} </i> by {sculpture.artist}</h2><h3> ({index + 1} of {sculptureList.length})</h3><img src={sculpture.url} alt={sculpture.alt}/><p>{sculpture.description}</p></>);
}
handleClick()
事件处理函数正在更新局部变量 index
。但存在两个原因使得变化不可见:
- 局部变量无法在多次渲染中持久保存。 当 React 再次渲染这个组件时,它会从头开始渲染——不会考虑之前对局部变量的任何更改。
- 更改局部变量不会触发渲染。 React 没有意识到它需要使用新数据再次渲染组件。
要使用新数据更新组件,需要做两件事:
- 保留 渲染之间的数据。
- 触发 React 使用新数据渲染组件(重新渲染)。
useState
Hook 提供了这两个功能:
- State 变量 用于保存渲染间的数据。
- State setter 函数 更新变量并触发 React 再次渲染组件。
添加一个 state 变量
要添加 state 变量,先从文件顶部的 React 中导入 useState
:
import { useState } from 'react';
然后,替换这一行:
let index = 0;
将其修改为
const [index, setIndex] = useState(0);
index
是一个 state 变量,setIndex
是对应的 setter 函数。
以下展示了它们在 handleClick()
中是如何共同起作用的:
function handleClick() {setIndex(index + 1);
}
遇见你的第一个Hook
在 React 中,useState
以及任何其他以“use
”开头的函数都被称为 Hook。
Hook 是特殊的函数,只在 React 渲染时有效(我们将在下一节详细介绍)。它们能让你 “hook” 到不同的 React 特性中去。
State 只是这些特性中的一个,你之后还会遇到其他 Hook。
陷阱
Hooks ——以 use
开头的函数——只能在组件或自定义 Hook 的最顶层调用。 你不能在条件语句、循环语句或其他嵌套函数内调用 Hook。Hook 是函数,但将它们视为关于组件需求的无条件声明会很有帮助。在组件顶部 “use” React 特性,类似于在文件顶部“导入”模块。
剖析 useState
当你调用 useState
时,你是在告诉 React 你想让这个组件记住一些东西:
const [index, setIndex] = useState(0);
useState
的唯一参数是 state 变量的初始值。在这个例子中,index
的初始值被useState(0)
设置为 0
。
每次你的组件渲染时,useState
都会给你一个包含两个值的数组:
- state 变量 (
index
) 会保存上次渲染的值。 - state setter 函数 (
setIndex
) 可以更新 state 变量并触发 React 重新渲染组件。
渲染过程
- 组件进行第一次渲染。 因为你将
0
作为index
的初始值传递给useState
,它将返回[0, setIndex]
。 React 记住0
是最新的 state 值。 - 你更新了 state。当用户点击按钮时,它会调用
setIndex(index + 1)
。index
是0
,所以它是setIndex(1)
。这告诉 React 现在记住index
是1
并触发下一次渲染。 - 组件进行第二次渲染。React 仍然看到
useState(0)
,但是因为 React 记住 了你将index
设置为了1
,它将返回[1, setIndex]
。 - 以此类推!
State 是隔离且私有的
State 是屏幕上组件实例内部的状态。换句话说,如果你渲染同一个组件两次,每个副本都会有完全隔离的 state!改变其中一个不会影响另一个。
在这个例子中,之前的 Gallery
组件以同样的逻辑被渲染了两次。试着点击每个画廊内的按钮。你会注意到它们的 state 是相互独立的:
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 (<section><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}/></section>);
}
这就是 state 与声明在模块顶部的普通变量不同的原因。 State 不依赖于特定的函数调用或在代码中的位置,它的作用域“只限于”屏幕上的某块特定区域。你渲染了两个 <Gallery />
组件,所以它们的 state 是分别存储的。
还要注意 Page
组件“不知道”关于 Gallery
state 的任何信息,甚至不知道它是否有任何 state。与 props 不同,state 完全私有于声明它的组件。父组件无法更改它。这使你可以向任何组件添加或删除 state,而不会影响其他组件。
如果你希望两个画廊保持其 states 同步怎么办?在 React 中执行此操作的正确方法是从子组件中删除 state 并将其添加到离它们最近的共享父组件中。接下来的几节将专注于组织单个组件的 state,但我们将在组件间共享 state 中回到这个主题。
摘要
- 当一个组件需要在多次渲染间“记住”某些信息时使用 state 变量。
- State 变量是通过调用
useState
Hook 来声明的。 - Hook 是以
use
开头的特殊函数。它们能让你 “hook” 到像 state 这样的 React 特性中。 - Hook 可能会让你想起 import:它们需要在非条件语句中调用。调用 Hook 时,包括
useState
,仅在组件或另一个 Hook 的顶层被调用才有效。 useState
Hook 返回一对值:当前 state 和更新它的函数。- 你可以拥有多个 state 变量。在内部,React 按顺序匹配它们。
- State 是组件私有的。如果你在两个地方渲染它,则每个副本都有独属于自己的 state。
渲染和提交
组件显示到屏幕之前,其必须被 React 渲染。理解这些处理步骤将帮助你思考代码的执行过程并能解释其行为。
想象一下,你的组件是厨房里的厨师,把食材烹制成美味的菜肴。在这种场景下,React 就是一名服务员,他会帮客户们下单并为他们送来所点的菜品。这种请求和提供 UI 的过程总共包括三个步骤:
- 触发 一次渲染(把客人的点单分发到厨房)
- 渲染 组件(在厨房准备订单)
- 提交 到 DOM(将菜品放在桌子上)
步骤1 :触发一次渲染
有两种原因会导致组件的渲染:
- 组件的 初次渲染。
- 组件(或者其祖先之一)的 状态发生了改变。
初次渲染
当应用启动时,会触发初次渲染。框架和沙箱有时会隐藏这部分代码,但它是通过调用 createRoot
方法并传入目标 DOM 节点,然后用你的组件调用 render
函数完成的:
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()
若干次:
export default function Gallery() {return (<section><h1>鼓舞人心的雕塑</h1><Image /><Image /><Image /></section>);
}function Image() {return (<imgsrc="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 节点。 例如,有一个组件,它每秒使用从父组件传递下来的不同属性重新渲染一次。注意,你可以添加一些文本到 <input>
标签,更新它的 value
,但是文本不会在组件重渲染时消失:
export default function Clock({ time }) {return (<><h1>{time}</h1><input /></>);
}
这个例子之所以会正常运行,是因为在最后一步中,React 只会使用最新的 time
更新 <h1>
标签的内容。它看到 <input>
标签出现在 JSX 中与上次相同的位置,因此 React 不会修改 <input>
标签或它的 value
!
尾声:浏览器绘制
在渲染完成并且 React 更新 DOM 之后,浏览器就会重新绘制屏幕。尽管这个过程被称为“浏览器渲染”(“browser rendering”),但我们还是将它称为“绘制”(“painting”),以避免在这些文档的其余部分中出现混淆。
摘要
- 在一个 React 应用中一次屏幕更新都会发生以下三个步骤:
- 触发
- 渲染
- 提交
- 你可以使用严格模式去找到组件中的错误
- 如果渲染结果与上次一样,那么 React 将不会修改 DOM
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);}}><textareaplaceholder="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 会更新界面以匹配返回的快照
作为一个组件的记忆,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>
随时间变化的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 变量的值永远不会在一次渲染的内部发生变化, 即使其事件处理函数的代码是异步的。在 那次渲染的 onClick
内部,number
的值即使在调用 setNumber(number + 5)
之后也还是 0
。它的值在 React 通过调用你的组件“获取 UI 的快照”时就被“固定”了。
React 会使 state 的值始终”固定“在一次渲染的各个事件处理函数内部。 你无需担心代码运行时 state 是否发生了变化。
摘要
- 设置 state 请求一次新的渲染。
- React 将 state 存储在组件之外,就像在架子上一样。
- 当你调用
useState
时,React 会为你提供该次渲染 的一张 state 快照。 - 变量和事件处理函数不会在重渲染中“存活”。每个渲染都有自己的事件处理函数。
- 每个渲染(以及其中的函数)始终“看到”的是 React 提供给这个 渲染的 state 快照。
- 你可以在心中替换事件处理函数中的 state,类似于替换渲染的 JSX。
- 过去创建的事件处理函数拥有的是创建它们的那次渲染中的 state 值。