React移动端项目---拼图验证码

258 阅读5分钟

前言

该文章记录一下拼图验证码组件,后续将会逐步增加,所有内容均从网上整理而来,加上自己得理解做一个整合,方便工作中使用。

一、需求

  • 拼图的背景图默认情况下随机生成
  • 有重置刷新功能
  • 验证状态要有:拖拽时-成功-失败
  • 自定义大小
  • 自定义事件

二、代码思路

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;
        }
      }
    }
  }
}