从新 Think in Hooks

本文首发于个人我的博客html

为何要从新来过?

我以前写过 一篇博客,介绍了 Class 组件的各个生命周期钩子函数在 Hooks 中对应的方案。那时 Hooks 刚刚发布,开发者最关心的莫过于代码的迁移问题,也就是怎么把现有的 Class 组件改形成 Hooks 的方式。react

尽管这种方式很是的直观有效,但很快咱们就发现,事情彷佛没那么简单。单纯用这个思惟来考虑问题,并不能很好地解释 Hooks 的一些行为,好比 useEffect 中的变量有时候没法获取最新的值、命令式的回调函数也不老是按照咱们的预期工做,useEffect 的依赖数组好像老是缺点什么。git

在亲自踩了 2 个多月的坑,参与了一些 React 官网的翻译工做,拜读了 几篇 很是好的 博客 以后,我对「如何 Think in Hooks」有了新的认识。github

所以这篇博客,咱们来「从新 Think in Hooks」。npm

当咱们讨论 Hooks 时,咱们到底在讨论什么?

要理解 Hooks,咱们得先回到 Hooks 的本质 —— 一种逻辑复用的方式。数组

Hooks 并非新的组件类型,当咱们讨论 Hooks 时,咱们讨论的实际上是函数组件 —— 就是那种只是根据 props 返回相应的 JSX 的渲染函数。Hooks 的出现让函数组件能够和 Class 组件同样能够拥有 state(是能够,不是必须)。所以确切的说,咱们是在讨论使用了 Hooks 的函数组件。缓存

可是「使用了 Hooks 的函数组件」这个词太长了,而下文我又将常常提到这个词,因此在后面的文字中,我将简单用 Hooks 来表示这个概念。闭包

忘掉你所学

当咱们在使用 Class 组件时,每当 props 或 state 有更新,全部的修改都发生在 React 组件实例上,就像修改一个对象的属性同样。这个逻辑放到 Hooks 里是行不通的,函数组件的渲染只是简单的函数调用,不加 new 的函数调用是不存在所谓生成实例的。这也是不少问题产生的根源。异步

因此要想真正 Think in Hooks,首先你得忘记如何 Think in Class,改成 Think in Functions。ide

为何个人 state 不更新?

Hooks 的本质是一个渲染函数,就像是把 Class 组件的 render() 函数单独提取出来同样。

render() 函数在运行时会根据那一次的 props 和 state 去渲染。若是在 render() 函数运行期间 props 或是 state 再次发生变化,并不会影响这一次的执行,而是会触发新一轮的渲染,render() 再一次被调用,而且这一次传入的是变化后的 props 和 state。

到这里咱们得出结论:

render() 函数中用到的 props 和 state 在函数执行的一开始就已经被肯定了。

好了,理论说得够多了,咱们来看代码吧。假设咱们有这样一个组件:

function Counter () {
  const [count, setCount] = useState(0)

  function onClick () {
    setTimeout(() => {
      setCount(count + 1)
    }, 2000)
  }

  return <p onClick={onClick}>You clicked {count} times</p>
}
复制代码

等价的 Class 组件实现能够是下面这样:

class Counter extends Component {
  state = {
    count: 0
  }

  onClick = () => {
    setTimeout(() => {
      this.setState({
        count: this.state.count + 1
      })
    }, 2000)
  }

  render () {
    return <p onClick={this.onClick}>You clicked {this.state.count} times</p>
  }
}

复制代码

对比一下两段函数,若是把 Class 的语法中的全部东西所有塞到 render() 函数里,而后把 render() 函数单独拎出来,给变量和函数换个名字 —— 恭喜你,你获得了一个等效的 Hooks !

开玩笑的,但这真的很像对不对。

如今考虑一个问题:若是我在 2 秒内点击组件 3 次,那么到第 5 秒的时候,组件会显示什么?

在类组件的实现中,结果是 3,由于触发了 3 次更新,每次都在原有的基础上加 1。

但在 Hooks 的实现中,结果意外地变成了 1。很奇怪对不对,明明是同样的逻辑,为何结果不同?(我向你保证这跟闭包没有关系)

若是你在 onClick 函数中 console.log 一下,你会发现点击事件确实被触发了 3 次,可是 3 次 count 的值是同样的。

这是为何?

还记得咱们前面的结论吗?「render() 函数中用到的 props 和 state 在函数执行的一开始就已经被肯定了」。为了简化问题,咱们能够把 Hooks 的代码中全部用到的 props 和 state 直接替换成那一次的取值:

// 第一次渲染
function Counter () {
  // 这里是对 useState 的等价替换
  const count = 0 // highlight-line
  const setCount = (val) => { ... }

  function onClick () {
    setTimeout(() => {
      setCount(0 + 1) // highlight-line
    }, 2000)
  }

  return <p onClick={onClick}>You clicked 0 times</p> // highlight-line
}
复制代码

注意到第 9 行的变化了么?这就是为何。在这 2 秒钟以内,不管点击多少次,咱们都是在给组件下达一样的指令:2 秒钟后把 count 设置为 1。2 秒以后组件或许会被更新屡次,但结果都是同样的。onClick 函数中 count 的值在一开始就已经被肯定了。

那若是我想实现 Class 版本的那种效果要怎么办?能够经过给 setCount() 传入一个回调函数来解决(若是能够的话,我推荐在更新 state 时尽可能采用这种写法,缘由后面会讲到):

function Counter () {
  const [count, setCount] = useState(0)

  function onClick () {
    setTimeout(() => {
      setCount(c => c + 1) // highlight-line
    }, 2000)
  }

  return <p onClick={onClick}>You clicked {count} times</p>
}
复制代码

这里表示无论 count 如今的值是多少,往上加一就行了。Class 组件中的 setState() 函数也有一样的写法,虽然它俩的目的并不相同。

useEffect 的依赖数组到底应该怎么用

这多是刚接触 Hooks 时最让人头疼的一个问题,相信每一个人都对「依赖数组里的内容会决定 Effect 是否会从新执行」这一点印象深入,给人感受这就是 componentDidUpdate() 的等效实现,按照咱们对 Class 组件的认知,只要依赖数组里的内容不变,Effect 就不会从新执行;若是某个变量不参与比对的过程,就不须要出如今依赖数组中。然而依赖数组并无咱们想象的这么简单。

依赖数组真正的含义,是「这个 Effect 引用了哪些外部变量」。无论它是否参与比对的过程,只要 Effect 中引用了(也就是 Effect 依赖了这个变量),就必须出如今依赖数组中。举个例子:

在下面的代码中,咱们想要实现:foobar 在被点击时自身加一,其中任何一个的变化都会触发 total 也加一,同时有一个 Effect 在每秒打印 total 的值。因为咱们只须要在组件挂载时启用一下计时器就好,所以咱们把依赖数组留空。

const App = (props) => {
  const [total, setTotal] = useState(0)
  const [foo, setFoo] = useState(0)
  const [bar, setBar] = useState(0)

  // highlight-range{1-5}
  useEffect(() => {
    setInterval(() => {
      console.log(total)
    }, 1000)
  }, [])

  function updateTotal () {
    setTotal(t => t + 1)
  }

  function addFoo () {
    setFoo(f => f + 1)
    updateTotal()
  }

  function addBar () {
    setBar(b => b + 1)
    updateTotal()
  }

  return <> <button onClick={addFoo}>{foo}</button> + <button onClick={addBar}>{bar}</button> = <span>{total}</span> </> } 复制代码

这个 Effect 引用了 total 这个变量,可是 total 并无参与「是否要执行这个 Effect」的决策。按照咱们以前对于 Class 组件的理解,total 不须要出如今依赖数组中。那么咱们来执行一下这段代码。

点击按钮,foobar 如咱们预期的那样自增了,页面上 total 也显示了最新的值。然而控制台打印出来的 total 却始终为 0。

为何会这样?

如咱们上一节所说的,「render() 函数中用到的 props 和 state 在函数执行的一开始就已经被肯定了」,Effect 也是 render 函数的一部分,所以一样适用这条规则,那么咱们带入变量值看一下:

// 初始化时
useEffect(() => {
  setInterval(() => {
    console.log(0)
  }, 1000)
}, [])
复制代码
// 点击 foo
useEffect(() => {
  setInterval(() => {
    console.log(0)
  }, 1000)
}, [])
复制代码
// 再点击 bar
useEffect(() => {
  setInterval(() => {
    console.log(0)
  }, 1000)
}, [])
复制代码

因为 total 并无在依赖数组中申明,所以 total 的更新不会触发 Effect 从新执行,也就不会去获取它的最新值,每次执行都引用了第一次执行时候的值。

要解决这个问题,咱们能够把 total 加入依赖数组,告诉 Effect 当 total 更新时从新执行 Effect,这样依赖 Effect 就能在从新执行时获取到 total 的最新值了。同时注意,因为每次 total 改变会引发 Effect 的从新执行,所以 setInterval() 也会重复执行,建立多个计时器,要解决这个问题,只要让 Effect 返回一个清理函数,结束掉上一个计时器便可:

useEffect(() => {
  const id = setInterval(() => {
    console.log(total)
  }, 1000)

  return () => {
    clearInterval(id)
  }
}, [total])
复制代码

这么一来,程序就正常了。

如今新版的 React 已经自带了对 Hooks 规则的一些检查,当它发现一些不合规的写法(好比 Effect 中引用了外部变量,但没有在依赖数组中进行申明),就会给出提示。只要保持使用最新版的 React,理论上就能够避免这一类的错误。若是你出于某些缘由不方便升级,也能够手动安装 eslint-plugin-react-hooks 来进行检查。

总的来讲,对于 useEffect() 的依赖数组,必定要牢记:

只要是 useEffect() 中用到的,都要在依赖数组中申明。

那若是 useEffect() 中引用了一些不参与「是否执行 Effect」的决策的变量,咱们要怎么处理这些尴尬的变量呢?别担忧,方法有不少:

  1. 用回调函数的方式来设置 state,以解除对某些 state 变量的引用。
  2. 若是组件内部的函数仅用于某个 Effect,能够把这个函数的定义移到 useEffect() 内部,以解除对某些函数的引用。
  3. 若是一些变量的存在是为了决定另外一些变量(好比 url 查询参数),能够把相关逻辑抽取为独立的函数,用 useCallback() 进行优化,而后咱们就能够把这部分变量提取到 Effect 以外去,以精简依赖数组。
  4. 实在无法优化了,还有个最简单粗暴的方法。在 useEffect() 中对全部参与决策的变量进行比对,判断是否发生变化,以决定是继续执行仍是就此返回。

不要担忧重复定义函数

从工程学的角度,咱们习惯经过缓存来避免频繁的销毁和重建一样的内容。在 Class 组件中,经过函数绑定,咱们能够很轻易的作到这一点。但在 Hooks 中,咱们或许须要改变一下习惯,试着接受这一类的开销。

因为函数组件的特性,它不像类组件的实例那样,存在生命周期的概念。函数组件的核心就只有一个渲染函数,即使 Hooks 引入了 state,函数组件的更新也仍是从新执行整个函数,而不是在某个实例上小修小改。这样的特定就决定了函数组件内定义的函数,会在组件每次从新渲染时被销毁而后重建,即使函数自己并无改变,只是传入的参数发生了改变。

好在,只要不是很是高频的更新,这种程度的开销并不会对咱们的应用形成明显的负面影响。所以咱们能够容许这种反模式的存在。

如何在 Hooks 中发起 HTTP 请求

在 Class 组件中,咱们常见的作法是定义一个获取数据的函数,在其中读取 props 和 state,拼接出要传递的参数,好一点的作法或许还要判断一下 loading 状态以免重复操做和异步冲突,而后发起其请求,等 Promise 被 resolve 后,处理返回的结果,更新一些 state。

但当咱们尝试在 Hooks 中重现这一套路时,咱们遇到了问题。要想读取最新的 props 和 state,咱们就必须把发起请求的函数写到一个 Effect 中,而且全部引用到的变量都必须放进依赖数组中。这就致使咱们必须很是当心地处理每个依赖的变化,一不当心就会陷入死循环。

具体的操做,展开来篇幅太长了,这里就不展开了,推荐一篇很是全面的文章,须要的能够看一下。这篇文章国内有很多人作了翻译,这篇是一个不错的译本,英文有压力的同窗能够看看。

如何使用 setInterval()

还有一个很是常见的命令式操做,就是设置定时器。

好比一个短信验证码的倒计时,在 Class 组件中,咱们一般会这么作:

state = {
  countdown: 0
}

timer = null

startCountdown = (duration) => {
  this.setState({ countdown: duration }, () => {
    this.timer = setInterval(() => {
      this.setState({ countdown: this.state.countdown - 1 }, () => {
        if (!this.state.countdown) {
          clearInterval(this.timer)
        }
      })
    }, 1000)
  })
}
复制代码

如今咱们尝试改用 Hooks 来实现。

设置一个 state 用于存储当前剩余秒数,而后在 setInterval() 的回调函数中更新这个值(经过回调函数的写法,咱们不须要引用这个 state 也能正确更新它)。很好,倒计时开始了,页面上也能获取到更新了,目前为止一切顺利。

const [countdown, setCountdown] = useState(0)
const timer = useRef()

function startCountdown (duration) {
  setCountdown(duration)
  timer = setInterval(() => {
    setCountdown(c => c - 1)
  }, 1000)
}
复制代码

三、二、一、0、-1?问题出现了。咱们但愿数到 0 的时候结束倒计时,为此咱们须要判断 countdown 是否为 0 以决定是否要 clearInterval(),然而如今咱们没法直接读取 countdown 的最新值。为了能读到 countdown 的最新值,咱们须要把这个逻辑放到一个 Effect 里,并把 countdown 放进依赖数组中。

const [countdown, setCountdown] = useState(0)

function startCountdown (duration) {
  setCountdown(duration)
}

useEffect(() => {
  if (!countdown) return
  timer = setInterval(() => {
    setCountdown(c => c - 1)
    if (!countdown) {
      clearInterval(timer)
    }
  }, 1000)
}, [countdown])
复制代码

然而事情尚未结束,仔细看一下代码,不难发现每次 countdown 更新都会触发一次新的 setInterval(),这并非咱们想要的。而且咱们无法提早结束这个计时器。

哎~明明在 Class 组件中很简单的事情,怎么到了 Hooks 中这么复杂。

解决方案看这里,你会惊讶的。

小结

第一次看到官方文档中的「It takes a bit of a mindshift to start “thinking in Hooks”」这句话的时候,我并无太当回事,以为无非就是有同样新东西要学而已。时隔几个月再看,这句话份量仍是挺重的。从 Class 到 Hooks 的变化真的很大,不少思惟模式都变了,咱们甚至须要接受一些曾经极力避免的反模式。

React 从一开始就推崇声明式的设计,万物皆组件,最大的感觉就是路由的设计。Hooks 相比 Class 更加符合声明式的设计,今后 React 进入「万物皆函数」的时代。

若是你以为 Hooks 是一颗重磅炸弹,我建议你了解一下 Concurrent Mode。而后你会发现,Hooks 只是一道前菜,是为后面真正的主菜作铺垫用的。

相关文章
相关标签/搜索