用 JavaScript 实现手势库 — 封装手势库前端组件化

Posted 三钻

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了用 JavaScript 实现手势库 — 封装手势库前端组件化相关的知识,希望对你有一定的参考价值。

前端《组件化系列》目录

经历了多次的迭代,我们的手势库功能都已经实现了。但是到了这里我们的代码确实需要重新整理和封装了。如果同学们还记得的,我们之间一开始获取的元素 element 是写死的。但是作为一个手势库,我们绑定的元素必然是由这个库的使用者而决定的。

有一些同学可能就会问:“为什么不一开始就想好怎么写,一开始就封装好呢?现在实现了所有功能,再回头去封装,不是重复工作,浪费了时间了吗?”

其实如果我们一开始我们就想怎么封装,应该怎么设计这个库。因为设计需要考虑的因素很多,而且要实现的功能还没有落实。其实往往这个时候设计出来的方案或者架构到了后面都会被修改 N 次的。那么最后我们花在设计的时间就会比我们花在实现这些功能上要多得多。但是如果我们是先实现了功能,然后再去封装,就变得简单的多。

所以接下来我们就开始封装这个手势库吧!

要封装这个手势库,第一件事就是列出现有的函数,并且给他们归类。那么我们的这个手势库的其实就 3 个部分

  • Listener 监听器
    • mouse 事件
      • mousedown
      • mouseup
      • mousemove
    • touch 事件
      • touchstart
      • touchmove
      • touchend
      • cancel
  • recognizer 识别器
    • start()
    • move()
    • end()
  • dispatcher 分发器
    • dispatch()

如果我们想把这个库做成一个 API 的话,我们就可以用上面提到的三个部分来解耦。

按照我们上面写的 3 个部分来看,其实他们是有串联关系,甚至是嵌套关系的。首先我们需要实例一个 Listener 监听器。然后这个 Listener 需要有一个 Recognizer 识别器,用来识别监听到的事件。最后我们的识别器需要有一个 Dispatcher 派发器,被识别的事件会通过派发器分发出去。

所以最后我们调用这个手势 API 的方式应该是这样的:

new Listener(new Recognizer(new Dispatcher()))

Listener 监听器

那么我们来看看怎么实现 Listener

因为一个 Listener 实例会默认传入一个 Recognizer,所以我们先建立一个 contructor 构造函数,让它接收传入的 Recognizer。Listener 也需要知道它监听的元素,所以我们 constructor 里面也需要接收一个 element 元素。

/**
 * 监听器
 */
export class Listener {
  constructor(element, recognizer) {}
}

然后我们就可以把我们之前写的所有监听的函数都复制到 Listener 类里面。我们之前是直接调用 startmoveend 事件处理函数的,而这里都换成使用 Recognizer 调用。

都改完之后,我们的 Listener 类应该是这样的。

/**
 * 监听器
 */
export class Listener {
  constructor(element, recognizer) {
    let contexts = new Map();
    let isListeningMouse = false;

    element.addEventListener('mousedown', event => {
      let context = Object.create(null);
      contexts.set(`mouse${1 << event.button}`, context);

      recognizer.start(event, context);

      let mousemove = event => {
        let button = 1;

        while (button <= event.buttons) {
          if (button & event.buttons) {
            let key;
            // Order of buttons & button is not the same
            if (button === 2) {
              key = 4;
            } else if (button === 4) {
              key = 2;
            } else {
              key = button;
            }

            let context = contexts.get('mouse' + key);
            recognizer.move(event, context);
          }
          button = button << 1;
        }
      };

      let mouseup = event => {
        let context = contexts.get(`mouse${1 << event.button}`);
        recognizer.end(event, context);
        contexts.delete(`mouse${1 << event.button}`);

        if (event.buttons === 0) {
          document.removeEventListener('mousemove', mousemove);
          document.removeEventListener('mouseup', mouseup);
          isListeningMouse = false;
        }
      };

      if (!isListeningMouse) {
        document.addEventListener('mousemove', mousemove);
        document.addEventListener('mouseup', mouseup);
        isListeningMouse = true;
      }
    });

    element.addEventListener('touchstart', event => {
      for (let touch of event.changedTouches) {
        let context = Object.create(null);
        contexts.set(touch.identifier, context);
        recognizer.start(touch, context);
      }
    });

    element.addEventListener('touchmove', event => {
      for (let touch of event.changedTouches) {
        let context = contexts.get(touch.identifier);
        recognizer.move(touch, context);
      }
    });

    element.addEventListener('touchend', event => {
      for (let touch of event.changedTouches) {
        let context = contexts.get(touch.identifier);
        recognizer.end(touch, context);
        contexts.delete(touch.identifier);
      }
    });

    element.addEventListener('cancel', event => {
      for (let touch of event.changedTouches) {
        let context = contexts.get(touch.identifier);
        recognizer.cancel(touch, context);
        contexts.delete(touch.identifier);
      }
    });
  }
}

这样我们就封装好我们的 Listener 监听器了。接下来我们就可以开始封装我们的 Recognizer 识别器。

Recognizer 识别器

Recognizer 是用来封装我们的 start, moveendcancel 四个函数的。这四个函数的作用无非就是识别这些鼠标事件是属于那种类型的,然后把对应的手势类型分发出去。

首先我们的 Recognizer 实例的时候是需要接收一个 Dispatcher 分发器的。这个类会在我们所有事件判断好之后,调用它的 dispatch 分发函数来派发我们的事件。所以我们简单的在 constructor 构造函数中记录下来即可。

然后我们把之前写好的四个函数复制到 Recognizer 。改好后我们整个 Recognizer 就是这样的:

/**
 * 识别器
 */
export class Recognizer {
  constructor(dispatcher) {
    this.dispatcher = dispatcher;
  }

  start(point, context) {
    (context.startX = point.clientX), (context.startY = point.clientY);

    context.points = [
      {
        t: Date.now(),
        x: point.clientX,
        y: point.clientY,
      },
    ];

    context.isPan = false;
    context.isTap = true;
    context.isPress = false;

    context.handler = setTimeout(() => {
      context.isPan = false;
      context.isTap = false;
      context.isPress = true;
      console.log('press-start');
      context.handler = null;
    }, 500);
  }

  move(point, context) {
    let dx = point.clientX - context.startX,
      dy = point.clientY - context.startY;

    if (!context.isPan && dx ** 2 + dy ** 2 > 100) {
      context.isPan = true;
      context.isTap = false;
      context.isPress = false;
      console.log('pan-start');
      clearTimeout(context.handler);
    }

    if (context.isPan) {
      console.log(dx, dy);
      console.log('pan');
    }

    context.points = context.points.filter(point => Date.now() - point.t < 500);

    context.points.push({
      t: Date.now(),
      x: point.clientX,
      y: point.clientY,
    });
  }

  end(point, context) {
    context.isFlick = false;

    if (context.isTap) {
      //console.log('tap');
      // 把原先的 console.log 换成 dispatch 调用
      // 这个事件不需要任何特殊属性,直接传`空对象`即可
      dispatch('tap', {});
      clearTimeout(context.handler);
    }
      
    context.points = context.points.filter(point => Date.now() - point.t < 500);

    let d, v;
    if (!context.points.length) {
      v = 0;
    } else {
      d = Math.sqrt(
      (point.clientX - context.points[0].x) ** 2 + (point.clientY - context.points[0].y) ** 2);
      v = d / (Date.now() - context.points[0].t);
    }

    if (v > 1.5) {
      context.isFlick = true;
      dispatch('flick', {});
    } else {
      context.isFlick = false;   
    }

    if (context.isPan) {
      dispatch('panend', {});
    }

    if (context.isPress) {
      console.log('press-end');
    }
  }

  cancel(point, context) {
    clearTimeout(context.handler);
    console.log('cancel');
  }
}

同学们还记得之前说过,这 4 个事件处理函数中的事件都是还没有分发出去的。所有事件识别后,我们都只是 console.log 打印了一下。

所以接下来我们来完成这一部分的逻辑吧~

首先是 press(或者是 press-start),这个我们是不需要传任何的参数出去的。所以我们直接 disptach 出去即可。

context.handler = setTimeout(() => {
  context.isPan = false;
  context.isTap = false;
  context.isPress = true;
  this.dispatcher.dispatch('press');
  context.handler = null;
}, 500);

然后就是 panstart 移动开始这个事件,这个事件就需要把数据传出去的。这里我们就把一下关键数据给分发出去:

  • startX - 开始点的 x 坐标
  • startY - 开始点的 y 坐标
  • clientX - 当前位置的 x 坐标
  • clientY - 当前位置的 y 坐标
  • isVertical - 当前的移动是否是垂直方向的,这个状态在做一些定向功能的时候会有用,所以我们这里附加了这个判断。
    • 计算也很简单,如果 dx 水平线的移动距离小于 dy 垂直的移动距离,那么现在这个移动动作就是垂直的,否则就是水平线的移动。
    • 这里我们要注意的是,要对比的是他们绝对的移动长度,我们是要忽略附属的情况(忽略是往左还是往右,往上还是往下,只需要移动的长度)
    • 所以我们要让 dx 和 dy 是一个正数,这里就使用 Math.abs()

在这个事件里,这 4 个数据就够了,如果遇到一些功能需要 panstart 给予更多的数据,我们可以回到这个库的这里进行添加即可。

if (!context.isPan && dx ** 2 + dy ** 2 > 100) {
  context.isPan = true;
  context.isTap = false;
  context.isPress = false;
  context.isVertical = Math.abs(dx) < Math.abs(dy)
  this.dispatcher.dispatch('panstart', {
    startX: context.startX,
    startY: context.startY,
    clientX: point.clientX,
    clientY: point.clientY,
    isVertical: context.isVertical,
  });
  clearTimeout(context.handler);
}

接下来的 pan 事件也是与我们的 panstart 的逻辑一样即可。

this.dispatcher.dispatch('pan', {
  startX: context.startX,
  startY: context.startY,
  clientX: point.clientX,
  clientY: point.clientY,
  isVertical: context.isVertical,
});

这里我们改造了一下 panend 触发的位置,因为无论当前是一个移动结束,还是一个 flick,有时候我们功能上是不需要监听 flick 的。所以之前我们存在 flick 就不输出 panend 其实是错误的。

所以这里我们就把 panend 的派发逻辑放在 flick 判断之后,然后放入一个 panend 的派发事件,传出去的参数与上面的 pan 一样,这里加上 isFlick 参数,把当前的移动状态是否是 flick 事件的状态也传出去给手势库的使用者。

虽然我们已经在 panend 中给出了 velocity (速度)的参数。但是使用场景来说,有些时候我们是需要单独监听 flick 事件的。所以我们当前的移动事件是一个 flick 的话,我们也一样会派发一个 flick 事件,并且在传出去的参数中加上 velocity 速度这个参数。

最后 isPan 判断里面的代码是这样的:

let d, v;
if (!context.points.length) {
  v = 0;
} else {
  d = Math.sqrt(
  (point.clientX - context.points[0].x) ** 2 + (point.clientY - context.points[0].y) ** 2);
  v = d / (Date.now() - context.points[0].t);
}

if (v > 1.5) {
  context.isFlick = true;
  dispatch('flick', {});
} else {
  context.isFlick = false;   
}

if (context.isPan) {
  this.dispatcher.dispatch('panend', {
    startX: context.startX,
    startY: context.startY,
    clientX: point.clientX,
    clientY: point.clientY,
    isVertical: context.isVertical,
    isFlick: context.isFlick,
  });
}

好,我们还有一个 press 和 cancel 事件的派发,这里我们就直接使用 this.dispatcher.dispatch 就好了,也不需要传任何的而外参数了。因为这些事件没有必要。

Dispatcher 派发器

最后我们就是来实现我们在 Recognizer 里面用到的 Dispatcher。这个非常的简单,就是把我们 dispatch 函数写入一个 Dispatcher 类里面即可。最后因为 element 是传进来 dipatcher 当中的。所以我们需要在 constructor 里面接收并且记录在类属性当中即可。

/**
 * 分发器
 */
export class Dispatcher {
  constructor(element) {
    this.element = element;
  }
  dispatch(type, properties) {
    let event = new Event(type);
    for (let name in properties) {
      event[name] = properties[name];
    }
    this.element.dispatchEvent(event);
  }
}

一体化启用函数

最后我们加入一个函数,可以让使用者直接通过这个方法来使用我们的手势库。记住 “高内聚” 的设计理念,就是让你的使用者不需要知道我们封装的服务里的任何复杂内容和使用方式。封装一些简单方便的方法给予使用者们,让他们更友好的使用这个功能。

所以这里我们就加入了 enableGesture 函数,这个函数值需要接收一个 element 参数,即可开启我们所有的事件监听能力。

/**
 * 给某个 element 启用手势库的监听
 * 一体化的处理方法
 *
 * @param {Element} element 元素
 */
export function enableGesture(element) {
  new Listener(element, new Recognizer(new Dispatcher(element)));
}

这样我们完美的写好了一个 Gesture 库,它可以完美地给我们的 Carousel 组件提供手势功能了。

接下来我们自己测试一下我们封装的代码是否可靠。在我们的 gesture.html 中引用我们刚刚写的 enable

<body oncontextmenu="event.preventDefault()"></body>
<script>
  import { enableGesture } from './gesture.js';
  enableGesture(document.documentElement);

  document.documentElement.addEventListener('tap', () => {
    console.log('Tapped!');
  });
</script>

没有任何问题的话,当我们点击一下浏览器空白页面的时候,我们 console 中就会输出一个 “Tapped!”。这样就证明我们的手势库可以投入使用了。

我是来自《技术银河》的三钻,一位正在重塑知识的技术人。下期再见。


⭐️ 三哥推荐

开源项目推荐

Hexo Theme Aurora


在最近在版本 1.5.0 更新了以下功能:

预览

✨ 新增