如何在React Hooks的useEffect中比较oldValues和newValues?

391
假设我有3个输入:汇率、发送金额和接收金额。我将这3个输入放在useEffect差分参数上。规则如下:
- 如果发送金额发生变化,则计算 `receiveAmount = sendAmount * rate` - 如果接收金额发生变化,则计算 `sendAmount = receiveAmount / rate` - 如果汇率发生变化,则当 `sendAmount > 0` 时计算 `receiveAmount = sendAmount * rate` 或者当 `receiveAmount > 0` 时计算 `sendAmount = receiveAmount / rate`
这是codesandbox https://codesandbox.io/s/pkl6vn7x6j 来演示问题。
是否有一种方法可以像在componentDidUpdate中一样比较oldValues和newValues,而不是为此情况制作3个处理程序?
谢谢

这是我的最终解决方案,使用了usePrevious https://codesandbox.io/s/30n01w2r06

在这种情况下,我不能使用多个useEffect,因为每次更改都会导致相同的网络调用。这就是为什么我还使用changeCount来跟踪更改。这个changeCount也有助于跟踪仅来自本地的更改,因此我可以防止由于服务器端的更改而进行不必要的网络调用。


componentDidUpdate 究竟如何帮助呢?你仍然需要编写这三个条件。 - Estus Flask
我添加了一个答案,其中包含2个可选解决方案。其中一个适用于您吗? - Ben Carp
17个回答

501

你可以编写自定义钩子,使用 useRef 来提供先前的 props。

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

然后在 useEffect 中使用它。

const Component = (props) => {
    const {receiveAmount, sendAmount } = props
    const prevAmount = usePrevious({receiveAmount, sendAmount});
    useEffect(() => {
        if(prevAmount.receiveAmount !== receiveAmount) {

         // process here
        }
        if(prevAmount.sendAmount !== sendAmount) {

         // process here
        }
    }, [receiveAmount, sendAmount])
}

然而,如果你想要分别处理每个变化id,最好使用两个useEffect分开使用,这样更清晰易读易懂。


11
我尝试了上面的代码,但是 eslint 警告我 useEffect 缺少依赖项 prevAmount - Littlee
3
由于prevAmount保存了状态/属性的先前值,您无需将其作为useEffect的依赖项传递,并且可以针对特定情况禁用此警告。您可以阅读此文章以获取更多详细信息。 - Shubham Khatri
15
usePrevious里,useEffect难道不应该依赖于value吗?否则,如果组件因为另一个状态的改变而重新渲染,在下一次渲染中,previousValue将等于value,对吗?或者是我漏掉了什么? - azizj
12
"react" 变得越来越糟糕:人们正在仅仅为了接收先前的属性(!)而使用 useRef,却不关心它会花费多少资源。 - puchu
3
有人需要说服我,证明这种编程模型比prevProps和prevState更好。 - cherouvim
显示剩余12条评论

150

如果有人正在寻找 TypeScript 版本的 usePrevious

在一个 .tsx 模块中:

import { useEffect, useRef } from "react";

const usePrevious = <T extends unknown>(value: T): T | undefined => {
  const ref = useRef<T>();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
};

或者在一个.ts模块中:

import { useEffect, useRef } from "react";

const usePrevious = <T>(value: T): T | undefined => {
  const ref = useRef<T>();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
};

5
注意,这个方法在 TSX 文件中无法使用。如果要在 TSX 文件中使用,请将 const usePrevious = <T extends any>(... 修改为 const usePrevious = <T extends any>(...,以便解释器认为 <T> 不是 JSX,而是一个类型约束。 - apokryfos
2
你能解释一下为什么 <T extends {}> 不起作用吗?它似乎对我来说是有效的,但我正在尝试理解像这样使用它的复杂性。 - Karthikeyan_kk
1
最好是扩展unknown,或者简单地将钩子放在一个.ts文件中。如果你扩展{},并且跳过了在usePrevious<T>中指定T的话,你就会得到错误。 - fgblomqvist
2
@fgblomqvist 更新了我的答案。再次感谢您的反馈。 - SeedyROM
7
为使其在 TSX 文件中正常工作,您还可以使用 <T,> 写法来区分 JSX 语法(注意尾随逗号)。 - Corvince
显示剩余10条评论

65

选项1 - 当值改变时运行useEffect

const Component = (props) => {

  useEffect(() => {
    console.log("val1 has changed");
  }, [val1]);

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

演示

选项二 - useHasChanged钩子

将当前值与先前值进行比较是一种常见模式,并且正当地需要一个自定义的hook,以隐藏其实现细节。

const Component = (props) => {
  const hasVal1Changed = useHasChanged(val1)

  useEffect(() => {
    if (hasVal1Changed ) {
      console.log("val1 has changed");
    }
  });

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

const useHasChanged= (val: any) => {
    const prevVal = usePrevious(val)
    return prevVal !== val
}

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


演示


第二个选项对我有用。您能否指导我为什么要写两次useEffect? - Tarun Nagpal
1
@TarunNagpal,您不一定需要两次使用useEffect。这取决于您的用例。想象一下,我们只想记录哪个val已更改。如果我们在依赖项数组中有val1和val2,它将在任何一个值更改时运行。然后,在我们传递的函数内部,我们必须找出哪个val已更改以记录正确的消息。 - Ben Carp
1
谢谢您的回复。当您说“在函数内部,我们将不得不找出哪个val已经改变”时,我们该如何实现它呢?请指导一下。 - Tarun Nagpal

47

根据被接受的答案,一种不需要自定义钩子的替代解决方案:

const Component = ({ receiveAmount, sendAmount }) => {
  const prevAmount = useRef({ receiveAmount, sendAmount }).current;

  useEffect(() => {
    if (prevAmount.receiveAmount !== receiveAmount) {
     // process here
    }

    if (prevAmount.sendAmount !== sendAmount) {
     // process here
    }

    return () => { 
      prevAmount.receiveAmount = receiveAmount;
      prevAmount.sendAmount = sendAmount;
    };
  }, [receiveAmount, sendAmount]);
};

假设你在“这里处理”的部分需要参考先前的值。否则,除非您的条件逻辑超出了直接使用!==比较的范围,否则最简单的解决方案只是:

const Component = ({ receiveAmount, sendAmount }) => {
  useEffect(() => {
     // process here
  }, [receiveAmount]);

  useEffect(() => {
     // process here
  }, [sendAmount]);
};

这将给你初始值而不是先前的值。usePrevious()钩子能够正常工作的原因是它被包装在useEffect()中。 - Justin
1
@Justin 返回函数应该在每次渲染时更新先前的值引用。你测试过了吗? - Drazen Bjelovuk

9

我刚刚发布了 react-delta,它解决了这种场景下的问题。在我看来,useEffect 承担了过多的责任。

责任

  1. 使用 Object.is 比较依赖数组中的所有值
  2. 根据 #1 的结果运行 effect/cleanup 回调函数

分解责任

react-deltauseEffect 的责任分解为几个更小的钩子。

责任 #1

责任 #2

在我的经验中,这种方法比 useEffect/useRef 的解决方案更加灵活、清晰和简洁。


正是我所需要的。我要试一试! - hackerl33t
@Hisato,这可能是过度设计了。这是一种实验性的API。坦率地说,我在团队中很少使用它,因为它并不被广泛知晓或采用。理论上听起来不错,但在实践中可能不值得。 - Austin Malerba

7

这是我使用的自定义钩子,我认为比使用 usePrevious 更直观。

import { useRef, useEffect } from 'react'

// useTransition :: Array a => (a -> Void, a) -> Void
//                              |_______|  |
//                                  |      |
//                              callback  deps
//
// The useTransition hook is similar to the useEffect hook. It requires
// a callback function and an array of dependencies. Unlike the useEffect
// hook, the callback function is only called when the dependencies change.
// Hence, it's not called when the component mounts because there is no change
// in the dependencies. The callback function is supplied the previous array of
// dependencies which it can use to perform transition-based effects.
const useTransition = (callback, deps) => {
  const func = useRef(null)

  useEffect(() => {
    func.current = callback
  }, [callback])

  const args = useRef(null)

  useEffect(() => {
    if (args.current !== null) func.current(...args.current)
    args.current = deps
  }, deps)
}

您将按以下方式使用useTransition
useTransition((prevRate, prevSendAmount, prevReceiveAmount) => {
  if (sendAmount !== prevSendAmount || rate !== prevRate && sendAmount > 0) {
    const newReceiveAmount = sendAmount * rate
    // do something
  } else {
    const newSendAmount = receiveAmount / rate
    // do something
  }
}, [rate, sendAmount, receiveAmount])

希望有所帮助。

这是我所需的最佳答案,谢谢! - Miguel

7
如果你更喜欢 useEffect 的替代方法:
const usePreviousEffect = (fn, inputs = []) => {
  const previousInputsRef = useRef([...inputs])

  useEffect(() => {
    fn(previousInputsRef.current)
    previousInputsRef.current = [...inputs]
  }, inputs)
}

并像这样使用:

usePreviousEffect(
  ([prevReceiveAmount, prevSendAmount]) => {
    if (prevReceiveAmount !== receiveAmount) // side effect here
    if (prevSendAmount !== sendAmount) // side effect here
  },
  [receiveAmount, sendAmount]
)

请注意,当效果第一次执行时,传递到您的fn的先前值将与您的初始输入值相同。只有在您想要在值未更改时执行某些操作时,这才对您有影响。

6
由于在函数组件中状态与组件实例没有紧密耦合,因此在 useEffect 中无法访问先前的状态,除非先将其保存,例如使用 useRef。这也意味着可能错误地在错误的位置实现了状态更新,因为先前的状态在 setState 更新程序函数中是可用的。
这是使用 useReducer 的一个很好的用例,它提供了类似 Redux 的存储,并允许实现相应的模式。状态更新是显式执行的,因此无需弄清楚更新哪个状态属性;这已经从分派的操作中清楚地表示出来。
这里是一个 示例,它可能看起来像:
function reducer({ sendAmount, receiveAmount, rate }, action) {
  switch (action.type) {
    case "sendAmount":
      sendAmount = action.payload;
      return {
        sendAmount,
        receiveAmount: sendAmount * rate,
        rate
      };
    case "receiveAmount":
      receiveAmount = action.payload;
      return {
        sendAmount: receiveAmount / rate,
        receiveAmount,
        rate
      };
    case "rate":
      rate = action.payload;
      return {
        sendAmount: receiveAmount ? receiveAmount / rate : sendAmount,
        receiveAmount: sendAmount ? sendAmount * rate : receiveAmount,
        rate
      };
    default:
      throw new Error();
  }
}

function handleChange(e) {
  const { name, value } = e.target;
  dispatch({
    type: name,
    payload: value
  });
}

...
const [state, dispatch] = useReducer(reducer, {
  rate: 2,
  sendAmount: 0,
  receiveAmount: 0
});
...

嗨 @estus,谢谢你的这个想法。它给了我另一种思考方式。但是,我忘了提到我需要为每个情况调用API。你有什么解决办法吗? - Erwin Zhang
你的意思是什么?在 handleChange 里面吗?'API' 是指远程 API 端点吗?你能更新 codesandbox 并提供详细信息吗? - Estus Flask
从更新中我可以推断出你想异步获取 sendAmount 等,而不是计算它们,对吗?这改变了很多。使用 useReduce 可以实现,但可能会有些棘手。如果你之前处理过 Redux,你可能知道异步操作并不简单。https://github.com/reduxjs/redux-thunk 是 Redux 的一个流行扩展,允许进行异步操作。这里有一个 demo,它使用相同的模式(useThunkReducer)增强了 useReducer,https://codesandbox.io/s/6z4r79ymwr 。请注意,分派函数是 async,你可以在那里进行请求(已注释)。 - Estus Flask
1
这个问题的问题在于你问了一个与你真实情况不同的事情,然后改变了它,所以现在它是一个非常不同的问题,而现有的答案似乎只是忽略了这个问题。这不是 SO 上推荐的做法,因为它不能帮助你得到想要的答案。请不要删除已经涉及到的问题重要部分(计算公式)。如果你仍然有问题(在我的先前评论之后(你很可能会有),考虑提出一个反映你情况的新问题,并将其链接到这个问题作为你之前的尝试。 - Estus Flask
嗨,Estus,我认为这个 CodeSandbox https://codesandbox.io/s/6z4r79ymwr 还没有更新。我会把问题改回来,对此很抱歉。 - Erwin Zhang
是的,在分叉后它没有自动保存。我已经更新了它。 - Estus Flask

5

要小心最受欢迎的答案。对于复杂的情况,以上变种的 usePrevious 可能会给您带来过多的重新渲染 (1) 或与原始值相同的值 (2)。

我们需要:

  1. useEffect 中添加 [value] 作为依赖项,仅在 value 改变时重新运行
  2. useEffect 内将 JSON.parse(JSON.stringify(value)) (或一些深拷贝)分配给 ref.current,以防止将状态的引用传递给 ref 而不是值

升级后的 hook:

const usePrevious = <T>(value: T): T => {
  const ref: any = useRef<T>()

  useEffect(() => {
    ref.current = JSON.parse(JSON.stringify(value))
  }, [value])

  return ref.current
}

useRef不会触发额外的重新渲染 - 请查看这个codesandbox链接 https://codesandbox.io/p/sandbox/magical-bell-vznjzr?file=%2Fsrc%2FApp.tsx%3A4%2C4 (附注:第一次点击时颜色不会改变,因为它不会触发状态更新) - undefined

4

如果要进行简单的属性比较,您可以使用useEffect轻松检查属性是否已更新。

const myComponent = ({ prop }) => {
  useEffect(() => {
    ---Do stuffhere----
  }, [prop])
}

useEffect将仅在属性更改时运行您的代码。


2
这只能起作用一次,连续的 prop 更改后,您将无法在此处执行操作。 - Karolis.sh
2
看起来你应该在~ do stuff here ~的结尾处调用setHasPropChanged(false)以“重置”状态。(但这将会导致额外的重新渲染) - Kevin Wang
感谢反馈,你们都是对的,已更新解决方案。 - Charlie Tupman
@AntonioPavicevac-Ortiz 我已经更新了答案,现在将propHasChanged呈现为true,这样就会在渲染时调用它一次,也许更好的解决方案是摆脱useEffect并仅检查prop。 - Charlie Tupman
我认为我最初使用它的意图已经丢失了。回顾代码,你可以只使用useEffect。 - Charlie Tupman
这很简单而且聪明。我为什么没想到呢,哈哈 :) 做得好。 - Jay Mayu

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