简单易懂的React状态管理Hook useState详解

202 阅读10分钟

useState是React提供的Hook中最基本的一个Hook,它可以让你在函数组件中使用状态变量。本文将详细讲解useState Hook的基本概念,使用方法和应用技巧。让你能够更好地利用useState Hook进行状态管理。

useState基本介绍

useState接受一个初始状态作为参数,返回一个包含当前状态值和一个更新状态的函数的数组。

【定义方式】

// 这里可以任意命名,因为返回的是数组,数组解构
const [state, setState] = useState(initialState);

【例如】

// 声明一个名为count的状态变量,初始值为0
const [count, setCount] = useState(0);

Tip:

  • 可以通过调用更新状态的函数【setState】来改变状态值,并触发组件重新渲染。

  • 可以在事件处理函数中或其他一些地方调用这个函数。它类似 class 组件的 this.setState,但是它不会把新的 state 和旧的 state 进行合并,而是直接替换

set函数

调用 set 函数 不会 改变已经执行的代码中当前的 state,即调用 set 函数不能改变运行中代码的状态。

这是什么意思呢?

比如现在定义了一个状态是

const [count, setCount] = useState(0);

然后通过定义一个事件来改变它的值

function handleClick() {
  console.log(count);  // 0

  setCount(count + 1); // 请求使用 1 重新渲染
  console.log(count);  // 仍然是 0!

  setTimeout(() => {
    console.log(count); // 还是 0!
  }, 5000);
}

运行代码,通过打印会发现,这个事件执行之后,会改变界面上值的渲染,但是如果在这个事件中直接打印count,不论通过什么方式,打印出来的值都是原来得的值。

构建state的原则

当你编写一个存有 state 的组件时,你需要选择使用多少个 state 变量。那要怎么构建出良好的state呢,可以参考以下几个原则:

合并关联的 state

如果某两个 state 变量总是一起变化,则将它们统一成一个 state 变量。

比如:

// ⭕️ bad 
const [x, setX] = useState(0);
const [y, setY] = useState(0);

// ✅ good
const [position, setPosition] = useState({ x: 0, y: 0 });

避免矛盾的 state

如果多个state之间始终是互斥的,则可以选择使用一个状态来代替,例如:

// ⭕️ bad 
const [text, setText] = useState('');
const [isSending, setIsSending] = useState(false);
const [isSent, setIsSent] = useState(false);

async function handleSubmit(e) {
  e.preventDefault();
  setIsSending(true);
  await sendMessage(text);
  setIsSending(false);
  setIsSent(true);
}

if (isSent) {
  return <h1>Thanks for feedback!</h1>
}

以上示例中,setIsSending-发送中,setIsSent-已发送,这两个状态是永远不可能为true的,所以可以改用一个 status 变量来代替它们,这个 state 变量可以采取三种有效状态其中之一:'typing' (初始), 'sending', 和 'sent':

// ✅ good
const [text, setText] = useState('');
const [status, setStatus] = useState('typing');

async function handleSubmit(e) {
  e.preventDefault();
  setStatus('sending');
  await sendMessage(text);
  setStatus('sent');
}

const isSending = status === 'sending';
const isSent = status === 'sent';

if (isSent) {
  return <h1>Thanks for feedback!</h1>
}

避免冗余的 state

如果你能在渲染期间从组件的 props 或其现有的 state 变量中计算出一些信息,则不应将这些信息放入该组件的 state 中。

示例:

一个关于计算的常见示例

//  ⭕️️ bad
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');

function handleFirstNameChange(e) {
  setFirstName(e.target.value);
  setFullName(e.target.value + ' ' + lastName);
}

function handleLastNameChange(e) {
  setLastName(e.target.value);
  setFullName(firstName + ' ' + e.target.value);
}

示例中有三个 state 变量:firstName、lastName 和 fullName。然而,fullName 是多余的。在渲染期间,始终可以从 firstName 和 lastName 中计算出 fullName,因此需要把它从 state 中删除。

// ✅  good
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');

const fullName = firstName + ' ' + lastName;

function handleFirstNameChange(e) {
  setFirstName(e.target.value);
}

function handleLastNameChange(e) {
  setLastName(e.target.value);
}

为什么,如果能在渲染期间从组件的 props 或其现有的 state 变量中计算出一些信息,则不应将这些信息放入该组件的 state 中

  • 避免不必要的计算和内存消耗。

    在React中,组件的state用于存储与组件的可变性相关的数据。当state发生变化时,React会重新渲染组件,以确保UI与最新的数据保持同步。然而,在渲染期间,React会执行大量的优化操作,其中包括对组件的props和state进行比较,以确定是否需要重新渲染。 假设我们将计算出的信息存储在组件的state中。每当组件的props或state发生变化时,React会重新计算这些信息,并将其存储在state中。这会导致不必要的计算和内存消耗。

  • 代码维护性问题

    我们在组件中添加更多的状态变量时,我们需要更多地关注它们之间的依赖关系和状态更新的逻辑。这可能导致代码变得难以维护和理解。

示例:

不要在 state 中镜像 props

// ⭕️ bad
function Message({ messageColor }) {
  const [color, setColor] = useState(messageColor);
}

// ✅  good
function Message({ messageColor }) {
  const color = messageColor;
}

bad写法存在一个问题,它会失去与父组件的同步,因为state 仅在第一次渲染期间初始化。

如果父组件稍后传递不同的 messageColor 值(例如,将其从 'blue' 更改为 'red'),则 color state 变量将不会更新!

但是这种写法也不算是错误,但你只需要props的初始值时,你可以这样写。

function Message({ initialColor }) {
// 这个 `color` state 变量用于保存 `initialColor` 的 **初始值**。
// 对于 `initialColor` 属性的进一步更改将被忽略。
const [color, setColor] = useState(initialColor);
}

删除不必要的state变量

例如,现在有一个表单,它的所有状态如下

【图取官网】

image-20230906101826889

现在先试着列出所有的 state,确保所有可能的视图状态都囊括其中:

const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);

现在我们可以来优化这些 state 变量,要怎么优化呢,可以根据这几个问题来判断是否需要这state

  • 这个 state 是否会导致矛盾?

    例如,isTyping 与 isSubmitting 的状态不能同时为 true。

    矛盾的产生通常说明了这个 state 没有足够的约束条件。两个布尔值有四种可能的组合,但是只有三种对应有效的状态。为了将“不可能”的状态移除,可以将 'typing'、'submitting' 以及 'success' 这三个中的其中一个与 status 结合。

  • 相同的信息是否已经在另一个 state 变量中存在?

    例如:isEmpty 和 isTyping 不能同时为 true。

    通过使它们成为独立的 state 变量,可能会导致它们不同步并导致 bug。幸运的是,你可以移除 isEmpty 转而用 message.length === 0。

  • 你是否可以通过另一个 state 变量的相反值得到相同的信息?

    isError 是多余的,因为你可以检查 error !== null。

在清理之后,现在只剩下 3 个(从原本的 7 个!)必要的 state 变量

const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'

组件状态共享

有的时候会希望两个组件的状态始终同步更改。要实现这一点,可以将相关 state 从这两个组件上移除,并把 state 放到它们的公共父级,再通过 props 将 state 传递给这两个组件。这被称为“状态提升”

折叠面板是一个比较常见的组件,可以通过折叠面板来收纳内容区域

示例1)

实现一个折叠面板,可同时展开多个面板,面板之间不影响

import { useState } from 'react';

function Panel({ title, children }) {
  const [isActive, setIsActive] = useState(false);
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={() => setIsActive(true)}>
          显示
        </button>
      )}
    </section>
  );
}

export default function Accordion() {
  return (
    <>
      <Panel title="关于">
        阿拉木图人口约200万,是哈萨克斯坦最大的城市。它在 1929 年到 1997 年间都是首都。
      </Panel>
      <Panel title="词源">
        这个名字来自于 <span lang="kk-KZ">алма</span>,哈萨克语中“苹果”的意思,经常被翻译成“苹果之乡”。事实上,阿拉木图的周边地区被认为是苹果的发源地,<i lang="la">Malus sieversii</i> 被认为是现今苹果的祖先。
      </Panel>
    </>
  );
}

在这个例子中,父组件 Accordion 渲染了 2 个独立的 Panel 组件。每个 Panel 组件都有一个布尔值 isActive,用于控制其内容是否可见。点击其中一个面板中的按钮并不会影响另外一个,他们是独立的。

示例2)

实现手风琴效果,每次只能展开一个面板

import { useState } from 'react';


function Panel({
  title,
  children,
  isActive, // 把控制权交给父组件,通过父组件的props传值来控制isActive
  onShow  // 向下传递事件处理函数,让子组件可以修改父组件的状态
}) {
  return (
    <section className="panel">
      <h3>{title}</h3>
      {isActive ? (
        <p>{children}</p>
      ) : (
        <button onClick={onShow}>
          显示
        </button>
      )}
    </section>
  );
}

export default function Accordion() {
  // 一次只能激活一个面板。所以Accordion 这个父组件需要记录哪个面板是被激活的面板
  const [activeIndex, setActiveIndex] = useState(0);
  return (
    <>
      <Panel
        title="关于"
        isActive={activeIndex === 0}
        onShow={() => setActiveIndex(0)}
      >
        阿拉木图人口约200万,是哈萨克斯坦最大的城市。它在 1929 年到 1997 年间都是首都。
      </Panel>
      <Panel
        title="词源"
        isActive={activeIndex === 1}
        onShow={() => setActiveIndex(1)}
      >
        这个名字来自于 <span lang="kk-KZ">алма</span>,哈萨克语中“苹果”的意思,经常被翻译成“苹果之乡”。事实上,阿拉木图的周边地区被认为是苹果的发源地,<i lang="la">Malus sieversii</i> 被认为是现今苹果的祖先。
      </Panel>
    </>
  );
}

受控组件和非受控组件

  • “受控”(由 prop 驱动),当组件中的重要信息是由 props 而不是其自身状态驱动时,就可以认为该组件是“受控组件”,它允许父组件完全指定其行为,例如示例2
  • “不受控”(由 state 驱动),如示例1中,带有 isActive 状态变量的 Panel 组件就是不受控制的,因为其父组件无法控制面板的激活状态。

小技巧

使用惰性初始值

不要把只需要计算一次的的东西直接放在函数组件内部顶层 block 中。

我们通常会给useState一个初始值,比如

// ✅  这样写不会有什么性能问题
const [count, setCount] = useState(0);

但是实际上,我们的初始值有可能是通过复杂的计算得到的,比如

// ❌  Bad,会产生性能问题
const initalState = heavyCompute(() => { /* 这里做很多计算*/});
const [count, setCount] = useState(initalState);

上面这段代码有什么问题呢?

首先要知道,组件每次useState的时候组件都会重新渲染。那么 initalState 在每次 render 时都会被重新计算,这无疑会造成严重的性能问题。

而对于 useState 的初始值,只需要计算一次即可,这个时候就可以通过 useState 的惰性初始值来解决这个问题。

// ✅ 这样初始值就只会被计算一次了
const [state,useState] = useState(() => heavyCompute(() => { /* 这里做很多计算 */}););

使用更新函数

现在有这样一个state和点击事件,在点击事件中调用了3次更新,它的结果会是什么呢?

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>
    </>
  )
}

我们期望的是,点击+3的时候number递增三次,但是运行之后,会发现,无论你调用多少次 setNumber(number + 1),每次点击的结果都是加1。

这是因为设置 state 只会为下一次渲染变更 state 的值。比如在第一次渲染期间,number0。即便在调用了 setNumber(number + 1) 之后,本次number 的值也仍然是 0,所以一直都是

setNumber(0 + 1);
setNumber(0 + 1);
setNumber(0 + 1);

【问题解决】

那如果要在一个事件中多次更新某些 state应该怎么办呢?

可以使用 setNumber(n => n + 1) 更新函数来处理


export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      // 这样写,每次点击就会+3
      <button onClick={() => {
        setNumber(n => n + 1);
        setNumber(n => n + 1);
        setNumber(n => n + 1);
        // 设置 state 不会更改现有渲染中的变量,但会请求一次新的渲染。
        // 这个时候第一点击,显示的是3,但是如果在这里直接打印number,结果依然是0
        console.log('number: ', number);       
      }}>+3</button>
    </>
  )
}

在这里,n => n + 1 被称为 更新函数。当你将它传递给一个 state 设置函数时:

  1. React 会将此函数加入队列,以便在事件处理函数中的所有其他代码运行后进行处理。
  2. 在下一次渲染期间,React 会遍历队列并给你更新之后的最终 state。

【命名惯例】

通常可以通过相应 state 变量的第一个字母来命名更新函数的参数

setEnabled(e => !e);
setLastName(ln => ln.reverse());
setFriendCount(fc => fc * 2);

🎨【点赞】【关注】不迷路,更多前端干货等你解锁

往期推荐

👉 React小白快速入门教程

👉 React小白进阶之useEffect和ref

👉 ES6中一些很好用的数组方法

👉 echarts | 柱状图实用配置