react 钩子中的 useEffect 执行顺序及其内部清理逻辑是啥?

Posted

技术标签:

【中文标题】react 钩子中的 useEffect 执行顺序及其内部清理逻辑是啥?【英文标题】:What's useEffect execution order and its internal clean-up logic in react hooks?react 钩子中的 useEffect 执行顺序及其内部清理逻辑是什么? 【发布时间】:2019-05-15 20:05:31 【问题描述】:

根据反应文档,useEffect 将在重新运行useEffect 部分之前触发清理逻辑。

如果你的效果返回一个函数,React 会在需要清理的时候运行它...

没有处理更新的特殊代码,因为useEffect 默认处理它们。它会在应用下一个效果之前清理之前的效果...

但是,当我在useEffect 中使用requestAnimationFramecancelAnimationFrame 时,我发现cancelAnimationFrame 可能无法正常停止动画。有时,我发现旧动画仍然存在,而下一个效果带来另一个动画,这导致我的 Web 应用程序性能问题(尤其是当我需要渲染大量 DOM 元素时)。

我不知道 react 钩子在执行清理代码之前是否会做一些额外的事情,这使得我的取消动画部分不能正常工作,useEffect 钩子会做一些类似于闭包的事情来锁定状态变量?

useEffect 的执行顺序和内部清理逻辑是什么?是不是我下面写的代码有问题,导致cancelAnimationFrame不能完美运行?

谢谢。

//import React,  useState, useEffect  from "react";

const useState, useEffect = React;

//import ReactDOM from "react-dom";

function App() 
  const [startSeconds, setStartSeconds] = useState(Math.random());
  const [progress, setProgress] = useState(0);

  useEffect(() => 
    const interval = setInterval(() => 
      setStartSeconds(Math.random());
    , 1000);

    return () => clearInterval(interval);
  , []);

  useEffect(
    () => 
      let raf = null;

      const onFrame = () => 
        const currentProgress = startSeconds / 120.0;
        setProgress(Math.random());
        // console.log(currentProgress);
        loopRaf();
        if (currentProgress > 100) 
          stopRaf();
        
      ;

      const loopRaf = () => 
        raf = window.requestAnimationFrame(onFrame);
        // console.log('Assigned Raf ID: ', raf);
      ;

      const stopRaf = () => 
        console.log("stopped", raf);
        window.cancelAnimationFrame(raf);
      ;

      loopRaf();

      return () => 
        console.log("Cleaned Raf ID: ", raf);
        // console.log('init', raf);
        // setTimeout(() => console.log("500ms later", raf), 500);
        // setTimeout(()=> console.log('5s later', raf), 5000);
        stopRaf();
      ;
    ,
    [startSeconds]
  );

  let t = [];
  for (let i = 0; i < 1000; i++) 
    t.push(i);
  

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <text>progress</text>
      t.map(e => (
        <span>progress</span>
      ))
    </div>
  );


ReactDOM.render(<App />,
document.querySelector("#root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.7.0-alpha.2/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.7.0-alpha.2/umd/react-dom.production.min.js"></script>
<div id="root"></div>

【问题讨论】:

您是否尝试对类组件做同样的事情?结果不同吗? useEffect(() =&gt; setStartSeconds(Math.random()); 你在创建效果时导致了更新,我很惊讶你没有得到一个无限循环,我认为 React 在这里保护你。 @Keith 他有一个空数组作为useEffect 调用的第二个参数,这使得它只在挂载和卸载时执行。该块只是初始化和清理。话虽如此,他本可以使用该值初始化 useState 调用。 @KyleRichardson 但是他的第二个效果有[startSeconds],所以无缘无故地有一个额外的渲染,正如他应该做的那样 -> const [startSeconds, setStartSeconds] = useState(Math.random()); 这可能不是问题的原因,但是对我来说,在效果初始化中改变状态感觉不对。 @Keith Ya 我知道,但这不是整个问题的原因。我同意这是不需要的重新渲染,应该在使用状态下初始化;但他遇到的问题是因为他在应该使用useLayoutEffect时使用useEffect 【参考方案1】:

将这三行代码放在一个组件中,您将看到它们的优先顺序。

  useEffect(() => 
    console.log('useEffect')
    return () => 
      console.log('useEffect cleanup')
    
  )

  window.requestAnimationFrame(() => console.log('requestAnimationFrame'))

  useLayoutEffect(() => 
    console.log('useLayoutEffect')
    return () => 
      console.log('useLayoutEffect cleanup')
    
  )

useLayoutEffect &gt; requestAnimationFrame &gt; useEffect

您遇到的问题是由loopRaf 在执行useEffect 的清理功能之前请求另一个动画帧引起的。

进一步的测试表明,useLayoutEffect 总是在 requestAnimationFrame 之前调用,并且它的清理函数在下一次执行之前调用,以防止重叠。

useEffect 更改为 useLayoutEffect 应该可以解决您的问题 问题。

useEffectuseLayoutEffect 按照它们在代码中出现的顺序被调用,用于类似的类型,就像 useState 调用一样。

您可以通过运行以下几行来看到这一点:

  useEffect(() => 
    console.log('useEffect-1')
  )
  useEffect(() => 
    console.log('useEffect-2')
  )
  useLayoutEffect(() => 
    console.log('useLayoutEffect-1')
  )
  useLayoutEffect(() => 
    console.log('useLayoutEffect-2')
  )

【讨论】:

非常感谢,我似乎没有注意到useEffectuseLayoutEffect 之间的用法差异,它现在对我有用。 喜欢这个答案,但希望得到文档或其他可靠参考资料的支持(如果可能的话)。谢谢!【参考方案2】:

在使用钩子并尝试实现生命周期功能时,您需要关注两种不同的钩子。

根据文档:

useEffect 在 react 渲染你的组件后运行,并确保 您的效果回调不会阻止浏览器绘画。这不同 从componentDidMountcomponentDidUpdate渲染后同步运行。

因此在这些生命周期中使用requestAnimationFrame 可以无缝工作,但与useEffect 有一点小故障。因此 useEffect 应该用于当您必须进行的更改不会阻止视觉更新时,例如在收到响应后进行导致 DOM 更改的 API 调用。

另一个不太流行但在处理可视 DOM 更新时非常方便的钩子是useLayoutEffectAs per the docs

签名与 useEffect 相同,但它是同步触发的 毕竟DOM突变。使用它从 DOM 中读取布局并 同步重新渲染。 useLayoutEffect 内安排的更新将 在浏览器有机会绘制之前同步刷新。

因此,如果您的效果正在改变 DOM(通过 DOM 节点 ref)并且 DOM 突变将在 DOM 节点被渲染到您的效果改变它的时间之间改变它的外观,那么 你不要不想使用useEffect。你会想要使用useLayoutEffect。否则,当您的 DOM 突变生效时,用户可能会看到闪烁,requestAnimationFrame 就是这种情况

//import React,  useState, useEffect  from "react";

const useState, useLayoutEffect = React;

//import ReactDOM from "react-dom";

function App() 
  const [startSeconds, setStartSeconds] = useState("");
  const [progress, setProgress] = useState(0);

  useLayoutEffect(() => 
    setStartSeconds(Math.random());

    const interval = setInterval(() => 
      setStartSeconds(Math.random());
    , 1000);

    return () => clearInterval(interval);
  , []);

  useLayoutEffect(
    () => 
      let raf = null;

      const onFrame = () => 
        const currentProgress = startSeconds / 120.0;
        setProgress(Math.random());
        // console.log(currentProgress);
        loopRaf();
        if (currentProgress > 100) 
          stopRaf();
        
      ;

      const loopRaf = () => 
        raf = window.requestAnimationFrame(onFrame);
        // console.log('Assigned Raf ID: ', raf);
      ;

      const stopRaf = () => 
        console.log("stopped", raf);
        window.cancelAnimationFrame(raf);
      ;

      loopRaf();

      return () => 
        console.log("Cleaned Raf ID: ", raf);
        // console.log('init', raf);
        // setTimeout(() => console.log("500ms later", raf), 500);
        // setTimeout(()=> console.log('5s later', raf), 5000);
        stopRaf();
      ;
    ,
    [startSeconds]
  );

  let t = [];
  for (let i = 0; i < 1000; i++) 
    t.push(i);
  

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <text>progress</text>
      t.map(e => (
        <span>progress</span>
      ))
    </div>
  );


ReactDOM.render(<App />,
document.querySelector("#root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.7.0-alpha.2/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.7.0-alpha.2/umd/react-dom.production.min.js"></script>
<div id="root"></div>

【讨论】:

感谢您的详细解释!好像没注意到useEffectuseLayoutEffect的用法区别,抱歉!【参考方案3】:

上述答案中不清楚的一点是,当您在混合中有多个组件时,效果的运行顺序。我们一直在做涉及通过 useContext 在父级和子级之间进行协调的工作,因此顺序对我们来说更重要。 useLayoutEffectuseEffect 在这方面的工作方式不同。

useEffect 在移动到下一个组件(深度优先)并执行相同操作之前运行清理和新效果。

useLayoutEffect 运行每个组件的清理(深度优先),然后运行所有组件的新效果(深度优先)。

render parent
render a
render b
layout cleanup a
layout cleanup b
layout cleanup parent
layout effect a
layout effect b
layout effect parent
effect cleanup a
effect a
effect cleanup b
effect b
effect cleanup parent
effect parent
const Test = (props) => 
  const [s, setS] = useState(1)

  console.log(`render $props.name`)

  useEffect(() => 
    const name = props.name
    console.log(`effect $props.name`)
    return () => console.log(`effect cleanup $name`)
  )

  useLayoutEffect(() => 
    const name = props.name
    console.log(`layout effect $props.name`)
    return () => console.log(`layout cleanup $name`)
  )

  return (
    <>
      <button onClick=() => setS(s+1)>update s</button>
      <Child name="a" />
      <Child name="b" />
    </>
  )


const Child = (props) => 
  console.log(`render $props.name`)

  useEffect(() => 
    const name = props.name
    console.log(`effect $props.name`)
    return () => console.log(`effect cleanup $name`)
  )

  useLayoutEffect(() => 
    const name = props.name
    console.log(`layout effect $props.name`)
    return () => console.log(`layout cleanup $name`)
  )

  return <></>

【讨论】:

以上是关于react 钩子中的 useEffect 执行顺序及其内部清理逻辑是啥?的主要内容,如果未能解决你的问题,请参考以下文章

react之Hook的useEffect详解

React/NextJS 使用 UseEffect 错误:渲染的钩子比上一次渲染期间更多

React父子组件中useEffect的正确执行顺序是啥?

React - nextjs 在 useEffect 中调用自定义钩子

如何将 React 钩子(useContext、useEffect)与 Apollo 反应钩子(useQuery)结合起来

如何在 React 函数组件中不使用 useEffect 钩子获取数据?