如何用原生 JS 實現(xiàn)手勢解鎖組件

之前一直都在用框架寫東西涝缝,也沒造過什么輪子,所以一直想用原生JS寫點什么東西榄笙,無奈自己水平又有限搭盾,因此只能上網(wǎng)找別人造好的輪子,然后自己研究痒钝。本項目并非原創(chuàng)秉颗,只是作為一個學(xué)習(xí)的案例。本篇文章用來記錄自己對該項目的學(xué)習(xí)總結(jié)送矩。

一.聲明:

  • 本項目全都使用es6/es7語法進(jìn)行編寫蚕甥,并且使用了多頁面開發(fā)環(huán)境進(jìn)行打包編譯。因此可以作為前端進(jìn)階的一個項目栋荸,如果你是新手建議去這里
  • 原項目文章寫的非常好菇怀,組件編寫的流程說的很清楚,詳情見原項目地址
  • 自己改(zhao)造(chao)的項目地址 (帶有大量注釋)
  • 原文涉及到的在這里就不再提了晌块,本文主要提及一些原文中沒有說到的東西
  • 在看本篇文章之前爱沟,先看原項目
  • 本文只是自己對項目源碼的理解,如有不對匆背,請及時指出
  • 多頁面開發(fā)環(huán)境的使用方法見這里
  • 演示地址 僅支持移動端

二.知識點

1. change事件

先看MDN上的介紹:

  • 規(guī)范 HTML5
  • 接口 Event
  • 冒泡 Yes
  • 可取消 No
  • 目標(biāo) Element
  • 默認(rèn)行為 undefined

從上述介紹來看呼伸,change事件可以冒泡,因此可以對表單元素使用事件代理钝尸,先看一段代碼:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Document</title>
</head>
<body>
    <div id="handler">
        <label>
            <input type="radio" name="mode" value="check"  id="checkmode">
            驗證密碼
        </label>
        <label>
            <input type="radio" name="mode" value="update" checked>
            設(shè)置密碼
        </label>
    </div>
    <script type="text/javascript">
        var handler = document.querySelector("#handler");
        var checkmode = document.querySelector("#checkmode")
        handler.addEventListener("change",function (){
            console.log("success");
        })
        setTimeout(function (){
            checkmode.checked = 'checked'
        },2000)
    </script>
</body>
</html>

這段代碼的意思是:一開始讓設(shè)置密碼單選按鈕被選中括享,2s之后再讓驗證密碼按鈕選中,觸發(fā)change事件珍促,這里change事件是被代理的铃辖。經(jīng)過測試你會發(fā)現(xiàn):兩秒后驗證密碼單選按鈕被選中,但是change事件回調(diào)沒有被觸發(fā)猪叙,WTF?娇斩。原來是這么回事仁卷,再看MDN上的一段描述:

事件觸發(fā)取決于表單元素的類型(type)和用戶對標(biāo)簽的操作:
1.<input type="radio"><input type="checkbox"> 的默認(rèn)選項被修改時(通過點擊或者鍵盤事件);
2.當(dāng)用戶完成提交動作時 (例如:點擊了<select>中的一個選項,從 <input type="date">標(biāo)簽選擇了一個日期犬第,通過 <input type="file">標(biāo)簽上傳了一個文件五督,等 );
3.當(dāng)標(biāo)簽的值被修改并且失焦后,但并未進(jìn)行提交 (例如:對<textarea>或者<input type="text">的值進(jìn)行編輯后瓶殃。).

checkmode.checked = 'checked'觸發(fā)了驗證密碼單選按鈕的change事件充包,但是沒有發(fā)生冒泡,只有單選按鈕的鼠標(biāo)事件或者鍵盤事件被觸發(fā)時遥椿,change事件才會冒泡基矮。因此解決辦法是使用click方法:

click方法可以用來模擬鼠標(biāo)左鍵單擊一個元素。
當(dāng)在支持click方法的元素上使用該方法時會觸發(fā)該元素的 click 事件

checkmode.click()
2.實現(xiàn)一個高度隨寬度自適應(yīng)的正方形
使用margin或者padding
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Document</title>
    <style type="text/css">
        #container{
            width: 400px;
        }
        #main{
            width: 100%;
            padding-bottom: 100%;
            height: 0;
            background: red;
        }
    </style>
</head>
<body>
    <div id="container">
        <div id="main"></div>
    </div>
</body>
</html>
3.touch事件取消默認(rèn)行為
container.addEventListener('touchstart',(evt)=>{
            evt.preventDefault();
    },{passive:false})

passive的默認(rèn)值是false,當(dāng)passive=true表示 listener永遠(yuǎn)不會調(diào)用 preventDefault()冠场。如果listener仍然調(diào)用了這個函數(shù)家浇,客戶端將會忽略它并拋出一個控制臺警告。

三. 實現(xiàn)細(xì)節(jié):

1.狀態(tài)切換

先上一張圖:


三種狀態(tài)之間進(jìn)行切換:

  1. 驗證密碼狀態(tài):如果驗證的密碼不足四位(四個圓圈)或者與設(shè)置的不匹配則再次返回驗證密碼狀態(tài)
  2. 第一次設(shè)置密碼狀態(tài):如果驗證的密碼不足四位(四個圓圈)則再次返回第一次設(shè)置密碼狀態(tài)碴裙,否則進(jìn)行第二次重復(fù)密碼設(shè)置
  3. 第二次重復(fù)設(shè)置密碼狀態(tài):如果驗證的密碼不足四位(四個圓圈)或者與第一次設(shè)置的不匹配則再次返第一次設(shè)置密碼狀態(tài)钢悲,否則轉(zhuǎn)移到驗證密碼狀態(tài)
import Recorder from './recorder.js';
import {defaultFunctions} from './config.js'
export default class Locker extends Recorder{
    static get ERR_NOT_MISMATCH(){
        return "not mismath"
    }
    constructor(options){
        options.check = Object.assign({},defaultFunctions.check,options.check);
        options.update = Object.assign({},defaultFunctions.update,options.update);
        /*
         super關(guān)鍵字:
         在子類的構(gòu)造函數(shù)中,只有調(diào)用super之后舔株,才可以使用this關(guān)鍵字莺琳,否則會報錯。
         這是因為子類實例的構(gòu)建载慈,是基于對父類實例加工惭等,只有super方法才能返回父類實例
         */
        super(options);
    }
    async check(password){
        let checked = this.options.check.checked;
        let res = await this.record();
        if(!res.err&&password!==res.records){
            res.err = Locker.ERR_NOT_MISMATCH
        }
        checked.call(this,res);
        this.check(password);
    }
    async update(){
        let beforeRepeat = this.options.update.beforeRepeat,
            afterRepeat = this.options.update.afterRepeat;
        let first = await this.record();
        beforeRepeat.call(this,first);
        if(first.err){
            return this.update();
        }
        let second = await this.record();
        if(!second.err&&second.records!==first.records){
            second.err = Locker.ERR_NOT_MISMATCH
        }
        afterRepeat.call(this,second);
        this.update();
    }
}

2. 解讀Recoder父類中的record方法

record方法中主要有touchstarttouchmovetouchend三個事件的回調(diào)函數(shù).并且record是一個異步的操作办铡,因此調(diào)用的時候要在async/await中調(diào)用辞做。

  • handler(touchstart和touchmove的事件回調(diào))主要用來畫固定線條、圓圈和移動線條寡具。詳情見下面的注釋
let handler = evt => { 
      let {clientX, clientY} = evt.changedTouches[0],
          {bgColor, focusColor, innerRadius, outerRadius, touchRadius} = options,
          touchPoint = getCanvasPoint(moveCanvas, clientX, clientY);
      /*
          畫固定線條秤茅、圓圈、移動線條的步驟:
            1.遍歷九個點童叠,與touchPoint求距離框喳,如果小于outerRaius,則該點就是手勢劃過的點拯钻,畫圓圈
            2.判斷密碼記錄數(shù)組有沒有值帖努,如果有撰豺,取出數(shù)組中最后一個值最為畫固定線條的起點粪般,第一步中
              遍歷到的點作為固定線條的終點
            3.把第一步遍歷到的點從this.circles刪除并添加到records數(shù)組中(用于記錄密碼的數(shù)組)
            4.判斷records數(shù)組長度,如果大于0污桦,數(shù)組中最后一個點作為移動線條的起點亩歹,手勢移動的點作為終點
              并且在畫移動線條的時候要先清除畫布,再重繪            
       */
      for(let i = 0; i < this.circles.length; i++){
        let point = this.circles[i],
            x0 = point.x,
            y0 = point.y;
        if(distance(point, touchPoint) < outerRadius){
          drawSolidCircle(circleCtx, bgColor, x0, y0, outerRadius);//畫一個空白的實心圓
          drawSolidCircle(circleCtx, focusColor, x0, y0, innerRadius);//畫一個紅色實心圓
          drawHollowCircle(circleCtx, focusColor, x0, y0, outerRadius);//畫一個空心圓,邊框是紅色的
          if(records.length){
            let p2 = records[records.length - 1],
                x1 = p2.x,
                y1 = p2.y;
            drawLine(lineCtx, focusColor, x1, y1, x0, y0);
          }

          let circle = this.circles.splice(i, 1);
          records.push(circle[0]);
          break;
        }
      }

      /*
            手勢在移動的時候直線跟著逐漸伸長
       */
      if(records.length){
        let point = records[records.length - 1],
            x0 = point.x,
            y0 = point.y,
            x1 = touchPoint.x,
            y1 = touchPoint.y;

        moveCtx.clearRect(0, 0, moveCanvas.width, moveCanvas.height);
        drawLine(moveCtx, focusColor, x0, y0, x1, y1);        
      }

    };
    circleCanvas.addEventListener('touchstart', handler);
    circleCanvas.addEventListener('touchmove', handler);
  • done主要用來移出事件回調(diào)并且resolve異步操作的結(jié)果,因為主要是touchend決定了異步操作的結(jié)果小作,因此把done方法封裝在了一個promise
    let done;
    // 異步操作的結(jié)束取決于什么時候touchend
    let promise = new Promise((resolve, reject) => {
      done = evt => {
        moveCtx.clearRect(0, 0, moveCanvas.width, moveCanvas.height);
        if(!records.length) return;//點擊空白處不執(zhí)行下面
        circleCanvas.removeEventListener('touchstart', handler);
        circleCanvas.removeEventListener('touchmove', handler);
        circleCanvas.removeEventListener('touchend', done);
        let err = records.length < options.minPoints ? Recorder.ERR_NOT_ENOUGH_POINTS : null;
        //這里可以選擇一些復(fù)雜的編碼方式亭姥,本例子用最簡單的直接把坐標(biāo)轉(zhuǎn)成字符串
        let res = {err, records: records.map(o => o.pos.join('')).join('')};
        resolve(res);
      };
      circleCanvas.addEventListener('touchend', done);
    });
3.難點解析

先來一張效果圖:


如圖所示:

一開始單選按鈕處于驗證密碼,我們沒有進(jìn)行任何繪制操作顾稀,當(dāng)點擊設(shè)置密碼進(jìn)行操作時會多出一條來自最開始點擊的圓圈的射線达罗。
這是因為,一開始處于驗證密碼狀態(tài)時静秆,調(diào)用了check方法粮揉,而check方法中調(diào)用了record,而每record一次就會給canvas綁定事件回調(diào),這樣當(dāng)點擊設(shè)置密碼進(jìn)行繪制時,先調(diào)用了update方法,update又調(diào)用了一次record抚笔,當(dāng)繪制的時候其實是執(zhí)行了兩次事件回調(diào)扶认,并且兩次事件回調(diào)用的是同一個circles數(shù)組,所以其中一個回調(diào)在執(zhí)行的時候circles數(shù)組中一直只有一項殊橙,這樣就造成了多出一條射線辐宾。

解決辦法:

在每次record方法執(zhí)行前先移除上一次recordcanvas綁定的事件回調(diào),但是怎么在本次record方法中移出上一次record方法中綁定的事件回調(diào)呢膨蛮?
那就是在record方法的底部使用一個閉包,在閉包中使用removeEventListener,這樣就可以把handlerdone“閉起來”叠纹,并且把這個閉包賦值給一個實例屬性(是個對象),這樣當(dāng)record的時候就可以移除上次record添加的事件回調(diào)。

cancel(){
    this.recordingTask&&this.recordingTask.cancel();
}

record(){
       let {
           circleCanvas,
           moveCanvas,
           circleCtx,
           lineCtx,
           moveCtx,
           options
       } = this;

       let {
           focusColor,
           bgColor,
           innerRadius,
           outerRadius,
           minPoint
       } = options;

      this.cancel();

      circleCanvas.addEventListener("touchstart",(evt)=>{
         this.clearPath();
      });

      let records = [];
      const handler = (evt)=>{
           let {clientX,clientY} = evt.touches[0],
               touchPoint = getCanvasPoint(circleCanvas,clientX,clientY);
           for(let i=0;i<this.circles.length;i++){
               let point = this.circles[i];
               let x0 = point.x,
                   y0 = point.y;
               if(distancePoint(point,touchPoint)<outerRadius){
                   drawSolidCircle(circleCtx,bgColor,x0,y0,outerRadius);
                   drawSolidCircle(circleCtx,focusColor,x0,y0,innerRadius);
                   drawHollowCircle(circleCtx,focusColor,x0,y0,outerRadius);
                   if(records.length){
                       let p2 = records[records.length-1],
                           x1 = p2.x,
                           y1 = p2.y;
                       drawLine(lineCtx,focusColor,x1,y1,x0,y0);
                   }
                   let circle = this.circles.splice(i,1);
                   records.push(circle[0])
                   break;
               }
           }

           if(records.length){
               let point  = records[records.length-1],
                   x0 = point.x,
                   y0 = point.y,
                   x1 = touchPoint.x,
                   y1 = touchPoint.y;
               moveCtx.clearRect(0,0,moveCanvas.width,moveCanvas.height)
               drawLine(moveCtx,focusColor,x0,y0,x1,y1)
           }
      };

     circleCanvas.addEventListener('touchstart',handler);
     circleCanvas.addEventListener('touchmove',handler);
     let done;
      // 異步操作的結(jié)束取決于什么時候touchend
     let promise  = new Promise(resolve=>{
         done = ()=>{
             moveCtx.clearRect(0,0,moveCanvas.width,moveCanvas.height);
             if(!records.length) return ;
             circleCanvas.removeEventListener('touchstart', handler);
             circleCanvas.removeEventListener('touchmove', handler);
             circleCanvas.removeEventListener('touchend', done);
             let err = records.length<minPoint?Recorder.ERR_NOT_ENOUGH_POINTS:null;
             let res = {err,records:records.map(item=>item.pos.join('')).join('')};
             resolve(res)
         };
         circleCanvas.addEventListener('touchend',done);
     });
     this.recordingTask = {};
     this.recordingTask.cancel = ()=>{
         circleCanvas.removeEventListener('touchstart', handler);
         circleCanvas.removeEventListener('touchmove', handler);
         circleCanvas.removeEventListener('touchend', done);
     };
      return promise
  }

【注】:this.recordingTask = {};的目的是避免第一次record的時候cancel方法不存在

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末敞葛,一起剝皮案震驚了整個濱河市吊洼,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌制肮,老刑警劉巖冒窍,帶你破解...
    沈念sama閱讀 221,695評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異豺鼻,居然都是意外死亡综液,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,569評論 3 399
  • 文/潘曉璐 我一進(jìn)店門儒飒,熙熙樓的掌柜王于貴愁眉苦臉地迎上來谬莹,“玉大人,你說我怎么就攤上這事桩了「矫保” “怎么了?”我有些...
    開封第一講書人閱讀 168,130評論 0 360
  • 文/不壞的土叔 我叫張陵井誉,是天一觀的道長蕉扮。 經(jīng)常有香客問我,道長颗圣,這世上最難降的妖魔是什么喳钟? 我笑而不...
    開封第一講書人閱讀 59,648評論 1 297
  • 正文 為了忘掉前任屁使,我火速辦了婚禮,結(jié)果婚禮上奔则,老公的妹妹穿的比我還像新娘蛮寂。我一直安慰自己,他們只是感情好易茬,可當(dāng)我...
    茶點故事閱讀 68,655評論 6 397
  • 文/花漫 我一把揭開白布酬蹋。 她就那樣靜靜地躺著,像睡著了一般抽莱。 火紅的嫁衣襯著肌膚如雪除嘹。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,268評論 1 309
  • 那天岸蜗,我揣著相機與錄音尉咕,去河邊找鬼。 笑死璃岳,一個胖子當(dāng)著我的面吹牛年缎,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播铃慷,決...
    沈念sama閱讀 40,835評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼单芜,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了犁柜?” 一聲冷哼從身側(cè)響起洲鸠,我...
    開封第一講書人閱讀 39,740評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎馋缅,沒想到半個月后扒腕,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,286評論 1 318
  • 正文 獨居荒郊野嶺守林人離奇死亡萤悴,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,375評論 3 340
  • 正文 我和宋清朗相戀三年瘾腰,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片覆履。...
    茶點故事閱讀 40,505評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡蹋盆,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出硝全,到底是詐尸還是另有隱情栖雾,我是刑警寧澤,帶...
    沈念sama閱讀 36,185評論 5 350
  • 正文 年R本政府宣布伟众,位于F島的核電站析藕,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏赂鲤。R本人自食惡果不足惜噪径,卻給世界環(huán)境...
    茶點故事閱讀 41,873評論 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望数初。 院中可真熱鬧找爱,春花似錦、人聲如沸泡孩。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,357評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽仑鸥。三九已至吮播,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間眼俊,已是汗流浹背意狠。 一陣腳步聲響...
    開封第一講書人閱讀 33,466評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留疮胖,地道東北人环戈。 一個月前我還...
    沈念sama閱讀 48,921評論 3 376
  • 正文 我出身青樓,卻偏偏與公主長得像澎灸,于是被迫代替她去往敵國和親院塞。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,515評論 2 359

推薦閱讀更多精彩內(nèi)容

  • Spring Cloud為開發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見模式的工具(例如配置管理性昭,服務(wù)發(fā)現(xiàn)拦止,斷路器,智...
    卡卡羅2017閱讀 134,701評論 18 139
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,286評論 25 707
  • 國家電網(wǎng)公司企業(yè)標(biāo)準(zhǔn)(Q/GDW)- 面向?qū)ο蟮挠秒娦畔?shù)據(jù)交換協(xié)議 - 報批稿:20170802 前言: 排版 ...
    庭說閱讀 11,005評論 6 13
  • 天津有這樣一個街頭表演樂隊糜颠,由音樂愛好者自發(fā)組建汹族。他們都是五六十歲的中老年人,經(jīng)常聚在一起吹拉彈唱其兴,每個區(qū)都有一個...
    2012融會貫通閱讀 1,633評論 0 1
  • 小時候看電視劇《三國演義》鞠抑,認(rèn)定曹操是十惡不赦、殺人不眨眼的奸雄忌警;周瑜是心胸狹窄搁拙,忌賢嫉能的陰險小人;劉備是雄才韜...
    玉墨清歡閱讀 399評論 0 3