当只有一个 effect 的 deps 发生变化,而不是其他的时,React useEffect Hook

React useEffect Hook when only one of the effect's deps changes, but not the others

我有一个使用 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 并将它们保存到状态。

组件收到一个 itemId prop(来自 React Router)。

每当 props.itemId 发生变化时,我希望它触发一个效果,在本例中将其记录到控制台。


问题在于,由于效果也取决于 items,每当 items 发生变化时,效果也会 运行,例如当 items 是按下按钮重新获取。

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


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


PS。 Hooks 是一种新事物,我想我们都在尽最大努力弄清楚如何正确地使用它们,所以如果我的思考方式对你来说似乎是错误的或尴尬的,请指出来。

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>
  );
}

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

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

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

从提供的示例来看,您的效果不取决于 itemsitemId,而是来自集合中的一项。

是的,您需要 itemsitemId 才能获得该项目,但这并不意味着您 必须 在依赖项数组中指定它们。

为确保它仅在目标项目更改时执行,您应该使用相同的查找逻辑将该项目传递给依赖项数组。

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

我刚刚自己尝试过,在我看来,您无需将内容放入 useEffect 依赖项列表即可获得它们的更新版本。这意味着您可以只输入 props.itemId 并且仍然在效果中使用 items

我在这里创建了一个片段来尝试 prove/illustrate 这个。如果有问题请告诉我。

const Child = React.memo(props => {
  const [items, setItems] = React.useState([]);
  const fetchItems = () => {
    setTimeout(() => {
      setItems((old) => {
        const newItems = [];
        for (let i = 0; i < old.length + 1; i++) {
          newItems.push(i);
        }
        return newItems;
      })
    }, 1000);
  }
  
  React.useEffect(() => {
    console.log('OLD (logs on both buttons) id:', props.id, 'items:', items.length);
  }, [props.id, items]);
  
  React.useEffect(() => {
    console.log('NEW (logs on only the red button) id:', props.id, 'items:', items.length);
  }, [props.id]);

  return (
    <div
      onClick={fetchItems}
      style={{
        width: "200px",
        height: "100px",
        marginTop: "12px",
        backgroundColor: 'orange',
        textAlign: "center"
      }}
    >
      Click me to add a new item!
    </div>
  );
});

const Example = () => {
  const [id, setId] = React.useState(0);

  const updateId = React.useCallback(() => {
    setId(old => old + 1);
  }, []);

  return (
    <div style={{ display: "flex", flexDirection: "row" }}>
      <Child
        id={id}
      />
      <div
        onClick={updateId}
        style={{
          width: "200px",
          height: "100px",
          marginTop: "12px",
          backgroundColor: 'red',
          textAlign: "center"
        }}
      >Click me to update the id</div>
    </div>
  );
};

ReactDOM.render(<Example />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>

<div id='root' style='width: 100%; height: 100%'>
</div>

我是 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])

Here's a simplified example of it in action,useEffectWhen 只会在 id 更改时显示在控制台中,这与 useEffect 不同,useEffect 在项目或 id 更改时记录。

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

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

并可选择在您的 .env 文件中将其添加到反应脚本中:

EXTEND_ESLINT=true

⚠️ NOTE: This answer is currently incorrect and could lead to unexpected bugs / side-effects. The useCallback variable would need to be a dependency of the useEffect hook, therefore leading to the same problem as OP was facing.

I will address it asap

最近 运行 在一个项目中加入了这个,我们的解决方案是将 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 道具作为其依赖项,并且回调项目 的 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 ]) 

一个简单的解决方法是编写一个自定义挂钩来帮助我们解决这个问题

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

基于 and inspired by react-use's useCustomCompareEffect implementation,我继续编写useGranularEffect hook来解决类似的问题:

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

Try it on codesandbox

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

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

在我看来,这使得 运行 的情况只有在某些依赖项发生变化时才更容易阅读。

备注:

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

更新useGranularEffect 现已发布到 granular-hooks 包中。所以只是:

npm install granular-hooks

然后

import { useGranularEffect } from 'granular-hooks'