How the useEffect Hook Works (with Examples)(译)

原文地址:How the useEffect Hook Works (with Examples)

想象一下:你有一个足够好的函数组件,并且有一天,你需要加一个生命周期函数到里头。

啊。

“也许我可以用某种方式解决它?” 最终变成“糟糕,我要将它转化成一个类组件”。

类组件继承自React.Component,将函数的主体复制黏贴到render方法中,然后将代码格式缩进,最后加上生命周期的方法。

useEffect 给你提供了一个更好的选择。

通过useEffect,你可以在函数组件内部直接操纵生命周期相关的事件。即,它们中的三个:componentDidMount, componentDidUpdate, and componentWillUnmount. 全部包含在一个函数中!让我们来看一个例子:

import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';

function LifecycleDemo() {
  // Pass useEffect a function
  useEffect(() => {
    // This gets called after every render, by default
    // (the first one, and every one after that)
    console.log('render!');

    // If you want to implement componentWillUnmount,
    // return a function from here, and React will call
    // it prior to unmounting.
    return () => console.log('unmounting...');
  })

  return "I'm a lifecycle demo";
}

function App() {
  // Set up a piece of state, so that we have
  // a way to trigger a re-render.
  const [random, setRandom] = useState(Math.random());

  // Set up another piece of state to keep track of
  // whether the LifecycleDemo is shown or hidden
  const [mounted, setMounted] = useState(true);

  // This function will change the random number,
  // and trigger a re-render (in the console,
  // you'll see a "render!" from LifecycleDemo)
  const reRender = () => setRandom(Math.random());

  // This function will unmount and re-mount the
  // LifecycleDemo, so you can see its cleanup function
  // being called.
  const toggle = () => setMounted(!mounted);

  return (
    <>
      <button onClick={reRender}>Re-render</button>
      <button onClick={toggle}>Show/Hide LifecycleDemo</button>
      {mounted && <LifecycleDemo/>}
    </>
  );
}

ReactDOM.render(<App/>, document.querySelector('#root'));

在CodeSandbox尝试一下.

单击Show/Hide按钮。 查看控制台。 它在消失之前打印“unmounting”,当它再次出现打印“render!” 。

Console output of clicking show/hide

现在,尝试一下Re-render按钮。每一次单击,它都会打印出“render!”和“umounting”,好像很奇怪...

单击重新渲染的控制台输出

为什么每次渲染都输出“unmounting”?

嗯,您可以(可选)从useEffect返回的清理函数不仅会在卸载组件时被调用。 每次效果生效前都会调用它-从上一次运行中清除。 实际上,它比componentWillUnmount生命周期更强大,因为如果需要,它可以让您在每个渲染之前和之后运行副作用。

不仅仅是生命周期

useEffect在每次渲染之后运行(默认情况下),并且可以选择在再次运行之前自行清理。

与其将useEffect当作一个可以完成3个独立生命周期工作的函数,不如将其简单地视为渲染后运行副作用的一种方式(包括您希望在每个操作之前进行的潜在清理)可能会更有帮助。 并在卸载之前。

每次进行渲染时阻止副作用

如果您想减少副作用的运行频率,可以提供第二个参数-一个数组。 将它们视为实现此效果的依赖项。 如果自上次以来某个依赖项已更改,则效果将再次运行。 (它还将在初始渲染后运行)

const [value, setValue] = useState('initial');

useEffect(() => {
  // This effect uses the `value` variable,
  // so it "depends on" `value`.
  console.log(value);
}, [value])  // pass `value` as a dependency

考虑该数组的另一种方式:它应该包含effect函数在周围范围中使用的每个变量。 那么,如果使用prop呢? 那在数组中。 是否使用state? 那在数组中。

useEffect不会主动 “监控”

一些框架是响应式的,这意味着它们会自动检测更改并在发生更改时更新UI。

React不会这样做-它只会响应状态更改而重新渲染。

useEffect也不会主动“监视”更改。 您可以将useEffect调用想像为以下伪代码:

let previousValues = [];
let hasRun = false;
function useEffect(effectFunc, dependencyArray = undefined) {
  // Let's pretend there's a function somewhere that will queue
  // this to run after the render is finished -- because we don't
  // want to run this effect NOW, we only want to queue it up.
  afterRenderIsDone(() => {
    // Check each dependency against the last time this was called
    for(let i = 0; i < dependencyArray.length; i++) {
      if(dependencyArray[i] !== previousValues[i]) {
        // One of the values has changed! Update them for next time,
        // and run the effect
        previousValues = dependencyArray;
        effectFunc();
        break;
      }
    }
  });
}

当您在组件中调用useEffect时,这实际上是在完成渲染后排队或安排可能运行的效果。

渲染完成后,useEffect将查看依赖项值的列表,如果其中任何一个已更改,则调用您的effect函数。

在挂载后只运行一次

您可以通过传递空数组[]的特殊值来表示“仅在挂载时运行,而在卸载时清除”。 因此,如果我们将上面的组件更改为像这样调用useEffect:

useEffect(() => {
  console.log('mounted');
  return () => console.log('unmounting...');
}, [])  // <-- add this empty array here

然后,它将在初始渲染后打印“mounted”,在整个生命周期中保持沉默,并在退出时打印“unmounting...”。

上面的伪代码不包含对此空数组功能的支持。 可能是这样的:

let previousValues = [];
let hasRun = false;
function useEffect(effectFunc, dependencyArray = undefined) {
  // Let's pretend there's a function somewhere that will queue
  // this to run after the render is finished -- because we don't
  // want to run this effect NOW, we only want to queue it up.
  afterRenderIsDone(() => {
    // 'undefined' is a special value, meaning "run every time"
    if(!dependencyArray) {
      effectFunc();
      return;
    }

    // empty array '[]' is also a special value: only run once
    if(dependencyArray.length === 0) {
      if(!hasRun) {
        hasRun = true;
        effectFunc();
      }
      return;
    }

    // Check each dependency against the last time this was called
    for(let i = 0; i < dependencyArray.length; i++) {
      if(dependencyArray[i] !== previousValues[i]) {
        // One of the values has changed! Update them for next time,
        // and run the effect
        previousValues = dependencyArray;
        effectFunc();
        break;
      }
    }
  });
}

请注意第二个参数:如果添加了一个依赖项,很容易忘记添加一个项目;如果您错过了一个依赖项,那么下次useEffect运行时该值将过时,并且可能会导致一些奇怪的问题。

useEffect何时运行?

默认情况下,useEffect在每次调用该组件的渲染之后运行。 通过示例最容易看到此时间。尝试下CodeSandbox上的交互式示例,确保你打开了控制台,你能看到这个时间。

在这个示例中,有3个嵌套组件,Top包含Middle,Middle包含Bottom。useEffect的时间取决于每个组件的渲染时间,并且初始将全部渲染3个组件。你会看到控制台打印出3条消息。

但是请注意,React是自下而上渲染的! 在这种情况下:底部,然后是中间,然后是顶部。 它是递归的-父级直到其所有子级都渲染完毕后才“完成”,并且useEffect仅在组件的渲染完成后才运行。

从那时起,将不会发生任何事情,直到您单击其中一个元素以增加其计数。 完成后,唯一会重新渲染的组件是您单击的组件及其下方的组件。 (请注意,如果您单击“底部”,您将不会看到“顶部”或“中间”的“已渲染”消息)

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

  useEffect(() => {
    console.log("Top rendered");
  });

  return (
    <div>
      <div onClick={() => setCount(count + 1)}>Top Level {count}</div>
      <Middle />
    </div>
  );
}

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

  useEffect(() => {
    console.log("Middle rendered");
  });

  return (
    <div>
      <div onClick={() => setCount(count + 1)}>Middle Level {count}</div>
      <Bottom />
    </div>
  );
}

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

  useEffect(() => {
    console.log("Bottom rendered");
  });

  return <div onClick={() => setCount(count + 1)}>Bottom Level {count}</div>;
}

在state变化时会运行useEffect

默认情况下,useEffect在每次渲染后运行,但是对于响应状态更改而运行一些代码也是完美的选择。 您可以通过将第二个参数传递给useEffect来限制效果的运行时间。

将第二个参数想像为“依赖项”数组–如果更改了变量,效果应重新运行。 这些可以是任何类型的变量:prop,state或其他任何变量。

在此示例中,有3个state变量和3个按钮。 该效果仅在count2更改时运行,否则将保持安静。 尝试交互式示例。

function ThreeCounts() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);
  const [count3, setCount3] = useState(0);

  useEffect(() => {
    console.log("count2 changed!");
  }, [count2]);

  return (
    <div>
      {count1} {count2} {count3}
      <br />
      <button onClick={() => setCount1(count1 + 1)}>Increment count1</button>
      <button onClick={() => setCount2(count2 + 1)}>Increment count2</button>
      <button onClick={() => setCount3(count3 + 1)}>Increment count3</button>
    </div>
  );
}

在prop变化时会运行useEffect

正如我们能够将useEffect设置为在状态变量更改时运行一样,使用props也可以做到这一点。 请记住,它们都是常规变量! useEffect可以在其中任何一个上触发。

在此示例中,PropChangeWatch组件正在接收2个props(a和b),并且其效果仅在更改值时才会运行(因为我们正在传递包含[a]作为第二个参数的数组)。

交互式示例中尝试一下.:

function PropChangeWatch({ a, b }) {
  useEffect(() => {
    console.log("value of 'a' changed to", a);
  }, [a]);

  return (
    <div>
      I've got 2 props: a={a} and b={b}
    </div>
  );
}

function Demo() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  return (
    <div>
      <PropChangeWatch a={count1} b={count2} />
      <button onClick={() => setCount1(count1 + 1)}>Increment count1</button>
      <button onClick={() => setCount2(count2 + 1)}>Increment count2</button>
    </div>
  );
}

专注于挂载

有时您只想在挂载时做一件小事,而做一件小事需要将一个函数重写为一个类。

在此示例中,让我们看一下如何结合使用useEffect和useRef钩子将输入控件集中在第一个渲染上。

import React, { useEffect, useState, useRef } from "react";
import ReactDOM from "react-dom";

function App() {
  // Store a reference to the input's DOM node
  const inputRef = useRef();

	// Store the input's value in state
  const [value, setValue] = useState("");

  useEffect(
    () => {
      // This runs AFTER the first render,
      // so the ref is set by now.
      console.log("render");
      // inputRef.current.focus();
    },
		// The effect "depends on" inputRef
    [inputRef]
  );

  return (
    <input
      ref={inputRef}
      value={value}
      onChange={e => setValue(e.target.value)}
    />
  );
}
ReactDOM.render(<App />, document.querySelector("#root"));

在顶部,我们使用useRef创建一个空的ref。 将其传递到输入的ref属性后,就需要在渲染DOM后对其进行设置。 而且,重要的是,useRef返回的值在渲染之间将保持稳定–不会更改。

因此,即使我们将[inputRef]作为useEffect的第二个参数传递,它在初始挂载时实际上只会运行一次。 这基本上是“ componentDidMount”(时间原因,我们将在后面讨论)。

为了证明这一点,请[尝试示例](https://codesandbox.io/s/z6pommq8km)。请注意它的聚焦方式(使用CodeSandbox编辑器时有点麻烦,但是请尝试单击右侧“浏览器”中的刷新按钮)。然后尝试在框中输入。 每个字符都会触发重新渲染,但是如果您查看控制台,则会看到“render”仅打印一次。

使用useEffect请求数据

让我们看看另一个常见的用例:请求数据并显示它。在类组件中,您需要将此代码放入componentDidMount方法中。为此,我们将使用useEffect。我们还需要useState来存储数据。

值得一提的是,当React新的Suspense功能的数据获取部分准备就绪时,这将是获取数据的首选方式。 从useEffect抓取有一个大难题(我们将继续介绍),而Suspense API的使用将变得更加容易。

这是一个从Reddit获取帖子并显示它们的组件:

import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom";

function Reddit() {
  // Initialize state to hold the posts
  const [posts, setPosts] = useState([]);

  // effect functions can't be async, so declare the
  // async function inside the effect, then call it
  useEffect(() => {
    async function fetchData() {
      // Call fetch as usual
      const res = await fetch(
        "https://www.reddit.com/r/reactjs.json"
      );

      // Pull out the data as usual
      const json = await res.json();

      // Save the posts into state
      // (look at the Network tab to see why the path is like this)
      setPosts(json.data.children.map(c => c.data));
    }

    fetchData();
  }); // <-- we didn't pass a value. what do you think will happen?

  // Render as usual
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

ReactDOM.render(
  <Reddit />,
  document.querySelector("#root")
);

您会注意到,我们没有在此处将第二个参数传递给useEffect。这是不好的。不要这样

不传递第二个参数将导致useEffect运行每个渲染。然后,当它运行时,它将获取数据并更新状态。然后,一旦状态更新,组件将重新渲染,这再次触发useEffect。您可以看到问题。

为了解决这个问题,我们需要传递一个数组作为第二个参数。数组应该是什么?

继续思考一下。

useEffect依赖的唯一变量是setPosts。因此,我们应该在此处传递数组[setPosts]。由于setPosts是useState返回的设置器,因此不会在每个渲染器中都重新创建它,因此效果只能运行一次。

有趣的事实:调用useState时,它返回的setter函数仅创建一次!每次渲染组件时,它都是完全相同的函数实例,这就是为什么效果可以安全依赖于一个实例的原因。这个有趣的事实对于useReducer返回的调度函数也是如此。

当数据变化时重新发送请求

让我们在示例上进行扩展,以涵盖另一个常见问题:如何在发生某些变化(例如用户ID或本例中为subreddit的名称)时重请求取数据。

首先,我们将Reddit组件更改为接受subreddit作为prop,基于该subreddit请求数据,并仅在prop更改时重新运行效果:

// 1. Destructure the `subreddit` from props:
function Reddit({ subreddit }) {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    async function fetchData() {
      // 2. Use a template string to set the URL:
      const res = await fetch(
        `https://www.reddit.com/r/${subreddit}.json`
      );

      const json = await res.json();
      setPosts(json.data.children.map(c => c.data));
    }

    fetchData();

    // 3. Re-run this effect when `subreddit` changes:
  }, [subreddit, setPosts]);

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

// 4. Pass "reactjs" as a prop:
ReactDOM.render(
  <Reddit subreddit='reactjs' />,
  document.querySelector("#root")
);

这仍然是硬编码的,但是现在我们可以通过将Reddit组件包装为一个组件来对其进行自定义,以使我们可以更改subreddit。 添加此新的App组件,并在底部进行渲染:

function App() {
  // 2 pieces of state: one to hold the input value,
  // another to hold the current subreddit.
  const [inputValue, setValue] = useState("reactjs");
  const [subreddit, setSubreddit] = useState(inputValue);

  // Update the subreddit when the user presses enter
  const handleSubmit = e => {
    e.preventDefault();
    setSubreddit(inputValue);
  };

  return (
    <>
      <form onSubmit={handleSubmit}>
        <input
          value={inputValue}
          onChange={e => setValue(e.target.value)}
        />
      </form>
      <Reddit subreddit={subreddit} />
    </>
  );
}

ReactDOM.render(<App />, document.querySelector("#root"));

尝试在CodeSandbox中运行这个示例

该应用程序在此处保持2种状态-当前输入值和当前subreddit。提交输入将“提交”子reddit,这将导致Reddit从新选择中重新获取数据。将输入包装在表格中,允许用户按Enter提交。

顺便说一句:仔细键入。没有错误处理。如果您输入不存在的subreddit,则该应用将崩溃。但是,实现错误处理将是一个很棒的练习! ;)

我们在这里只可以使用一种状态-存储输入,并将相同的值发送给Reddit-但是Reddit组件将在每次按键时获取数据。

顶部的useState可能看起来有些奇怪,尤其是第二行:

const [inputValue,setValue] = useState(“ reactjs”);
const [subreddit,setSubreddit] = useState(inputValue);

我们正在将“ reactjs”的初始值传递给第一状态,这很有意义。该价值永远不会改变。

但是第二行呢?如果初始状态改变怎么办? (并且,当您在框中键入内容时,它会显示)

请记住,useState是有状态的(有关useState的更多信息)。它只使用一次,即第一次渲染时的初始状态。之后,它将被忽略。因此,可以传递一个瞬态值(例如可能会更改的prop或其他变量)是安全的。

一百种用途

useEffect函数就像瑞士军刀的钩子。 它可以用于很多事情,从设置订阅到创建和清除计时器,再到更改引用的值。

不利的一件事是进行对用户可见的DOM更改。 计时的工作方式是,effect函数仅在浏览器完成布局和绘制后才会启动-如果您想进行视觉更改,为时已晚。

对于这些情况,React提供了useMutationEffect和useLayoutEffect钩子,除了它们被触发时机,它们的作用与useEffect相同。 看看docs for useEffect,尤其是有关the timing of effects的部分,如果您需要进行可见的DOM更改。

这似乎是一个额外的麻烦。 另一件事要担心。 不幸的是,这有点。 这个(heh)的积极副作用是,由于useEffect在布局和绘制之后运行,因此缓慢的效果不会使UI变得混乱。 不利的一面是,如果要将旧代码从生命周期转移到挂钩,则必须谨慎一点,因为这意味着useEffect在计时方面几乎等同于componentDidUpdate。

试用useEffect

您可以在启用了钩子的CodeSandbox中尝试使用useEffect。 一些想法...

  • 渲染输入框并使用useState存储其值。 然后在效果中设置document.title。 (例如Dan在React Conf上的演示

  • 制作一个自定义挂钩,以从URL提取数据

  • 将单击处理程序添加到文档,并在每次用户单击时打印一条消息。 (不要忘记清理处理程序!)

如果您需要灵感,请访问Nik Graf的React Hooks集合-目前star数量为440! 它们中的大多数很容易自行实现。 (例如useOnMount,我敢打赌,您可以根据您在本文中学到的知识实现该功能!)

学习React可能会很麻烦-太多的库和工具!
我的建议? 忽略所有人:)
有关循序渐进的方法,请访问我的Pure React研讨会

posted @ 2020-10-01 17:11  mingL  阅读(314)  评论(0编辑  收藏  举报