用 JavaScript 实现手势库 — 支持多键触发【前端组件化】

《前端进阶》 专栏收录该内容
29 篇文章 43 订阅

tQPgM1k6EbQ

前端《组件化系列》目录


上一期《实现手势库 — 手势逻辑》我们完成了手势库的所有手势逻辑,这一期我们继续来完善我们的手势库。

我们来想想目前代码中的问题,我们的 handlerstartXstartYisPanisPressisTap 都放入全局作用域之中了,那么这些变量是放在全局当中是否是正确的呢?

其实我们忽视了一些情况。如果我们从触屏的角度去考虑,我们就会有多个 touch 的情况。如果我们从鼠标点击的角度去考虑,那么它就有可能有左右键的区分。在这种场景下我们把这些变量放在全局显然是不对的,因为这些状态和变量需要适应不用的场景而定的。

那么我们应该放在哪里呢?显然除了全局以外我们也只有一种选项,就是在函数调用的时候添加多一个 context 对象参数。那么 context 对象中就会有我们上面所有的属性。

首先我们把所有调用到这些变量的地方,在变量名前面加入 context.

改好了原有变量的调用方式之后,我们就要来想想,我们要怎么存储这个 context 对象,并且如何把属性插入进去和获取出来传给这些函数。

改造触屏事件

首先我们来看看触屏的事件怎么改:

  • 支持多触点/按键
    • 像我们之前说的,我们可能有多个触点同时触发,或者多个鼠标按键行为
    • 所以我们是需要把所有的 context 都存储起来的。
    • 那么我们就在全局当中建立一个 contexts 数据结构,这里我们使用 Map 数据结构来存储。
  • 构建 context
    • context 需要在第一个触发的 touchstart 事件中生成,这里我们直接生成一个空对象即可
    • 然后把新生成的 context 加入到 contexts 阵列中,唯一值就用触点的 identifier
    • 最后在 start 函数调用时传入 context
  • 获取 context
    • 我们在 touchstart 中生成了 context,那么我们在其他事件中就要把 context 取出
    • 这里我们使用每个事件中得到的 touch 触点的 identifier 去取得对应的 context
    • 最后把 context 传入事件调用函数
  • 清除 context
    • 在事件结束的时候,我们是需要去从 contexts 阵列中回收对应的 context的
    • 我们触屏的结束事件有两个,一个是 touchend,一个是 touchancel
    • 在这两个事件中,我们需要调用 contexts.delete(touch.identifier) 来清楚对应的 context
let contexts = new Map();

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

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

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

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

这样我们触屏的事件都改好了,接下来我们来看看鼠标事件需要怎么修改。

鼠标拖拽与按钮

鼠标的拖拽我们只支持左键拖拽即可,那么我们就在调用 start 之前判断一下左键是否被按下就可以了。在我们浏览器的模型里面,它至少支持了 5 个键,分别是左键右键中建前进后退,而这些键每个都支持两种行为事件:点击(down)和松开(up)。

如果我们这里想把 5 个键的 2 种状态行为分开来处理,例如,左键拖拽和右键拖拽分别做不同的事情。这种我们是可以在 mouse 的事件里面去实现的。

但是这些按键我们应该如何判断呢?这里我们就需要了解一下 event 中的 button 属性。这个 button 属性会是 01234 这样的数值,而它们表示的是我们按下某一个键对应的标识值。平常我们最常用的有以下几项:

  • 0 为 左键点击
  • 1 为 中键点击
  • 2 为 右键点击

知道 event 中的 button 属性,我们就可以与触屏一样,在 mousedown 事触发的时候,构建 context 对象。同理,我们使用 Object.create(null) 来创建一个空对象。

这里插一句:这种创建对象的方式,是一个非常好的习惯。这种方式表示我们要用一个对象做一个 K-V(Key values)关系数据结构。并且也可以避免 object 上原始的属性跳出来添乱。

接着上一步,我们需要把新创建的 context 插入到 contexts map 中,但是在 mouse 事件中,event 是没有 identifier 这个属性的(而在上面讲到触屏事件中的 event 是有 identifier 这样的唯一值的)。所以为了保持 context 在 contexts 对象中的唯一性,同时也与触屏的 context 区分开,我们就需要自己组装一个唯一的 key

恰好我们 event 中的 event.button 在按键中是唯一的,加上一个字符 mouse 这样就可以与我们的触屏事件的 context 的 key 区分开了。所以这里我们就使用 mouse + event.button 这样的 key 格式。

最后我们把 context 传入start(event, context) 函数即可。

Mousedown 事件

好,我们就从 element.addEventListener('mosuedown', event =>{ 后的第一行开始改造。

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

  start(event, context);

  let mousemove = event => {
    move(event);
  };

  let mouseup = event => {
    end(event);
    element.removeEventListener('mousemove', mousemove);
    element.removeEventListener('mouseup', mouseup);
  };

  element.addEventListener('mousemove', mousemove);
  element.addEventListener('mouseup', mouseup);
});

这里我们就处理好 mousedown 事件触发事件的逻辑了。

Mousemove 事件

接下来就是处理 mousemove 事件。但是 mousemove 中我们就会发现一个问题,mousemove 的 event 中是没有 button 这个属性的。

“那咋搞呀兄弟?” —— 不过我们有 buttons 属性,buttons 的属性值是包含了当前状态下所有被按下的鼠标键。但是我们会发现这个值很古怪,它与我们 mousedown 中按键的属性值是对应不上的。

这里 buttons 的值是用了一个非常古典的设计,叫做掩码。掩码就是用二进制来表示的:

  • 没有按键或者是没有初始化:
    • 数值:0
  • 左键:
    • 二进制码:0b00001,数值:1
  • 右键:
    • 二进制码:0b00010,数值:2
  • 中键:
    • 二进制码:0b00100,数值:4
  • 第四按键(通常是“浏览器后退”键):
    • 二进制:0b01000,数值:8
  • 第五按键(通常是 “浏览器前进”):
    • 二进制码:0b10000,数值:16

buttons 中的数值是多个按键的数值之和。也就是说如果我们同时按下左键和右键,buttons 的数值就是 1 + 2 = 3 1+2=3 1+2=3,而它的二进制就是 0b00011

同学们在这些值中有没有看出什么规律?有没有发现这个二进制码位置的特殊性?其实 buttons 的二进制码的 “标记位” 是有表示对应的状态的。

在 JavaScript 中使用 按位移动操作符 进行位运算来创建和读取标记位,是非常常见的用法。

这里的二进制码的 1 所在的位置,就是一个标记位,标记这那种状态现在是启动了的。(想详细了解这块知识的同学,可以去看 MDN 的《标记位与掩码》的示例)

既然 mousemove 事件中的 buttons 是使用掩码的,那么我们也只能适应,使用掩码的形式去解决它。

接下来我们就来看看代码中怎么去处理这一块的逻辑。

首先我们需要通过使用按位移动操作符,从 1 开始不断的移位寻找 buttons 里面含有哪些按键。如果我们使用 “左移” 的话,我们就会得到 2,4,8,16 的数值。这些数值就代表着有哪些按键现在被触发了。

为了不让我们的循环移动超过我们的边界,这里我们就需要加一个边界截止条件 button <= event.buttons。这样我们就可以在这个循环里面一直移动到 event.buttons 的值。

在这个循环里面,我们通过 button 的移位得到的数字,在没有到达 event.buttons 的值之前的值,都是我们按下的按钮的值。

比如,我们按下了左键和右键,这个时候 event.buttons 的值就是 3,循环第一遍是 1,第二遍是 2,第三遍是 4。在第三遍的时候循环被结束了,所以我们捕捉到的 button 值就有 1 和 2。

用捕捉到的 button 值创建 context 对象,这样我们就能找到对应 context 对象,并且传入 move 函数做移动事件的处理了。

循环的最后同学们不要忘记给 button 再往左移动一位哦。

这里需要注意的两个点:

  • 这里有一个情况,我们没有按键的时候也一样会触发 context 创建,并且调用了 move 函数,这样 move 计算是会出错的,所以我们需要去判断,只有按钮被点击了才会触发我们的逻辑。这里使用的就是 “按位运算符” &,使用 button & event.buttons 来确保有按钮被按下了,再执行逻辑。
  • 第二个点就是 event.buttons 和 event.button 中,右键和中键的值是刚好相反的,所以我们要做一个处理,让他们反转过来。
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);
      move(event, context);
    }
    button = button << 1;
  }
};

写完这个 mousemove 的事件,同学们有没有发现一个问题?我们这里创建 context 时使用的 button 值是一个二进制的值。也就是 1、 2、4、8、16。但是我们之前写的 mousedown 中的 button 值是 012345,这样是不是就对应不上呢?

是会对应不上的,所以我们需要回去改造一下 mousedown 事件的 context 中的 button 值。这里我们想要它们对应上,其实有一个非常奇妙的技巧。

mousedown 中的 button 值是 0、1、2、3… 这样的,换一个角度去看,其实它们就是 1 往左边移动了多少位。

我们来验证一下,button 的掩码中 “左键” 的值是 1,但是在 mousedown 的事件中它是 0。如果我们用数值 1 作为基础位,然后用 mousedown 中的 button 值作为移动位数。这样我们就会有一个 1 << 0,这个的结果就是 1。

我们验证一下中键,中建在掩码中的值是 2,在 mousedown 的事件中是 1,那么我们使用 1 << 1,这个时候移位后的结果就是 2。噢噢噢,好像是这么一回事哦!

所以我们用 1 << event.button, 这样的方式就可以让 mousedown 中的 button 值和 mousemove 的 button 值一一对应上了。

// 把原来的 contexts.set(`event.button}`, context); 改为:
contexts.set(`mouse${1 << event.button}`, context);

Mouseup 事件

接下来我们看看怎么修改 mouseup 的事件。mouseup 的事件中,我们是需要把对应的 contextcontexts 中取出来。那么用了新的 contexts 去储存我们的 context 对象的话,我们就需要一个对应的健值,才能从 contexts 中取出对应的 context 对象。

不过 mouseup 中的 event 是和 mousedown 的一样的,里面是有 button 这个属性的。所以我们可以用 mosuedown 中一样的逻辑获得对应的 button 值。

获取到 context 之后我们就把它传入 end 函数中。最后这个 context 被使用完了之后,是需要在 contexts 中移除掉的。

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

  element.removeEventListener('mousemove', mousemove);
  element.removeEventListener('mouseup', mouseup);
};

多按钮触发

最后我们发现还有一个问题需要解决的,就是当我们多个按键同时按下,在我们松开的时候,
就会抛错。Cannot read property 'isTap' of undefined。那么为什么有抛出这个错误呢?

我们仔细看看代码中的逻辑,我们会发现里面有一个漏洞。当我们按下两个按键的时候,我们的 mousedown 是会触发两次的,那么在最后每个 addEventListener 也是会触发两次。因为我们重复监听了,松开任何一个键的时候也会触发两次 mouseup 事件,同时也是会调用两次 removeEventListener。而第二次的 remove 事件就会失败了。

所以这里我们是需要加入一个判断,让我们事件监听被添加了之后不再重复监听。这里我们加入一个状态变量 isListeningMouse,在第一次执行添加时间监听的时候,我们就把这个变量置为 true。那么如果还有一个按钮被点击的时候,就不会重复监听了。

另外一个需要改的地方在 mouseup 回调函数里面,首先我们需要判断当前是否已经没有其他按钮了,我们可以使用 event.buttons 的值。如果这个属性的值是 0,证明已经没有任何按钮被按下了,这个时候我们就可以执行移除监听事件的逻辑。在移除之后我们就可以把 isListeningMouse 的值置为 false

let isListeningMouse = false;

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

  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);
        move(event, context);
      }
      button = button << 1;
    }
  };

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

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

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

到这里为止,我们已经把监听和识别都写完了,下一期来我们将会跟大家讨论一下,如何去实事件的派发和处理。

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


⭐️ 三哥推荐

开源项目推荐

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/

相关推荐
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值