定时器踩坑!为什么用了React Hook之后倒计时不动了?

4,581 阅读5分钟

React从16.8.0版本开始支持Hook,这让我们可以在函数组件里使用state以及其他的React特性,这给大家带来了很多便利,同时也增加了一些学习成本,其中定时器的使用就会让很多童鞋感到困扰。

例如我们需要一个10s的倒计时,下面这段代码就有一些问题:

// 错误示例
import React, { useEffect, useState } from 'react'
const Timer = () => {
  const [count, setCount] = useState(10)
  const otherFn = () => {
    console.log('otherFn')
  }
  useEffect(() => {
    const timer = setInterval(() => {
      if (count === 0) {
        clearInterval(timer)
        return
      }
      console.log('count', count)
      setCount(count - 1)
    }, 1000)
    otherFn() // 只需要调用一次的方法
    return () => {
      console.log('clean')
      clearInterval(timer)
    }
  }, []) // 避免otherFn多次运行,传入空数组
  return <div>剩余{count}秒</div>
}

export default Timer

运行后会发现count从10变成9之后就不再减一,从控制台打印的count看到,定时器其实一直在运行,只是count的值一直是10。

这是因为我们给useEffect的第二个参数传入了一个空数组,所以此hook只在组件挂载时运行一次,不会依赖count的变化再次运行。而函数组件每次渲染都会生成一个单独的版本(一个闭包),每个版本都有自己的count,Timer组件在首次的版本中count的值是10,不管定时器的delay时间是多久,它拿到的count永远是10。

那如何实现我们的需求呢?

将count写入依赖useEffect(() => {}, [count])似乎可以实现,事实也确实可以,但这样会导致定时器被频繁重置,如此setInterval就类似于setTimeout了。

而且我们只需要调用一次的方法otherFn也会被频繁调用。所以这种方法是不可取的。

React官方给出了三种解决思路:

  • 1、使用函数式更新count

setCount(c => c - 1)

这种类似于this.setState(() => {})的写法可以在函数内部获取到最新的count,但是函数外面的定时器里的count仍然是10,无法通过判断count的值进行if (count === 0) { clearInterval(timer) return }这样的逻辑处理,所以这种方案pass。

  • 2、 useReducer Hook 把 state 更新逻辑移到 effect 之外

详情可参考:adamrackis.dev/state-and-u… 对于我们这种简单的逻辑,书写大量的模板代码有点杀鸡用牛刀了,所以这种方法也不是很推荐,在此不做赘述。

  • 3、使用ref保存变量 useRef 返回一个可变的 ref 对象,我们将count的最新值保存到ref的current属性里,结合第一条思路修改代码如下:
import React, { useEffect, useState, useRef } from 'react'

const Timer = () => {
  const [count, setCount] = useState(10)
  const latestCount = useRef(count) // 定义一个ref,初始值是10
  const otherFn = () => {
    console.log('otherFn')
  }
  useEffect(() => {
    latestCount.current = count // 更新
  })
  useEffect(() => {
    const timer = setInterval(() => {
      if (latestCount.current === 0) { // 此处判断latestCount.current,而不是count
        clearInterval(timer)
        return
      }
      setCount(c => c - 1)
    }, 1000)
    otherFn()
    return () => {
      clearInterval(timer)
    }
  }, [])
  return <div>{count}</div>
}

export default Timer

这里用到了两个useEffect,在第一个useEffect里我们将latestCount.current的值指向count,类似于class组件中的this.count,而在另一个useEffect里我们处理其他的逻辑,这样就可以实现我们的需求啦。

接下来是思考时间

  • 1)我们知道hook其实是一个js函数,那么我们是不是可以将第一个useEffect提取出来,自定义一个hook呢?
import { useRef } from 'react'

const useValueRef = (params: any) => {
  const paramsRef = useRef(null)
  paramsRef.current = params
  return paramsRef
}

export default useValueRef

如此我们的代码就可以用useValueRef进一步改写,删除第一个useEffect:

import React, { useEffect, useState } from 'react'
import useValuesRef from './useValuesRef.ts'

const Timer = () => {
  const [count, setCount] = useState(10)
  const latestCount = useValuesRef(count) // useValuesRef
  const otherFn = () => {
    console.log('otherFn')
  }
  useEffect(() => {
    const timer = setInterval(() => {
      if (latestCount.current === 0) {
        clearInterval(timer)
        return
      }
      setCount(c => c - 1)
    }, 1000)
    otherFn()
    return () => { 
      clearInterval(timer)
    }
  }, [])
  return <div>{count}</div>
}

export default Timer

这样我们的代码就更加清晰便于阅读。

  • 2)我们回到之前使用两个useEffect的代码再次思考,可不可以在一个useEffect里定义定时器,另一个useEffect里处理逻辑呢?
// 错误示例
const [count, setCount] = useState(10)
let timer
useEffect(() => {
    otherFn()
    timer = setInterval(() => {
      setCount(c => c - 1)
    }, 1000)
    return () => {
      clearInterval(timer)
    }
  }, [])

  useEffect(() => {
    if (count === 0) {
      clearInterval(timer) // 此处可以清除定时器吗?
      return
    }
  }, [count])

上述代码犯了和开头代码同样的错误,函数组件每次渲染都会产生一个新的timer,所以在第二个useEffect里并不能清除第一次渲染时设置的定时器。解决方案同样是使用ref:

const [count, setCount] = useState(10)
const timer = useRef(null)
useEffect(() => {
    otherFn()
    timer.current = setInterval(() => {
      setCount(c => c - 1)
    }, 1000)
    return () => {
      clearInterval(timer.current)
    }
  }, [])

  useEffect(() => {
    if (count === 0) {
      clearInterval(timer.current) // 这里可以成功清除定时器
      return
    }
  }, [count])
  • 3)我们同样可以尝试将2)中的第二个useEffect挪出去,使组件里只有一个useEffect,这就需要一种全新的思路:
import { useEffect } from 'react'
import useValuesRef from './useValuesRef.ts'

const useInterval = (callback, delay) => {
  const savedCallback = useValuesRef(callback)

  useEffect(() => {
    if (delay !== null) {
      const timer = setInterval(() => {
        savedCallback.current()
      }, delay)
      return () => {
        clearInterval(timer) // delay改变时,旧的timer会被清除
      }
    }
  }, [delay])
}

export default useInterval

这里我们没有把timer保存到ref,而是将interval的函数入参变为可变的,这样每次获取的count值都是最新的,并且当delay改变时,我们的定时器会被清除,而如果我们传入delay为null时,定时器不会重新创建,改写代码如下:

import React, { useEffect, useState } from 'react'
import useInterval from './useInterval.ts'

const Timer = () => {
  const [count, setCount] = useState(10)
  const otherFn = () => {
    console.log('otherFn')
  }

  useInterval(() => {
    setCount(count - 1) // 每次渲染都会走这里,所以count值为最新
  }, count === 0 ? null : 1000)

  useEffect(() => {
    otherFn()
  }, [])

  return <div>{count}</div>
}

export default Timer

这样我们还可以实现更多功能,比如增加一个按钮实现定时器暂停和重置等等,童鞋们可以自己试一下。这种方式可以说一劳永逸,后面其他组件可以直接使用,方便快捷。

总结

这篇文章主要介绍了三种思路,来避免函数组件闭包产生的定时器的问题,文中具体实现了使用ref的方式,同时进行了三种优化,自定义了两个hook,最终推荐了方法3)。当然其他方法也都各有所长,实际使用中可以自行选取。童鞋们,你们学废了吗?

参考文章:
react.docschina.org/docs/hooks-…
overreacted.io/zh-hans/a-c…
overreacted.io/making-seti…