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

本文详细介绍了如何从零开始,逐步封装一个前端手势库,包括Listener、Recognizer和Dispatcher的实现,以及如何提供一个友好的API供开发者使用。通过实例演示了如何处理鼠标和触屏事件,以及识别和分发各种手势行为。
摘要由CSDN通过智能技术生成

前端《组件化系列》目录

经历了多次的迭代,我们的手势库功能都已经实现了。但是到了这里我们的代码确实需要重新整理和封装了。如果同学们还记得的,我们之间一开始获取的元素 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 更新了以下功能:

预览

✨ 新增

  • 自适应 “推荐文章” 布局 (增加了一个新的 “置顶文章布局” !!)
    • 能够在“推荐文章”和“置顶文章”模式之间自由切换
    • 如果总文章少于 3 篇,将自动切换到“置顶文章”模式
    • 在文章卡上添加了“置顶”和“推荐”标签
    • 📖 文档
  • 增加了与 VuePress 一样的自定义容器 #77
    • Info 容器
    • Warning 容器
    • Danger 容器
    • Detail 容器
    • 预览
  • 支持了更多的 SEO meta 数据 #76
    • 添加了 description
    • 添加了 keywords
    • 添加了 author
    • 📖 文档

最近博主在全面投入开发一个可以 “迈向未来的” Hexo 主题,以极光为主题的博客主题。

如果你是一个开发者,做一个个人博客也是你简历上的一个亮光点。而如果你有一个超级炫酷的博客,那就更加是亮上加亮了,简直就闪闪发光。

如果喜欢这个主题,可以在 Github 上给我点个 🌟 让彼此都发光吧~

主题 Github 地址:https://github.com/auroral-ui/hexo-theme-aurora
主题使用文档:https://aurora.tridiamond.tech/zh/


VSCode Aurora Future


对,博主还做了一个 Aurora 的 VSCode 主题。用了Hexo Theme Aurora 相对应的颜色色系。这个主题的重点特性的就只用了 3 个颜色,减少在写代码的时候被多色多彩的颜色所转移了你的注意力,让你更集中在写代码之中。

喜欢的大家可以支持一下哦! 直接在 VSCode 的插件搜索中输入 “Aurora Future” 即可找到这个主题哦!~

主题 Github 地址:https://github.com/auroral-ui/aurora-future-vscode-theme
主题插件地址:https://marketplace.visualstudio.com/items?itemName=auroral-ui.aurora-future


Firefox Aurora Future

我不知道大家,但是最近我在用火狐浏览器来做开发了。个人觉得火狐还真的是不错的。推荐大家尝试一下。

当然我这里想给大家介绍的是我在火狐也做了一个 Aurora 主题。对的!用的是同一套的颜色体系。喜欢的小伙伴可以试一下哦!

主题地址:https://addons.mozilla.org/en-US/firefox/addon/aurora-future/

评论 19
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

三钻

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值