React Hooks中useEffect的执行顺序和它内部的清理逻辑是什么?

What's useEffect execution order and its internal clean-up logic in react hooks?

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

If your effect returns a function, React will run it when it is time to clean up...

There is no special code for handling updates because useEffect handles them by default. It cleans up the previous effects before applying the next effects...

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

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

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(() => {
    console.log('useEffect')
    return () => {
      console.log('useEffect cleanup')
    }
  })

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

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

useLayoutEffect > requestAnimationFrame > useEffect

您遇到的问题是由于 loopRaf 在执行 useEffect 的清理函数之前请求另一个动画帧造成的。

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

Change useEffect to useLayoutEffect and it should solve your problem.

useEffectuseLayoutEffect 按照它们在您的代码中出现的顺序调用,就像 useState 调用一样。

您可以通过 运行 以下几行查看:

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

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

根据文档:

useEffect runs after react renders your component and ensures that your effect callback does not block browser painting. This differs from the behavior in class components where componentDidMount and componentDidUpdate run synchronously after rendering.

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

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

The signature is identical to useEffect, but it fires synchronously after all DOM mutations. Use this to read layout from the DOM and synchronously re-render. Updates scheduled inside useLayoutEffect will be flushed synchronously, before the browser has a chance to paint.

因此,如果您的效果是改变 DOM(通过 DOM 节点引用)并且 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>

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

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

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

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