Skip to content
This page has been auto-translated and may contain errors.View in English

State

函数内的普通变量在函数执行完后就被遗忘了,所以无法保存随时间变化的值并显示在屏幕上。State 就是来填补这个空缺的:它让组件在多次渲染之间记住某个值。Props 是从外部传入的数据,组件永远不会改变它;而 state 是组件自己拥有并自己更新的数据。useState Hook 给了组件这种记忆能力,并给了你一个更新值的方式,这个方式还会告诉 React 重新绘制界面。

这是一个计数器的例子:

jsx
import { useState } from 'react'

function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

useState(0) 设置起始值,然后返回一对东西:当前的值 count 和改变它的函数 setCount。这两个名字由你决定;[value, setValue] 这种形式是约定俗成的。按钮的 onClick 每次点击时都会运行一个函数,Events 这一章会完整介绍它,现在先相信我就行。那个函数调用 setCount(count + 1),这个调用做了两件事。它存储新值,并重新渲染 Counter 让屏幕显示更新后的数字。调用 setter 就是告诉 React 有什么东西改变了,组件应该再运行一次。

基于上一个值来更新

你经常会看到 setter 被调用时传的是一个函数,而不是普通的值:

jsx
setCount(c => c + 1)

这就是 updater 形式。React 会用最新的值来调用你的函数,然后用你返回的值作为下一个 state。只要新值是根据旧值计算出来的,就用这种形式,特别是当你连续更新多次时。这两行看起来像是要做的事,但实际上做不了:

jsx
setCount(count + 1)
setCount(count + 1)

两次对 count 的读取在这次渲染中看到的是同一个值,所以合起来只会把 count 增加一。Updater 形式解决了这个问题,因为每次调用都会收到前一次的结果:

jsx
setCount(c => c + 1)
setCount(c => c + 1)

现在 count 会增加二。翻转布尔值也是一样的道理,而且是你最常见到 updater 形式的地方:setIsOpen(open => !open) 获取当前值然后返回它的反面。当你的新值取决于当前值时,updater 形式就是安全的默认做法。

State 是每个组件实例独有的

每次你渲染一个组件,它都会拥有自己独立的 state。同一个页面上的两个计数器各自保持自己的计数:

jsx
function App() {
  return (
    <>
      <Counter />
      <Counter />
    </>
  )
}

点击第一个按钮不会影响第二个。它们共享相同的 Counter 代码,但每一个都持有独立的值。State 属于屏幕上的每个实例,所以一个函数可以驱动许多独立的副本。

替换 state,不要修改它

你通过给 React 一个新值来更新 state,React 会比较新旧值来判断什么改变了。直接修改现有的值会跳过这个信号,所以屏幕会变得陈旧。这对对象和数组最重要。要往数组里添加东西,应该构建一个新数组而不是推入到旧数组:

jsx
// 跳过更新:React 永远看不到新值
todos.push(newTodo)
setTodos(todos)

// 有效:一个全新的数组
setTodos(prev => [...prev, newTodo])

对象也是一样:把旧对象展开到一个新对象里,应用你的改变,然后从 updater 返回那个新对象。把你从 useState 得到的值当作 只读的,总是给 setter 传一个新东西。

嵌套需要特别注意。展开一个对象只复制它的顶层,所以嵌套在里面的对象仍然是同一个对象,修改那个嵌套的对象会影响到原对象:

jsx
// 修改了原对象:嵌套的 address 对象在新旧之间仍然是共享的
user.address.city = 'Beijing'
setUser({ ...user })

// 有效:从顶层到改变处的每一层都是新对象
setUser(prev => ({
  ...prev,
  address: { ...prev.address, city: 'Beijing' }
}))

对象外面的圆括号是让箭头函数返回它,而不是把它读作一段代码。从顶层到改变处的每一层都需要自己的展开,这在嵌套变深时会变得很冗长。当你发现自己在写一串展开时,那通常意味着 state 想要被拆散成更小的片段。

修改列表中的一项

修改数组里已经存在的某一项是人们最容易卡住的地方。处理这个的工具是 map。遍历数组,对你要找的项返回一个改变后的副本,其他每一项原样返回:

jsx
function toggleTodo(id) {
  setTodos(prev => prev.map(todo =>
    todo.id === id ? { ...todo, done: !todo.done } : todo
  ))
}

Setter 采用 updater 形式,因为下一个数组是根据当前的数组构建的。三元运算符做了选择:匹配的 todo 得到一个副本,其中 done 被翻转,其他一切原样通过。那些通过的项是它们一直以来都是的同一个对象,这是可以的。只有你改变的那一项需要是新的,连同包含它的数组,而 map 为 setter 构建了那个新数组。

删除一项用 filter,添加一项用上一部分的展开。这三个方法合起来覆盖了大部分列表更新。

当初始值很昂贵时

JavaScript 在 useState 运行之前先计算你传给它的参数,React 只在第一次时保存结果。对于 0 这样的值,花费不了多少。对于做真实工作的函数调用,它会在每次渲染时都运行,而结果在第一次之后就被丢弃:

jsx
// buildBoard() 在每次渲染时都运行,结果在第一次之后被丢弃
const [board, setBoard] = useState(buildBoard())

// buildBoard 只在第一次渲染时被调用一次
const [board, setBoard] = useState(buildBoard)

第一行调用函数并把结果交给 React。第二行把函数本身交给 React,让 React 调用它一次。把这个留给真正花钱的工作,比如从存储读取,生成大结构,或繁重的计算。在 useState(0) 上用这个就是噪音。

版本说明

在类组件里,state 住在 this.state 里,你用 this.setState 更新它。useState 是函数组件的等价物,是现代 React 中保存 state 的标准方式。

有几个实用的习惯可以让你少麻烦。当下一个值是根据当前值构建时,默认用 updater 形式(setCount(c => c + 1)),当设置一个忽略旧值的全新值时用普通形式(setCount(0))。这一个规则就能避免大部分时序问题,不用你费脑子。

也要保持 state 最小化。只存储你无法从已有的东西算出来的东西。如果你在 state 里保存了一个项目列表,就不要也保存它们的数量在 state 里;在渲染时用 items.length 读取。每个额外的 state 都是另一个要保持同步的值,而重复的 state 正是两个副本偏离的地方。当好几个值总是一起改变时,单个对象或 useReducer Hook 常常比散开的一堆 useState 调用更稳定。

把 state 当作快照来想很有帮助。对于任何单次渲染,count 都是个固定的值,是在那次渲染开始时决定的,在它运行期间不会改变。setCount 不是重新赋值那个变量;它请求 React 再渲染一次,新值只在下一次渲染的快照里出现。所以在设置后立即读 state 会给你旧值:

jsx
function handleClick() {
  setCount(count + 1)
  console.log(count) // 仍然是这次渲染的值,不是新值
}

这也是陈旧闭包的来源。你在一次渲染期间定义的每个函数,事件处理器、Effect、定时器,都闭包了那次渲染的 state 值。如果它后来运行了,比如一个 setTimeout 回调,它仍然看到它捕获的 count,即使这个值早已改变。Updater 形式回避了这个问题,因为 setCount(c => c + 1) 要求 React 在它运行的那一刻提供当前值,而不是依赖捕获的那个。

批处理是图景的另一半。React 把同一事件处理器内发生的 state 更新分组,在处理器完成后重新渲染一次,而不是每次 setCount 后都重新渲染。这就是为什么用 count + 1 调用 setter 两次仍然只把值移动一:两次调用都在任何重新渲染发生前排队到同一个快照。相比之下,updater 函数是排队的,按顺序彼此运行,所以它们组合在一起。从 React 18 开始,这个批处理扩展到了 Promise、超时和其他异步回调里的更新,所以同样的规则在纯事件处理器之外也运作良好。

JunoState 是组件的记忆 普通变量在函数每次运行时都忘了它的值,而 useState 记住了。你拿回值和 setter,调用那个 setter 既存储新值也重绘组件。当新值是根据旧值构建时,用 setCount(c => c + 1) 这样你总是基于最新的在工作。当那个值是数组或对象时,给 setter 一个全新的,应用了你的改变。
JunoState 是组件的记忆useState 给你一个值和 setter,调用 setter 就触发重新渲染。当下一个值依赖于当前值或当你连续更新多次时,用 updater 形式。State 是按实例的,所以两个相同的组件保持独立的值,你总是给 setter 一个新值而不是修改旧的数组或对象。对于列表,这意味着 map:你改变的那一项的新副本,其他一切保持原样。
JunoState 是组件的记忆 把 state 当作快照:在一次渲染期间固定,只在下一次改变,这就是为什么在设置后立即读它会给旧值,为什么捕获的闭包会变陈旧。React 批处理事件中的更新并重新渲染一次,所以针对同一快照的 setter 调用不会堆积,而 updater 函数会。当正确性取决于前一个值时,用 updater 形式;当起始值是昂贵的构建时,给 useState 函数本身这样 React 只运行它一次。

下一步:Events,在那里你一直在写的那些 onClick 处理器会得到好好的解释。