useState vs. useRef:相似性、差异性和使用案例

3,604 阅读13分钟

这篇文章解释了React HooksuseStateuseRef 。你将学习它们的基本用法,并了解这两个Hooks的不同使用情况。

你可以找到作为CodeSandbox的一部分的例子。要想看到不同的例子,只需改编以下一行,在App.js:

export default AppDemo6; // change to AppDemo<Nr>

了解useState 钩子

钩子 [useState](https://reactjs.org/docs/hooks-state.html)钩子能够为功能组件开发组件状态。在React 16.8之前,只有基于类的组件才能实现组件的本地状态。

看一下下面的代码。

import { useState } from "react";
function AppDemo1() {
  const stateWithUpdater = useState(true);
  const darkMode = stateWithUpdater[0];
  const darkModeUpdater = stateWithUpdater[1];
  return (
    <div>
      <p>{darkMode ? "dark mode on" : "dark mode off"}</p>
      <button onClick={() => darkModeUpdater(!darkMode)}>
        toggle dark mode
      </button>
    </div>
  );
}

useState 钩子返回一个有两个项目的数组。在这个例子中,我们实现了一个布尔组件的状态,并且我们用true 来初始化我们的Hook。

useState 的这个单一参数只在初始渲染周期中被考虑。然而,如果你需要一个计算复杂的初始值,那么你可以传递一个回调函数以达到性能优化的目的。

第一个数组项代表实际状态,第二个项构成状态更新函数。onClick 处理器演示了如何使用更新器函数 (darkModeUpdate) 来改变状态变量 (darkMode) 。像这样准确地更新你的状态是很重要的。下面的代码是非法的。

darkMode = true;

如果你对useState Hook有一些经验,你可能会对我的例子的语法感到疑惑。默认的用法是在数组析构的帮助下利用返回的数组项。

const [darkMode, setDarkMode] = useState(true);

作为提醒,在使用任何Hook时,遵循Hook的规则是至关重要的,不仅仅是useStateuseRef

  • Hooks只能从你的React函数的最高层调用
  • Hooks不能从嵌套代码中调用(如循环、条件)。
  • 钩子也可以在顶层从自定义钩子中调用

现在我们已经涵盖了基础知识,让我们通过下面的示例代码来看看Hook的各个方面。

import { useState } from "react";
import "./styles.css";
function AppDemo2() {
  console.log("render App");
  const [darkMode, setDarkMode] = useState(false);
  return (
    <div className={`App ${darkMode && "dark-mode"}`}>
      <h1>The useState hook</h1>
      <h2>Click the button to toggle the state</h2>
      <button
        onClick={() => {
          setDarkMode(!darkMode);
        }}
      >
        toggle dark mode
      </button>
    </div>
  );
}

如果darkMode 被设置为true ,那么在className 中加入了一个额外的CSS类(dark-mode),背景和文本的颜色被颠倒了。你可以从录音中的控制台输出看到,每次状态改变,相应的组件都会被重新渲染。

App Component Re-rendering on Every State Change

每一个状态的变化都会重新渲染App 组件。

React DevTools在这里特别有帮助,可以直观地突出组件渲染时的更新。在上一段录音中,你可以看到组件周围闪烁的边框,通知你另一个组件的渲染周期。

Enabling React DevTools Option to Highlight Re-renders

视觉上突出显示重新渲染的选项。

在下一个例子中,标题被提取到一个单独的React组件(Description)。

import { useState } from "react";
import "./styles.css";
function AppDemo3() {
  console.log("render App");
  const [darkMode, setDarkMode] = useState(false);
  return (
    <div className={`App ${darkMode && "dark-mode"}`}>
      <Description />
      <button
        onClick={() => {
          setDarkMode(!darkMode);
        }}
      >
        toggle dark mode
      </button>
    </div>
  );
}
const Description = () => {
  console.log("render Description");
  return (
    <>
      <h1>The useState hook</h1>
      <h2>Click the button to toggle the state</h2>
    </>
  );
};

只要用户点击按钮,App 组件就会被渲染,因为相应的点击处理程序会更新darkMode 状态变量。此外,子组件Description 也会被渲染。

App and Child Components Re-rendering on Every State Change

每一个状态变化都会重新渲染App 和子组件。

下图说明了一个状态变化会导致一个渲染周期。

Diagram of the React Hooks Lifecycle

一个状态的更新会重新渲染相应的组件。

为什么理解React Hooks的生命周期很重要?一方面,只要你不通过updater函数更新状态,状态就会在渲染过程中被保留下来,这本身就会触发一个新的渲染周期。

使用useState Hook与useEffect

另一个需要理解的重要概念是 [useEffect](https://reactjs.org/docs/hooks-effect.html)Hook,你很可能要在你的应用程序中使用它来调用异步代码(例如,获取数据)。正如你在前面的图中看到的,useStateuseEffect Hooks 是紧密耦合的,因为状态变化可能会调用效果。

让我们看一下下面的例子。我们引入了两个额外的状态变量:loadinglang 。每当url 道具发生变化时,该效果就会被调用。它获取一个语言字符串(ende )并通过setLang 更新器函数更新状态。

根据语言的不同,标题内的英语或德语字符串会被呈现出来。此外,在获取的过程中,loading 状态被设置,根据其值(truefalse ),一个加载指示器被呈现出来,而不是标题。

import { useEffect, useState } from "react";
import "./styles.css";
  function App4({ url }) {
  console.log("render App");
  const [loading, setLoading] = useState(true);
  const [lang, setLang] = useState("de");
  const [darkMode, setDarkMode] = useState(false);
  useEffect(() => {
    console.log("useEffect");
    const fetchData = async function () {
      try {
        setLoading(true);
        const response = await axios.get(url);
        if (response.status === 200) {
          const { language } = response.data;
          setLang(language);
        }
      } catch (error) {
        throw error;
      } finally {
        setLoading(false);
      }
    };
    fetchData();
  }, [url]);
  return (
    <div className={`App ${darkMode && "dark-mode"}`}>
      {loading ? (
        <div>Loading...</div>
      ) : (
        <>
          <h1>
            {lang === "en"
              ? "The useState hook is awesome"
              : "Der useState Hook ist toll"}
          </h1>
          <button
            onClick={() => {
              setDarkMode(!darkMode);
            }}
          >
            toggle dark mode
          </button>
        </>
      )}
    </div>
  );
}

Setting Loading and Lang State Inside useEffect

useEffect 内设置加载和lang状态。

让我们假设我们想在获取了当前语言的时候切换黑暗模式。我们在更新语言后,添加一个对setDarkMode 更新器的调用。此外,我们需要将darkMode 状态作为一个依赖项添加到效果的依赖数组中。

为什么必须这样做超出了本文的范围,但你可以在我以前的文章中详细了解useEffect Hook

import { useEffect, useState } from "react";
import "./styles.css";
function AppDemo5({ url }) {
  console.log("render App");
  const [loading, setLoading] = useState(true);
  const [lang, setLang] = useState("de");
  const [darkMode, setDarkMode] = useState(false);
  useEffect(() => {
    console.log("useEffect");
    const fetchData = async function () {
      try {
        setLoading(true);
        const response = await axios.get(url);
        if (response.status === 200) {
          const { language } = response.data;
          setLang(language);
          setDarkMode(!darkMode);
        }
      } catch (error) {
        throw error;
      } finally {
        setLoading(false);
      }
    };
    fetchData();
  }, [url, darkMode]);
  return (
    <div className={`App ${darkMode && "dark-mode"}`}>
      {loading ? (
        <div>Loading...</div>
      ) : (
        <>
          <h1>
            {lang === "en"
              ? "The useState hook is awesome"
              : "Der useState Hook ist toll"}
          </h1>
          <button
            onClick={() => {
              setDarkMode(!darkMode);
            }}
          >
            toggle dark mode
          </button>
        </>
      )}
    </div>
  );
}

不幸的是,我们已经造成了一个无限的循环。

Incorrect Use of useEffect Causes an Infinite Loop of Renders

错误地使用状态与useEffect ,会导致无限循环。

这是为什么呢?因为我们在效果的依赖数组中添加了darkMode ,我们在效果中更新了这个确切的状态,效果再次被调用,再次更新状态,这样一直持续下去。

但是有一个办法!我们可以通过从以前的状态中计算新的状态来避免darkMode 作为效果的依赖。我们通过传递一个以先前状态为参数的函数,以不同方式调用setDarkMode 更新器。

修改后的useEffect 实现看起来像这样。

  useEffect(() => {
    console.log("useEffect");
    const fetchData = async function () {
      try {
        setLoading(true);
        const response = await axios.get(url);
        if (response.status === 200) {
          const { language } = response.data;
          setLang(language);
          setDarkMode((previous) => !previous); // no access of darkMode state
        }
      } catch (error) {
        throw error;
      } finally {
        setLoading(false);
      }
    };
    fetchData();
  }, [url]); // no darkMode dependency

与基于类的组件的区别

如果你已经使用React很久了,或者你目前正在处理遗留的代码,你知道基于类的组件。在基于类的组件中,你有一个代表组件状态的单一对象。要更新整体状态的一个片断,你可以利用通用的 [setState]([https://reactjs.org/docs/state-and-lifecycle.html](https://reactjs.org/docs/state-and-lifecycle.html))方法。

想象一下,我们只想更新darkMode 的状态变量。那么你可以只把更新的属性放到对象中;其余的状态不受影响。

this.setState({darkMode: false});

然而,对于功能组件,首选的方式是使用可以单独更新的原子状态变量。否则,你会很快发现自己处于泪水的谷底。

AppDemo6 相比,下面这个组件(AppDemo7 )只在状态管理方面进行了重构。我们使用一个状态对象(state),而不是三个具有原始数据类型的原子状态变量。

import { useEffect, useState } from "react";
import "./styles.css";
function AppDemo7({ url }) {
  const initialState = {
    loading: true,
    lang: "de",
    darkMode: true
  };
  const [state, setState] = useState(initialState);
  console.log("render App", state);
  useEffect(() => {
    console.log("useEffect");
    const fetchData = async function () {
      try {
        setState((prev) => ({
          loading: true,
          lang: prev.lang,
          darkMode: prev.darkMode
        }));
        const response = await axios.get(url);
        if (response.status === 200) {
          const { language } = response.data;
          setState((prev) => ({
            lang: language,
            darkMode: !prev.darkMode,
            loading: prev.loading
          }));
        }
      } catch (error) {
        throw error;
      } finally {
        setState((prev) => ({
          loading: false,
          lang: prev.lang,
          darkMode: prev.darkMode
        }));
      }
    };
    fetchData();
  }, [url]);
  return (
    <div className={`App ${state.darkMode && "dark-mode"}`}>
      {state.loading ? (
        <div>Loading...</div>
      ) : (
        <>
          <h1>
            {state.lang === "en"
              ? "The useState hook is awesome"
              : "Der useState Hook ist toll"}
          </h1>
          <button
            onClick={() => {
              setState((prev) => ({
                darkMode: !prev.darkMode,
                // lang: prev.lang,
                loading: prev.loading
              }));
            }}
          >
            toggle dark mode
          </button>
        </>
      )}
    </div>
  );
}

正如你所看到的,这段代码很乱,很难维护。它还包括一个在onClick 处理程序中被注释掉的属性所说明的错误。当用户点击按钮时,整体状态没有被正确计算。

在这种情况下,lang 属性是不存在的。这导致了一个错误,即导致文本以德语呈现,因为state.langundefined 。我希望我已经明确地表明,这是一个坏主意。顺便说一下,React团队也不建议这样做

了解useRef 钩子

钩子 [useRef](https://reactjs.org/docs/hooks-reference.html#useref)Hook类似于useState ,但不同的是😀 。在明确这一点之前,我将解释它的基本用法。

import { useRef } from 'react';
const AppDemo8 = () => {
  const ref1 = useRef();
  const ref2 = useRef(2021);
  console.log("render");
  console.log(ref1, ref2);
  return (
    <div>
      <h2>{ref1.current}</h2>
      <h2>{ref2.current}</h2>
    </div>
  );
};

结果并不引人注目,但显示了问题的关键所在。

useRef Values Are Stored in the Current Property

这些值被存储在current 属性中。

我们通过调用初始化了两个引用(又称refs)。钩子调用返回一个对象,该对象有一个属性current ,它存储了实际值。如果你向useRef(initialValue) 传递一个参数initialValue ,那么这个值就存储在current

这就是为什么第一个console.log 输出存储了undefined :因为我们调用Hook时没有任何参数。不要担心,我们可以在以后分配值。

要访问一个ref的值,你需要访问它的current 属性,就像我们在JJSX部分所做的那样。Refs在被定义后可以直接在初始渲染中使用。

但是为什么我们需要useRef ?为什么不使用普通的let 变量来代替呢?请稍安勿躁--我们会回来讨论这个问题。

浏览器的常见使用情况useRef

让我们看一下下面的例子。

import { useRef } from "react";
import "./styles.css";
const AppDemo9 = () => {
  const countRef = useRef(0);
  console.log("render");
  return (
    <div className="App">
      <h2>count: {countRef.current}</h2>
      <button
        onClick={() => {
          countRef.current = countRef.current + 1;
          console.log(countRef.current);
        }}
      >
        increase count
      </button>
    </div>
  );
};

我们的目标是定义一个叫做countRef 的Ref,用0 来初始化这个值,并在每次点击按钮时增加这个计数器变量。渲染的计数值应该更新。不幸的是,这并不奏效--甚至控制台的输出也证明了current 属性持有正确的更新。

Count Doesn't Update on Button Click

点击按钮时,计数没有更新。

正如你从我们的其他控制台输出渲染中看到的,我们的组件没有重新渲染。我们可以利用useState 来代替这种行为。

什么?所以useRef 是非常无用的?并非如此--它与其他触发重现的钩子结合使用很方便,例如useState, [useReducer](https://reactjs.org/docs/hooks-reference.html#usereducer),以及 [useContext](https://reactjs.org/docs/hooks-reference.html#usecontext).

你必须把useRef 作为你工具箱中的另一个工具,你必须了解何时使用它。还记得上面的组件生命周期图吗?在整个渲染周期中,Refs的值会持续存在(特别是current 属性)。这不是一个错误,而是一个特点。

考虑一下这样的情况:你想更新一个组件的数据(即它的状态变量)来触发一次渲染,以便更新用户界面。你也可以有这样的情况:你希望有同样的行为,但有一个例外:你不希望触发一个渲染周期,因为这可能会导致bug、尴尬的用户体验(例如,闪烁)或性能问题。

你可以把Refs看作是基于类的组件的实例变量。一个ref是一个通用的容器,用来存储任何种类的数据,比如原始数据或对象。

很好,我们将展示一个有用的例子。

import { useState } from "react";
import "./styles.css";
const AppDemo10 = () => {
  const [value, setValue] = useState("");
  console.log("render");
  const handleInputChange = (e) => {
    setValue(e.target.value);
  };
  return (
    <div className="App">
      <input value={value} onChange={handleInputChange} />
    </div>
  );
};

从下面的录音中可以看到,这个组件只是渲染了一个输入字段,并将其值存储在value 状态变量中。控制台的输出显示,AppDemo10 组件在每次按键时都会被重新渲染。

这可能是你想要的行为,例如,在每个字符上执行一个操作,如搜索。这被称为受控组件。然而,这可能正好相反,渲染变得有问题。那么你就需要一个不受控的组件

A Controlled Component Rendering on Every Keystroke

受控组件在每个按键上进行渲染。

让我们重写这个例子,使用一个不受控制的组件,useRef 。因此,我们需要一个按钮来更新组件的状态,并存储完全填充的输入字段。

import { useState, useRef } from "react";
import "./styles.css";
const AppDemo11 = () => {
  const [value, setValue] = useState("");
  const valueRef = useRef();
  console.log("render");
  const handleClick = () => {
    console.log(valueRef);
    setValue(valueRef.current.value);
  };
  return (
    <div className="App">
      <h4>Value: {value}</h4>
      <input ref={valueRef} />
      <button onClick={handleClick}>click</button>
    </div>
  );
};

有了这个解决方案,我们就不会在每个按键上引起渲染循环。在另一方面,我们需要用一个按钮来 "提交 "输入,以更新状态变量value 。你可以从控制台的输出中看到,第二次渲染首先发生在按钮的点击上。

An Uncontrolled Component Does Not Trigger a Re-render

一个不受控制的组件不会在变化时触发重新渲染。

顺便说一下,上面的例子显示了refs的第二个用例。

<input ref={valueRef} />

通过ref 属性,React提供了对React组件或HTML元素的直接访问。控制台输出显示,我们确实可以访问input 元素。该引用被存储在current 属性中。

这构成了useRef 的第二个用例,除了利用它作为一个通用容器在整个组件生命周期中持久化数据。如果你需要直接访问一个DOM元素,你可以利用ref 道具。下一个例子显示了如何在组件初始化后聚焦输入字段。

import { useEffect, useRef } from "react";
import "./styles.css";
const AppDemo12 = () => {
  const inputRef = useRef();
  console.log("render");
  useEffect(() => {
    console.log("useEffect");
    inputRef.current.focus();
  }, []);
  return (
    <div className="App">
      <input ref={inputRef} placeholder="input" />
    </div>
  );
};

useEffect 的回调中,我们调用本地的 [focus](https://developer.mozilla.org/en-US/docs/Web/API/HTMLOrForeignElement/focus)方法。

Adding Focus to an Input Field Via refs

在ref的帮助下聚焦一个输入字段。

在React项目中,当你需要直接访问DOM元素时,这种技术也被广泛用于与第三方(非React)组件相结合。

另一个常见的用例是当你需要前一个渲染周期的状态值时。下面的例子展示了如何做到这一点。当然,你也可以把这些逻辑提取到一个自定义的 [usePrevious](https://usehooks.com/usePrevious/)钩子。

import { useEffect, useState, useRef } from "react";
import "./styles.css";
const AppDemo13 = () => {
  console.log("render");
  const [count, setCount] = useState(0);
  // Get the previous value (was passed into hook on last render)
  const ref = useRef();
  // Store current value in ref
  useEffect(() => {
    console.log("useEffect");
    ref.current = count;
  }, [count]); // Only re-run if value changes
  return (
    <div className="App">
      <h1>
        Now: {count}, before: {ref.current}
      </h1>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
};

在最初的渲染之后,执行一个效果,将状态变量count 赋给ref.current 。因为没有发生额外的渲染,渲染的值是undefined 。由于对setCount 的调用,对按钮的点击引发了状态的更新。

接下来,用户界面被重新渲染,之前的标签显示了正确的值(0 )。渲染之后,另一个效果被调用。现在1 被分配给我们的ref,以此类推。

Accessing Previous State Via useRef

useRef 的帮助下访问以前的状态。

需要注意的是,所有的引用都需要在useEffect 回调或处理程序中得到更新。在渲染过程中突变 ref,即从刚才提到的那些地方以外的地方突变 ref,可能会带来bug。这一点也适用于useState ,也是如此。

为什么let 不能替代useRef

现在我还欠你一个解决方案,即为什么let 变量不能取代ref的概念。下一个例子从useEffect Hook里面用一个普通的JavaScript变量赋值代替了useRef 的使用。

import { useEffect, useState } from "react";
import "./styles.css";
const AppDemo14 = () => {
  console.log("render");
  const [count, setCount] = useState(0);
  let prevCount;
  useEffect(() => {
    console.log("useEffect", prevCount);
    prevCount = count;
  }, [count]);
  return (
    <div className="App">
      <h1>
        Now: {count}, before: {prevCount}
      </h1>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
};

然而,下面的记录会显示这并不奏效。控制台的输出加强了这个问题,因为useEffect 里面的赋值在每个新的渲染周期都会被覆盖。undefined 因为let prevCount; 而被隐式赋值。

A Normal Variable Assignment Cannot Replace useRef

一个正常的变量赋值不能取代useRef。

甚至强大的ESLintRules of Hooks插件也告诉你,我们应该利用useRef

Warning From the ESLint Rules of Hooks Plugin

ESLint插件警告你要使用变量而不是Refs。

useRefuseState 之间的区别一目了然

下面的区别已经被详细讨论过了,但在这里再一次以简洁的总结的形式呈现。

  • 两者都在渲染周期和UI更新期间保留其数据,但只有useState Hook及其更新函数会导致重新渲染
  • useRef 会返回一个对象,该对象有一个current 的属性来保存实际值。相比之下,useState 返回一个有两个元素的数组:第一项构成状态,第二项代表状态更新器函数
  • useRef'的current 属性是可变的,但useState'的状态变量不是。与useRefcurrent 属性不同,你不应该直接给useState 的状态变量赋值。相反,总是使用updater函数(即第二个数组项)。正如React团队在基于类的组件中的setState 文档中所建议的那样(但对于函数组件来说仍然如此),将状态视为不可变的变量
  • useStateuseRef 可以被认为是数据钩子,但只有useRef 可以用于另一个应用领域:获得对 React 组件或 DOM 元素的直接访问

结语

这篇文章讨论了useStateuseRef Hooks。在这一点上应该很清楚,没有所谓的好或坏的Hook。你的React应用需要这两种Hooks,因为它们是为不同的应用设计的。

如果你想更新数据并导致UI更新,useState 是你的Hook。如果你在整个组件的生命周期中需要某种数据容器,而不会在突变你的变量时引起渲染周期,那么useRef 是你的解决方案。

The postuseState vs. useRef:相似性、差异性和使用案例首次出现在LogRocket博客上。