原生js+canvas實現滑動拼圖驗證碼


上圖為網易雲盾的滑動拼圖驗證碼,同一張圖片裁剪的位置是固定的,猜想其應該有一個專門的圖片庫,保存了被裁剪的背景圖和可移動的滑塊。我的想法是,隨機生成圖片,隨機生成位置,再用canvas裁剪出滑塊和背景圖。下面介紹具體步驟。

1、首先隨便找一張圖片渲染到canvas上,這里#canvas作為畫布,#block作為裁剪出來的小滑塊。

<canvas width="310" height="155" id="canvas"></canvas>
<canvas width="310" height="155" id="block"></canvas>
  var canvas = document.getElementById('canvas')
  var block = document.getElementById('block')
  var canvas_ctx = canvas.getContext('2d')
  var block_ctx = block.getContext('2d')
  var img = document.createElement('img')
  img.onload = function() {
    canvas_ctx.drawImage(img, 0, 0, 310, 155)
    block_ctx.drawImage(img, 0, 0, 310, 155)
  };
  img.src = 'img.jpg'

2.用canvas畫一個正方形

下面考慮如何裁剪出拼圖的形狀,拼圖形狀比較復雜,首先我們畫一個正方形,接着上邊的代碼寫:

 var x = 150, y = 40, w = 42, r = 10, PI = Math.PI 
  function draw(ctx) {
    ctx.beginPath()
    ctx.moveTo(x, y)
    ctx.lineTo(x + w, y)
    ctx.lineTo(x + w, y + w)
    ctx.lineTo(x, y + w)
    ctx.clip()
  }
  draw(canvas_ctx)
  draw(block_ctx)

x,y為正方形左上角的坐標,現在先隨便寫后邊生成的時候用隨機數,w為正方形的邊長,r為后邊畫缺口的圓的半徑。我們先把繪圖過程用函數封裝起來,方便后面同時操作背景和滑塊。用clip()方法裁剪圖片后生成一個正方形。

3.接下來用canvas畫正方形上邊和右邊的圓形("+"號代表比前一步新增的代碼,“-”號代表比前一步減少的代碼):

function draw(ctx) {
    ctx.beginPath()
    ctx.moveTo(x,y)
 +  ctx.lineTo(x+w/2,y)//繪制頂部圓
 +  ctx.arc(x+w/2,y-r+2, r,0,2*PI)//繪制頂部圓
 +  ctx.lineTo(x+w/2,y)//繪制頂部圓
    ctx.lineTo(x+w,y)
 +  ctx.lineTo(x+w,y+w/2)//繪制右部圓
 +  ctx.arc(x+w+r-2,y+w/2,r,0,2*PI) //繪制右部圓
 +  ctx.lineTo(x+w,y+w/2)//繪制右部圓
    ctx.lineTo(x+w,y+w)
    ctx.lineTo(x,y+w)
    ctx.lineTo(x,y)
    ctx.clip()
  }

兩處注釋的位置將圓心往內偏移了2px,實現缺口的樣式。

4.用canvas畫左邊空心的部分

然后是左邊空心的部分,由於clip是裁剪路徑內的部分,因此直接像上面畫圓是不行的,我們開啟一條新的路徑,然后畫圓將這個正方形“遮蓋”出一個缺口,這里會用到globalCompositeOperation 屬性,'xor'顧名思義。代碼接上邊("+"號代表比前一步新增的代碼,“-”號代表比前一步減少的代碼):

function draw(ctx) {
    ctx.beginPath()
    ...
    ctx.lineTo(x,y)
    ctx.clip()
+   ctx.beginPath()
+   ctx.arc(x,y+w/2, r,1.5*PI,0.5*PI) // 只需要畫正方形內的半圓就行,方便背景圖片的裁剪
+   ctx.globalCompositeOperation = "xor"
+   ctx.fill()
}

 

5.調整#block的大小

現在一個基本的拼圖形狀有了,我們調整#block的大小,並將裁剪出來的滑塊放入#block中("+"號代表比前一步新增的代碼,“-”號代表比前一步減少的代碼):

img.onload = function() {
    ctx.drawImage(img, 0, 0, 310, 155)
    block_ctx.drawImage(img, 0, 0, 310, 155)

+   var blockWidth = w + r * 2
+   var _y = y - r * 2 + 2 // 滑塊實際的y坐標
+   var ImageData = block_ctx.getImageData(x, _y, blockWidth, blockWidth)
+   block.width = blockWidth
+   block_ctx.putImageData(ImageData, 0, _y)
  }

 

6.摳掉原圖片的滑塊部分

現在我們需要把左邊畫布展示原來的圖片,並且摳掉中間滑塊的部分,這里畫路徑的過程都是一樣的,唯一不同只是clip()那里改成fill()即可實現效果,我們前面已經把畫路徑的過程封裝成函數了,稍作改動即可("+"號代表比前一步新增的代碼,“-”號代表比前一步減少的代碼):

-  function draw(ctx) {
+  function draw(ctx, operation) {
    ...
-  ctx.clip()

+  ctx.fillStyle = '#fff'
+  ctx[operation]()
   ...
}
+  draw(canvas_ctx, 'fill')
+  draw(block_ctx, 'clip')

7.寫拖動事件

然后就是寫拖動事件,我們可以在鼠標按下時記錄鼠標位置,然后在拖動時給滑塊設置left值。最后在松開鼠標時,判斷滑塊此時的left值和最開始裁剪滑塊時的x值,如果在一定范圍內就算驗證通過,否則驗證失敗。

最后再加上隨機圖片和隨機剪切位置,基本就ok了。另外可以判斷下鼠標移動時y軸的變化,以判斷是否是“人”在操作,當然web安全這塊神魔亂舞的,我就不班門弄斧了,只是做個簡單判斷就行。

8.詳細代碼:

8.1html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>滑動拼圖驗證碼</title>
  <style>
    .container {
      width: 310px;
      margin: 100px auto;
    }
    input {
      display: block;
      width: 290px;
      line-height: 40px;
      margin: 10px 0;
      padding: 0 10px;
      outline: none;
      border:1px solid #c8cccf;
      border-radius: 4px;
      color:#6a6f77;
    }
    #msg {
      width: 100%;
      line-height: 40px;
      font-size: 14px;
      text-align: center;
    }
    a:link,a:visited,a:hover,a:active {
      margin-left: 100px;
      color: #0366D6;
    }
    
    .block {
  position: absolute;
  left: 0;
  top: 0;
  cursor: pointer;
  cursor: grab;
}

.block:active {
  cursor: grabbing;
}

.sliderContainer {
  position: relative;
  text-align: center;
  width: 310px;
  height: 40px;
  line-height: 40px;
  margin-top: 15px;
  background: #f7f9fa;
  color: #45494c;
  border: 1px solid #e4e7eb;
}

.sliderContainer_active .slider {
  height: 38px;
  top: -1px;
  border: 1px solid #1991FA;
}

.sliderContainer_active .sliderMask {
  height: 38px;
  border-width: 1px;
}

.sliderContainer_success .slider {
  height: 38px;
  top: -1px;
  border: 1px solid #52CCBA;
  background-color: #52CCBA !important;
}

.sliderContainer_success .sliderMask {
  height: 38px;
  border: 1px solid #52CCBA;
  background-color: #D2F4EF;
}

.sliderContainer_success .sliderIcon {
  background-position: 0 0 !important;
}

.sliderContainer_fail .slider {
  height: 38px;
  top: -1px;
  border: 1px solid #f57a7a;
  background-color: #f57a7a !important;
}

.sliderContainer_fail .sliderMask {
  height: 38px;
  border: 1px solid #f57a7a;
  background-color: #fce1e1;
}

.sliderContainer_fail .sliderIcon {
  top: 14px;
  background-position: 0 -82px !important;
}
.sliderContainer_active .sliderText, .sliderContainer_success .sliderText, .sliderContainer_fail .sliderText {
  display: none;
}

.sliderMask {
  position: absolute;
  left: 0;
  top: 0;
  height: 40px;
  border: 0 solid #1991FA;
  background: #D1E9FE;
}

.slider {
  position: absolute;
  top: 0;
  left: 0;
  width: 40px;
  height: 40px;
  background: #fff;
  box-shadow: 0 0 3px rgba(0, 0, 0, 0.3);
  transition: background .2s linear;
  cursor: pointer;
  cursor: grab;
}

.slider:active {
  cursor: grabbing;
}

.slider:hover {
  background: #1991FA;
}

.slider:hover .sliderIcon {
  background-position: 0 -13px;
}

.sliderIcon {
  position: absolute;
  top: 15px;
  left: 13px;
  width: 14px;
  height: 12px;
  background: url(http://cstaticdun.126.net//2.6.3/images/icon_light.f13cff3.png) 0 -26px;
  background-size: 34px 471px;
}

.refreshIcon {
  position: absolute;
  right: 0;
  top: 0;
  width: 34px;
  height: 34px;
  cursor: pointer;
  background: url(http://cstaticdun.126.net//2.6.3/images/icon_light.f13cff3.png) 0 -437px;
  background-size: 34px 471px;
}
  </style>
</head>
<body>
<h3><a href="https://github.com/yeild/jigsaw">返回GitHub</a></h3>
<div class="container">
  <input value="admin" readonly/>
  <input type="password" value="1234567890" readonly/>
  <div id="captcha"></div>
  <div id="msg"></div>
</div>
<script src="./jigsaw.js"></script>
<script>
  window.jigsaw.init({
    el: document.getElementById('captcha'),
    onSuccess: function() {
      document.getElementById('msg').innerHTML = '登錄成功!'
    },
    onFail: cleanMsg,
    onRefresh: cleanMsg
  })
  function cleanMsg() {
    document.getElementById('msg').innerHTML = ''
  }
</script>
</body>
</html>

8.2jigsaw.js

let w = 310 // canvas寬度
let h = 155 // canvas高度
const l = 42 // 滑塊邊長
const r = 9 // 滑塊半徑
const PI = Math.PI
const L = l + r * 2 + 3 // 滑塊實際邊長

function getRandomNumberByRange (start, end) {
  return Math.round(Math.random() * (end - start) + start)
}

function createCanvas (width, height) {
  const canvas = document.createElement('canvas')
  canvas.width = width
  canvas.height = height
  return canvas
}

function createImg (onload) {
  const img = 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) {
        const file = new FileReader() // FileReader僅支持IE10+
        file.readAsDataURL(e.target.response)
        file.onloadend = function (e) {
          img.src = e.target.result
        }
      }
      xhr.open('GET', src)
      xhr.responseType = 'blob'
      xhr.send()
    }
    else img.src = src
  }

  img.setSrc(getRandomImgSrc())
  return img
}

function createElement (tagName, className) {
  const elment = document.createElement(tagName)
  elment.className = className
  return elment
}

function addClass (tag, className) {
  tag.classList.add(className)
}

function removeClass (tag, className) {
  tag.classList.remove(className)
}

function getRandomImgSrc () {
  return `https://picsum.photos/${w}/${h}/?image=${getRandomNumberByRange(0, 1084)}`
}

function draw (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[operation]()
  ctx.globalCompositeOperation = 'destination-over'
}

function sum (x, y) {
  return x + y
}

function square (x) {
  return x * x
}

class jigsaw {
  constructor ({ el, width = 310, height = 155, onSuccess, onFail, onRefresh }) {
    w = width
    h = height
    el.style.position = 'relative'
    el.style.width = w + 'px'
    Object.assign(el.style, {
      position: 'relative',
      width: w + 'px',
      margin: '0 auto'
    })
    this.el = el
    this.onSuccess = onSuccess
    this.onFail = onFail
    this.onRefresh = onRefresh
  }

  init () {
    this.initDOM()
    this.initImg()
    this.bindEvents()
  }

  initDOM () {
    const canvas = createCanvas(w, h) // 畫布
    const block = canvas.cloneNode(true) // 滑塊
    const sliderContainer = createElement('div', 'sliderContainer')
    sliderContainer.style.width = w + 'px'
    const refreshIcon = createElement('div', 'refreshIcon')
    const sliderMask = createElement('div', 'sliderMask')
    const slider = createElement('div', 'slider')
    const sliderIcon = createElement('span', 'sliderIcon')
    const text = createElement('span', 'sliderText')

    block.className = 'block'
    text.innerHTML = '向右滑動填充拼圖'

    const el = this.el
    el.appendChild(canvas)
    el.appendChild(refreshIcon)
    el.appendChild(block)
    slider.appendChild(sliderIcon)
    sliderMask.appendChild(slider)
    sliderContainer.appendChild(sliderMask)
    sliderContainer.appendChild(text)
    el.appendChild(sliderContainer)

    Object.assign(this, {
      canvas,
      block,
      sliderContainer,
      refreshIcon,
      slider,
      sliderMask,
      sliderIcon,
      text,
      canvasCtx: canvas.getContext('2d'),
      blockCtx: block.getContext('2d')
    })
  }

  initImg () {
    const img = createImg(() => {
      this.draw()
      this.canvasCtx.drawImage(img, 0, 0, w, h)
      this.blockCtx.drawImage(img, 0, 0, w, h)
      const y = this.y - r * 2 - 1
      const ImageData = this.blockCtx.getImageData(this.x - 3, y, L, L)
      this.block.width = L
      this.blockCtx.putImageData(ImageData, 0, y)
    })
    this.img = img
  }

  draw () {
    // 隨機創建滑塊的位置
    this.x = getRandomNumberByRange(L + 10, w - (L + 10))
    this.y = getRandomNumberByRange(10 + r * 2, h - (L + 10))
    draw(this.canvasCtx, this.x, this.y, 'fill')
    draw(this.blockCtx, this.x, this.y, 'clip')
  }

  clean () {
    this.canvasCtx.clearRect(0, 0, w, h)
    this.blockCtx.clearRect(0, 0, w, h)
    this.block.width = w
  }

  bindEvents () {
    this.el.onselectstart = () => false
    this.refreshIcon.onclick = () => {
      this.reset()
      typeof this.onRefresh === 'function' && this.onRefresh()
    }

    let originX, originY, trail = [], isMouseDown = false

    const handleDragStart = function (e) {
      originX = e.clientX || e.touches[0].clientX
      originY = e.clientY || e.touches[0].clientY
      isMouseDown = true
    }

    const handleDragMove = (e) => {
      if (!isMouseDown) return false
      const eventX = e.clientX || e.touches[0].clientX
      const eventY = e.clientY || e.touches[0].clientY
      const moveX = eventX - originX
      const moveY = eventY - originY
      if (moveX < 0 || moveX + 38 >= w) return false
      this.slider.style.left = moveX + 'px'
      const blockLeft = (w - 40 - 20) / (w - 40) * moveX
      this.block.style.left = blockLeft + 'px'

      addClass(this.sliderContainer, 'sliderContainer_active')
      this.sliderMask.style.width = moveX + 'px'
      trail.push(moveY)
    }

    const handleDragEnd = (e) => {
      if (!isMouseDown) return false
      isMouseDown = false
      const eventX = e.clientX || e.changedTouches[0].clientX
      if (eventX === originX) return false
      removeClass(this.sliderContainer, 'sliderContainer_active')
      this.trail = trail
      const { spliced, verified } = this.verify()
      if (spliced) {
        if (verified) {
          addClass(this.sliderContainer, 'sliderContainer_success')
          typeof this.onSuccess === 'function' && this.onSuccess()
        } else {
          addClass(this.sliderContainer, 'sliderContainer_fail')
          this.text.innerHTML = '請再試一次'
          this.reset()
        }
      } else {
        addClass(this.sliderContainer, 'sliderContainer_fail')
        typeof this.onFail === 'function' && this.onFail()
        setTimeout(() => {
          this.reset()
        }, 1000)
      }
    }
    this.slider.addEventListener('mousedown', handleDragStart)
    this.slider.addEventListener('touchstart', handleDragStart)
    this.block.addEventListener('mousedown', handleDragStart)
    this.block.addEventListener('touchstart', handleDragStart)
    document.addEventListener('mousemove', handleDragMove)
    document.addEventListener('touchmove', handleDragMove)
    document.addEventListener('mouseup', handleDragEnd)
    document.addEventListener('touchend', handleDragEnd)
  }

  verify () {
    const arr = this.trail // 拖動時y軸的移動距離
    const average = arr.reduce(sum) / arr.length
    const deviations = arr.map(x => x - average)
    const stddev = Math.sqrt(deviations.map(square).reduce(sum) / arr.length)
    const left = parseInt(this.block.style.left)
    return {
      spliced: Math.abs(left - this.x) < 10,
      verified: stddev !== 0, // 簡單驗證拖動軌跡,為零時表示Y軸上下沒有波動,可能非人為操作
    }
  }

  reset () {
    this.sliderContainer.className = 'sliderContainer'
    this.slider.style.left = 0
    this.block.style.left = 0
    this.sliderMask.style.width = 0
    this.clean()
    this.img.setSrc(getRandomImgSrc())
  }
}

window.jigsaw = {
  init: function (opts) {
    return new jigsaw(opts).init()
  }
}

轉自:https://www.cnblogs.com/huanglei-/p/8568405.html


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM