前言
该文章记录一下拼图验证码组件,后续将会逐步增加,所有内容均从网上整理而来,加上自己得理解做一个整合,方便工作中使用。
一、需求
- 拼图的背景图默认情况下随机生成
- 有重置刷新功能
- 验证状态要有:拖拽时-成功-失败
- 自定义大小
- 自定义事件
二、代码思路
1.HTML结构
<>
<div className={styles.sliderVertify}>
{/* 顶部图片部分: canvas图片背景+ canvas图片碎块 + 加载提示 + 重置按钮 */}
<div className={styles.content}>
<div className={styles.canvasArea}>
{/* canvas图片背景 */}
<canvas ref={canvasRef} width={w} height={h}/>
{/* canvas图片碎块 */}
<canvas ref={blockRef} className={styles.blockCanvas} width={w} height={h}/>
</div>
{/* 加载提示 */}
<div className={styles.loading} style={{ display: loading ? 'flex' : 'none' }}>
<LoadingOutlined />
<div className={styles.loadingTips}>加载中···</div>
</div>
{/* 重置按钮 */}
<div className={styles.reset} onClick={resetCanvas}>
<ReloadOutlined />
</div>
</div>
{/* 底部滑块部分: 轨道+按钮 + 文字提示 */}
<div className={classNames(styles.sliderBox, styles[status])}>
{/* 轨道 */}
<div className={styles.sliderMask} style={{ width: sliderLeft + 'px' }}>
{/* 按钮 */}
<div className={styles.slider}>
{(status === '' || status === 'move') && <ArrowRightOutlined />}
{status === 'fail' && <CloseOutlined />}
{status === 'success' && <CheckOutlined />}
</div>
</div>
{/* 文字提示 */}
{!isMove && <div className={styles.tips}>{'向右滑动填充拼图'}</div>}
</div>
</div>
</>
2.制作canvas图片背景 + canvas图片碎块
//canvas基本数值
const w = 320; // canvas宽度
const h = 200; // canvas高度
const l = 42; // 滑块的大小
const r = 9; // 滑块上剪切的圆的半径
const PI = Math.PI;
const L = l + r * 2 + 3; // 滑块实际边长
const imgRef = useRef<any>(null);
const blockRef = useRef<any>(null);
const canvasRef = useRef<any>(null);
function getRandomNumberByRange(start, end) {
return Math.round(Math.random() * (end - start) + start);
}
//随机获取图片-截取指定的长宽
function getRandomImgSrc() {
return `https://picsum.photos/id/${getRandomNumberByRange(0,1084)}/${w}/${h}`;
}
//1.创建img,设置其src
function createImg(onload) {
const img: any = new Image();
img.crossOrigin = 'Anonymous'//允许加载跨域图片
img.onload = onload;//当图像加载成功时,调用传入的 `onload` 回调函数
img.onerror = () => {
//果图像加载失败,将调用`setSrc`方法,尝试使用 `getRandomImgSrc()` 获取一个随机的图像源并重新加载
img.setSrc(getRandomImgSrc());
};
//定义了 `setSrc` 方法,用于设置图像的源(`src`)
img.setSrc = function(src) {
const isIE = window.navigator.userAgent.indexOf('Trident') > -1;
if (isIE) {
// IE浏览器无法通过img.crossOrigin跨域,使用ajax获取图片blob然后转为dataURL显示
const xhr = new XMLHttpRequest();
xhr.onloadend = function(e: any) {
// FileReader仅支持IE10+
const file = new FileReader();
file.readAsDataURL(e.target.response);
file.onloadend = function(e: any) {
img.src = e.target.result;
};
};
xhr.open('GET', src);
xhr.responseType = 'blob';
xhr.send();
} else img.src = src;
};
//初始化图像源
img.setSrc(getRandomImgSrc());
return img;
}
//制作拼图的方法(在Canvas上根据给定的起始坐标绘制一个复杂的路径)
function drawPath(ctx, x, y, operation) {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.arc(x + l / 2, y - r + 2, r, 0.72 * PI, 2.26 * PI);
ctx.lineTo(x + l, y);
ctx.arc(x + l + r - 2, y + l / 2, r, 1.21 * PI, 2.78 * PI);
ctx.lineTo(x + l, y + l);
ctx.lineTo(x, y + l);
ctx.arc(x + r - 2, y + l / 2, r + 0.4, 2.76 * PI, 1.24 * PI, true);
ctx.lineTo(x, y);
ctx.lineWidth = 2;
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
ctx.stroke();
ctx.globalCompositeOperation = 'destination-over';
operation === 'fill' ? ctx.fill() : ctx.clip();
}
//2.用于在画布上绘制一个复杂的拼图形状
function draw(img) {
// 随机位置创建拼图形状
const x = getRandomNumberByRange(L + 10, w - (L + 10));
const y = getRandomNumberByRange(10 + r * 2, h - (L + 10));
setRandomNum({ x, y });
//画入图片
const canvasCtx = canvasRef?.current.getContext('2d');
const blockCtx = blockRef?.current.getContext('2d');
drawPath(canvasCtx, x, y, 'fill');
drawPath(blockCtx, x, y, 'clip');
canvasCtx.drawImage(img, 0, 0, w, h);
blockCtx.drawImage(img, 0, 0, w, h);
// 提取拼图碎片并放到最左边
const y1 = y - r * 2 - 1;
const ImageData = blockCtx.getImageData(x - 3, y1, L, L);
blockRef.current.width = L;
blockCtx.putImageData(ImageData, 0, y1);
}
// 初始化:包含createImg()和draw(),暂时得到canvas图片背景 + canvas图片碎块
function initDOM() {
setLoading(true);
//创建一个img,并将其放入画布中
const img = createImg(() => {
setLoading(false);
draw(img);
});
imgRef.current = img;
}
3.拖拽事件、重置事件
一般将
鼠标移动/鼠标释放(当触摸设备上手指移动时/指从触摸屏上抬起)事件绑定在整个组件在最外层HTML元素上;但是通常拼图验证码组件是点击碎片或者底部滑块进行拖动,所以在碎片/滑块上也需要绑定事件,用变量来记录是否要执行最外层元素的事件,并且记录最初始的点击位置,然后和最外层元素的事件中反馈的位置进行计算,得出移动的距离。
//1.碎片和滑块绑定点击/触摸事件:onMouseDown={handleDragStart} onTouchStart={handleDragStart}
//获得初始点击位置---开启移动状态
function handleDragStart(e) {
if (loading) return;
setClickPositon({
x: e.clientX || e.touches[0].clientX,
y: e.clientY || e.touches[0].clientY,
});
setIsMove(true);
}
//2.最外层元素绑定点击/触摸事件
//2-1.移动事件---开始拖动,移动碎片/滑块的位置
function handleDragMove(e) {
if (!isMove) return;
e.preventDefault();
setStatus('move');
var eventX = e.clientX || e.touches[0].clientX;
var eventY = e.clientY || e.touches[0].clientY;
var moveX = eventX - clickPositon.x;
var moveY = eventY - clickPositon.y;
//范围检查
if (moveX < 0 || moveX + 38 >= w) return;
//移动拼图位置-滑块
const blockLeft = ((w - 40 - 20) / (w - 40)) * moveX;
blockRef.current.style.left = blockLeft + 'px';
setSliderLeft(moveX);
//记录竖直方向移动
setVerticalNum([...verticalNum, moveY]);
}
//2-2.鼠标/手指抬起事件---结束拖动,验证位置是否正确
function handleDragEnd(e) {
if (!isMove) return;
e.preventDefault();
setIsMove(false);
const eventX = e.clientX || e.changedTouches[0].clientX;
if (eventX === clickPositon.x) return;
const { spliced, verified } = verify();
if (spliced && verified) {
setStatus('success');
setTimeout(() => {
typeof onSuccess === 'function' && onSuccess();
}, 1000);
} else {
setTimeout(() => resetCanvas(), 1000);
setStatus('fail');
}
}
//校验方法:根据
function verify() {
//y轴的移动距离
const average = verticalNum.reduce(sum) / verticalNum.length;
const deviations = verticalNum.map(function(x) {
return x - average;
});
const stddev = Math.sqrt(
deviations.map(square).reduce(sum) / verticalNum.length,
);
const left = parseInt(blockRef.current.style.left);
return {
//x轴的移动距离与在画布上创建拼图形状时的随机位置比较,是否移动到正确范围
spliced: Math.abs(left - randomNum.x) < 10,
//简单验证拖动轨迹,为零时表示Y轴上下没有波动,可能非人为操作
verified: stddev !== 0,
};
}
//重置canvas事件
const resetCanvas = () => {
setLoading(true);
//清空画布
canvasRef.current.getContext('2d').clearRect(0, 0, w, h);
blockRef.current.getContext('2d').clearRect(0, 0, w, h);
imgRef.current.setSrc(getRandomImgSrc());
//恢复原位
blockRef.current.width = w;
blockRef.current.style.left = 0 + 'px';
setSliderLeft(0);
setStatus('');
};
三、完整版代码
//父组件--引用
import SliderVertify from '../sliderVertify';
<SliderVertify onSuccess={successVertify}
import React, { useEffect, useState, useRef } from 'react';
import styles from './index.less';
import { connect } from 'dva';
import {
ReloadOutlined,
LoadingOutlined,
ArrowRightOutlined,
CheckOutlined,
CloseOutlined,
} from '@ant-design/icons';
import classNames from 'classnames';
const SliderVertify = props => {
const { onSuccess } = props;
const w = 320; // canvas宽度
const h = 200; // canvas高度
const l = 42; // 滑块的大小
const r = 9; // 滑块上剪切的圆的半径
const PI = Math.PI;
const L = l + r * 2 + 3; // 滑块实际边长
const imgRef = useRef<any>(null);
const blockRef = useRef<any>(null);
const canvasRef = useRef<any>(null);
const [loading, setLoading] = useState(false);
const [isMove, setIsMove] = useState(false);
const [clickPositon, setClickPositon] = useState({ x: 0, y: 0 });
const [randomNum, setRandomNum] = useState({ x: 0, y: 0 });
const [verticalNum, setVerticalNum] = useState<Array<number>>([]);
const [sliderLeft, setSliderLeft] = useState(0);
const [status, setStatus] = useState('');
function sum(x, y) {
return x + y;
}
function square(x) {
return x * x;
}
function getRandomNumberByRange(start, end) {
return Math.round(Math.random() * (end - start) + start);
}
//随机获取图片
function getRandomImgSrc() {
return `https://picsum.photos/id/${getRandomNumberByRange(
0,
1084,
)}/${w}/${h}`;
}
//创建img-设置src
function createImg(onload) {
const img: any = new Image();
img.crossOrigin = 'Anonymous';
img.onload = onload;
img.onerror = () => {
img.setSrc(getRandomImgSrc());
};
img.setSrc = function(src) {
const isIE = window.navigator.userAgent.indexOf('Trident') > -1;
if (isIE) {
// IE浏览器无法通过img.crossOrigin跨域,使用ajax获取图片blob然后转为dataURL显示
const xhr = new XMLHttpRequest();
xhr.onloadend = function(e: any) {
// FileReader仅支持IE10+
const file = new FileReader();
file.readAsDataURL(e.target.response);
file.onloadend = function(e: any) {
img.src = e.target.result;
};
};
xhr.open('GET', src);
xhr.responseType = 'blob';
xhr.send();
} else img.src = src;
};
img.setSrc(getRandomImgSrc());
return img;
}
//拼图
function drawPath(ctx, x, y, operation) {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.arc(x + l / 2, y - r + 2, r, 0.72 * PI, 2.26 * PI);
ctx.lineTo(x + l, y);
ctx.arc(x + l + r - 2, y + l / 2, r, 1.21 * PI, 2.78 * PI);
ctx.lineTo(x + l, y + l);
ctx.lineTo(x, y + l);
ctx.arc(x + r - 2, y + l / 2, r + 0.4, 2.76 * PI, 1.24 * PI, true);
ctx.lineTo(x, y);
ctx.lineWidth = 2;
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
ctx.stroke();
ctx.globalCompositeOperation = 'destination-over';
operation === 'fill' ? ctx.fill() : ctx.clip();
}
//用于在画布上绘制一个复杂的拼图形状
function draw(img) {
// 随机位置创建拼图形状
const x = getRandomNumberByRange(L + 10, w - (L + 10));
const y = getRandomNumberByRange(10 + r * 2, h - (L + 10));
setRandomNum({ x, y });
//画入图片
const canvasCtx = canvasRef?.current.getContext('2d');
const blockCtx = blockRef?.current.getContext('2d');
drawPath(canvasCtx, x, y, 'fill');
drawPath(blockCtx, x, y, 'clip');
canvasCtx.drawImage(img, 0, 0, w, h);
blockCtx.drawImage(img, 0, 0, w, h);
// 提取滑块并放到最左边
const y1 = y - r * 2 - 1;
const ImageData = blockCtx.getImageData(x - 3, y1, L, L);
blockRef.current.width = L;
blockCtx.putImageData(ImageData, 0, y1);
}
// 初始化
function initDOM() {
setLoading(true);
//创建一个img,并将其放入画布中
const img = createImg(() => {
setLoading(false);
draw(img);
});
imgRef.current = img;
}
//刷新
const resetCanvas = () => {
setLoading(true);
//清空画布
canvasRef.current.getContext('2d').clearRect(0, 0, w, h);
blockRef.current.getContext('2d').clearRect(0, 0, w, h);
imgRef.current.setSrc(getRandomImgSrc());
//恢复原位
blockRef.current.width = w;
blockRef.current.style.left = 0 + 'px';
setSliderLeft(0);
setStatus('');
};
//点击拖拽-获得位置-开启移动状态
function handleDragStart(e) {
if (loading) return;
setClickPositon({
x: e.clientX || e.touches[0].clientX,
y: e.clientY || e.touches[0].clientY,
});
setIsMove(true);
}
//开始拖动
function handleDragMove(e) {
if (!isMove) return;
e.preventDefault();
setStatus('move');
var eventX = e.clientX || e.touches[0].clientX;
var eventY = e.clientY || e.touches[0].clientY;
var moveX = eventX - clickPositon.x;
var moveY = eventY - clickPositon.y;
//范围检查
if (moveX < 0 || moveX + 38 >= w) return;
//移动拼图位置-滑块
const blockLeft = ((w - 40 - 20) / (w - 40)) * moveX;
blockRef.current.style.left = blockLeft + 'px';
setSliderLeft(moveX);
//记录竖直方向移动
setVerticalNum([...verticalNum, moveY]);
}
//结束拖动
function handleDragEnd(e) {
if (!isMove) return;
e.preventDefault();
setIsMove(false);
const eventX = e.clientX || e.changedTouches[0].clientX;
if (eventX === clickPositon.x) return;
const { spliced, verified } = verify();
if (spliced && verified) {
setStatus('success');
setTimeout(() => {
typeof onSuccess === 'function' && onSuccess();
}, 1000);
} else {
setTimeout(() => resetCanvas(), 1000);
setStatus('fail');
}
}
//校验
function verify() {
//y轴的移动距离
const average = verticalNum.reduce(sum) / verticalNum.length;
const deviations = verticalNum.map(function(x) {
return x - average;
});
const stddev = Math.sqrt(
deviations.map(square).reduce(sum) / verticalNum.length,
);
//x轴的移动距离
const left = parseInt(blockRef.current.style.left);
return {
spliced: Math.abs(left - randomNum.x) < 10,
//简单验证拖动轨迹,为零时表示Y轴上下没有波动,可能非人为操作
verified: stddev !== 0,
};
}
useEffect(() => {
initDOM();
}, []);
return (
<>
<div
className={styles.sliderVertify}
onMouseMove={handleDragMove}
onMouseUp={handleDragEnd}
onTouchMove={handleDragMove}
onTouchEnd={handleDragEnd}
>
<div className={styles.content}>
<div className={styles.canvasArea}>
<canvas width={w} height={h} ref={canvasRef}></canvas>
<canvas
onMouseDown={handleDragStart}
onTouchStart={handleDragStart}
className={styles.blockCanvas}
width={w}
height={h}
ref={blockRef}
></canvas>
</div>
<div
className={styles.loading}
style={{ display: loading ? 'flex' : 'none' }}
>
<LoadingOutlined />
<div className={styles.loadingTips}>加载中···</div>
</div>
<div className={styles.reset} onClick={resetCanvas}>
<ReloadOutlined />
</div>
</div>
<div className={classNames(styles.sliderBox, styles[status])}>
<div
className={styles.sliderMask}
style={{ width: sliderLeft + 'px' }}
>
<div
className={styles.slider}
onMouseDown={handleDragStart}
onTouchStart={handleDragStart}
>
{(status === '' || status === 'move') && <ArrowRightOutlined />}
{status === 'fail' && <CloseOutlined />}
{status === 'success' && <CheckOutlined />}
</div>
</div>
{!isMove && <div className={styles.tips}>{'向右滑动填充拼图'}</div>}
</div>
</div>
</>
);
};
export default connect()(SliderVertify);
.sliderVertify {
.content {
width: 320px;
height: 200px;
background: #edf0f2;
position: relative;
.canvasArea {
width: 100%;
height: 100%;
}
.blockCanvas {
position: absolute;
left: 0;
top: 0;
cursor: pointer;
cursor: grab;
}
.loading {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: #edf0f2;
position: absolute;
top: 0;
left: 0;
z-index: 2;
.loadingTips {
font-size: 14px;
color: #45494c;
margin-top: 10px;
letter-spacing: 1px;
}
}
.reset {
position: absolute;
top: 0;
right: 0;
z-index: 1;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
}
.sliderBox {
width: 320px;
height: 40px;
position: relative;
border: 1px solid #e4e7eb;
background-color: #f7f9fa;
.tips {
color: #45494c;
text-align: center;
line-height: 38px;
font-size: 15px;
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 0;
}
.sliderMask {
height: 100%;
position: relative;
top: 0;
left: 0;
z-index: 1;
height: 40px;
.slider {
width: 40px;
height: 40px;
background: #fff;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.3);
transition: background 0.2s linear;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: -1px;
right: -40px;
}
}
&.move {
.sliderMask {
height: 40px;
top: -1px;
left: -1px;
background-color: #d1e9fe;
border: 1px solid #1991fa;
box-sizing: border-box;
.slider {
height: 38px;
top: -1px;
border: 1px solid #1991fa;
box-sizing: content-box;
}
}
}
&.success {
.tips {
display: none;
}
.sliderMask {
height: 40px;
top: -1px;
left: -1px;
border: 1px solid #52ccba;
background-color: #d2f4ef;
box-sizing: border-box;
.slider {
height: 38px;
top: -1px;
box-sizing: content-box;
border: 1px solid #52ccba;
background-color: #52ccba !important;
color: #fff;
}
}
}
&.fail {
.tips {
display: none;
}
.sliderMask {
height: 40px;
top: -1px;
left: -1px;
border: 1px solid #f57a7a;
background-color: #fce1e1;
box-sizing: border-box;
.slider {
height: 38px;
top: -1px;
box-sizing: content-box;
border: 1px solid #f57a7a;
background-color: #f57a7a !important;
color: #fff;
}
}
}
}
}