使用React.useMemo进行异步调用

74
情景相对简单:我们有一个长时间运行的按需计算,发生在远程服务器上。我们想要记忆结果。即使我们是从远程资源异步获取的,这也不是副作用,因为我们只是想要这个计算的结果显示给用户,而且我们绝对不想在每次渲染时进行此操作。
问题:似乎React.useMemo不直接支持TypeScript的async/await并将返回一个Promise:
//returns a promise: 
let myMemoizedResult = React.useMemo(() => myLongAsyncFunction(args), [args])
//also returns a promise:
let myMemoizedResult = React.useMemo(() => (async () => await myLongAsyncFunction(args)), [args])

什么是使用React.useMemo正确等待异步函数结果并记忆结果的方法?我已经在普通的JS中使用了常规的Promise,但在这些情况下仍然遇到了问题。我尝试过memoize-one等其他方法,但问题似乎是由于React函数组件的工作方式而导致“this”上下文发生变化破坏了记忆化,这就是为什么我正在尝试使用React.useMemo的原因。也许我在把一个方形钉子塞进圆孔,如果是这样的话,也很好知道。现在,我可能只会编写自己的记忆函数。编辑:我认为其中一部分是我在使用memoize-one时犯了一个不同的愚蠢错误,但我仍然对这里关于React.memo的答案感兴趣。这里有一个片段-想法是不直接在渲染方法中使用记忆结果,而是将其作为事件驱动方式的参考,即在单击“计算”按钮时。
export const MyComponent: React.FC = () => {
    let [arg, setArg] = React.useState('100');
    let [result, setResult] = React.useState('Not yet calculated');

    //My hang up at the moment is that myExpensiveResultObject is 
    //Promise<T> rather than T
    let myExpensiveResultObject = React.useMemo(
        async () => await SomeLongRunningApi(arg),
        [arg]
    );

    const getResult = () => {
        setResult(myExpensiveResultObject.interestingProperty);
    }

    return (
        <div>
            <p>Get your result:</p>
            <input value={arg} onChange={e => setArg(e.target.value)}></input>
            <button onClick={getResult}>Calculate</button>
            <p>{`Result is ${result}`}</p>
        </div>);
}

为什么不使用 let myMemoizedResult = await React.useMemo(() => myLongAsyncFunction(args), [args]) - Gazihan Alankus
@GazihanAlankus 那样做不会导致记忆化的 Promise 每次都回调到长时间运行的异步函数吗,即使参数没有改变? - Doug Denniston
1
抱歉,我不知道useMemo的相关内容,我只是认为你在一般情况下遇到了async/await的问题。看起来他们反对你正在尝试做的事情:https://www.digitalocean.com/community/tutorials/react-usememo#when-not-to-usememo “您不希望useMemo触发任何副作用或任何异步调用。这两者都更适合包含在useEffect中。” - Gazihan Alankus
感谢您的回复 - 这就是我包含这部分内容的原因,即这不是副作用。useEffect 在每次渲染时都会检查参数,这里并不是期望的行为,因为用户可能希望在调用长时间运行的计算之前更改多个输入。 - Doug Denniston
文档中指出,必须仅将useMemo用作性能优化。编写代码时,请确保在不使用useMemo的情况下仍然可以正常工作,然后再添加以优化性能。 在上述情况下,它确实会返回一个Promise,因为useMemo执行的函数的返回类型是Promise,它使用了async。目前,React没有提供一种在渲染中执行API并获取结果的方法。随着悬挂的引入,这很快就会成为可能。现在,您可以在useEffect中执行API并更新状态。 - Shubham Khatri
1
我认为我需要在原始问题中添加更多细节,以使我尝试做什么更清晰明了。 - Doug Denniston
3个回答

94

你真正想要的是在异步调用结束后重新渲染组件。仅靠记忆化无法帮助您实现这一目标。相反,您应该使用 React 的状态 - 它将保留异步调用返回的值,并允许您触发重新渲染。

此外,触发异步调用属于副作用,因此不应在呈现阶段执行 - 无论是在组件函数的主体内部还是在 useMemo(...) 内部,后者也发生在呈现阶段。相反,所有副作用都应在 useEffect 内部触发。

以下是完整的解决方案:

const [result, setResult] = useState()

useEffect(() => {
  let active = true
  load()
  return () => { active = false }

  async function load() {
    setResult(undefined) // this is optional
    const res = await someLongRunningApi(arg1, arg2)
    if (!active) { return }
    setResult(res)
  }
}, [arg1, arg2])

在这里,我们在 useEffect 中调用异步函数。请注意,您不能使整个回调函数都异步 - 因此,我们声明了一个异步函数 load 并在不等待的情况下调用它。

arg 中的一个值发生改变时,effect 将重新运行 - 这通常是您想要的结果。因此,请确保在渲染时对 arg 进行记忆化处理。执行 setResult(undefined) 是可选的 - 您可能希望在获得下一个结果之前将前一个结果保留在屏幕上。或者您可能会执行像 setLoading(true) 这样的操作,以便用户知道正在发生什么。

使用 active 标志非常重要。如果没有它,你就会面临一种竞态条件可能在第二个异步函数调用之前就已经结束:

  1. 开始第一次调用
  2. 开始第二次调用
  3. 第二次调用完成,setResult() 发生
  4. 第一次调用完成,setResult() 再次发生,覆盖正确的结果

并且您的组件处于不一致的状态。我们通过使用 useEffect 的清除函数来重置 active 标志来避免这种情况:

  1. 设置 active#1 = true,开始第一次调用
  2. arg 发生更改,调用清除函数,将 active#1 = false
  3. 设置 active#2 = true,开始第二次调用
  4. 第二次调用完成,setResult() 发生
  5. 第一次调用完成,由于 active#1false,因此不会发生 setResult()

1
这个答案重新格式化一下怎么样?也许可以这样写:# 做这个(好的实现,必要时加注释)# 注意这个(坏的例子,带有注释) - Simon B.
谢谢邀请,不过在点击编辑时,它显示“建议编辑队列已满”,所以要么我被禁止或限制编辑,要么是这个答案的队列已满。无论如何,我看到你做了一些改进 :) 很好! - Simon B.
3
@SimonB。谢谢。我最终完全重写了答案,希望现在更好了。 - torvin
这应该是异步操作的正确答案。它在@SimonB那里完美地工作。 - john
1
它可以工作但并不推荐。在 useEffect 钩子中设置状态通常是不明智的做法。事实上,使用您的代码也将触发 react hooks exhaustive deps 警告。 - crg

5

编辑:由于调用的异步性质,我原先下面的回答似乎具有一些意外副作用。我建议您考虑在服务器上记忆实际计算结果,或使用自己编写的闭包来检查arg是否已更改,以避免此类问题。否则,您仍然可以像我下面描述的那样使用useEffect

我认为问题在于async函数始终隐式返回一个Promise对象。因此,您可以直接await结果以取消Promise的包装:

const getResult = async () => {
  const result = await myExpensiveResultObject;
  setResult(result.interestingProperty);
};

这里可以查看示例Codesandbox。

然而,我认为更好的一种模式可能是利用useEffect,该useEffect依赖于某个状态对象,在这种情况下,该状态对象仅在按钮单击时设置,但看起来useMemo也应该可以工作。


4

我认为React明确指出,useMemo不应该用于管理异步API调用等副作用。它们应该在useEffect钩子中进行管理,其中设置了适当的依赖项以确定是否应该重新运行。


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