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 每次点击时都会运行一个函数,事件 章节接下来会详细讲,现在你先姑且相信就好。这个函数调用 setCount(count + 1),这个调用做两件事。它存储新值,然后重新渲染 Counter,这样屏幕上就显示更新后的数字了。调用 setter 是向 React 发出信号,说明有东西改变了,组件应该再运行一次。

根据前一个值进行更新

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

jsx
setCount(c => c + 1)

这就是更新函数形式。React 用最新的值来调用你的函数,然后把你返回的东西作为下一个 state。每当新值是建立在旧值的基础上时就用它,特别是当你连续更新多次时。这两行代码看起来是一回事,但实际上不是:

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

count 的两次读取都看到这次渲染中的同一个值,所以合起来计数只增加了一。更新函数形式就能解决这个问题,因为每次调用都会收到前一次调用的结果:

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

现在计数增加了两次。反转布尔值的方式是一样的,这也是你最常见到更新函数形式的地方:setIsOpen(open => !open) 取当前值并返回它的反面。当你的新值取决于当前值时,更新函数形式是安全的默认选择。

State 是每个组件实例独立的

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

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

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

替换 state,不要修改它

你通过给 React 一个新值来更新 state,React 会将新值和旧值进行比较,以判断什么改变了。直接修改现有的值会跳过这个信号,所以屏幕会显示过期的内容。这对对象和数组特别重要。要向数组添加东西,应该构建一个新数组而不是向旧数组中 push:

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

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

对象也是一样的:把旧对象展开到一个新对象中,应用你的更改,然后从更新函数中返回它。把从 useState 获取的值视为 只读,始终给 setter 传递一个新的东西。

嵌套需要特别小心。展开一个对象只复制它的顶层,所以其中嵌套的对象仍然是同一个对象,修改那个嵌套对象会影响到原始对象:

jsx
// 修改了原始对象:嵌套的 address 对象仍然与新对象共享
user.address.city = '北京'
setUser({ ...user })

// 有效:每一层都有新对象,直到做出改变的地方
setUser(prev => ({
  ...prev,
  address: { ...prev.address, city: '北京' }
}))

对象周围的括号让箭头函数返回它,而不是把它当作代码块来读。每层直到做出改变的地方都需要自己的展开,这在嵌套加深时会变得不方便。当你发现自己在写一串这样的代码时,通常意味着 state 需要扁平化成更小的部分。

修改列表中的单个项

修改数组中已有的项是人们最常卡住的地方。工具是 map。遍历数组,对你要找的项返回一个修改后的副本,其他项保持原样返回:

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

Setter 采用更新函数形式,因为下一个数组是从当前数组构建的。三元运算符做选择:匹配的 todo 得到一个 done 被反转的副本,其他东西直接通过。那些直接通过的项仍然是它们一直以来的同一个对象,这很好。只有你改变的那一个需要是新的,加上装着它们的数组,map 为 setter 构建了那个新数组。

删除项用 filter,添加项用前面章节的展开。这三种方法涵盖了大部分列表更新。

当初始值计算成本高时

JavaScript 在每次渲染时都会计算你传给 useState 的参数,在 useState 本身运行之前,React 只在第一次时保留结果。对于像 0 这样的值,成本几乎没有。对于一个做真正工作的函数调用,它会在每次渲染时运行,结果在第一次之后就被丢弃了:

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

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

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

版本说明

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

有几个实用的习惯能在这里为你省麻烦。默认使用更新函数形式(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) // 仍然是这次渲染的值,不是新值
}

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

批处理是另一半的图景。React 将单个事件处理程序内发生的 state 更新分组,在处理程序完成后重新渲染一次,而不是在每个 setCount 之后。这就是为什么用 count + 1 连续调用 setter 两次仍然只增加一次值:两个调用都在任何重新渲染发生前都针对同一个快照排队。相比之下,更新函数被排队并彼此按顺序运行,所以它们是可组合的。从 React 18 起,这种批处理延伸到 promises、timeouts 和其他异步回调中的更新,所以相同的规则在远超普通事件处理程序的地方也适用。

JunoState 是组件的记忆 普通变量在函数每次运行时都会忘记它的值,而 useState 会记住它。你拿到值和一个 setter,调用那个 setter 既存储新值也会重绘组件。

当新值建立在旧值基础上时,用 setCount(c => c + 1) 这样你总是在用最新的值。当那个值是数组或对象时,给 setter 一个全新的、应用了你改变的那个。

JunoState 是组件的记忆useState 给你一个值和一个 setter,调用这个 setter 就是触发重新渲染的东西。当下一个值取决于当前值,或者你连续更新多次时,使用更新函数形式。

State 是每个实例独立的,所以两个相同的组件保持分离的值,你总是给 setter 传递一个新值而不是修改旧数组或对象。对于列表,这意味着 map:你改变的项的新副本,其他项保持不变。

JunoState 是组件的记忆 把 state 当作一个快照:在渲染期间固定不变,只在下一次渲染时更新,这就是为什么在设置之后立即读取它会得到旧值,为什么捕获的闭包会变陈旧。

React 批处理事件中的更新并只重新渲染一次,所以针对同一快照的 setter 调用不会堆积,而更新函数会。

当正确性取决于前一个值时,使用更新函数形式;当初始值计算成本高时,把函数本身交给 useState 这样 React 只会运行一次。

接下来:事件,你一直在写的那些 onClick 处理程序会得到一个恰当的讲解。