React滑块验证码V2

采用react-redux最佳实践RTK Query,并且TypeScript

index.tsx

import React, { useCallback, useEffect, useRef, useState } from 'react'
import './index.scss'
import { Button, Modal, Skeleton, Spin } from 'antd'
import { SUCCESS_STATUS } from '@/constants'
import { ReloadOutlined, CloseOutlined, ArrowRightOutlined } from '@ant-design/icons'
import type { RootState } from '@/store'
import { useLazyGetCaptchaQuery } from '@/features/api'
import { useSelector } from 'react-redux'
import moment from 'moment'

const emptyCaptcha =
  'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAV4AAACvCAYAAAC1krYSAAAAAXNSR0IArs4c6QAABv5JREFUeF7t1jFuHEEQA0Dd/3+sRAbsxIkN6bTHWVLlWLfdXRwQfry/v3+8+UeAAAECMYGH4o1ZG0SAAIHfAorXQyBAgEBYQPGGwY0jQICA4vUGCBAgEBZQvGFw4wgQIKB4vQECBAiEBRRvGNw4AgQIKF5vgAABAmEBxRsGN44AAQKK1xsgQIBAWEDxhsGNI0CAgOL1BggQIBAWULxhcOMIECCgeL0BAgQIhAUUbxjcOAIECCheb4AAAQJhAcUbBjeOAAECitcbIECAQFhA8YbBjSNAgIDi9QYIECAQFlC8YXDjCBAgoHi9AQIECIQFFG8Y3DgCBAgoXm+AAAECYQHFGwY3jgABAorXGyBAgEBYQPGGwY0jQICA4vUGCBAgEBZQvGFw4wgQIKB4vQECBAiEBRRvGNw4AgQIKF5vgAABAmEBxRsGN44AAQKK1xsgQIBAWEDxhsGNI0CAgOL1BggQIBAWULxhcOMIECCgeL0BAgQIhAUUbxjcOAIECCheb4AAAQJhAcUbBjeOAAECitcbIECAQFhA8YbBjSNAgIDi9QYIECAQFlC8YXDjCBAgoHi9AQIECIQFFG8Y3DgCBAgoXm+AAAECYQHFGwY3jgABAorXGyBAgEBYQPGGwY0jQICA4vUGCBAgEBZQvGFw4wgQIKB4vQECBAiEBRRvGNw4AgQIKF5vgAABAmEBxRsGN44AAQKK1xsgQIBAWEDxhsGNI0CAgOL1BggQIBAWULxhcOMIECCgeL0BAgQIhAUUbxjcOAIECCheb4AAAQJhAcUbBjeOAAECitcbIECAQFhA8YbBjSNAgIDi9QYIECAQFlC8YXDjCBAgoHi9AQIECIQFFG8Y3DgCBAgoXm+AAAECYQHFGwY3rlfg4+Pj7fF49B5g89sIKN7bRGGRuwso3rsn1LOf4u3JyqaHBRTv4QCGxiveoTCd8loBxfta35/0dcU7nPbfRaE0hoN2Wp2A4q2LrHNhxd+Zm61fI6B4X+PqqwQIEPingOL1OOYEvvK/66/87RyUg44JKN5j9AbfQUDx3iGFn7eD4v15md/q4tPFd3r+rcKwTExA8caozwy6e7Gc3u/0/DOvwtTTAor3dAIH59+hdE7vcHr+wfiNPiigeA/iN4++qrCu+s6zlqfn/2/vO+/2rLff/RFQvF7CUYHT5XJ6/lF8w48JKN5j9AbfQeDK4r3yW3ewscPrBBTv62x9uUBAWRaENLii4h0M1UmfF1C8n7fyl9cJKN7rLCu+dFXRXPWd02h3vuPOu53OrX2+4m1P0P7fElBu3+Lz4ycFFO+TcH62IaB4N3Jsu0LxtiVm30sFni3eZ3936fI+ViugeGujs/gVAgr0CkXf+KqA4v2qmL+fElC8U3HWHKN4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwKKdyVJdxAgUCOgeGuisigBAisCinclSXcQIFAjoHhrorIoAQIrAop3JUl3ECBQI6B4a6KyKAECKwK/APJhIeCPK54wAAAAAElFTkSuQmCC'

interface Props {
  /**
   * 是否打开
   */
  captchaOpen: boolean
  /**
   * 校验函数
   * @param randomKey 滑块随机key
   * @param x 滑动x坐标
   * @returns Func
   */
  endMove: (randomKey: string, x: number) => void
  /**
   * 改变父组件中状态
   * @param open 是否打开
   * @returns Func
   */
  changeModalOpen: (open: boolean) => void
  /**
   * 校验错误
   */
  checkError: boolean
  /**
   * 改变校验错误
   * @param error 是否校验错误
   * @returns Func
   *
   */
  changeCheckError: (error: boolean) => void
  /**
   * 校验中
   */
  checkLoading: boolean
  /**
   * 背景图与裁剪图的padding
   */
  backPadding: number
}

const ImageCaptcha: React.FC<Props> = (
  props: Props = {
    captchaOpen: false,
    endMove: () => {},
    changeModalOpen: () => {},
    checkError: false,
    changeCheckError: () => {},
    checkLoading: false,
    backPadding: 4,
  }
) => {
  const { captchaOpen, endMove, changeModalOpen, checkError, changeCheckError, checkLoading, backPadding } = props

  const [getCaptcha] = useLazyGetCaptchaQuery()
  const isMobile = useSelector((state: RootState) => state.config.isMobile)

  const [backImage, setBackImage] = useState('')
  const [cutImage, setCutImage] = useState('')
  const [position, setPosition] = useState({ x: backPadding, y: 0 })
  const positionRef = useRef({ x: backPadding, y: 0 })
  const [modalLoading, setModalLoading] = useState(false)
  const [randomKey, setRandomKey] = useState('')
  const [cutWidth, setCutWidth] = useState(0)
  const [cutHeight, setCutHeight] = useState(0)
  const [costTime, setCostTime] = useState(0)
  const [showCostTime, setShowCostTime] = useState(false)
  const sliderAreaRef = useRef<any>(null)
  const sliderBoxRef = useRef<any>(null)
  const startTimeRef = useRef<number>(0)
  const movableRef = useRef<boolean>(false)
  const movePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
  const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
  const handleMouseUpRef = useRef<(() => void) | null>(null) // 使用 ref 存储 handleMouseUp,避免循环依赖

  const getCaptchaData = useCallback(async () => {
    setModalLoading(true)
    try {
      const res: any = await getCaptcha().unwrap()
      if (res) {
        if (res.status === SUCCESS_STATUS) {
          setBackImage('data:image/png;base64,' + res.data.oriImage)
          setCutImage('data:image/png;base64,' + res.data.cutImage)
          setPosition({ x: backPadding, y: res.data.y })
          positionRef.current = { x: backPadding, y: res.data.y }
          movePositionRef.current = { x: backPadding, y: res.data.y }
          movableRef.current = false
          setCutHeight(res.data.cutImageHeight)
          setCutWidth(res.data.cutImageWidth)
          setRandomKey(res.data.randomKey)
          setModalLoading(false)
          mousePositionRef.current = { x: 0, y: 0 }
          setShowCostTime(false)
        } else {
          setModalLoading(false)
          setShowCostTime(false)
        }
      }
    } catch (error) {
      console.log('😅 Failed to get captcha error:', error)
    }
  }, [getCaptcha, backPadding])

  /**
   * 处理弹窗打开后的回调
   * @param open 是否打开
   */
  const handleAfterOpenChange = (open: boolean) => {
    if (open) {
      setModalLoading(true)
      getCaptchaData()
    }
  }

  /**
   * 处理校验错误 - 使用 useEffect 在提交阶段执行
   */
  useEffect(() => {
    if (checkError) {
      // 使用 requestAnimationFrame 确保在浏览器绘制后执行
      const timer = requestAnimationFrame(() => {
        getCaptchaData()
        changeCheckError(false)
      })
      return () => cancelAnimationFrame(timer)
    }
  }, [checkError, changeCheckError, getCaptchaData])

  /**
   * 检查当前浏览器是否支持被动事件监听器
   * @returns 如果支持返回 true,否则返回 false
   */
  const isPassiveEventSupported = () => {
    let supportsPassive = false
    try {
      const opts = Object.defineProperty({}, 'passive', {
        get: () => {
          supportsPassive = true
          return true
        },
      })
      // 使用类型断言解决 TypeScript 类型错误
      window.addEventListener('test', () => {}, opts)
    } catch (error) {
      console.log('😅 Failed to detect passive event support:', error)
    }
    return supportsPassive
  }

  // 使用 requestAnimationFrame 节流
  const rafIdRef = useRef<number | null>(null)

  /**
   * 处理移动事件
   * @param e 移动事件
   */
  const handleMouseMove = useCallback(
    (e: MouseEvent | TouchEvent) => {
      // 在移动端或者不支持passive事件的情况下,阻止默认行为
      if ('touches' in e || !isPassiveEventSupported()) {
        e.preventDefault()
      }
      if (!movableRef.current) return

      // 使用 requestAnimationFrame 节流,避免频繁更新
      if (rafIdRef.current) return

      rafIdRef.current = requestAnimationFrame(() => {
        rafIdRef.current = null
        const enventX = 'touches' in e ? e.touches[0].clientX : e.clientX
        const moveX = enventX - mousePositionRef.current.x
        const rect = sliderAreaRef.current.getBoundingClientRect()

        let newX = moveX
        if (moveX < backPadding) {
          newX = backPadding
        } else if (moveX > rect.width - sliderBoxRef.current.offsetWidth - backPadding) {
          newX = rect.width - sliderBoxRef.current.offsetWidth - backPadding
        }

        positionRef.current = { ...positionRef.current, x: newX }
        movePositionRef.current = { ...movePositionRef.current, x: newX }
        setPosition({ x: newX, y: positionRef.current.y })
      })
    },
    [backPadding]
  )

  /**
   * 处理移动结束事件
   * @returns 校验函数
   */
  const handleMouseUp = useCallback(() => {
    document.removeEventListener('mousemove', handleMouseMove)
    document.removeEventListener('mouseup', handleMouseUpRef.current!)
    document.removeEventListener('touchmove', handleMouseMove)
    document.removeEventListener('touchend', handleMouseUpRef.current!)
    // 取消未执行的动画帧
    if (rafIdRef.current) {
      cancelAnimationFrame(rafIdRef.current)
      rafIdRef.current = null
    }
    if (!movableRef.current) return
    if (backImage === '' || cutImage === '') return
    movableRef.current = false
    setCostTime(moment().valueOf() - startTimeRef.current)
    setShowCostTime(true)
    setTimeout(() => {
      endMove(randomKey, movePositionRef.current.x)
    }, 500)
  }, [handleMouseMove, backImage, cutImage, endMove, randomKey])

  // 使用 useEffect 在提交阶段更新 ref
  useEffect(() => {
    handleMouseUpRef.current = handleMouseUp
  })

  /**
   * 处理移动开始事件
   * @param e 移动事件
   */
  const handleMouseDown = useCallback(
    (e: React.MouseEvent | React.TouchEvent) => {
      // 在移动端或者不支持passive事件的情况下,阻止默认行为
      if ('touches' in e || !isPassiveEventSupported()) {
        e.preventDefault()
      }
      if (backImage === '' || cutImage === '' || movableRef.current) return
      startTimeRef.current = moment().valueOf()
      movableRef.current = true
      mousePositionRef.current = {
        x: 'touches' in e ? e.touches[0].clientX : e.clientX,
        y: 'touches' in e ? e.touches[0].clientY : e.clientY,
      }
      document.addEventListener('mousemove', handleMouseMove)
      document.addEventListener('mouseup', handleMouseUpRef.current!)
      document.addEventListener('touchmove', handleMouseMove, { passive: false })
      document.addEventListener('touchend', handleMouseUpRef.current!, { passive: false })
    },
    [handleMouseMove, backImage, cutImage]
  )

  /**
   * 关闭弹窗
   */
  const closeModal = () => {
    // 取消未执行的动画帧
    if (rafIdRef.current) {
      cancelAnimationFrame(rafIdRef.current)
      rafIdRef.current = null
    }
    changeModalOpen(false)
    setBackImage('')
    setCutImage('')
    setPosition({ x: backPadding, y: 0 })
    positionRef.current = { x: backPadding, y: 0 }
    mousePositionRef.current = { x: 0, y: 0 }
    movePositionRef.current = { x: backPadding, y: 0 }
    movableRef.current = false
    setModalLoading(false)
    setCutWidth(0)
    setCutHeight(0)
    setRandomKey('')
  }

  return (
    <Modal
      destroyOnHidden
      open={captchaOpen}
      title="请进行验证"
      footer={null}
      width={isMobile ? 320 : 370}
      centered
      onCancel={() => closeModal()}
      afterOpenChange={handleAfterOpenChange}
      styles={{ container: { padding: '20px 0px' }, body: { padding: '24px 10px' }, title: { padding: '0px 16px' } }}
    >
      <Spin spinning={checkLoading || modalLoading}>
        {showCostTime && (
          <div
            className="cost-time"
            style={{ height: `${showCostTime && costTime > 0 ? 30 : 0}px`, bottom: isMobile ? 60 : 70 }}
          >
            耗时{costTime / 1000.0}s
          </div>
        )}
        {!checkLoading || !modalLoading ? (
          <>
            <div className="image-captcha-box" style={{ height: isMobile ? 150 : 175 }}>
              <img src={backImage || emptyCaptcha} alt="背景图" className="image-captcha-box-bg" />
              {cutImage && (
                <img
                  src={cutImage}
                  alt="裁剪图"
                  className="image-captcha-box-cut"
                  style={{
                    top: `${position.y}px`,
                    left: `${position.x}px`,
                    height: `${cutHeight}px`,
                    width: `${cutWidth}px`,
                  }}
                />
              )}
            </div>

            <div className="slider-area" ref={sliderAreaRef}>
              <div
                className="slider-box"
                ref={sliderBoxRef}
                onMouseDown={(e) => handleMouseDown(e)}
                onTouchStart={(e) => handleMouseDown(e)}
                style={{ left: `${position.x}px`, width: `${cutWidth || 50}px` }}
              >
                <ArrowRightOutlined />
              </div>
            </div>

            <div style={{ width: '100%', height: isMobile ? 30 : 40 }}></div>
          </>
        ) : (
          <Skeleton
            active
            style={{ width: '100%', height: '100%' }}
            paragraph={{
              rows: 6,
            }}
          />
        )}
      </Spin>

      <div className="button-box">
        <Button disabled={checkLoading || modalLoading} onClick={() => closeModal()}>
          <CloseOutlined />
          关闭
        </Button>
        <Button disabled={checkLoading || modalLoading} type="primary" onClick={() => getCaptchaData()}>
          <ReloadOutlined />
          刷新
        </Button>
      </div>
    </Modal>
  )
}

export default ImageCaptcha

index.scss

.cost-time {
  position: absolute;
  line-height: 30px;
  width: 100%;
  height: 30px;
  text-align: center;
  background-color: #3cbb4c;
  color: #fff;
  transition: all 0.5s ease-in-out;
  z-index: 1;
}

.image-captcha-box {
  width: 100%;
  position: relative;

  .image-captcha-box-bg {
    width: 100%;
    height: 100%;
    position: absolute;
    top: 0;
    left: 0;
  }

  .image-captcha-box-cut {
    position: absolute;
  }
}

.slider-area {
  position: relative;
  width: 100%;
  height: 10px;
  margin-top: 20px;
  border-radius: 50px;
  box-sizing: border-box;
  background-color: #f0f0f0;
  z-index: 2;

  .slider-box {
    position: absolute;
    top: 50%;
    height: 24px;
    color: #fff;
    font-size: 16px;
    line-height: 24px;
    transform: translate(0, -50%);
    background-color: #007bff;
    border-radius: 25px;
    cursor: pointer;
    text-align: center;
  }
}

.button-box {
  width: 100%;
  display: flex;
  justify-content: space-between;
}

使用

<ImageCaptcha
  captchaOpen={captchaOpen}
  endMove={(randomKey, x) => {
    checkCaptcha(randomKey, x)
  }}
  changeModalOpen={() => setCaptchaOpen(false)}
  checkError={captchaError}
  checkLoading={captchaLoading}
  changeCheckError={() => setCaptchaError(false)}
  backPadding={4}
/>