Skip to content
On this page

图片滑动验证的实现

本章主要讲的是登录的时候为了防止机器操作而使用图片拼图进行验证的实现方法。

实现方式

第一步:先将基本样式完善好以及使用到参数准备好

template部分代码

vue
<template>
	<div class="sv-box" :style="{ width: w + 'px' }" onselectstart="return false">
		<!-- 加载背景图片遮罩层 -->
		<div :class="{ 'bg-mask': state.loadFlag }"></div>
        <!-- 刷新 -->
		<div v-show="show" class="sv-refresh" @click="refresh"></div>
		<canvas :width="props.w" :height="props.h" ref="canvas"> </canvas>
		<canvas :width="props.w" :height="props.h" ref="block" class="canvas-block"> </canvas>
		<!-- 滑动条容器 -->
		<div
			class="slider"
			:class="{
                // 滑动过程中的样式
				active: state.sliderActive,
                // 验证通过时的样式
				success: state.sliderSuccess,
                // 验证失败时的样式
				fail: state.sliderFail
			}"
		>
			<div class="slider--mask" :style="{ width: state.sliderMaskWidth }">
				<!-- 滑动块 -->
				<div class="slider--inner" :style="{ left: state.sliderLeft }" @mousedown="sliderDown">
					<div class="slider--icon"></div>
				</div>
			</div>
			<span class="slider--text">{{ text }}</span>
		</div>
	</div>
</template>

ts参数部分

ts
const props = defineProps({
		l: {
			type: Number,
			default: 42
		},
		w: {
			type: Number,
			default: 310
		},
		h: {
			type: Number,
			default: 155
		},
		text: {
			type: String,
			default: '向右侧滑动'
		},
        // 精准度
		accuracy: {
			type: Number,
			default: 5
		},
        // 是否展示刷新
		show: {
			type: Boolean,
			default: true
		},
		bgList: {
			type: Array,
			default: () => [
				'https://storage.beta.custouch.com/res/8663/bg1.png',
				'https://storage.beta.custouch.com/res/8664/bg2.png',
				'https://storage.beta.custouch.com/res/8662/banner2.png',
				'https://storage.beta.custouch.com/res/8665/bg3.png',
				'https://storage.beta.custouch.com/res/8666/bg4.png',
				'https://storage.beta.custouch.com/res/8667/bg5.png'
			]
		}
	})

	const state = reactive<Record<string, any>>({
		sliderActive: false,
		sliderSuccess: false,
		sliderFail: false,
		canvasCtx: null,
		blockCtx: null,
		block: null,
		blockX: undefined,
		blockY: undefined,
		L: props.l + 4,
		img: undefined,
		originX: undefined,
		isMouseDown: false,
		sliderLeft: '0px',
		sliderMaskWidth: 0,
		success: false,
		loadFlag: false
	})

	const block = ref<HTMLCanvasElement>()
	const canvas = ref<HTMLCanvasElement>()

第二步:添加一个校验区域以及被校验区域

使用canvas画出对应的校验区域以及被校验区域,被校验区域通过canvas的getImageData()复制画布上指定的矩形的像素元素,再通过putImageData()将图像放回画布上。代码如下:

ts
const initDom = () => {
		// 获取canvas画布的绘图环境
		state.block = block.value
		state.canvasCtx = canvas.value?.getContext('2d')
		state.blockCtx = state.block.getContext('2d')
	}
    const initImg = () => {
		const img = createImg(() => {
			state.loadFlag = false
			drawBlock()
			state.canvasCtx.drawImage(img, 0, 0, props.w, props.h)
			state.blockCtx.drawImage(img, 0, 0, props.w, props.h)
			const _y = state.blockY - 1
			const imageDate = state.blockCtx.getImageData(state.blockX, _y, state.L, state.L)
			state.block.width = state.L
			state.blockCtx.putImageData(imageDate, 0, _y)
		})
		state.img = img
	}
    const createImg = (onload: any) => {
		state.loadFlag = true
		const img = new Image()
		img.crossOrigin = 'Anonymous'
		// 图片预加载方法
		img.onload = onload
		img.onerror = () => {
			console.error('Background image failed to load')
		}
		img.src = getRandomBg() as string

		return img
	}
	const getRandomBg = () => {
		const len = props.bgList.length
		return props.bgList[getRandomNumberByRange(0, len)]
	}

	const getRandomNumberByRange = (start: number, end: number) => {
		return Math.round(Math.random() * (end - start) + start)
	}
    const drawBlock = () => {
		state.blockX = getRandomNumberByRange(state.L + 10, props.w - (state.L + 10))
		state.blockY = getRandomNumberByRange(10, props.h - (state.L + 10))
		draw(state.canvasCtx, state.blockX, state.blockY, 'fill')
		draw(state.blockCtx, state.blockX, state.blockY, 'clip')
	}
    const draw = (ctx: any, x: number, y: number, type: string) => {
		const { l } = props
		ctx.beginPath()
		ctx.rect(x, y, l, l)
		ctx.lineWidth = 2
		ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'
		ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'
		ctx.stroke()
		ctx[type]()
		// Canvas的合成操作,destination-over目标绘制在源之上
		ctx.globalCompositeOperation = 'destination-over'
	}

第三步:绑定事件

在滑块上绑定mousedown以及在document上绑定对应的mousemove和mouseup事件,通过计算滑块的移动距离与校验区域所在位置差的绝对值是否小于等于精确度,是的话则说明校验成功,否的话则说明校验失败。代码如下:

ts
const sliderDown = (e: any) => {
		if (state.success) return
		state.originX = e.clientX
		state.isMouseDown = true
	}
	const bindEvt = () => {
		const moveEvt = (e: any) => {
			if (!state.isMouseDown) return
			const moveX = e.clientX - state.originX
			if (moveX < 0 || moveX + props.l >= props.w) return
			state.sliderLeft = moveX + 'px'
			state.block.style.left = moveX + 'px'

			state.sliderActive = true // add active
			state.sliderMaskWidth = moveX + 'px'
		}
		const upEvt = (e: any) => {
			if (!state.isMouseDown) return
			state.isMouseDown = false
			if (e.clientX === state.originX) return
			state.sliderActive = false // remove active
			const blockLeft = state.block.style.left.replace(/px/gi, '')
			if (Math.abs(blockLeft - state.blockX) <= props.accuracy) {
				state.sliderSuccess = true
				state.success = true
			} else {
				state.sliderFail = true
				setTimeout(() => {
					reset()
				}, 1000)
			}
		}
		document.addEventListener('mousemove', moveEvt)
		document.addEventListener('mouseup', upEvt)
	}

校验失败的时候进行reset操作或者用户可以进行手动刷新

ts
const reset = () => {
		state.success = false
		state.sliderFail = false
		state.sliderSuccess = false
		state.sliderLeft = 0
		state.block.style.left = 0
		state.sliderMaskWidth = 0
		// canvas
		const { w, h } = props
		// 清空指定矩形区域内的绘图
		state.canvasCtx.clearRect(0, 0, w, h)
		state.blockCtx.clearRect(0, 0, w, h)
		state.block.width = w

		// 生成图片
		state.img.src = getRandomBg()
	}

	const refresh = () => {
		reset()
	}

展示效果

查看源码
vue
<template>
	<div class="sv-box" :style="{ width: w + 'px' }" onselectstart="return false">
		<!-- 加载背景图片遮罩层 -->
		<div :class="{ 'bg-mask': state.loadFlag }"></div>
        <!-- 刷新 -->
		<div v-show="show" class="sv-refresh" @click="refresh"></div>
		<canvas :width="props.w" :height="props.h" ref="canvas"> </canvas>
		<canvas :width="props.w" :height="props.h" ref="block" class="canvas-block"> </canvas>
		<!-- 滑动条容器 -->
		<div
			class="slider"
			:class="{
                // 滑动过程中的样式
				active: state.sliderActive,
                // 验证通过时的样式
				success: state.sliderSuccess,
                // 验证失败时的样式
				fail: state.sliderFail
			}"
		>
			<div class="slider--mask" :style="{ width: state.sliderMaskWidth }">
				<!-- 滑动块 -->
				<div class="slider--inner" :style="{ left: state.sliderLeft }" @mousedown="sliderDown">
					<div class="slider--icon"></div>
				</div>
			</div>
			<span class="slider--text">{{ text }}</span>
		</div>
	</div>
</template>
<script lang="ts" setup>
	import { ref, onMounted, reactive } from 'vue'

	const props = defineProps({
		l: {
			type: Number,
			default: 42
		},
		w: {
			type: Number,
			default: 310
		},
		h: {
			type: Number,
			default: 155
		},
		text: {
			type: String,
			default: '向右侧滑动'
		},
        // 精准度
		accuracy: {
			type: Number,
			default: 5
		},
        // 是否展示刷新
		show: {
			type: Boolean,
			default: true
		},
		bgList: {
			type: Array,
			default: () => [
				'https://storage.beta.custouch.com/res/8663/bg1.png',
				'https://storage.beta.custouch.com/res/8664/bg2.png',
				'https://storage.beta.custouch.com/res/8662/banner2.png',
				'https://storage.beta.custouch.com/res/8665/bg3.png',
				'https://storage.beta.custouch.com/res/8666/bg4.png',
				'https://storage.beta.custouch.com/res/8667/bg5.png'
			]
		}
	})

	const state = reactive<Record<string, any>>({
		sliderActive: false,
		sliderSuccess: false,
		sliderFail: false,
		canvasCtx: null,
		blockCtx: null,
		block: null,
		blockX: undefined,
		blockY: undefined,
		L: props.l + 4,
		img: undefined,
		originX: undefined,
		isMouseDown: false,
		sliderLeft: '0px',
		sliderMaskWidth: 0,
		success: false,
		loadFlag: false
	})

	const block = ref<HTMLCanvasElement>()
	const canvas = ref<HTMLCanvasElement>()

	const init = () => {
		initDom()
		initImg()
		bindEvt()
	}

	const initDom = () => {
		// 获取canvas画布的绘图环境
		state.block = block.value
		state.canvasCtx = canvas.value?.getContext('2d')
		state.blockCtx = state.block.getContext('2d')
	}

	const initImg = () => {
		const img = createImg(() => {
			state.loadFlag = false
			drawBlock()
			state.canvasCtx.drawImage(img, 0, 0, props.w, props.h)
			state.blockCtx.drawImage(img, 0, 0, props.w, props.h)

			const _y = state.blockY - 1
			const imageDate = state.blockCtx.getImageData(state.blockX, _y, state.L, state.L)
			state.block.width = state.L
			state.blockCtx.putImageData(imageDate, 0, _y)
		})
		state.img = img
	}

	const bindEvt = () => {
		const moveEvt = (e: any) => {
			if (!state.isMouseDown) return
			const moveX = e.clientX - state.originX
			if (moveX < 0 || moveX + props.l >= props.w) return
			state.sliderLeft = moveX + 'px'
			state.block.style.left = moveX + 'px'

			state.sliderActive = true // add active
			state.sliderMaskWidth = moveX + 'px'
		}
		const upEvt = (e: any) => {
			if (!state.isMouseDown) return
			state.isMouseDown = false
			if (e.clientX === state.originX) return
			state.sliderActive = false // remove active
			const blockLeft = state.block.style.left.replace(/px/gi, '')
			if (Math.abs(blockLeft - state.blockX) <= props.accuracy) {
				state.sliderSuccess = true
				state.success = true
			} else {
				state.sliderFail = true
				setTimeout(() => {
					reset()
				}, 1000)
			}
		}
		document.addEventListener('mousemove', moveEvt)
		document.addEventListener('mouseup', upEvt)
	}

	const sliderDown = (e: any) => {
		if (state.success) return
		state.originX = e.clientX
		state.isMouseDown = true
	}

	const reset = () => {
		state.success = false
		state.sliderFail = false
		state.sliderSuccess = false
		state.sliderLeft = 0
		state.block.style.left = 0
		state.sliderMaskWidth = 0
		// canvas
		const { w, h } = props
		// 清空指定矩形区域内的绘图
		state.canvasCtx.clearRect(0, 0, w, h)
		state.blockCtx.clearRect(0, 0, w, h)
		state.block.width = w

		// 生成图片
		state.img.src = getRandomBg()
	}

	const refresh = () => {
		reset()
	}

	const drawBlock = () => {
		state.blockX = getRandomNumberByRange(state.L + 10, props.w - (state.L + 10))
		state.blockY = getRandomNumberByRange(10, props.h - (state.L + 10))
		draw(state.canvasCtx, state.blockX, state.blockY, 'fill')
		draw(state.blockCtx, state.blockX, state.blockY, 'clip')
	}

	const draw = (ctx: any, x: number, y: number, type: string) => {
		const { l } = props
		ctx.beginPath()
		ctx.rect(x, y, l, l)
		ctx.lineWidth = 2
		ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'
		ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'
		ctx.stroke()
		ctx[type]()
		// Canvas的合成操作
		ctx.globalCompositeOperation = 'destination-over'
	}

	const createImg = (onload: any) => {
		state.loadFlag = true
		const img = new Image()
		img.crossOrigin = 'Anonymous'
		// 图片预加载方法
		img.onload = onload
		img.onerror = () => {
			console.error('Background image failed to load')
		}
		img.src = getRandomBg() as string

		return img
	}

	const getRandomBg = () => {
		const len = props.bgList.length
		return props.bgList[getRandomNumberByRange(0, len)]
	}

	const getRandomNumberByRange = (start: number, end: number) => {
		return Math.round(Math.random() * (end - start) + start)
	}

	onMounted(() => {
		init()
	})
</script>
<style scoped>
.sv-box{
  position: relative;
  margin: 20px;
}
.sv-box .bg-mask{
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  background: rgba(255, 255, 255, 0.9);
  z-index: 999;
  animation: loading 1.5s infinite;
}
@keyframes loading{
  0% {
    opacity: 0.7;
  }
  100% {
    opacity: 1;
  }
}
.sv-box .canvas-block{
  position: absolute;
  left: 0;
  top: 0;
}
.sv-box .sv-refresh{
  position: absolute;
  right: 0;
  top: 0;
  width: 34px;
  height: 34px;
  cursor: pointer;
  background: url('./images/icons-sprite.png');
  background-position: 34px 35px;
  z-index: 999;
}
.sv-box .slider{
  position: absolute;
  text-align: center;
  width: 100%;
  height: 40px;
  line-height: 40px;
  margin-top: 15px;
  color: #45494c;
  border: 1px solid #e4e7eb;
  background: #f7f9fa;
}
.sv-box .slider .slider--mask{
  position: absolute;
  left: -1px;
  top: -1px;
  height: 40px;
  border: 1px solid transparent;
}
.sv-box .slider .slider--mask .slider--inner{
  position: absolute;
  top: 0;
  left: 0;
  width: 40px;
  height: 40px;
  background: #fff;
  cursor: pointer;
  transition: background 0.2 linear;
}
.sv-box .slider .slider--mask .slider--inner .slider--icon{
  position: absolute;
  top: 15px;
  left: 13px;
  width: 14px;
  height: 12px;
  background: url('./images/icons-sprite.png');
  background-position: 34px 445px;
}
.sv-box .slider .slider--mask .slider--inner:hover{
  background: #1991fa;
}
.sv-box .slider .slider--mask .slider--inner:hover .slider--icon{
  background-position: 34px 458px;
}
.sv-box .active .slider--mask{
  border: 1px solid #1991fa;
}
.sv-box .active .slider--inner{
  top: -1px !important;
  border: 1px solid #1991fa;
  background-color: #1991fa !important;
}
.sv-box .success .slider--mask{
  border: 1px solid #52ccba;
}
.sv-box .success .slider--inner{
  top: -1px !important;
  border: 1px solid #52ccba;
  background-color: #52ccba !important;
}
.sv-box .success .slider--icon{
  background-position: 0 0 !important;
}
.sv-box .fail .slider--mask{
  border: 1px solid #f57a7a;
  background-color: #fce1e1;
}
.sv-box .fail .slider--inner{
  top: -1px !important;
  border: 1px solid #f57a7a;
  background-color: #f57a7a !important;
}
.sv-box .active .slider--text,.sv-box .success .slider--text,.sv-box .fail .slider--text{
  display: none;
}
</style>
向右侧滑动

Date: 2023/02/02

Authors: 周明杰

Tags: 趣谈、canvas