烧脑预警,useEffect 进阶思考

2023-02-24 10:47:45 浏览数 (1)

在上一篇文章中,我们使用函数组件实现了方块两次移动的动画效果,核心代码如下:

代码语言:javascript复制
useEffect(() => {
  anime01 && animate01();
  anime02 && animate02();
}, [anime01, anime02]);

因为需求中的方块有两次不同的动画过程,因此我定义了两个布尔型状态来表达每段状态运行与否,当状态为 true 时,执行对应的动画函数

针对这个案例,我们还需要探讨几个问题

1. 为什么要使用 useEffect 来解决该需求?是否合理?

2. 当需求变动,白色方块存在三个甚至更多段动画,那么我们应该怎么办?

01

逻辑解耦

目标对象有两段动画,每段动画效果不一样。因此我们需要分别去定义每段动画的执行。

一个好的解耦方式,是每段动画分别定义。

对于第一段动画,我只需要关注这段动画自身如何执行,以及执行完毕之后,下一段动画该谁来执行

代码语言:javascript复制
function animate01() {
  anime({
    targets: element.current,
    translateX: 400,
    backgroundColor: '#FF8F42',
    borderRadius: ['0%', '50%'],
    complete: () => {
      // 执行完毕后,想办法让下一段动画执行
    }
  })
}

此处我们只关注动画的执行与动画执行结束的时刻,可以用不同的方式来实现,本处案例并非唯一方案

当需求变动,需要执行 3 段动画,甚至更多,我们只需要相对应的增加不同的动画函数即可

02

开关

如果给每一段的动画设计一个开关,当该动画需要执行时,将对应开关设置为 true 即可

代码语言:javascript复制
anime01 && animate01();

也就是说,当对应的开关为 true,动画函数就会执行,那么,在复杂的逻辑之下,我们只需要控制开关,就能控制动画的执行,因此

第一段动画执行完毕,下一段动画要开始执行,我们只需要关闭第一段动画的开关,打开第二段动画的开关

代码语言:javascript复制
function animate01() {
  anime({
    targets: element.current,
    translateX: 400,
    backgroundColor: '#FF8F42',
    borderRadius: ['0%', '50%'],
    complete: () => {
      // 关闭第一段动画的开关
      setAnime01(false)
      // 打开第二段动画的开关
      setAnime02(true)
    }
  })
}

useEffect 为这样的解题思路提供了技术支撑。如下,只要开关状态改变,对应的动画函数逻辑就会执行

代码语言:javascript复制
useEffect(() => {
  anime01 && animate01();
  anime02 && animate02();
}, [anime01, anime02]);

03

状态解耦

此时,所有的开关都被放在一个 useEffect 中聚合,从可读性的角度来看并不可取。如果动画增多,那么开关状态也会变多,useEffect 的依赖项也会变得更加复杂

代码语言:javascript复制
useEffect(() => {
  anime01 && animate01();
  anime02 && animate02();
  anime03 && animate03();
}, [anime01, anime02, anime03]);

useEffect 的依赖项应该简单明了,一目了然。当我们发现依赖项过多时,就应该结合实际情况拆分为多个 useEffect,以提高代码的可读性和,减少维护成本

代码语言:javascript复制
useEffect(() => {
  anime01 && animate01();
}, [anime01])

useEffect(() => {
  anime02 && animate02();
}, [anime02])

...

于是,一段逻辑清晰,维护成本低的代码就演变出来了,回过头来再思考一下序言中我们提的两个问题,已经迎刃而解

大家在使用 useEffect 时,缺乏这样的思考,所以很多时候逻辑会变得不可控,也希望借助这个案例让大家意识到基础能力的重要性

有的从业人员在使用 useEffect 时会无意识中增加依赖项的复杂度,更有甚者还演变成一个复杂的多层级引用类型。当发生这种情况时,我们应该在好的解耦思路的帮助下简化依赖项,而不是去思考更复杂的依赖相对比应该如何做。反面案例就是大量利用类似 useDeepCompareEffect 这样的自定义 hook 来解决引用数据类型作为依赖项时的变化问题,只有在逼不得已的情况我们才会去考虑这样的使用方式。

04

破除

useEffect 的第一个参数为一个函数,我们称之为 effect 函数。

许多同学对 useEffect 的依赖项使用缺乏思考。在 eslint 的提示指引下,无脑将所有 effect 函数中使用到的 state 都加入依赖项中而导致代码变得复杂。我们应该破除这样的思维,在使用依赖项时认真去分析。什么时候使用什么样的依赖项,应该由你来控制,而非 eslint 来控制。

对于 eslint 的提示,我们可以通过自定义规则配置将其关掉

因此,我们应该对 useEffect 的基础有更深刻的认知,才能做到收放自如

在此之前,你要确保自己对闭包完全领悟。不是那种只知道概念,一知半解的似懂非懂。要能一眼看出来闭包在 useEffect 中对代码的影响,该影响并非坊间流传甚广的所谓心智负担或者闭包陷阱,而是对于闭包这个知识点的基础认知是否到位。

例如以下案例中,我们在 effect 函数中使用了 loading,那么此时是否有闭包产生,具体情况如何?当 loading 发生变化时,执行的 effect 中访问的 loading 是否是最新值,如果是,那么可能是如何做到的?

代码语言:javascript复制
function Demo() {
  const [loading, setLoading] = useState(false)
  
  useEffect(() => {
    if (loading) {
      xxApi()
    }
  }, [loading])
  
  
  ...
}

其次,你要确保自己对事件循环完全领悟,要明确知道 effect 函数的准确执行时机,而非组件渲染之后这样的笼统模糊的描述。

如果这几个问题你暂时还没有确切的答案,你可以稍微停一停先思考清楚,因为你的 JavaScript 基础还不足以支撑你对 useEffect 有更深层次的领悟。除此之外,我会在付费群直播里答疑环节详细讲解

05

闭包

闭包的底层影响贯穿了整个 hooks 的实现,hooks 进阶使用对闭包掌握程度要求很高. state 的状态之所以能够在函数重复执行的过程中得以保存,全都得益于闭包的特性

这里有两个重要前提我们需要关注

1. 定义组件的函数会因为 state 的变化而重复执行

2. 重复执行的过程中我们需要保存上一次执行之后的一些状态

例如,在经典案例中,当点击按钮让 count 递增,函数会重新执行,我们也能够在下一次的执行中访问到递增之后的 cout 值

代码语言:javascript复制
function Demo() {
  const [count, setCount] = useState(0)
  
  return (
    <div onClick={() => setCount(count   1)}>
      {count}
    </div>
  )
}

count 被 useState 保存在闭包中,因此我们能持续访问到 count 被修改之后的值

这是基于闭包的实现,有不理解的进入付费群里直播跟大家分享

在函数组件中,当 effect 函数中访问了 state 中的变量,例如上面的例子,访问 anime01,此时,新的闭包又会产生

代码语言:javascript复制
useEffect(() => {
  anime01 && animate01();
}, [anime01])

而此时,问题就产生了,许多同学在面临这个问题时,拿不准 effect 函数中访问的 state 是否是最新的值,还是闭包中缓存的值,什么时候是最新的值,什么时候是缓存的值,于是无法做到自由发挥,也因此对依赖项的使用也不得其法

下面这段话非常关键,务必逐句搞懂

当组件函数重新执行时,useEffect 函数本身也会重新执行。useEffect 接受的第一个参数 effect 函数为一个匿名函数,它总会重新定义,因此,不管依赖项如何,该 effect 函数始终都能访问到最新的 state。但是,React 底层会利用闭包缓存 effect 函数,真正执行的并非每次重新定义的 effect 函数,而是被缓存起来的 effect 函数。在初始化和任意依赖项发生变化时,该缓存的函数会重新赋值

理解了这个前提条件之后,我们就有了简化依赖项的基础,我们只需要确保被执行的 effect 函数中总是能访问到正确的值,那么就无需添加冗余的依赖项

06

案例

实现一个组件,组件上要展示一个作者信息,并展示是否关注了该作者。当点击关注按钮时,如果已经关注则取消关注状态,如果没有关注则关注该作者,并弹出提示告知用户

我们使用 auther 来表示作者信息,使用一个状态 star 来表示你是否关注了该作者,点击关注之后弹出的提示信息为副作用逻辑

该 effect 函数中访问了两个 state 值

代码语言:javascript复制
function Demo01() {
  const [auther, setAuther] = useState({ name: 'Jake', id: '11231231' })
  const [star, setStar] = useState(false)

  useEffect(() => {
    if (star) {
      console.log(`你关注了${auther.name}`)
    }
  }, [star])

  ...
}

该案例中,effect 中访问了两个 state,分析他们的特性之后发现,auther 是一个相对稳定的值,并不需要反复改变,因此,我们只需要关注 star 的变化,这样,被执行的 effect 函数就能总是访问到最新的 auther 与 star 值

问题升级,如果页面中 auther 也会发生变化呢?例如页面初始化时,需要从接口中请求 auther,那么此时应该怎么办?

当新的 auther 出现时,我们应该从逻辑解耦的方向上来思考,此时可以用一个新的 useEffect 来处理 auther 的变化情况

代码语言:javascript复制
function Demo01() {
  const [auther, setAuther] = useState({})
  const [star, setStar] = useState(false)

  useEffect(() => {
    if (star) {
      console.log(`你关注了${auther.name}`)
    }
  }, [star])

  useEffect(() => {
    api().then(res => {
      setAuther(res.auther)
    })
  }, [])

  ...
}

问题继续升级。新出现的作者,应该携带你之前是否关注过他的信息,如果已经关注过,那么就只需要在页面上直接标识,而不需要弹出提示了。那这个时候我们发现 star 的副作用不存在了。这个时候 star 的存在就必须要重新思考,新作者出现可能是已经关注的状态,但是在之后的交互中我们还可以取消关注或者重新关注,此时对于 star 来说,就应该有初始化和更新的区分思考

那么代码应该怎么写呢?思考一下

代码语言:javascript复制

function Demo01() {
  const [auther, setAuther] = useState({})
  const [star, setStar] = useState(false)

  useEffect(() => {
    api().then(res => {
      setAuther(res.auther)
      setStar(res.star)
    })
  }, [])

  function starClickHandler() {
    if (!star) {
      console.log(`你关注了${auther.name}`)
    }
    setStar(!star)
  }

  ...
}

我们抽取共性,star 的存在就只关注页面上 UI 的变化,而将 star 在交互过程中的副作用交给点击事件回调函数处理。React 新的官方文档试图将 useEffect 理解为逃生舱,就是这样的思路。不过这样的思路是有局限的,在更复杂的情况下也并非最佳方案

问题继续升级。但是很多时候在实践中,副作用逻辑不会这么简单,因此我们希望简化操作,只把点击回调考虑成操作开关,其他的副作用逻辑依然交给 useEffect 来处理,又应该怎么办?

此时 star 需要表示两个状态,是否初始化与是否关注,因此我们只需要把 star 定义为一个对象就可以了

代码语言:javascript复制
function Demo01() {
  const [auther, setAuther] = useState({})
  const [star, setStar] = useState({ isInit: false, value: false })

  useEffect(() => {
    if (!star.isInit && star.value) {
      console.log(`你关注了${auther.name}`)
    }
  }, [star.value])

  useEffect(() => {
    api().then(res => {
      setAuther(res.auther)
      if(res.star) {
        setStar({ isInit: true, value: true })
      }
    })
  }, [])

  function starClickHandler() {
    setStar({ isInit: false, value: !star.value })
  }

  ...
}

这里体现的是,当需求变动,我们应该根据实际情况合理的去设计开关状态,而并非僵化的将 useEffect 固化为逃生舱。这里需要特别注意的是,依赖项并非继续使用 star ,因为 star 已经变成了一个引用数据类型,而我们关注的仅仅只是 star.value,不需要把整个 star 对象作为依赖项

我们应该尽量避免让引用数据类型成为依赖项

问题继续升级。当页面上新增了一个刷新按钮,auther 信息会在该按钮点击时出现新的作者

也就是说,除了初始化之外,后续的交互中作者会频繁发生变化,是否关注也会频繁的发生变化,那么我们应该如何做呢?

设计一个新的开关状态,用于控制 auther 信息的刷新

代码如下:

代码语言:javascript复制

function Demo01() {
  const [auther, setAuther] = useState({})
  const [refresh, setRefresh] = useState(true)
  const [star, setStar] = useState({ isInit: false, value: false })

  useEffect(() => {
    if (!star.isInit && star.value) {
      console.log(`你关注了${auther.name}`)
    }
  }, [star.value])

  useEffect(() => {
    refresh && api().then(res => {
      setAuther(res.auther)
      setRefresh(false)
      if(res.star) {
        setStar({ isInit: true, value: true })
      }
    })
  }, [refresh])

  function starClickHandler() {
    setStar({ isInit: false, value: !star.value })
  }

  // 开关思维重在简化该操作
  function autherRefreshHandler() {
    setRefresh(true)
  }

  ...
}

基于这样的代码情况,我们可以将处理 auther 信息的代码部分封装成为一个自定义 hook

代码语言:javascript复制
function useAuther(cb) {
  const [auther, setAuther] = useState({})
  const [refresh, setRefresh] = useState(true)
  
  useEffect(() => {
    refresh && api().then(res => {
      setAuther(res.auther)
      setRefresh(false)
      cb(res)
    })
  }, [refresh])

  return {
    auther,
    refresh,
    setRefresh
  }
}

然后组件代码就变得相对简单了

代码语言:javascript复制
function Demo02() {
  const [star, setStar] = useState({ isInit: false, value: false })
  const {refresh, setRefresh, auther} = useAuther((res) => {
    if (res.star) {
      setStar({ isInit: true, value: true })
    }
  })

  useEffect(() => {
    if (!star.isInit && star.value) {
      console.log(`你关注了${auther.name}`)
    }
  }, [star.value])

  function starClickHandler() {
    setStar({ isInit: false, value: !star.value })
  }

  function autherRefreshHandler() {
    setRefresh(true)
  }
  
  ...
}

自定义 hook 部分略微超纲,如果无法理解可以以后重新回过头来看

于是,我们基于逻辑解耦,将 auther 的逻辑与关注与否的逻辑各自处理,各自封装,并希望事件操作像操作开关那样简单,最后呈现出来的思路我们就梳理完毕了。这个例子需要不断思考沉淀,对于新手玩家来说非常烧脑,对于有经验的玩家来说提供了非常宝贵的探索思路,值得反复阅读

0 人点赞