JS 实现一个发送验证码的计时器效果(附 React 中实现)

553 阅读7分钟

一场闭包导致的渲染问题引发的记录...
本文涉及到的一些关键点:setIntervalclearInterval、闭包、React 更新机制

一、实现效果

tutieshi_640x377_16s.gif

需求:

  1. 点击按钮后开始读秒
  2. 读秒期间不可点击
  3. 读秒结束后可以继续点击

二、实现思路(步骤)

  1. 要做到计时器的效果,首先得实现一个读秒的效果
let count = 60; // 这里只做演示说明 Gif 中是 10
setInterval(() => {
  count = count - 1
}, 1000)
  1. 清除定时器的效果,处理状态
版本一: 执行结束后清除
let count = 10;
let timer = setInterval(() => {
  count = count - 1
  if (count === 0) {
    timer && clearInterval(timer)
  }
}, 1000)
版本二: 使用 setTimeout 销毁 setInterval
let count = 10;
let timer = setInterval(() => {
  count = count - 1
}, 1000)

// 这里我用 setTimeout 来做 clearInterval 的操作
setTimeout(function () {
  timer && clearInterval(timer)
  // 清理定时器的时机为 10 次之后
}, 10 * 1000)

很明显,第一种实现方案更加优雅一点,后续 步骤 3 中也可以使用版本一。在这里我选择了版本二的实现方式。

注:

这里对于 setTimeout 清理 setInterval 这个定时器做一个说明
原本我的实现是在 setInterval 的执行中通过判断计数来销毁这个定时器的(方案一),但是发现在 React 中实现时,定时器依然在执行,执行的原因是:
1、setInterval 函数会在执行完回调函数后,创建一个新的定时器,并将旧的定时器销毁。
2、在使用 hook 来存储状态的时候,闭包保持了状态,需要注意状态的引用以免影响到执行时机。

通过以上两步,就可以实现了需求 1。将上述操作封装函数就可以得到一个简单的定时器功能了。

  1. 处理定时器的读秒期间不可点击实现(需求 2
/**
 * @param {(count: number) => count} time 定时器执行时间间隔
 * @param {number} initCount 调用次数
 * @param {number} time 定时器间隔毫秒数
 * @returns [开始调用, 计数器, 是否执行中]
 */
let tiggering = false;
export const tigger = (callback: (count: number) => count, initCount = 60, delay = 1000) => {
    let count = initCount;
    let timer: NodeJS.Timeout | null = null
    
    // 如果未触发,则可以执行定时器
    if (!tiggering) {
        tiggering = true
        timer = setInterval(() => {
            count = count - 1
            // 定时器每次执行后更新回调,这样可以在页面使用该状态
            callback(count)
        }, delay)
    }

    setTimeout(function () {
        // 定时器结束时设置更新触发状态
        tiggering = false
        timer && clearInterval(timer)
    }, count * delay)
}

在这里我是将逻辑部分抽离为工具函数的使用,在实际项目中有步骤 2,在合适的时机做相对应的逻辑处理,基本够用了。
工具函数支持传入一个回调、执行次数以及每次执行的时间间隔。

这段代码的实现基本没什么大的问题了,tiggering 状态的维护是放在了工具函数之外,感觉不是很优雅。在函数内部的实现话就需要用到闭包维护内部函数的形式来保护这个变量了,这点大家可以看看表达下自己的想法~

三、封装一个适用的 React 自定义 hook

实现思路大致和 步骤 3 类似:
import { useState } from 'react'

/**
 * @param initCount 调用次数
 * @param time 定时器执行时间间隔,这里支持修改真实的时间间隔
 * @returns [开始调用, 计数器, 是否执行中]
 */
export default (initCount = 120, delay = 1000): [() => void, number, boolean] => {
  // 维护调用次数
  const [count, setCount] = useState(initCount)
  // 维护触发状态,用于 UI 部分实现禁用
  const [tiggering, setTiggering] = useState(false)

  let timer: NodeJS.Timeout | null = null
  const tigger = () => {
    // 注:在这里又进行了一次 setCount 赋值操作,是为了解决第一次验证码读秒结束后,
    // 第二次验证码执行(tigger 函数调用)时,自定义 hook 已经执行,count 的值为 0 的情况
    setCount(initCount)
    if (!tiggering) {
      setTiggering(true)
      timer = setInterval(() => {
        setCount(count => count - 1)
      }, delay)
    }

    setTimeout(function () {
      timer && clearInterval(timer)
      setTiggering(false)
    }, initCount * delay)
  }

  return [tigger, count, tiggering]
}
组件中使用

这里简写实现过程

// ... 引入略
const [tigger, count, tiggering] = useTimeCount(10)

const sendCode = () => {
    // 发送验证吗
    getMailCode({ receiver: code }).then(res => {
        if (res) {
            tigger()
            // 这里可以执行其他逻辑,如发送成功的提示
        }
    })
}

return (
    <Button disabled={tiggering} className='send-btn' onClick={sendCode}>
        {tiggering ? `${count}s` : 'Send code'}
    </Button>
)

四、在 React 开发实现需要注意到的地方

如果看官对 React 的运行机制不是很感兴趣可以跳过本段落内容
这一小节更适合初步掌握 React 开发的同学,内容只对 clearInterval 触发时机这一核心逻辑展开讨论。不考虑边界情况。

1. 闭包问题

先来看看如下代码:使用 setInterval 的时候,期望在适应的时机使用 clearInterval 来销毁定时器中的问题

// ❎ 错误代码
import { useState } from 'react'

export default (delay = 1000) => {
    const [count, setCount] = useState(60)

    let timer: NodeJS.Timeout | null = null
    const tigger = () => 
        timer = setInterval(() => {
            setCount(count => count - 1)
            if (count === 0) {
                timer && clearInterval(timer)
            }
        }, delay)
    }

    return [tigger, count]
}

这段代码的执行有一个问题:count 会随着 setCount 的执行依次 -1 操作,页面上的读秒也会 -1
但是会有一个问题:当 count === 0 时,clearInterval 并没有触发。 造成这一现象的原因是:在代码中 count 的判断使用产生了闭包。

image.png setCount 此时已经执行到 54 了,但是这里闭包里保护的变量依然是 initCount 的值(60)。致使判断一直失效。

有一种不太优雅的解决方案是拿到最新的状态来做判断:

// ✅ 正确但不优雅的方案
import { useState } from 'react'

export default (delay = 1000) => {
    const [count, setCount] = useState(60)

    let timer: NodeJS.Timeout | null = null
    const tigger = () => 
        timer = setInterval(() => {
            setCount(count => {
                // 拿到最新的 count 做判断
                if (count === 0) {
                    timer && clearInterval(timer)
                    
                }
                return count - 1
            })
        }, delay)
    }

    return [tigger, count]
}

这里在 setCount 中拿到最新的 count 来做判断就可以实现效果了。React 不推荐在 setState 中写逻辑操作,我能想到的是代码看起来混乱暂时也没有其他的坏处了,看看大家有什么的建议可以一起讨论~

2. 使用 useEffect 监听

这里是一个使用 useEffect 监听 count 在合适的时机执行销毁的例子:

// ✅ 正确代码
import { useState, useEffect } from 'react'

export default (delay = 1000) => {
    const [count, setCount] = useState(60)
    /**
    * setCount 的执行会触发函数更新,使得原本 timer 的值为初始值 null;
    * 这样会在 clearInterval(timer) 失效;
    * 所以这里 timer 使用 useState 来保护变量。
    */
    const [timer, setTimer] = useState<NodeJS.Timeout | null>(null)
    // let timer: NodeJS.Timeout | null = null
    const tigger = () => 
        const timer = setInterval(() => {
            setCount(count => count - 1)
        }, delay)
        setTimer(timer)
    }

    useEffect(() => {
        if (count === 0) {
            timer && clearInterval(timer)
            setTiggering(false)
        }
    }, [count])

    return [tigger, count]
}

3. 不使用任何监听,直接在函数最顶层 clearInterval

我们都知道,每一次 setState 的执行都会触发 React 状态的更新从而达到组件的执行更新逻辑。
那么,我在这个自定义 hooks 每次执行更新的时候进行判断,于是我写下了如下代码:

// ❎ 错误代码
import { useState } from 'react'

export default (delay = 1000) => {
    const [count, setCount] = useState(60)
    const [timer, setTimer] = useState<NodeJS.Timeout | null>(null)

    const tigger = () => 
        const timer = setInterval(() => {
            setCount(count => count - 1)
        }, delay)
        setTimer(timer)
    }

    if (count === 0) {
        timer && clearInterval(timer)
        setTiggering(false)
    }

    return [tigger, count]
}

正当我信心满满写下这段代码的时候: image.png

死循环了,修复一下小插曲过后:

// ✅ 正确代码
import { useState } from 'react'

export default (initCount = 60, delay = 1000) => {
    const [count, setCount] = useState(initCount)
    const [timer, setTimer] = useState<NodeJS.Timeout | null>(null)

    const tigger = () => 
        const timer = setInterval(() => {
            setCount(count => count - 1)
        }, delay)
        setTimer(timer)
    }

    if (count === 0) {
        timer && clearInterval(timer)
        // 正是 count 为 0 的时候,执行 setTiggering 导致组件更新,
        // 这时候判断成立,循环 setTiggering
        setTiggering(false)
        // 所以这里要结束判断,在下一次生命周期中不判断
        setCount(initCount)
    }

    return [tigger, count]
}

这段代码运行应该是没有问题,虽然有点奇怪并且有发展为 “坏代码” 的趋势...

单从性能的角度上来说,以我浅显的认知,这段代码少了 useEffect 的使用,比起之前少了 setTimeout 的使用,首先内存上是节约了。但对于 hook 使用产生的 “副作用” 并不是一个好的处理。所以最终我的实现方案选择了 setTimeout 的处理方式来执行 clearInterval

小结

在实现这套流程的时候,一开始觉得是个简单的实现,在实现过程中还是踩了一些小坑的,比如上文中说到的 clearInterval 的销毁时机的问题;以及考虑了下普通函数封装(步骤 3)。
先记录一下~
如果有所帮助,可以点赞,另外文章中有书写✍🏻 不清晰或不到位的地方希望大家可以指出,谢谢。