使React的useEffect钩子不在初始渲染时运行

340

根据文档:

componentDidUpdate()在更新发生后立即调用。此方法不会在初始渲染时调用。

我们可以使用新的useEffect()钩子来模拟componentDidUpdate(),但似乎useEffect()会在每次渲染时运行,甚至是第一次渲染。怎样才能使它不在初始渲染时运行?

如下面的示例所示,componentDidUpdateFunction在初始渲染时被打印,但componentDidUpdateClass没有在初始渲染时打印。

function ComponentDidUpdateFunction() {
  const [count, setCount] = React.useState(0);
  React.useEffect(() => {
    console.log("componentDidUpdateFunction");
  });

  return (
    <div>
      <p>componentDidUpdateFunction: {count} times</p>
      <button
        onClick={() => {
          setCount(count + 1);
        }}
      >
        Click Me
      </button>
    </div>
  );
}

class ComponentDidUpdateClass extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
    };
  }

  componentDidUpdate() {
    console.log("componentDidUpdateClass");
  }

  render() {
    return (
      <div>
        <p>componentDidUpdateClass: {this.state.count} times</p>
        <button
          onClick={() => {
            this.setState({ count: this.state.count + 1 });
          }}
        >
          Click Me
        </button>
      </div>
    );
  }
}

ReactDOM.render(
  <div>
    <ComponentDidUpdateFunction />
    <ComponentDidUpdateClass />
  </div>,
  document.querySelector("#app")
);
<script src="https://unpkg.com/react@16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16.7.0-alpha.0/umd/react-dom.development.js"></script>

<div id="app"></div>


1
我可以问一下,在什么情况下根据渲染次数而不是显式状态变量(如count)来执行某些操作有意义? - Aprillion
@Aprillion,在我的情况下,需要更改一个H2的内容,该文本需要在项目列表之后更改,该列表为空并且一开始甚至不同。相同的列表在从API获取数据之前也为空,因此基于数组长度的正常条件呈现将覆盖初始值。 - Carmine Tambascia
19个回答

306

我们可以使用useRef hook来存储任何可变的值, 因此我们可以使用它来跟踪是否是第一次运行useEffect函数。

如果我们希望该效果在与componentDidUpdate相同的阶段运行,则可以改用useLayoutEffect

示例

const { useState, useRef, useLayoutEffect } = React;

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

  const firstUpdate = useRef(true);
  useLayoutEffect(() => {
    if (firstUpdate.current) {
      firstUpdate.current = false;
      return;
    }

    console.log("componentDidUpdateFunction");
  });

  return (
    <div>
      <p>componentDidUpdateFunction: {count} times</p>
      <button
        onClick={() => {
          setCount(count + 1);
        }}
      >
        Click Me
      </button>
    </div>
  );
}

ReactDOM.render(
  <ComponentDidUpdateFunction />,
  document.getElementById("app")
);
<script src="https://unpkg.com/react@16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16.7.0-alpha.0/umd/react-dom.development.js"></script>

<div id="app"></div>


13
我尝试用useState替换useRef,但是当我使用setter时会触发重新渲染,而当我将其赋值给firstUpdate.current时不会发生重新渲染,所以我猜这是唯一好的方法 :) - Aprillion
7
为什么在不改变或测量DOM的情况下要使用layout effect? - ZenVentzi
7
@ZenVentzi 在这个例子中并不是必需的,但问题是如何使用hooks模拟componentDidUpdate,所以我才使用了它。 - Tholle
1
我基于这个答案创建了一个自定义钩子在这里。感谢您的实现! - Patrick Roberts
7
最近的React18在开发模式下调用了两次useEffect,你可能想要添加一个cleanUp函数,以便继续阻止在第一次渲染时运行:useLayoutEffect(() => { if (firstUpdate.current) { firstUpdate.current = false; return; } console.log("componentDidUpdateFunction"); return () => (firstUpdate.current = true); }); - hane Smitter
显示剩余2条评论

175
你可以将其转化为自定义钩子(新的文档页面:使用自定义钩子重用逻辑,就像这样:
import React, { useEffect, useRef } from 'react';

const useDidMountEffect = (func, deps) => {
    const didMount = useRef(false);

    useEffect(() => {
        if (didMount.current) func();
        else didMount.current = true;
    }, deps);
}

export default useDidMountEffect;

使用示例:
import React, { useState, useEffect } from 'react';

import useDidMountEffect from '../path/to/useDidMountEffect';

const MyComponent = (props) => {    
    const [state, setState] = useState({
        key: false
    });    

    useEffect(() => {
        // you know what is this, don't you?
    }, []);

    useDidMountEffect(() => {
        // react please run me if 'key' changes, but not on initial render
    }, [state.key]);    

    return (
        <div>
             ...
        </div>
    );
}
// ...

6
这种方法会出现警告,提示依赖列表不是数组字面量。 - theprogrammer
1
我在我的项目中使用了这个钩子,但是没有看到任何警告,请您提供更多信息? - Mehdi Dehghani
2
@vsync 你正在考虑一个不同的情况,即你想在初始渲染时运行一次效果,以后不再运行。 - Programming Guy
2
@vsync 在 https://reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects 的注释部分中明确指出:“如果您想仅在挂载和卸载时运行效果并清理它,可以将空数组([])作为第二个参数传递。” 这符合我的观察行为。 - Programming Guy
3
只需使用 return func() 将返回值发送给 useEffect,以防它是析构函数(即清理函数)。 - skot
显示剩余11条评论

78

我写了一个简单的 useFirstRender 钩子来处理像聚焦表单输入这样的情况:

import { useRef, useEffect } from 'react';

export function useFirstRender() {
  const firstRender = useRef(true);

  useEffect(() => {
    firstRender.current = false;
  }, []);

  return firstRender.current;
}

它最初为true,然后在useEffect中切换为false,该函数仅运行一次,之后不再运行。

在您的组件中使用它:

const firstRender = useFirstRender();
const phoneNumberRef = useRef(null);

useEffect(() => {
  if (firstRender || errors.phoneNumber) {
    phoneNumberRef.current.focus();
  }
}, [firstRender, errors.phoneNumber]);

针对你的情况,你只需要使用 if(!firstRender){ ...


2
如果在useEffect的依赖数组中添加firstRender,它将在挂载时运行两次(第一次和当firstRender设置为false时)。我从我的代码中删除了它的依赖项,然后它就正常工作了。 - Rafael Duarte
@RafaelDuarte 我认为不会。据我所知,当Ref更新时,React不会触发重新渲染。如果firstRender是一个状态,那么它会这样做。我错了吗?编辑:哦,但是当钩子结果改变时,可能会重新渲染... - Mario Eis
从内存中,它会重新渲染。但这就是 if 的用途 :) - Marius Marais
因为布尔值在 ES 中是原始类型并作为值传递。所以 useEffect 不知道它的引用。 - Dima Vishnyakov

17

Tholle的答案相同的方法,但使用useState替代useRef

const [skipCount, setSkipCount] = useState(true);

...

useEffect(() => {
    if (skipCount) setSkipCount(false);
    if (!skipCount) runYourFunction();
}, [dependencies])

编辑

虽然这种方法也可以实现,但它涉及更新状态,这将导致组件重新渲染。如果您的组件及其所有子组件的useEffect都有依赖数组,那么这就无关紧要了。但请记住,任何没有依赖数组的useEffect(例如useEffect(() => {...}))都会再次运行。

使用和更新useRef将不会导致任何重新渲染。


10

@ravi,你的代码没有调用传入的卸载函数。这里是一个更完整的版本:

/**
 * Identical to React.useEffect, except that it never runs on mount. This is
 * the equivalent of the componentDidUpdate lifecycle function.
 *
 * @param {function:function} effect - A useEffect effect.
 * @param {array} [dependencies] - useEffect dependency list.
 */
export const useEffectExceptOnMount = (effect, dependencies) => {
  const mounted = React.useRef(false);
  React.useEffect(() => {
    if (mounted.current) {
      const unmount = effect();
      return () => unmount && unmount();
    } else {
      mounted.current = true;
    }
  }, dependencies);

  // Reset on unmount for the next mount.
  React.useEffect(() => {
    return () => mounted.current = false;
  }, []);
};


1
@KevDing,只需要在调用时省略 dependencies 参数即可。 - Whatabrain
你可以删掉第二个 useEffect。它没有任何作用。你重置的那个引用会直接被回收。如果有下一个挂载点,它会发生在一个新的钩子实例上,并拥有属于自己的 mounted 引用,初始化为 false。 - skot
@skot 我写了第二个效果是为了完整性,但在下一个版本的React中,多次挂载和卸载将成为可能,这使得它更加必要。你对卸载简化的发现很好。你是对的! - Whatabrain
@Whatabrain,下一个版本是真的吗?我非常想了解这个...听起来像是范式的根本性变革。你有文档或公告的链接吗? - skot
@skot 这是 React 18 中严格模式的一部分 -- https://reactjs.org/blog/2022/03/29/react-v18.html#new-strict-mode-behaviors - Whatabrain
显示剩余4条评论

6
function useEffectAfterMount(effect, deps) {
  const isMounted = useRef(false);

  useEffect(() => {
    if (isMounted.current) return effect();
    else isMounted.current = true;
  }, deps);

  // reset on unmount; in React 18, components can mount again
  useEffect(() => {
    isMounted.current = false;
  });
}

我们需要返回effect()的结果,因为它可能是一个清除函数。但我们无需确定它是否是清除函数,只需将其传递并让useEffect来确定。

在此帖子早期版本中,我说重置引用(isMounted.current = false)是不必要的。但在React 18中,这是必要的,因为组件可以重新挂载其先前的状态(感谢@Whatabrain)。


3
您可以使用自定义钩子在挂载后运行useEffect。
const useEffectAfterMount = (cb, dependencies) => {
  const mounted = useRef(true);

  useEffect(() => {
    if (!mounted.current) {
      return cb();
    }
    mounted.current = false;
  }, dependencies); // eslint-disable-line react-hooks/exhaustive-deps
};

这是TypeScript版本:
const useEffectAfterMount = (cb: EffectCallback, dependencies: DependencyList | undefined) => {
  const mounted = useRef(true);

  useEffect(() => {
    if (!mounted.current) {
      return cb();
    }
    mounted.current = false;
  }, dependencies); // eslint-disable-line react-hooks/exhaustive-deps
};

3

我认为创建一个自定义hook会过度,而且我不想通过使用与布局无关的useLayoutEffect hook来混淆我的组件的可读性,所以在我的情况下,我只是检查了我的有状态变量selectedItem的值是否为其原始值,以确定它是否是初始渲染:

export default function MyComponent(props) {
    const [selectedItem, setSelectedItem] = useState(null);

    useEffect(() => {
        if(!selectedItem) return; // If selected item is its initial value (null), don't continue
        
        //... This will not happen on initial render

    }, [selectedItem]);

    // ...

}

如果在组件的生命周期内,这个特定的状态变量再次改变为这个值怎么办?这是一个情况,其中某人百分之百知道永远不会发生,因为作为 useEffect 的依赖项,它的目的是控制状态变化的副作用。 - Vaggelis
如果你在问“如果selectedItem再次变成null怎么办”,那么你需要做到以下两点中的任意一点:A) 确保它永远不会再被设置为null,或者B) 使用useState()初始化一个非null的值,例如-1,你知道它永远不会被再次设置为null - A__
1
只有这个对我有效。在我的情况下,我给出了 value > 0 - Atal Shrivastava

3

一个简单的方法是在你的组件外创建一个let,并将其设置为true。

然后说如果它是true,就将其设置为false,然后返回(停止)useEffect函数。

就像这样:


    import { useEffect} from 'react';
    //your let must be out of component to avoid re-evaluation 
    
    let isFirst = true
    
    function App() {
      useEffect(() => {
          if(isFirst){
            isFirst = false
            return
          }
    
        //your code that don't want to execute at first time
      },[])
      return (
        <div>
            <p>its simple huh...</p>
        </div>
      );
    }

与@Carmine Tambasciabs的解决方案相似,但不使用状态:)


2

这是我使用 typescript 创造的最佳实现。基本上,想法是相同的,使用 Ref 但同时也考虑了 useEffect 返回的回调函数,在组件卸载时执行清理操作。

import {
  useRef,
  EffectCallback,
  DependencyList,
  useEffect
} from 'react';

/**
 * @param effect 
 * @param dependencies
 *  
 */
export default function useNoInitialEffect(
  effect: EffectCallback,
  dependencies?: DependencyList
) {
  //Preserving the true by default as initial render cycle
  const initialRender = useRef(true);

  useEffect(() => {
    let effectReturns: void | (() => void) = () => {};

    // Updating the ref to false on the first render, causing
    // subsequent render to execute the effect
    if (initialRender.current) {
      initialRender.current = false;
    } else {
      effectReturns = effect();
    }

    // Preserving and allowing the Destructor returned by the effect
    // to execute on component unmount and perform cleanup if
    // required.
    if (effectReturns && typeof effectReturns === 'function') {
      return effectReturns;
    } 
    return undefined;
  }, dependencies);
}

您可以像使用useEffect钩子一样简单地使用它,但这次它不会在初始渲染时运行。以下是如何使用此钩子的方法。

useNoInitialEffect(() => {
  // perform something, returning callback is supported
}, [a, b]);

如果您使用ESLint并希望在自定义钩子中使用react-hooks/exhaustive-deps规则,请按以下方式操作:
{
  "rules": {
    // ...
    "react-hooks/exhaustive-deps": ["warn", {
      "additionalHooks": "useNoInitialEffect"
    }]
  }
}

你需要在返回值周围加上所有的逻辑吗?你不能只是 return effect() 吗? - skot

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接