验证码组件开发之路......来自毕业不到一年的菜鸡前端

231 阅读7分钟

菜鸡一枚,最近没事想着能不能将现在流行的验证码类型全部汇总到一起,并通过写文章记录一下这个过程,于是说干就干,目前为止完成了滑块和图片的验证码,效果如下

录制_2022_12_05_15_45_03_979.gif

录制_2022_12_05_15_45_38_635.gif

两种验证码都是用canvas来实现的,其中滑块验证码一开始没有思路,是看了Liben文章,用他的思路使用两个canvas将背景和凹凸块分开来实现的

话不多说,开整

捕获.PNG

总体布局

首先,既然是将所有验证码类型汇总到一起,就得先有一个外层组件用来包裹,先创建一个verify-code的文件夹,在文件夹下创建VerifyCodevue文件和一个index.ts文件,graph-verify-codeslide-verify-code分别为图形和滑块验证码文件夹

捕获1.PNG

index.ts

import type { App } from "vue"
import VerifyCode from "./VerifyCode.vue"
export default {
    install:(app:App)=>{
        app.component('VerifyCode',VerifyCode)
    }
}

index.ts文件中就是简单的将验证码根组件暴露出去

VerifyCode.vue

<script lang="ts" setup>
    import { onBeforeMount,shallowRef,defineAsyncComponent } from "vue"
    interface PropType {
        verifyType:'slide' | 'graph'
    }
    const {
        verifyType
    } = withDefaults(defineProps<PropType>(),{})
    const verifyComponent = shallowRef<any>()
    onBeforeMount(() => {
        if(verifyType == 'slide'){
            verifyComponent.value = defineAsyncComponent(()=>import('./slide-verify-code/index.vue'))
        }else if(verifyType == 'graph'){
            verifyComponent.value = defineAsyncComponent(()=>import('./graph-verify-code/index.vue'))
        }
    })
</script>
<template>
    <component :is="verifyComponent"></component>
</template>

根组件中,因为要根据不同的类型渲染不同的验证码,所以采用按需引入的方式

图形验证码

总的布局已经完成,接下来先完成图形验证码的组件

介绍下大体思路,首先生成一个包含数字和大小写字母的随机字符串,再将每个字符串通过canvas渲染出并进行一定的变换,在canvas渲染完成后通过vueemit将原始的验证码传递出去

一、html 部分

<template>
    <canvas ref="graph" 
            :width="width" :height="width / 2" :style="{backgroundColor: graphBgColor}" 
            @click="reFreshCode">
    </canvas>
</template>

<style lang="less" scoped>
    canvas:hover{
        cursor: var(--is-hover);
    }
</style>

这部分很简单,widthgraphBgColor是外部传递的prop--is-hover用来实现canvas的鼠标悬浮效果的切换

二、js 部分

import { ref,onMounted } from 'vue'
interface PropType {
    width?:number,
    graphBgColor?:string, //canvas背景色
    graphClickReFresh?:boolean //是否开启点击刷新验证码
}
const {
    width,
    graphBgColor,
    graphClickReFresh
} = withDefaults(defineProps<PropType>(),{
    width:120,
    graphBgColor:'rgb(241, 242, 243)',
    graphClickReFresh:true
})
const emits = defineEmits(['getCode'])
const graph = ref<HTMLCanvasElement>()
onMounted(() => {
    if(graphClickReFresh){
        graph.value?.style.setProperty('--is-hover','pointer')
    }else{
        graph.value?.style.setProperty('--is-hover','default')
    }
    initCode()
})
function reFreshCode() {
    if(graphClickReFresh){
        initCode()
    }
}

生成随机验证码

function getCode():string {
    const arr = [getNum,getLetter]
    let code = ''
    for (let i = 0; i < 6; i++) {
        code += (arr[Math.floor(Math.random() * 2)])()
    }
    return code
    function getNum():string {
        return Math.floor(Math.random() * 10).toString()
    }
    function getLetter():string {
        return String.fromCharCode(Math.floor(Math.random() * 26) + ['a','A'][Math.floor(Math.random() * 2)].charCodeAt(0))
    }
}

可能大佬们有更好的方法,不过自己目前只能这么实现了...

为了样式好看一点,还需要一个生成随机颜色的函数

function getColor():string {
    return `rgb(${Math.floor(Math.random()*256)},${Math.floor(Math.random()*256)},${Math.floor(Math.random()*256)})`
}

接下来就是初始化canvas的函数

function initCode() {
    (graph.value as HTMLCanvasElement).width = width //每次刷新验证码先清空画布
    const ctx = graph.value?.getContext('2d')
    if(ctx){
        const code = getCode()
        for (let i = 0; i < code.length; i++) { //将每个字符变换位置、颜色、大小、角度
            ctx.save()
            ctx.fillStyle = getColor()
            ctx.font = `normal ${Math.floor(Math.random() * width / 10 + width / 7.5)}px serif`
            ctx.translate(width / 40 + i * width / 6 , Math.floor(Math.random() * width / 5 + width / 5) )
            ctx.rotate(Math.floor(Math.random() * 61 - 30) * Math.PI / 180)
            ctx.fillText(code[i] , 0 , 0)
            ctx.restore()
        }
        //一条朴实无华的线
        ctx.strokeStyle = getColor()
        ctx.beginPath()
        ctx.moveTo(Math.random() * width / 5, Math.random() * width / 2.5)
        ctx.lineTo(width - Math.random() * width / 10 , width / 2 - Math.random() * width / 5)
        ctx.stroke()
        ctx.closePath()
        //第二条朴实无华的线
        ctx.strokeStyle = getColor()
        ctx.beginPath()
        ctx.moveTo(Math.random() * width / 5, width / 2 - Math.random() * width / 2.5)
        ctx.lineTo(width - Math.random() * width / 10 , Math.random() * width / 5)
        ctx.stroke()
        ctx.closePath()
        //初始化完成后将一开始生成的验证码传递出去
        emits('getCode',code)
    }
}

滑块验证码

滑块验证码的难度比图形验证码的要大一些,关于背景图和凹凸块的部分我也是看了大佬的文章才完成出来(大佬的文章在开头有链接,这里再放一次)

html 部分

<div :style="{width:width+'px',position:'relative'}">
    <canvas class="canvas-bg" :width="width" :height="width/2"></canvas>
    <canvas class="canvas-block" :width="width" :height="width/2"></canvas>
    <div class="sliding-block">
        <div class="slide" ref="slide">
            <svg v-show="status === 0" version="1.1" width="30" height="15" >
                <line id="svg_4" y2="7.40525" x2="29.94681" y1="7.29887" x1="-0.05319" stroke-width="1.5" stroke="#fff" fill="none"/>
                <line id="svg_5" y2="7.51164" x2="29.94681" y1="0.06483" x1="18.03191" stroke-width="1.5" stroke="#fff" fill="none"/>
                <line id="svg_6" y2="14.6393" x2="18.35106" y1="7.7244" x1="29.62766" stroke-width="1.5" stroke="#fff" fill="none"/>
            </svg>
            <svg v-show="status === 1" version="1.1" width="30" height="30" >
                <path stroke="#fff" d="m2.68367,13.92409l2.78579,-2.96246l7.63163,8.1117l12.72169,-13.5195l2.78678,2.96037l-15.50846,16.48405" stroke-width="1" fill="none"/>
            </svg>
            <svg v-show="status === 2" version="1.1" width="30" height="30" >
                <path d="m-57.38475,-32.74536l3.18376,-2.93425l8.72187,8.03444l14.53907,-13.39074l3.18489,2.93217l-17.72396,16.32706" fill-opacity="null" stroke-opacity="null" stroke-width="1" stroke="#fff" fill="none"/>
                <path d="m25.85306,21.29581l-6.97114,-6.5427l6.96987,-6.5427l-3.58313,-3.36529l-6.97114,6.5427l-6.97114,-6.5427l-3.58313,3.36529l6.96987,6.5427l-6.97114,6.5427l3.58567,3.3641l6.96987,-6.5427l6.96987,6.5427" fill-opacity="null" stroke-opacity="null" stroke-width="1" stroke="#fff" fill="none"/>
            </svg>
        </div>
        <p class="sliding-tip" :style="{opacity:isMouseDown?.5:1}">{{slideTip}}</p>
        <div class="sliding-move" :style="{width:slidingWidth}"></div>
    </div>
    <svg class="refresh" version="1.1" width="20" height="20" @click="reFresh">
        <path d="m10.01859,3.54213c0.73,0.00088 1.42739,0.12091 2.08785,0.32143l-0.5227,0.8585l3.71035,0l-0.92782,-1.52593l-0.92712,-1.52547l-0.48804,0.80383c-0.91467,-0.31756 -1.90071,-0.49529 -2.93184,-0.49529c-4.74622,0 -8.59318,3.65344 -8.59318,8.1606c0,1.87062 0.66997,3.58931 1.78494,4.96633l1.30664,-0.95271c-0.90176,-1.11282 -1.44395,-2.50147 -1.44689,-4.01318c0.00657,-3.64398 3.11106,-6.59254 6.94782,-6.59812l0,0zm6.80915,1.63306l-1.30663,0.95316c0.90153,1.1124 1.44372,2.50017 1.4462,4.01233c-0.00657,3.64395 -3.11106,6.59208 -6.94805,6.59809c-0.67994,-0.00083 -1.33043,-0.10583 -1.95055,-0.28141l0.49188,-0.80772l-3.71033,0l0.92714,1.52508l0.92781,1.52719l0.51727,-0.85245c0.87728,0.29002 1.81619,0.45182 2.79679,0.45224c4.7469,-0.00084 8.5932,-3.65428 8.59432,-8.16145c-0.00112,-1.87062 -0.67156,-3.58889 -1.78584,-4.96506l0,0z" stroke-width="0.1" stroke="#ffffff" fill="#cccccc"/>
    </svg>
</div>

两个canvas一个用来展示背景图,一个通过js改造成凹凸块,中间的三个svg分别展示滑块中间的图标在默认、验证成功和验证失败的样式,这里有一个在线制作的svg的网站分享一下,我也是看别人的文章知道的,不过忘了是哪篇文章了。最下面的svg是右上角的刷新按钮

顺便附上css样式

*{
    box-sizing: border-box;
    padding: 0;
    margin: 0;
    user-select: none;
}
.canvas-block{
    position: absolute;
    z-index: 999;
    top: 0;
    left: 0;
}
.code_box{
    background-repeat: no-repeat;
    background-size: 100% 100%;
}
.sliding-block{
    position: relative;
    margin-top: 10px;
    height: 40px;
    border-radius: 1px;
    background-color: rgb(249, 246, 246);
    border: 1px solid rgb(219, 218, 218);
    box-shadow: 0 0 2px 0px rgb(230, 230, 230);
}
.sliding-move{
    position: absolute;
    height: 100%;
    top: 0;
    left: 0;
    background-color: var(--slide-color);
    opacity: .4;
    animation: sliding-move .5s linear infinite alternate;
}
.sliding-tip{
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%,-50%);
    color: rgb(130, 126, 126);
    font-size: 17px;
}
.slide{
    position: relative;
    z-index: 998;
    height: 100%;
    width: 50px;
    border-radius: 1px;
    display: flex;
    justify-content: center;
    align-items: center;
    background-color: var(--slide-color);
}
.slide:hover{
    cursor: pointer;
    box-shadow: inset 0 0 3px rgb(244, 244, 245);
}
.slide::after{
    content: '';
    position: absolute;
    width: 100%;
    height: 100%;
    top: 0;
    left: 0;
    background-color: #fff;
    opacity: .2;
}
.slide:hover::after{
    content: none;
}
.slide:hover:before{
    content: '';
    position: absolute;
    opacity: .5;
    z-index: 999;
    height: 1px;
    width: 1px;
    left: 70px;
    top: 50%;
    transform: translateY(-50%);
    box-shadow: 0 0px 5px 20px #fff;
    animation: light-move 1.8s linear infinite;
}
.slide:active:before{
    content: none;
}
.refresh{
    position: absolute;
    top: 10px;
    right: 10px;
}
.refresh:hover{
    cursor: pointer;
}
@keyframes light-move {
    100%{
        left: calc(var(--slide-width) - 10px);
    }
}
@keyframes sliding-move{
    0%{
        box-shadow: inset 0 0 0px rgb(238, 240, 240);
    }
    100%{
        box-shadow: inset 0 0 3px 1px rgb(238, 240, 240);
    }
}

js 部分

这部分关于控制样式的就不进行详细的介绍了

interface PropType{
    width?:number,
    slideTip?:string, //滑块部分中间的提示文字
    verifyImgList?:Array<string>, //背景图片的数组
    slideBg?:string, //滑块背景色
    checkOK?:string, //验证成功滑块背景色
    checkFail?:string  //验证失败滑块背景色
}
const emits = defineEmits(['ok','fail'])
const { 
    width,
    slideBg,
    checkOK,
    checkFail,
    slideTip,
    verifyImgList
} =  withDefaults(defineProps<PropType>(),{
    width:360,
    slideBg:'rgb(75, 137, 237)',
    checkOK:'#6ECCAF',
    checkFail:'#DC3535',
    slideTip:'拖动滑块验证',
    verifyImgList:()=>['https://t7.baidu.com/it/u=1956604245,3662848045&fm=193&f=GIF']
})
const slide = ref<HTMLBodyElement>()
const isMouseDown = ref<boolean>(false) //控制样式所需
const slidingWidth = ref<string>('0px') //控制样式所需
const isRegisterDrag = ref<boolean>(false) //控制滑块是否可以拖动
const imgUrl = ref<string>('') //当前展示的背景图
const targetBlockX = ref<number>(0) //凹凸块所需到达的X坐标
const originalBlockX = ref<number>(0) // 初始化时凹凸块的X坐标
const status = ref<number>(0) //滑块状态
onMounted(() => {
    initGlobalProperty()
    initCanvas()
})

加载背景图

初始化时先根据props中的verifyImgList随机选择一张图片作为背景图,如果加载失败则在canvas中显示文字提示并使滑块无法拖动,所以可以使用promise进行简单的控制

const getImg = ()=> {
   return new Promise((resolve,reject)=>{
        const originalImg = verifyImgList[Math.floor(Math.random()*verifyImgList.length)]
        const img = document.createElement('img')
        //vite项目使用这个语法引入静态资源,webpack使用require,网络资源直接写图片地址就行了
        img.src = new URL(originalImg, import.meta.url).href 
        img.crossOrigin = "anonymous"
        img.onload = ()=> {
            resolve(img)
        }
        img.onerror = ()=> {
            reject('获取图片失败')
        }
    })
}

初始化canvas

首先完成一个在背景图上画出凹凸块和重置canvas的辅助函数

function draw(ctx:any,picX:number,picY:number) {} // 这部分是当时看的大佬文章
function resetCanvas(canvas:HTMLCanvasElement) {
    canvas.width = width
    canvas.height = width / 2
}

然后就是初始化canvas的函数

const initCanvas = ()=> {
    const canvasBg = document.querySelector('.canvas-bg') as HTMLCanvasElement
    const ctx = canvasBg.getContext('2d')
    const canvasBlock = document.querySelector('.canvas-block') as HTMLCanvasElement
    const ctxBlock = canvasBlock.getContext('2d')
    if(ctx && ctxBlock){
        getImg()
        .then((img:any)=>{
            resetCanvas(canvasBg)
            resetCanvas(canvasBlock)
            //生成凹凸块坐标要使它随机出现的位置控制在整个背景图的右半部分,同时要避免和右上角的刷新按钮重合
            const picX = Math.floor(Math.random() * (width / 2 - 80) + width / 2)
            const picY = Math.floor(Math.random() * (width / 2 - 70))
            targetBlockX.value = picX
            //这部分也是看的大佬文章
                ctx.drawImage(img,0,0,width,width/2)
                draw(ctx,picX,picY)
                ctx.fill()
                draw(ctxBlock,picX,picY)
                ctxBlock.drawImage(img,0,0,width,width/2)
                const imgData = ctxBlock.getImageData(picX-1,picY-1,42,32)
                canvasBlock.width = 42
                canvasBlock.height = 32
                ctxBlock.putImageData(imgData, 0, 0)
                canvasBlock.style.top = picY + 'px'
                const blockLeft = Math.floor(Math.random() * 50)
                canvasBlock.style.left = blockLeft + 'px'
                originalBlockX.value = blockLeft
            //
            if(!isRegisterDrag.value){
                isRegisterDrag.value = true
                dragSlide(slide) //这个是给滑块注册的拖拽事件,之后会介绍
            }
        })
        .catch((err:string)=>{
            isRegisterDrag.value = false
            dragSlide(slide,true) //如果图片获取失败,传入第二个参数卸载事件,使滑块无法拖动
            ctx.font = 'bold 30px serif'
            ctx.fillText(err,0,100)
        })
    }
}

这里面的isRegisterDrag是为了在点刷新按钮的时候防止重复注册拖拽事件产生bug

当图片加载失败时的显示效果:

捕获2.PNG

滑块部分

这里通过mousedowmmousemovemouseup来完成,但是需要在鼠标点下时再注册mousemovemouseup事件,但是,如果mousemovemouseup是注册在滑块元素本身上的话,在实际使用中当鼠标滑动过快使鼠标脱离滑块时会导致两个事件出现问题,所以这两个事件我选择注册在了document.documentElement上,同时在mouseup事件中移除自身以及mousemove

mousedowm
function handleDown(e:any) {
    isMouseDown.value = true
    originalX = e.pageX
    document.documentElement.addEventListener('mousemove',handleMove)
    document.documentElement.addEventListener('mouseup',handleUp)
}
mousemove
function handleMove(e:any){
    const moveX = e.pageX - originalX
    if(moveX <= maxMove && moveX >= 0){
        slidingWidth.value = moveX + 'px'
        targetVal.style.left = moveX + 'px'
        canvasBlock.style.left = originalBlockX.value + moveX + 'px'
        if(moveX >= width - originalBlockX.value - blockWdith){
            canvasBlock.style.left = width - blockWdith + 'px'
        }
    }else if(moveX > maxMove){
        slidingWidth.value = maxMove + 'px'
        targetVal.style.left = maxMove + 'px'
        canvasBlock.style.left = width - blockWdith + 'px'
    }else if(moveX < 0){
        slidingWidth.value = '0px'
        targetVal.style.left = '0px'
        canvasBlock.style.left = originalBlockX.value + 'px'
    }
}
mouseup
function handleUp(e:any) {
    checkVerifyCode()
    document.documentElement.removeEventListener('mousemove',handleMove)
    document.documentElement.removeEventListener('mouseup',handleUp)
    setTimeout(() => {
        if(status.value === 1){
            emits('ok')
        }else if(status.value === 2){
            emits('fail')
        }
        reFresh()
    }, 1000)
}
验证函数
function checkVerifyCode() {
    const differenceValue = Math.abs(canvasBlock.offsetLeft - targetBlockX.value)
    if(differenceValue <= 3){
        document.documentElement.style.setProperty('--slide-color',checkOK as string)
        status.value = 1
    }else{
        document.documentElement.style.setProperty('--slide-color',checkFail as string)
        status.value = 2
    }
}
整体的dragSlide函数
function dragSlide(target:Ref,remove?:boolean){
    if(target){
        const targetVal = target.value
        const maxMove = width - targetVal.offsetWidth - 2
        const canvasBlock = document.querySelector('.canvas-block') as HTMLCanvasElement
        const blockWdith = canvasBlock.offsetWidth
        let originalX = 0
        targetVal.addEventListener('mousedown',handleDown)
        if(remove){
            targetVal.removeEventListener('mousedown',handleDown)
        }
        function handleDown(e:any) {}
        function handleUp(e:any) {}
        function handleMove(e:any){}
        function checkVerifyCode() {}
    }
}

还有一个刷新按钮的事件,就是将各种参数重置到初始状态,再重新调用一下initCanvas

const reFresh = ()=> {
    (slide.value as HTMLBodyElement).style.left = '0px';
    (document.querySelector('.canvas-block') as HTMLCanvasElement).style.left = originalBlockX.value + 'px'
    document.documentElement.style.setProperty('--slide-color',slideBg as string)
    isMouseDown.value = false
    slidingWidth.value = '0px'
    status.value = 0
    initCanvas()
}

结尾

第一次写文章,只想着记录一下自己开发的过程,可能中间还有很多地方有更好的实现方法以及一些我没有发现的bug,欢迎大佬们指出。

这个组件我已经发布到npm上了,可以通过npm i zyhzyh-ui下载,在main.js中引入并通过app.use使用

import { VerifyCode} from 'zyhzyh-ui'
import 'zyhzyh-ui/es/style.css'
const app = createApp(App)
app.use(VerifyCode)

或者

import ZyhUI from 'zyhzyh-ui'
import 'zyhzyh-ui/es/style.css'
const app = createApp(App)
app.use(ZyhUI)

虽然现在毕业不到一年,但是我会朝着两年半的目标努力的,end

20200429032429227.jpg