当只有一个效果的 deps 发生变化,而其他的不发生变化时,对 useEffect Hook 做出react

IT技术 javascript reactjs react-hooks
2021-03-28 23:51:40

我有一个使用 Hooks 的功能组件:

function Component(props) {
  const [ items, setItems ] = useState([]);

  // In a callback Hook to prevent unnecessary re-renders 
  const handleFetchItems = useCallback(() => {
    fetchItemsFromApi().then(setItems);
  }, []);

  // Fetch items on mount
  useEffect(() => {
    handleFetchItems();
  }, []);

  // I want this effect to run only when 'props.itemId' changes,
  // not when 'items' changes
  useEffect(() => {
    if (items) {
      const item = items.find(item => item.id === props.itemId);
      console.log("Item changed to " item.name);
    }
  }, [ items, props.itemId ])

  // Clicking the button should NOT log anything to console
  return (
    <Button onClick={handleFetchItems}>Fetch items</Button>
  );
}

该组件items在挂载时获取一些并将它们保存到状态。

该组件接收一个itemIdprop(来自 React Router)。

每当props.itemId发生变化时,我希望它触发一个效果,在这种情况下将其记录到控制台。


问题是,由于效果也依赖于items,因此无论何时items发生变化,效果也会运行,例如当items按下按钮重新获取 时。

这可以通过将前props.itemId一个存储在单独的状态变量中并比较两者来解决,但这似乎是一个黑客并添加了样板。使用 Component 类可以通过比较 中的当前和以前的 props 来解决这个问题componentDidUpdate,但是使用功能组件是不可能的,这是使用 Hooks 的要求。


仅当其中一个参数发生变化时,触发依赖于多个参数的效果的最佳方法是什么?


附注。Hooks 是一种新事物,我认为我们都在尽最大努力弄清楚如何正确地使用它们,所以如果我的想法在你看来是错误的或尴尬的,请指出。

6个回答

React 团队表示,获取 prev 值的最佳方法是使用 useRef:https ://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state

function Component(props) {
  const [ items, setItems ] = useState([]);

  const prevItemIdRef = useRef();
  useEffect(() => {
    prevItemIdRef.current = props.itemId;
  });
  const prevItemId = prevItemIdRef.current;

  // In a callback Hook to prevent unnecessary re-renders 
  const handleFetchItems = useCallback(() => {
    fetchItemsFromApi().then(setItems);
  }, []);

  // Fetch items on mount
  useEffect(() => {
    handleFetchItems();
  }, []);

  // I want this effect to run only when 'props.itemId' changes,
  // not when 'items' changes
  useEffect(() => {
    if(prevItemId !== props.itemId) {
      console.log('diff itemId');
    }

    if (items) {
      const item = items.find(item => item.id === props.itemId);
      console.log("Item changed to " item.name);
    }
  }, [ items, props.itemId ])

  // Clicking the button should NOT log anything to console
  return (
    <Button onClick={handleFetchItems}>Fetch items</Button>
  );
}

我认为这可能对您的情况有所帮助。

注意:如果不需要之前的值,另一种方法是为 props.itemId 多写一个 useEffect

React.useEffect(() => {
  console.log('track changes for itemId');
}, [props.itemId]);

⚠️注意:这个答案目前是不正确的,可能会导致意外的错误/副作用。useCallback变量需要是useEffect钩子的依赖项,因此会导致与 OP 面临的问题相同的问题。

我会尽快解决

最近在一个项目中遇到了这个问题,我们的解决方案是将 的内容移动useEffect到回调(在这种情况下已记忆) - 并调整两者的依赖关系。使用您提供的代码,它看起来像这样:

function Component(props) {
  const [ items, setItems ] = useState([]);

  const onItemIdChange = useCallback(() => {
    if (items) {
      const item = items.find(item => item.id === props.itemId);
      console.log("Item changed to " item.name);
    }
  }, [items, props.itemId]);

  // I want this effect to run only when 'props.itemId' changes,
  // not when 'items' changes
  useEffect(onItemIdChange, [ props.itemId ]);

  // Clicking the button should NOT log anything to console
  return (
    <Button onClick={handleFetchItems}>Fetch items</Button>
  );
}

所以useEffect只将 ID props作为它的依赖项,并且回调项目ID。

实际上,您可以从回调中删除 ID 依赖项并将其作为参数传递给onItemIdChange回调:

const onItemIdChange = useCallback((id) => {
  if (items) {
    const item = items.find(item => item.id === id);
    console.log("Item changed to " item.name);
  }
}, [items]);

useEffect(() => {
  onItemIdChange(props.itemId)
}, [ props.itemId ]) 
@BradAdams 如果您删除此答案会很好,因为它可能会导致人们养成坏习惯!或者至少在编辑时注明这是不可接受的答案。
2021-06-06 23:51:40
我在什么时候useCallback应该在正常回调上使用时遇到了麻烦items记住所有更改还是仅在表达式和useEffect触发时记住
2021-06-08 23:51:40
很好的解决方案,而不必使用 refs
2021-06-13 23:51:40
对不起,伙计,这不对。onItemIdChange(函数)需要声明为useEffect. 这将使此代码在考虑 OP 问题时无效。
2021-06-17 23:51:40
是的,@GunarGessner是正确的,我“发现”这个恐怕不长张贴这样的回答后(是相当新当时钩),与创建的方法useCallback 确实被定义为的依赖useEffect挂钩。我会更新我的答案👍
2021-06-18 23:51:40

一个简单的方法是编写一个自定义钩子来帮助我们

// Desired hook
function useCompare (val) {
  const prevVal = usePrevious(val)
  return prevVal !== val
}

// Helper hook
function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

然后在 useEffect

function Component(props) {
  const hasItemIdChanged = useCompare(props.itemId);
  useEffect(() => {
    if(hasItemIdChanged) {
      // …
    }
  }, [props.itemId, hasItemIdChanged])
  return <></>
}
我喜欢加useCompare+1
2021-06-03 23:51:40
我将此解决方案用于我想在更改值而不是在初始化时运行一些代码的情况;但是,我必须做出重要更改,我认为这是由于实现中的一个缺陷:如果值不断变化,布尔值hasItemChanged将保持为真,并且不会运行任何效果。如果你需要运行的每一个变化的影响props.itemId,但只有当值是不同的,你需要添加props.itemId到阵列的依赖,像这样:[hasItemIdChanged, props.itemId]希望这可以帮助
2021-06-04 23:51:40
简单干净。不错的解决方案。
2021-06-07 23:51:40
@GiorgioTempesta 你说得对。我已经运行了一些测试并确认了您的修复。我现在已经相应地修复了我的答案中的代码。谢谢你。
2021-06-10 23:51:40

我是一个 react hooks 初学者,所以这可能不对,但我最终为这种场景定义了一个自定义钩子:

const useEffectWhen = (effect, deps, whenDeps) => {
  const whenRef = useRef(whenDeps || []);
  const initial = whenRef.current === whenDeps;
  const whenDepsChanged = initial || !whenRef.current.every((w, i) => w === whenDeps[i]);
  whenRef.current = whenDeps;
  const nullDeps = deps.map(() => null);

  return useEffect(
    whenDepsChanged ? effect : () => {},
    whenDepsChanged ? deps : nullDeps
  );
}

它监视第二个依赖项数组(可能少于 useEffect 依赖项)的更改,并在这些更改中的任何一个时生成原始 useEffect。

以下是您如何在示例中使用(和重用)它而不是 useEffect:

// I want this effect to run only when 'props.itemId' changes,
// not when 'items' changes
useEffectWhen(() => {
  if (items) {
    const item = items.find(item => item.id === props.itemId);
    console.log("Item changed to " item.name);
  }
}, [ items, props.itemId ], [props.itemId])

这是它的一个简化示例, useEffectWhen 只会在 id 更改时显示在控制台中,而不是 useEffect 在项目或 id 更改时记录。

这将在没有任何 eslint 警告的情况下起作用,但这主要是因为它混淆了 exlint-deps 的 eslint 规则!如果你想确保你有你需要的 deps,你可以在 eslint 规则中包含 useEffectWhen。您将需要在 package.json 中使用它:

"eslintConfig": {
  "extends": "react-app",
  "rules": {
    "react-hooks/exhaustive-deps": [
      "warn",
      {
        "additionalHooks": "useEffectWhen"
      }
    ]
  }
},

并可选择在您的 .env 文件中使用 react-scripts 来获取它:

EXTEND_ESLINT=true
我喜欢这个答案,但它违反了这条规则:“React Hook useEffect 收到了一个依赖项未知的函数。而是传递一个内联函数。eslintreact-hooks/exhaustive-deps”。不过,我还没有找到另一种确实违反这些规则之一的解决方案。
2021-05-22 23:51:40
喜欢这个,但我将它与 lodash.isEqual 配对以比较对象或数组的变化,效果很好
2021-05-23 23:51:40
很高兴它有帮助!
2021-05-29 23:51:40
这是迄今为止最好的答案。简洁明了。如果我能给这个投票 100 次,我会的。谢谢!
2021-06-16 23:51:40

基于前面的答案,并受到react-use 的 useCustomCompareEffect implementation 的启发,我继续编写useGranularEffect钩子来解决类似的问题:

// I want this effect to run only when 'props.itemId' changes,
// not when 'items' changes
useGranularEffect(() => {
  if (items) {
    const item = items.find(item => item.id === props.itemId);
    console.log("Item changed to " item.name);
  }
}, [ items ], [ props.itemId ])

实现为(typescript):

export const useGranularEffect = (
  effect: EffectCallback,
  primaryDeps: DependencyList,
  secondaryDeps: DependencyList
) => {
  const ref = useRef<DependencyList>();

  if (!ref.current || !primaryDeps.every((w, i) => Object.is(w, ref.current[i]))) {
    ref.current = [...primaryDeps, ...secondaryDeps];
  }

  // eslint-disable-next-line react-hooks/exhaustive-deps
  return useEffect(effect, ref.current);
};

在codeandbox上试试

的签名useGranularEffect与 相同useEffect,只是依赖列表被拆分为两个:

  1. 主要依赖项:效果仅在这些依赖项更改时运行
  2. 次要依赖项:效果中使用的所有其他依赖项

在我看来,它使仅在某些依赖项更改时才运行效果的情况更易于阅读。

笔记:

  • 不幸的是,没有 linting 规则可以帮助您确保这两个依赖项数组是详尽无遗的,因此您有责任确保没有遗漏任何
  • 忽略实现中的 linting 警告是安全的,useGranularEffect因为effect它不是实际的依赖项(它是效果函数本身)并且ref.current包含所有依赖项的列表(主要 + 次要,linter 无法猜测)
  • 我正在使用 Object.is 来比较依赖项,以便它与 的行为一致useEffect,但可以随意使用您自己的比较函数,或者更好的是添加一个比较器作为参数