超小手勢ライブラリalloyfingerとそのvue版の深い解析を実現

18497 ワード

alloyfingerは非常に軽量なオープンソースジェスチャーライブラリであり、軽量、原生jsベースなどの特性で広く使用されている.その原理について、その公式チームの解析は非常に詳細である.高い数を学んだことがあると信じている人は難しくないはずですが、ここでは深く解析しません.
そのコアコードは300行以上しかなく、14個のジェスチャーが完了した.そのジェスチャーはブラウザのオリジナルイベントではなく、touchstart、touchmove、touchend、touchcancelの4つのオリジナルブラウザイベントhackを傍受するジェスチャーであるため、その使い方はオリジナルとは少し異なる可能性がある.たとえば、デフォルトのイベントをブロックしたり、泡をブロックしたりして、元のイベントのように使用できません.
公式コードにはalloyfingerのコアライブラリのほかにreact、vueの実装もあります.ここでは、コアライブラリであるvueバージョンのみを解析します.
コアライブラリ:
/* AlloyFinger v0.1.10
 * By dntzhang
 * Github: https://github.com/AlloyTeam/AlloyFinger
 * Note By keenjaan
 * Github: https://github.com/keenjaan
 */
; (function () {
    //              

    //                  (         )
    function getLen(v) {
        return Math.sqrt(v.x * v.x + v.y * v.y);
    }
    //                      
    function dot(v1, v2) {
        return v1.x * v2.x + v1.y * v2.y;
    }
    //             
    function getAngle(v1, v2) {
        var mr = getLen(v1) * getLen(v2);
        if (mr === 0) return 0;
        var r = dot(v1, v2) / mr;
        if (r > 1) r = 1;
        return Math.acos(r);
    }
    //          ,(     0,     0)
    function cross(v1, v2) {
        return v1.x * v2.y - v2.x * v1.y;
    }
    //         ,     
    function getRotateAngle(v1, v2) {
        var angle = getAngle(v1, v2);
        if (cross(v1, v2) > 0) {
            angle *= -1;
        }
        return angle * 180 / Math.PI;
    }
    //                
    var HandlerAdmin = function(el) {
        this.handlers = []; //       
        this.el = el;       //     
    };
    //               
    HandlerAdmin.prototype.add = function(handler) {
        this.handlers.push(handler);
    }
    //               
    HandlerAdmin.prototype.del = function(handler) {
        if(!handler) this.handlers = []; // handler    ,          
        for(var i=this.handlers.length; i>=0; i--) {
            if(this.handlers[i] === handler) {
                this.handlers.splice(i, 1);
            }
        }
    }
    //             
    HandlerAdmin.prototype.dispatch = function() {
        for(var i=0,len=this.handlers.length; i 0 && this.delta <= 250 && Math.abs(this.preTapPosition.x - this.x1) < 30 && Math.abs(this.preTapPosition.y - this.y1) < 30);
            }
            this.preTapPosition.x = this.x1;    //            preTapPosition。
            this.preTapPosition.y = this.y1;
            this.last = this.now;               //          
            var preV = this.preV,               //            
                len = evt.touches.length;       //     
            if (len > 1) {                      //       1
                this._cancelLongTap();          //   longTap   
                this._cancelSingleTap();        //   singleTap   
                var v = { x: evt.touches[1].pageX - this.x1, y: evt.touches[1].pageY - this.y1 };
                //             ,    prev   ,    this.preV 。
                preV.x = v.x;
                preV.y = v.y;
                this.pinchStartLen = getLen(preV);  //          
                this.multipointStart.dispatch(evt); //   multipointStart  
            }
            //   longTap     ,  750ms            longTap  。
            this.longTapTimeout = setTimeout(function () {
                this.longTap.dispatch(evt);
            }.bind(this), 750);
        },
        move: function (evt) {
            if (!evt.touches) return;
            var preV = this.preV,   // start              。
                len = evt.touches.length,   //     
                currentX = evt.touches[0].pageX,    //       x  
                currentY = evt.touches[0].pageY;    //       y  
            this.isDoubleTap = false;               //             
            if (len > 1) {
                //              ,   v   。
                var v = { x: evt.touches[1].pageX - currentX, y: evt.touches[1].pageY - currentY };
                // start   preV   ,pinchStartLen  0
                if (preV.x !== null) {
                    if (this.pinchStartLen > 0) {
                        //          start     ,     ,   evt   
                        evt.zoom = getLen(v) / this.pinchStartLen;  
                        this.pinch.dispatch(evt);   //   pinch  
                    }

                    evt.angle = getRotateAngle(v, preV);    //        ,   evt   
                    this.rotate.dispatch(evt);      //   rotate  
                }
                preV.x = v.x;   //  move                preV,      this.preV
                preV.y = v.y;
            } else {
                //        pressMove  

                //      move ,this.x2 null,move      this.x2  。
                if (this.x2 !== null) {
                    //     move       move  ,  x,y  move  。
                    evt.deltaX = currentX - this.x2;
                    evt.deltaY = currentY - this.y2;

                } else {
                    //      move,       0, evt.deltaX,evt.deltaY   0.
                    evt.deltaX = 0;
                    evt.deltaY = 0;
                }
                //   pressMove  
                this.pressMove.dispatch(evt);
            }
            //   touchMove  ,        evt      
            this.touchMove.dispatch(evt);

            //        ,750ms         。
            this._cancelLongTap();
            this.x2 = currentX;     //            
            this.y2 = currentY;
            if (len > 1) {
                evt.preventDefault();   //             
            }
        },
        end: function (evt) {
            if (!evt.changedTouches) return;
            //        ,750ms        
            this._cancelLongTap();   
            var self = this;    //     this  。
            //              2,  multipointEnd  
            if (evt.touches.length < 2) {
                this.multipointEnd.dispatch(evt);
            }

            // this.x2 this.y2       move  。
            // Math.abs(this.x1 - this.x2)   x       。
            //     x   y         30px    swipe  
            if ((this.x2 && Math.abs(this.x1 - this.x2) > 30) ||
                (this.y2 && Math.abs(this.y1 - this.y2) > 30)) {
                //   swipe      evt  。
                evt.direction = this._swipeDirection(this.x1, this.x2, this.y1, this.y2);
                this.swipeTimeout = setTimeout(function () {
                    self.swipe.dispatch(evt);   //     swipe  

                }, 0)
            } else {
                this.tapTimeout = setTimeout(function () {
                    self.tap.dispatch(evt); //     tap  
                    // trigger double tap immediately
                    if (self.isDoubleTap) { // start             
                        self.doubleTap.dispatch(evt);   //       
                        clearTimeout(self.singleTapTimeout);    //   singleTap     
                        self.isDoubleTap = false;   //       
                    }
                }, 0)

                if (!self.isDoubleTap) {    //          
                    self.singleTapTimeout = setTimeout(function () {
                        self.singleTap.dispatch(evt);   //   singleTap  
                    }, 250);
                }
            }

            this.touchEnd.dispatch(evt);    //   touchEnd  
            // end          
            this.preV.x = 0;
            this.preV.y = 0;
            this.zoom = 1;
            this.pinchStartLen = null;
            this.x1 = this.x2 = this.y1 = this.y2 = null;
        },
        cancel: function (evt) {
       
            //        
            clearTimeout(this.singleTapTimeout);
            clearTimeout(this.tapTimeout);
            clearTimeout(this.longTapTimeout);
            clearTimeout(this.swipeTimeout);
            this.touchCancel.dispatch(evt);
        },
        _cancelLongTap: function () {
            clearTimeout(this.longTapTimeout); //   longTap   
        },
        _cancelSingleTap: function () {
            clearTimeout(this.singleTapTimeout); //   singleTap   
        },
        _swipeDirection: function (x1, x2, y1, y2) {
            //   swipe  
            return Math.abs(x1 - x2) >= Math.abs(y1 - y2) ? (x1 - x2 > 0 ? 'Left' : 'Right') : (y1 - y2 > 0 ? 'Up' : 'Down')
        },
        //  14              
        on: function(evt, handler) {
            if(this[evt]) { //      14   ,           
                this[evt].add(handler);
            }
        },
        //  14              
        off: function(evt, handler) {
            if(this[evt]) { //      14   ,         
                this[evt].del(handler);
            }
        },
        //   ,      
        destroy: function() {
            //        
            if(this.singleTapTimeout) clearTimeout(this.singleTapTimeout);
            if(this.tapTimeout) clearTimeout(this.tapTimeout);
            if(this.longTapTimeout) clearTimeout(this.longTapTimeout);
            if(this.swipeTimeout) clearTimeout(this.swipeTimeout);
            //   touch     
            this.element.removeEventListener("touchstart", this.start);
            this.element.removeEventListener("touchmove", this.move);
            this.element.removeEventListener("touchend", this.end);
            this.element.removeEventListener("touchcancel", this.cancel);
            //            
            this.rotate.del();
            this.touchStart.del();
            this.multipointStart.del();
            this.multipointEnd.del();
            this.pinch.del();
            this.swipe.del();
            this.tap.del();
            this.doubleTap.del();
            this.longTap.del();
            this.singleTap.del();
            this.pressMove.del();
            this.touchMove.del();
            this.touchEnd.del();
            this.touchCancel.del();
            //       
            this.preV = this.pinchStartLen = this.zoom = this.isDoubleTap = this.delta = this.last = this.now = this.tapTimeout = this.singleTapTimeout = this.longTapTimeout = this.swipeTimeout = this.x1 = this.x2 = this.y1 = this.y2 = this.preTapPosition = this.rotate = this.touchStart = this.multipointStart = this.multipointEnd = this.pinch = this.swipe = this.tap = this.doubleTap = this.singleTap = this.pressMove = this.touchMove = this.touchEnd = this.touchCancel = null;

            return null;
        }
    };
    //         module,exports es6  ,   AlloyFingerPlugin  
    if (typeof module !== 'undefined' && typeof exports === 'object') {
        module.exports = AlloyFinger;
    } else {  //    AlloyFingerPlugin       
        window.AlloyFinger = AlloyFinger;
    }
})();

vueバージョンコード:
/* AlloyFinger v0.1.0 for Vue
 * By june01
 * Github: https://github.com/AlloyTeam/AlloyFinger
 * Note By keenjaan
 * Github: https://github.com/keenjaan
 */

; (function() {

  var AlloyFingerPlugin = {
    //   vue     install  
    install: function(Vue, options) {
      // options          
      options = options || {};
      // AlloyFinger     ,     options   。
      var AlloyFinger = window.AlloyFinger || options.AlloyFinger;
      //   vue   
      var isVue2 = !!(Vue.version.substr(0,1) == 2);
      //     AlloyFinger    
      if(!AlloyFinger) {
        throw new Error('you need include the AlloyFinger!');
      }
      // 14     
      var EVENTMAP = {
        'touch-start': 'touchStart',
        'touch-move': 'touchMove',
        'touch-end': 'touchEnd',
        'touch-cancel': 'touchCancel',
        'multipoint-start': 'multipointStart',
        'multipoint-end': 'multipointEnd',
        'tap': 'tap',
        'double-tap': 'doubleTap',
        'long-tap': 'longTap',
        'single-tap': 'singleTap',
        'rotate': 'rotate',
        'pinch': 'pinch',
        'press-move': 'pressMove',
        'swipe': 'swipe'
      };
      //              。
      var CACHE = [];
      //      ,    vue     directive     
      var directiveOpts = {};

      //        CACHE     ,    index,     null
      var getElemCacheIndex = function(elem) {
        for(var i=0,len=CACHE.length; i

コード全体の解析には、いくつかの問題点があります.
1、長押ししてtap、swipe、touchend、singleTap、doubleTapなどend内のすべてのイベントをキャンセルする必要があるかどうか.
end内のすべてのイベントをキャンセルする場合は、longTapイベントがトリガーされたときにtrueに設定されたフィールドisLongTapを追加します.endでisLongTapの値を判断し、trueであればreturnを落とし、endのすべてのイベントを阻止し、isLongTapをfalseにリセットする
2、swipeイベントとdoubleTapの定義.ソースコードのswipeとtapの違いはmoveの距離であり、moveの距離がx、y方向ともに30 px以下の場合はtapイベントであり、30 px以上の場合はswipeイベントである.doubleTapも同様に、2回のクリックの距離はx、y方向ともに30 px以下であり、その定義された30 pxは以下のコードを設定している.


すなわち、ページ幅をデバイスの理想的なビューポートに設定します.私の実際のプロジェクトで上記のように設定すると、30 pxという値が少し大きいかもしれませんが、swipeイベントをトリガーしたい結果tapイベントになります.どれだけの効果があるかについては、チームに合った境界値を見つけてみてください.
また、実際のモバイルプロジェクトでは、淘宝チームのflexibleなどのビューポートを設定することはできません.そのios端はページビューポートをスケーリングし、android端は理想的なビューポート(ビューポートをスケーリングしていない)であり、30 pxが画面に対応するスライド距離が異なる.ios端でスライド距離が小さいとswipeイベントがトリガーされます.この場合、直接使用することはできません.モバイル側のアダプタライブラリと組み合わせて、alloyfingerソースコードを調整します.モバイル端末の適合については、私のこの文章の転送ドアを見ることができます.
方法1:alloyfingerソースコードでviewportのスケールを直接読み取り、異なるアダプティブ機種に対して異なる修正値を設定し、すべての機種でswipeイベントをトリガーし、指の移動距離が同じになるようにする.
方法2:vueバージョンの実装に対して、vueのカスタム命令を通じて、命令に掛かる時、動的にパラメータを通じて伝達します.
Vue.use(AlloyFingerVue, option) //        。

AlloyFingerPluginのinstall関数でoptionオブジェクトを取得し、alloyfingerオブジェクトにoptionオブジェクトを注入し、alloyfingerでswipeの境界値を修正します.具体的な実現方案は私のソースコードの中ですでに実現して、注釈ははっきり書いて、私に聞くことができて、ソースコードのリンクは文章の終わりを参照してください.
3、バブルを阻止する.そのイベントはtouchstart、touchmove、touchend、touchcancelの4つのオリジナルイベントを除いてhackであるため、オリジナルイベントのようにリスニング関数にバブルを阻止すると書くことはできない.対応する原生イベントでバブルを阻止する必要がある.vueバージョンでは、命令を登録するときにパラメータを入力してバブルを阻止できます.次のようになります.
v-finger:tap.stoppropagation

doOnOrOff関数ではmodifiersフィールドからstoppropagationフィールドに読み込み、stoppropagationフィールドをalloyfingerオブジェクトに登録できます.このフィールドをalloyfingerオブジェクトペアで除去して、バブルを阻止する必要があるかどうかを判断します.
利点:バブルを阻止するのに便利で、イベントをバインドするときに修飾子を追加すればいいです.
欠点:いったんバブルを阻止すると、この要素上のすべてのイベントがバブルを阻止し、あるイベントがバブルを必要とする場合は、特殊な処理が必要です.
以上の3点について、公式版で修正しました.ソースコードは転送ゲートを参照してください
公式プロジェクトvueバージョンbug
最近、プロジェクトで問題が発生し、ページボタンのバインドイベントに失敗したページもあります.最後に問題を見つけました.公式のvueバージョンにはバグが適しています.vue-routerを使用してルーティングを切り替えると、前のページが破棄されると、すべてのバインドイベントの要素がdoUnbindEvent関数をトリガーし、1つの要素が複数のイベントをバインドするとdoUnbindEvent関数が複数回トリガーされます.1つの要素については、次のようになります.

doUnbindEvent関数:
var doUnbindEvent = function(elem) {
  var index = getElemCacheIndex(elem);

  if ( index ) {
    return true;
  }
  if(!isNaN(index)) {
    var delArr = CACHE.splice(index, 1);
    if(delArr.length && delArr[0] && delArr[0].alloyFinger.destroy) {
      delArr[0].alloyFinger.destroy();
    }
  }
};

初めてdoUnbindEvent関数をトリガーすると、indexは必ずnumberタイプの数字を返し、CACHEから要素を削除します.
2回目にdoUnbindEventがトリガーされると、要素が削除されたためindexはnullを返し、if条件はnullという値をブロックすることはできません.
if(!isNaN(index)) {
  //
}
 :
delArr = CACHE.splice(index, 1) = CACHE.splice(null, 1) = CACHE.splice(0, 1);

CACHE配列の最初の要素を常に切り取るようになりました.
一方,ルーティングが切り替わると,前のページではdoUnbindEvent関数がトリガーされ,新しいページではdoBindEvent関数がトリガーされ,両者は同時にトリガーされ,CACHE配列にバインド要素を追加しながらCACHE配列から要素を除去する.1つの要素が複数のイベントをバインドする場合、indexがnullの場合、新しいページ要素がバインドされたばかりのイベントが削除されます.新しいページバインドイベントに失敗しました.公式にissueを提出しました.リンク