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}
/>
React简易滑块验证码V2
https://www.youcats.cn/archives/1775919514715
评论