如何用原生 JS 实现手势解锁组件

之前一直都在用框架写东西,也没造过什么轮子,所以一直想用原生JS写点什么东西,无奈自己水平又有限,因此只能上网找别人造好的轮子,然后自己研究。本项目并非原创,只是作为一个学习的案例。本篇文章用来记录自己对该项目的学习总结。

一.声明:

  • 本项目全都使用es6/es7语法进行编写,并且使用了多页面开发环境进行打包编译。因此可以作为前端进阶的一个项目,如果你是新手建议去这里
  • 原项目文章写的非常好,组件编写的流程说的很清楚,详情见原项目地址
  • 自己改(zhao)造(chao)的项目地址 (带有大量注释)
  • 原文涉及到的在这里就不再提了,本文主要提及一些原文中没有说到的东西
  • 在看本篇文章之前,先看原项目
  • 本文只是自己对项目源码的理解,如有不对,请及时指出
  • 多页面开发环境的使用方法见这里
  • 演示地址 仅支持移动端

二.知识点

1. change事件

先看MDN上的介绍:

  • 规范 HTML5
  • 接口 Event
  • 冒泡 Yes
  • 可取消 No
  • 目标 Element
  • 默认行为 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>
            设置密码
        </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>

这段代码的意思是:一开始让设置密码单选按钮被选中,2s之后再让验证密码按钮选中,触发change事件,这里change事件是被代理的。经过测试你会发现:两秒后验证密码单选按钮被选中,但是change事件回调没有被触发,WTF?。原来是这么回事,再看MDN上的一段描述:

事件触发取决于表单元素的类型(type)和用户对标签的操作:
1.<input type="radio"><input type="checkbox"> 的默认选项被修改时(通过点击或者键盘事件);
2.当用户完成提交动作时 (例如:点击了<select>中的一个选项,从 <input type="date">标签选择了一个日期,通过 <input type="file">标签上传了一个文件,等 );
3.当标签的值被修改并且失焦后,但并未进行提交 (例如:对<textarea>或者<input type="text">的值进行编辑后。).

checkmode.checked = 'checked'触发了验证密码单选按钮的change事件,但是没有发生冒泡,只有单选按钮的鼠标事件或者键盘事件被触发时,change事件才会冒泡。因此解决办法是使用click方法:

click方法可以用来模拟鼠标左键单击一个元素。
当在支持click方法的元素上使用该方法时会触发该元素的 click 事件

checkmode.click()
2.实现一个高度随宽度自适应的正方形
使用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事件取消默认行为
container.addEventListener('touchstart',(evt)=>{
            evt.preventDefault();
    },{passive:false})

passive的默认值是false,当passive=true表示 listener永远不会调用 preventDefault()。如果listener仍然调用了这个函数,客户端将会忽略它并抛出一个控制台警告。

三. 实现细节:

1.状态切换

先上一张图:


三种状态之间进行切换:

  1. 验证密码状态:如果验证的密码不足四位(四个圆圈)或者与设置的不匹配则再次返回验证密码状态
  2. 第一次设置密码状态:如果验证的密码不足四位(四个圆圈)则再次返回第一次设置密码状态,否则进行第二次重复密码设置
  3. 第二次重复设置密码状态:如果验证的密码不足四位(四个圆圈)或者与第一次设置的不匹配则再次返第一次设置密码状态,否则转移到验证密码状态
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关键字:
         在子类的构造函数中,只有调用super之后,才可以使用this关键字,否则会报错。
         这是因为子类实例的构建,是基于对父类实例加工,只有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三个事件的回调函数.并且record是一个异步的操作,因此调用的时候要在async/await中调用。

  • handler(touchstart和touchmove的事件回调)主要用来画固定线条、圆圈和移动线条。详情见下面的注释
let handler = evt => { 
      let {clientX, clientY} = evt.changedTouches[0],
          {bgColor, focusColor, innerRadius, outerRadius, touchRadius} = options,
          touchPoint = getCanvasPoint(moveCanvas, clientX, clientY);
      /*
          画固定线条、圆圈、移动线条的步骤:
            1.遍历九个点,与touchPoint求距离,如果小于outerRaius,则该点就是手势划过的点,画圆圈
            2.判断密码记录数组有没有值,如果有,取出数组中最后一个值最为画固定线条的起点,第一步中
              遍历到的点作为固定线条的终点
            3.把第一步遍历到的点从this.circles删除并添加到records数组中(用于记录密码的数组)
            4.判断records数组长度,如果大于0,数组中最后一个点作为移动线条的起点,手势移动的点作为终点
              并且在画移动线条的时候要先清除画布,再重绘            
       */
      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主要用来移出事件回调并且resolve异步操作的结果,因为主要是touchend决定了异步操作的结果,因此把done方法封装在了一个promise
    let done;
    // 异步操作的结束取决于什么时候touchend
    let promise = new Promise((resolve, reject) => {
      done = evt => {
        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 < options.minPoints ? Recorder.ERR_NOT_ENOUGH_POINTS : null;
        //这里可以选择一些复杂的编码方式,本例子用最简单的直接把坐标转成字符串
        let res = {err, records: records.map(o => o.pos.join('')).join('')};
        resolve(res);
      };
      circleCanvas.addEventListener('touchend', done);
    });
3.难点解析

先来一张效果图:


如图所示:

一开始单选按钮处于验证密码,我们没有进行任何绘制操作,当点击设置密码进行操作时会多出一条来自最开始点击的圆圈的射线。
这是因为,一开始处于验证密码状态时,调用了check方法,而check方法中调用了record,而每record一次就会给canvas绑定事件回调,这样当点击设置密码进行绘制时,先调用了update方法,update又调用了一次record,当绘制的时候其实是执行了两次事件回调,并且两次事件回调用的是同一个circles数组,所以其中一个回调在执行的时候circles数组中一直只有一项,这样就造成了多出一条射线。

解决办法:

在每次record方法执行前先移除上一次recordcanvas绑定的事件回调,但是怎么在本次record方法中移出上一次record方法中绑定的事件回调呢?
那就是在record方法的底部使用一个闭包,在闭包中使用removeEventListener,这样就可以把handlerdone“闭起来”,并且把这个闭包赋值给一个实例属性(是个对象),这样当record的时候就可以移除上次record添加的事件回调。

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;
      // 异步操作的结束取决于什么时候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方法不存在

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266

推荐阅读更多精彩内容