jtwang7 / React-Note

React 学习笔记
8 stars 2 forks source link

React - useEffect 依赖项数组填坑(附 usePrevious / useDeepCompareEffect) #19

Open jtwang7 opened 3 years ago

jtwang7 commented 3 years ago

React - useEffect 依赖项数组填坑

参考文章: 开发随笔: useRef获取之前状态以及useEffect的坑小记

前言

在使用 useEffect 的时候,我们通常会将变量添加到依赖项数组中,来手动控制 useEffect 的触发。useEffect 依赖项可以帮我们避免不必要的重复渲染,但是也会导致一些问题。目前遇到最多的大致有三类:

  1. 依赖项中包含引用类型
  2. 依赖项数组中存在多个变量时,一个依赖改变,整个 useEffect 就会重新执行。
  3. 依赖项频繁变动,导致 useEffect 被频繁触发。

问题1⃣️:依赖项数组中包含引用类型

由于 useEffect 对依赖项进行的是浅比较 Object.is(),且 react 变量遵循 immutable 不可变原则,所以当引用类型作为依赖项时,react 判断上次与本次的值,返回的始终是 false,因此会不断触发重渲染。 举例:

解决方法:

  1. 最直接的办法,尽量避免在依赖项中使用引用类型
  2. 针对函数作为依赖项的问题,可使用 useCallback 包裹函数,保证该函数在组件的整个渲染周期中不可变。
  3. 若依赖项是(对象 / 数组),我们尽量传入对象或数组的某个属性值。即尽可能传入值类型而非引用类型。

举个例子感受一下解决方法3⃣️:

const [state, setState] = useState({a:1, b:2});

useEffect(()=>{
  setInterval(()=>{
    setState(prev => ({...prev, a: prev.a+1}))
  }, 1000)
}, [])

useEffect(()=>{
  console.log(state.b);
}, [state.b])

我们希望只有当属性 b 发生改变时,才触发 useEffect。 传统方法,若直接传入 state (引用类型),每次调用 setState,都会返回新的 state 对象,都会触发 useEffect。 我们取 state.b 作为依赖项,此时 useEffect 监听的就是一个值类型,尽管每次 setState 都返回新的 state 对象,但 useEffect 每次比较的都是 state.b 的值,而 state.b 是值类型,所以可以被 Object.is() 正确比较。若 state.b 没有发生变化,则 react 会跳过这条 useEffect。

usePrevious

除上述方法外,还有一种方法看上去更加符合 react 应有的做法。从语义上理解,我们希望 useEffect 能够监听一个引用值且仅监听该引用值的某些属性,那么依赖数组中必然存放的是该对象,而不是将它们一个个解构出来。因此,另一种解决思路是:将上一次的引用的属性值与本次引用的属性值进行比较,若发生改变则执行后续的业务逻辑。 听上去不错,但是,上一次的引用从哪里获取呢?我们知道函数式更新的 useState,它可以调用 prev => {...} 来获取历史值。但显然 useEffect 并没有这个功能。答案是: useEffect + useRef 关于 usePrevious,我找到了两处说明:

  1. 官方说明:如何获取上一轮的 props 或 state?
  2. Introduction to useRef Hook

下面一段代码就是 usePrevious 的具体实现

import {useEffect, useRef} from 'react';

const usePrevious = (value) => {
    const ref = useRef()

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

    return ref.current
}

它实现了将参数 value 保存的功能。usePrevious 首次调用时,接受一个 value (被包裹的目标),将该 value 添加到 useRef 中保存。useRef 的特性决定了其保存的值会贯穿整个组件渲染周期且保持不变,在下一次组件重新创建时,它会返回一模一样的存储值。

useRef 与 useState 最大的区别在于,改变 useRef 包裹值的动作不会被 react 侦听,而 useState 一旦更新状态,就会触发组件重新渲染。

有人可能会问:既然 useRef 可以永久保留值,为什么还要加一个 useEffect 呢? 答:useEffect 是确保保存的值始终是上一次记录的关键。组件每次重新渲染时都会执行其组件函数,显然 usePrevious 也会被执行,此时 value 会再一次的传入 usePrevious,由于传入的 value 为引用类型,所以 usePrevious 内部的 useEffect 会被触发,ref.current 值自然也就会替换为 value 的值,这就保证了 usePrevious 始终会跟踪到这一变化,返回最新的保存值。

这里解释一下为什么会触发 useEffect:value 是引用类型,而函数参数传递是值传递,也就是说实际上传递的是引用值的地址,当引用值地址发生改变时,useEffect 就会被触发,从而更新 useRef 的保存值。因为 React 采用了 Immutable Variable,所以每次更新引用值,都会生成一个新的内存地址,因此可以保证引用值改变,就触发 useEffect。

请注意:usePrevious 始终返回的是上一轮的结果,这是因为 usePrevious 代码中, useRef 和 return 语句都是在 render 函数发生前同步执行的,而 useEffect 内的函数是在 render 函数结束,整个页面渲染完成后执行,因此,每次调用 usePrevious 实际返回的都是之前保存的值,本轮传入的值还未被更新。

官方说后续可能会将 usePrevious 作为新的 hooks 出现,可以稍微期待一下!

关于 usePrevious 的使用: 这里就直接参考Introduction to useRef Hook中的例子:

  1. use lodash isEqual method for deep comparision We have just removed the dependency array in our effect and use the lodash isEqual method instead to make a deep comparison. Unfortunately, we run into a new issue because of the missing previousUser value. If we do the same thing with a class component in ComponentDidUpdate lifecycle, we can easily have the previous state value. 🔥 useRef comes to rescue

    用 lodash 库的 isEqual 方法做深度比较 对于引用类型的值,采取做深度比较的方式,而不是将其纳入 useEffect 依赖项中。但不幸的是,我们缺少了 previous value。在类组件中,我们可以在 componentDidUpdate 中轻松获取 previous state value,但在函数组件中尚未实现这样的 hooks。因此,useRef 派上用途了!

    
    const Profile = () => {
    const [user, setUser] = React.useState({name: 'Alex', weight: 40})

    React.useEffect(() => { if (!_.isEqual(previousUser, user) { console.log('You need to do exercise!') } })

    ... }

export default Profile

2. useRef for saving the previous state
To keep track of the previousUser value, we save it to the .current property of useRef hook because it can survive even when the component rerenders. To do that another effect will be used to update the previousUserRef.current value after every renders. Finally, we can extract the previousUser value from previousUserRef.current, then we deep compare the previous value with the new one to make sure our effect only run when those values are different
> 为了跟踪 previousUser 值,我们将它保存到 useRef 钩子的 .current 属性中,因为即使组件重新渲染它也能存活。 同时要保证每次渲染后都及时地更新 previousUserRef.current 值,因此引入了 useEffect。 最后,我们可以从 previousUserRef.current 中提取 previousUser 值,然后我们将先前的值与新的值进行深度比较,以确保我们的效果仅在这些值不同时才运行

```js
const Profile = () => {
   const [user, setUser] = React.useState({name: 'Alex', weight: 20})

   React.useEffect(() => {
       const previousUser = previousUserRef.current
       if (!_.isEqual(previousUser, user) {
           console.log('You need to do exercise!')
       }
   })

   const previousUserRef = React.useRef()
   React.useEffect(() => {
      previousUserRef.current = user
   })

    ...
}

export default Profile
  1. extract effects to the custom Hooks If you want to reuse the code, we can make a new custom hook. I just extract the code above to a function called usePrevious

    将这段代码抽离成更加通用的 hook,我们称之为 usePrevious

const usePrevious = (value) => {
    const ref = React.useRef()

    React.useEffect(() => {
      ref.current = value
    }, [value])

    return ref.current
}

然后这样去使用它:

const Profile = () => {
    const initialValue = {name: 'Alex', weight: 20}
   const [user, setUser] = React.useState(initialValue)

    const previousUser = usePrevious(user) // 保存当前值作为历史记录

   React.useEffect(() => {
       // 此处用 lodash 的 isEqual 方法进行比较,判断最新的值和历史值是否一致,若满足条件才能触发后续业务逻辑
       if (!_.isEqual(previousUser, user) {
           console.log('You need to do exercise!')
       }
   })

   const gainWeight = () => {
      const newWeight = Math.random() >= 0.5 ? user.weight : user.weight + 1
      setUser(user => ({...user, weight: newWeight})) // 在这里值发生了改变,触发了组件重渲染
   }

   return (
      <>
         <p>Current weight: {user.weight}</p>
         <button onClick={gainWeight}>Eat burger</button>
      </>
   )
}

export default Profile

use-deep-compare-effect

另一种深层比较 effect 依赖项的方式,一个第三方库。请参阅 use-deep-compare-effect 通过 npm install --save use-deep-compare-effect 安装。 以如下方式引入并引用:

import React from 'react'
import ReactDOM from 'react-dom'
import useDeepCompareEffect from 'use-deep-compare-effect' // 引入 useDeepCompareEffect

function Query({query, variables}) {
  // some code...

  // 像 useEffect 一样使用它,区别在于 useEffect 对依赖项做的是浅比较,而 useDeepCompareEffect 是深度比较。它可以有效处理引用类型的依赖项
  useDeepCompareEffect(
    () => {
      // make an HTTP request or whatever with the query and variables
      // optionally return a cleanup function if necessary
    },
    // query is a string, but variables is an object. With the way Query is used
    // in the example above, `variables` will be a new object every render.
    // useDeepCompareEffect will do a deep comparison and your callback is only
    // run when the variables object actually has changes.
    [query, variables],
  )

  return <div>{/* awesome UI here */}</div>
}

问题2⃣️:依赖项数组中存在多个变量时,一个依赖改变,整个 useEffect 就会重新执行。

实际上,依赖项数组中,一个依赖改变就触发整个 useEffect 是正确且必要的,因为一个 useEffect 里的业务逻辑必然是紧密耦合的。如果有一天,你发现你需要使依赖项中某个依赖改变而不去触发当前 useEffect,很明显,你显然不需要将该变量放入依赖。或者说,你的 useEffect 逻辑并没有达到最佳,它可以拆分成更细节的业务逻辑,每个相关的依赖项管理相关的逻辑即可。

问题3⃣️:依赖项频繁变动,导致 useEffect 被频繁触发

请参照官方文档如果我的 effect 的依赖频繁变化,我该怎么办? 显然它给出了最佳解释。简单来说,最常用的就是两种方法:

  1. 使用 useState 的函数式更新形式 (请多使用它,它是 useState 更新状态的最佳实践)
  2. 利用了 useReducer 返回的 dispatch 函数身份稳定的特性,处理更加复杂的依赖逻辑。