從零開發(fā)一款輕量級(jí)滑動(dòng)驗(yàn)證碼插件(深度復(fù)盤)
之前一直在分享 低代碼 和 可視化 的文章,其中涉及到很多有意思的知識(shí)點(diǎn)和設(shè)計(jì)思想,今天繼續(xù)和大家分享一款非常有趣且實(shí)用的前端實(shí)戰(zhàn)項(xiàng)目——從零基于 react + canvas 實(shí)現(xiàn)一個(gè)滑動(dòng)驗(yàn)證碼,并將其發(fā)布到 npm 上供他人使用。當(dāng)然如果大家更喜歡 vue 的開發(fā)方式,也不用擔(dān)心,文中的設(shè)計(jì)思想和思路都是通用的,如果大家想學(xué)習(xí)如何封裝 vue 組件并發(fā)布到 npm 上,也可以參考我之前的文章: 從零到一教你基于vue開發(fā)一個(gè)組件庫。
從這個(gè)實(shí)戰(zhàn)項(xiàng)目中我們可以學(xué)到如下知識(shí)點(diǎn):
- 前端組件設(shè)計(jì)的基本思路和技巧
- canvas 基本知識(shí)和使用
- react hooks 基本知識(shí)和使用
- 滑動(dòng)驗(yàn)證碼基本設(shè)計(jì)原理
- 如何封裝一款可擴(kuò)展的滑動(dòng)驗(yàn)證碼組件
- 如何使用 dumi 搭建組件文檔
- 如何發(fā)布自己第一個(gè)npm組件包
如果你對(duì)以上任意知識(shí)點(diǎn)感興趣,相信這篇文章都會(huì)給你帶來啟發(fā)。
效果演示

滑動(dòng)驗(yàn)證組件基本使用和技術(shù)實(shí)現(xiàn)
上圖是實(shí)現(xiàn)的滑動(dòng)驗(yàn)證組件的一個(gè)效果演示,當(dāng)然還有很多配置項(xiàng)可以選擇,以便支持更多 定制化 的場景。接下來我先介紹一下如何安裝和使用這款驗(yàn)證碼插件,讓大家有一個(gè)直觀的體驗(yàn),然后我會(huì)詳細(xì)介紹一下滑動(dòng)驗(yàn)證碼的實(shí)現(xiàn)思路,如果大家有一定的技術(shù)基礎(chǔ),也可以直接跳到技術(shù)實(shí)現(xiàn)部分。
基本使用
因?yàn)?react-slider-vertify 這款組件我已經(jīng)發(fā)布到 npm 上了,所以大家可以按照如下方式安裝和使用:
1.安裝
- # 或者 yarn add @alex_xu/react-slider-vertify
- npm i @alex_xu/react-slider-vertify -S
2.使用
- import React from 'react';
- import { Vertify } from '@alex_xu/react-slider-vertify';
- export default () => {
- return <Vertify
- width={320}
- height={160}
- onSuccess={() => alert('success')}
- onFail={() => alert('fail')}
- onRefresh={() => alert('refresh')}
- />
- };
通過以上兩步我們就可以輕松使用這款滑動(dòng)驗(yàn)證碼組件了,是不是很簡單?
當(dāng)然我也暴露了很多可配置的屬性,讓大家對(duì)組件有更好的控制。參考如下:
技術(shù)實(shí)現(xiàn)
在做這個(gè)項(xiàng)目之前我也研究了一些滑動(dòng)驗(yàn)證碼的知識(shí)以及已有的技術(shù)方案,收獲很多。接下來我會(huì)以我的組件設(shè)計(jì)思路來和大家介紹如何用 react 來實(shí)現(xiàn)和封裝滑動(dòng)驗(yàn)證碼組件,如果大家有更好的想法和建議, 也可以在評(píng)論區(qū)隨時(shí)和我反饋。
1.組件設(shè)計(jì)的思路和技巧
每個(gè)人都有自己設(shè)計(jì)組件的方式和風(fēng)格,但最終目的都是更 優(yōu)雅 的設(shè)計(jì)組件。這里我大致列舉一下 優(yōu)雅 組件的設(shè)計(jì)指標(biāo):
- 可讀性(代碼格式統(tǒng)一清晰,注釋完整,代碼結(jié)構(gòu)層次分明,編程范式使用得當(dāng))
- 可用性(代碼功能完整,在不同場景都能很好兼容,業(yè)務(wù)邏輯覆蓋率)
- 復(fù)用性(代碼可以很好的被其他業(yè)務(wù)模塊復(fù)用)
- 可維護(hù)性(代碼易于維護(hù)和擴(kuò)展,并有一定的向下/向上兼容性)
- 高性能
以上是我自己設(shè)計(jì)組件的考量指標(biāo),大家可以參考一下。
另外設(shè)計(jì)組件之前我們還需要明確需求,就拿滑動(dòng)驗(yàn)證碼組件舉例,我們需要先知道它的使用場景(用于登錄注冊(cè)、活動(dòng)、論壇、短信等高風(fēng)險(xiǎn)業(yè)務(wù)場景的人機(jī)驗(yàn)證服務(wù))和需求(交互邏輯,以什么樣的方式驗(yàn)證,需要暴露哪些屬性)。
以上就是我梳理的一個(gè)大致的組件開發(fā)需求,在開發(fā)具體組件之前,如果遇到復(fù)雜的業(yè)務(wù)邏輯,我們還可以將每一個(gè)實(shí)現(xiàn)步驟列舉出來,然后一一實(shí)現(xiàn),這樣有助于整理我們的思路和更高效的開發(fā)。
2.滑動(dòng)驗(yàn)證碼基本實(shí)現(xiàn)原理
在介紹完組件設(shè)計(jì)思路和需求分析之后,我們來看看滑動(dòng)驗(yàn)證碼的實(shí)現(xiàn)原理。
我們都知道設(shè)計(jì)驗(yàn)證碼的主要目的是為了防止機(jī)器非法暴力地入侵我們的應(yīng)用,其中核心要解決的問題就是判斷應(yīng)用是誰在操作(人 or 機(jī)器),所以通常的解決方案就是隨機(jī)識(shí)別。
上圖我們可以看到只有用戶手動(dòng)將滑塊拖拽到對(duì)應(yīng)的鏤空區(qū)域,才算驗(yàn)證成功,鏤空區(qū)域的位置是隨機(jī)的(隨機(jī)性測(cè)試這里暫時(shí)以前端的方式來實(shí)現(xiàn),更安全的做法是通過后端來返回位置和圖片)。
基于以上分析我們就可以得出一個(gè)基本的滑動(dòng)驗(yàn)證碼設(shè)計(jì)原理圖:
接下來我們就一起封裝這款可擴(kuò)展的滑動(dòng)驗(yàn)證碼組件。
3.封裝一款可擴(kuò)展的滑動(dòng)驗(yàn)證碼組件
按照我開發(fā)組件一貫的風(fēng)格,我會(huì)先基于需求來編寫組件的基本框架:
- import React, { useRef, useState, useEffect, ReactNode } from 'react';
- interface IVertifyProp {
- /**
- * @description canvas寬度
- * @default 320
- */
- width:number,
- /**
- * @description canvas高度
- * @default 160
- */
- height:number,
- /**
- * @description 滑塊邊長
- * @default 42
- */
- l:number,
- /**
- * @description 滑塊半徑
- * @default 9
- */
- r:number,
- /**
- * @description 是否可見
- * @default true
- */
- visible:boolean,
- /**
- * @description 滑塊文本
- * @default 向右滑動(dòng)填充拼圖
- */
- text:string | ReactNode,
- /**
- * @description 刷新按鈕icon, 為icon的url地址
- * @default -
- */
- refreshIcon:string,
- /**
- * @description 用于獲取隨機(jī)圖片的url地址
- * @default https://picsum.photos/${id}/${width}/${height}, 具體參考https://picsum.photos/, 只需要實(shí)現(xiàn)類似接口即可
- */
- imgUrl:string,
- /**
- * @description 驗(yàn)證成功回調(diào)
- * @default ():void => {}
- */
- onSuccess:VoidFunction,
- /**
- * @description 驗(yàn)證失敗回調(diào)
- * @default ():void => {}
- */
- onFail:VoidFunction,
- /**
- * @description 刷新時(shí)回調(diào)
- * @default ():void => {}
- */
- onRefresh:VoidFunction
- }
- export default ({
- width = 320,
- height = 160,
- l = 42,
- r = 9,
- imgUrl,
- text,
- refreshIcon = 'http://yourimgsite/icon.png',
- visible = true,
- onSuccess,
- onFail,
- onRefresh
- }: IVertifyProp) => {
- return <div className="vertifyWrap">
- <div className="canvasArea">
- <canvas width={width} height={height}></canvas>
- <canvas className="block" width={width} height={height}></canvas>
- </div>
- <div className={sliderClass}>
- <div className="sliderMask">
- <div className="slider">
- <div className="sliderIcon">→</div>
- </div>
- </div>
- <div className="sliderText">{ textTip }</div>
- </div>
- <div className="refreshIcon" onClick={handleRefresh}></div>
- <div className="loadingContainer">
- <div className="loadingIcon"></div>
- <span>加載中...</span>
- </div>
- </div>
- }
以上就是我們組件的基本框架結(jié)構(gòu)。從代碼中可以發(fā)現(xiàn)組件屬性一目了然,這都是提前做好需求整理帶來的好處,它可以讓我們?cè)诰帉懡M件時(shí)思路更清晰。在編寫好基本的 css 樣式之后我們看到的界面是這樣的:
接下來我們需要實(shí)現(xiàn)以下幾個(gè)核心功能:
- 鏤空效果的 canvas 圖片實(shí)現(xiàn)
- 鏤空?qǐng)D案 canvas 實(shí)現(xiàn)
- 滑塊移動(dòng)和驗(yàn)證邏輯實(shí)現(xiàn)
上面的描述可能比較抽象,我畫張圖示意一下:
因?yàn)榻M件實(shí)現(xiàn)完全采用的 react hooks ,如果大家對(duì) hooks 不熟悉也可以參考我之前的文章:
10分鐘教你手寫8個(gè)常用的自定義hooks
一.實(shí)現(xiàn)鏤空效果的 canvas 圖片
在開始 coding 之前我們需要對(duì) canvas 有個(gè)基本的了解,建議不熟悉的朋友可以參考高效 canvas 學(xué)習(xí)文檔: Canvas of MDN。
由上圖可知首先要解決的問題就是如何用 canvas 畫不規(guī)則的圖形,這里我簡單的畫個(gè)草圖:
我們只需要使用 canvas 提供的 路徑api 畫出上圖的路徑,并將路徑填充為任意半透明的顏色即可。建議大家不熟悉的可以先了解如下 api :
- beginPath() 開始路徑繪制
- moveTo() 移動(dòng)筆觸到指定點(diǎn)
- arc() 繪制弧形
- lineTo() 畫線
- stroke() 描邊
- fill() 填充
- clip() 裁切路徑
實(shí)現(xiàn)方法如下:
- const drawPath = (ctx:any, x:number, y:number, operation: 'fill' | 'clip') => {
- 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)
- // anticlockwise為一個(gè)布爾值。為true時(shí),是逆時(shí)針方向,否則順時(shí)針方向
- 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.8)'
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'
- ctx.stroke()
- ctx.globalCompositeOperation = 'destination-over'
- // 判斷是填充還是裁切, 裁切主要用于生成圖案滑塊
- operation === 'fill'? ctx.fill() : ctx.clip()
- }
這塊實(shí)現(xiàn)方案也是參考了 yield 大佬的原生 js 實(shí)現(xiàn),這里需要補(bǔ)充的一點(diǎn)是 canvas 的 globalCompositeOperation 屬性,它的主要目的是設(shè)置如何將一個(gè)源(新的)圖像繪制到目標(biāo)(已有)的圖像上。
- 源圖像 = 我們打算放置到畫布上的繪圖
- 目標(biāo)圖像 = 我們已經(jīng)放置在畫布上的繪圖
w3c上有個(gè)形象的例子:
這里之所以設(shè)置該屬性是為了讓鏤空的形狀不受背景底圖的影響并覆蓋在背景底圖的上方。如下:
接下來我們只需要將圖片繪制到畫布上即可:
- const canvasCtx = canvasRef.current.getContext('2d')
- // 繪制鏤空形狀
- drawPath(canvasCtx, 50, 50, 'fill')
- // 畫入圖片
- canvasCtx.drawImage(img, 0, 0, width, height)
當(dāng)然至于如何生成隨機(jī)圖片和隨機(jī)位置,實(shí)現(xiàn)方式也很簡單,前端實(shí)現(xiàn)的話采用 Math.random 即可。
二.實(shí)現(xiàn)鏤空?qǐng)D案 canvas
上面實(shí)現(xiàn)了鏤空形狀,那么鏤空?qǐng)D案也類似,我們只需要使用 clip() 方法將圖片裁切到形狀遮罩里,并將鏤空?qǐng)D案置于畫布左邊即可。代碼如下:
- const blockCtx = blockRef.current.getContext('2d')
- drawPath(blockCtx, 50, 50, 'clip')
- blockCtx.drawImage(img, 0, 0, width, height)
- // 提取圖案滑塊并放到最左邊
- const y1 = 50 - r * 2 - 1
- const ImageData = blockCtx.getImageData(xRef.current - 3, y1, L, L)
- // 調(diào)整滑塊畫布寬度
- blockRef.current.width = L
- blockCtx.putImageData(ImageData, 0, y1)
上面的代碼我們用到了 getImageData 和 putImageData,這兩個(gè) api 主要用來獲取 canvas 畫布場景像素?cái)?shù)據(jù)和對(duì)場景進(jìn)行像素?cái)?shù)據(jù)的寫入。實(shí)現(xiàn)后 的效果如下:
三.實(shí)現(xiàn)滑塊移動(dòng)和驗(yàn)證邏輯
實(shí)現(xiàn)滑塊移動(dòng)的方案也比較簡單,我們只需要利用鼠標(biāo)的 event 事件即可:
- onMouseDown
- onMouseMove
- onMouseUp
以上是一個(gè)簡單的示意圖,具體實(shí)現(xiàn)代碼如下:
- const handleDragMove = (e) => {
- if (!isMouseDownRef.current) return false
- e.preventDefault()
- // 為了支持移動(dòng)端, 可以使用e.touches[0]
- const eventX = e.clientX || e.touches[0].clientX
- const eventY = e.clientY || e.touches[0].clientY
- const moveX = eventX - originXRef.current
- const moveY = eventY - originYRef.current
- if (moveX < 0 || moveX + 36 >= width) return false
- setSliderLeft(moveX)
- const blockLeft = (width - l - 2r) / (width - l) * moveX
- blockRef.current.style.left = blockLeft + 'px'
- }
當(dāng)然我們還需要對(duì)拖拽停止后的事件做監(jiān)聽,來判斷是否驗(yàn)證成功,并埋入成功和失敗的回調(diào)。代碼如下:
- const handleDragEnd = (e) => {
- if (!isMouseDownRef.current) return false
- isMouseDownRef.current = false
- const eventX = e.clientX || e.changedTouches[0].clientX
- if (eventX === originXRef.current) return false
- setSliderClass('sliderContainer')
- const { flag, result } = verify()
- if (flag) {
- if (result) {
- setSliderClass('sliderContainer sliderContainer_success')
- // 成功后的自定義回調(diào)函數(shù)
- typeof onSuccess === 'function' && onSuccess()
- } else {
- // 驗(yàn)證失敗, 刷新重置
- setSliderClass('sliderContainer sliderContainer_fail')
- setTextTip('請(qǐng)?jiān)僭囈淮?)
- reset()
- }
- } else {
- setSliderClass('sliderContainer sliderContainer_fail')
- // 失敗后的自定義回調(diào)函數(shù)
- typeof onFail === 'function' && onFail()
- setTimeout(reset.bind(this), 1000)
- }
- }
實(shí)現(xiàn)后的效果如下:
當(dāng)然還有一些細(xì)節(jié)需要優(yōu)化處理,這里在 github 上有完整的代碼,大家可以參考學(xué)習(xí)一下,如果大家想對(duì)該組件參與貢獻(xiàn),也可以隨時(shí)提 issue。
四.如何使用 dumi 搭建組件文檔
為了讓組件能被其他人更好的理解和使用,我們可以搭建組件文檔。作為一名熱愛開源的前端 coder,編寫組件文檔也是個(gè)很好的開發(fā)習(xí)慣。接下來我們也為 react-slider-vertify 編寫一下組件文檔,這里我使用 dumi 來搭建組件文檔,當(dāng)然大家也可以用其他方案(比如storybook)。我們先看一下搭建后的效果:
dumi 搭建組件文檔非常簡單,接下來和大家介紹一下安裝使用方式。
1.安裝
- $ npx @umijs/create-dumi-lib # 初始化一個(gè)文檔模式的組件庫開發(fā)腳手架
- # or
- $ yarn create @umijs/dumi-lib
- $ npx @umijs/create-dumi-lib --site # 初始化一個(gè)站點(diǎn)模式的組件庫開發(fā)腳手架
- # or
- $ yarn create @umijs/dumi-lib --site
2.本地運(yùn)行
- npm run dev
- # or
- yarn dev
3.編寫文檔
dumi 約定式的定義了文檔編寫的位置和方式,其官網(wǎng)上也有具體的飯介紹,這里簡單給大家上一個(gè) dumi 搭建的組件目錄結(jié)構(gòu)圖:
我們可以在 docs 下編寫組件庫文檔首頁和引導(dǎo)頁的說明,在單個(gè)組件的文件夾下使用 index.md 來編寫組件自身的使用文檔,當(dāng)然整個(gè)過程非常簡單,我這里舉一個(gè)文檔的例子:
通過這種方式 dumi 就可以幫我們自動(dòng)渲染一個(gè)組件使用文檔。如果大家想學(xué)習(xí)更多組件文檔搭建的內(nèi)容,也可以在 dumi 官網(wǎng)學(xué)習(xí)。
五.發(fā)布自己第一個(gè)npm組件包
最后一個(gè)問題就是組件發(fā)布。之前很多朋友問我如何將自己的組件發(fā)布到 npm 上讓更多人使用,這塊的知識(shí)網(wǎng)上有很多資料可以學(xué)習(xí),那今天就以滑動(dòng)驗(yàn)證碼 @alex_xu/react-slider-vertify 的例子,來和大家做一個(gè)簡單的介紹。
1.擁有一個(gè) npm 賬號(hào)并登錄
如果大家之前沒有 npm 賬號(hào),可以在 npm 官網(wǎng) 注冊(cè)一個(gè),然后用我們熟悉的 IDE 終端登錄一次:
- npm login
跟著提示輸入完用戶名密碼之后我們就能通過命令行發(fā)布組件包了:
- npm publish --access public
之所以指令后面會(huì)加 public 參數(shù),是為了避免權(quán)限問題導(dǎo)致組件包無法發(fā)布成功。我們?yōu)榱耸∈乱部梢园寻l(fā)布命令配置到 package.json 中,在組件打包完成后自動(dòng)發(fā)布:
- {
- "scripts": {
- "start": "dumi dev",
- "release": "npm run build && npm publish --access public",
- }
- }
這樣我們就能將組件輕松發(fā)布到 npm 上供他人使用啦! 我之前也開源了很多組件庫,如果大家對(duì)組件打包細(xì)節(jié)和構(gòu)建流程有疑問,也可以參考我之前開源項(xiàng)目的方案。發(fā)布到 npm 后的效果:
本文轉(zhuǎn)載自微信公眾號(hào)「趣談前端」