note icon indicating copy to clipboard operation
note copied to clipboard

原生JS实现基于用户行为预测的无延迟二级菜单

Open liuyib opened this issue 6 years ago • 0 comments

前言

在很多电商网站中,一般都会有能够展开多级子菜单的菜单列表。例如:

aliyun_menu_show

一般来说,用户会径直移动鼠标去选择商品,而不是先将鼠标平移到子菜单,然后再选择商品,如图:

aliyun_menu_show2

当用户径直移动鼠标去选择商品时,对于交互体验好的菜单列表来说,会通过预测用户的行为,判断出用户是想选择商品,而不是切换菜单项。

解决问题

下面我们要实现的效果是:当用户选择商品时,即使经过其他菜单项,也不会切换菜单。而当用户想要切换菜单项时,可以进行无延迟切换。效果如下:

test

代码实现

HTML:

查看内容
<div id="cate_wrapper">
  <ul id="cate_menu" class="cate_menu">
    <li class="cate_menu_item">
      精选<i>&gt;</i>
    </li>
    <li class="cate_menu_item">
      云计算<i>&gt;</i>
    </li>
    <li class="cate_menu_item">
      安全<i>&gt;</i>
    </li>
    <li class="cate_menu_item">
      大数据<i>&gt;</i>
    </li>
    <li class="cate_menu_item">
      人工智能<i>&gt;</i>
    </li>
    <li class="cate_menu_item">
      企业应用<i>&gt;</i>
    </li>
    <li class="cate_menu_item">
      物联网<i>&gt;</i>
    </li>
    <li class="cate_menu_item">
      开发运维<i>&gt;</i>
    </li>
  </ul>

  <div id="cate_part" class="cate_part">
    <ul class="cate_part_col cate_part_col_show">
      <li class="cate_item">云服务器 ECS</li>
      <li class="cate_item">云数据库 RDS MySQL 版</li>
      <li class="cate_item">对象存储 OSS</li>
      <li class="cate_item">域名注册</li>
      <li class="cate_item">网站建设</li>
      <li class="cate_item">CDN</li>
      <li class="cate_item">SSL 证书</li>
      <li class="cate_item">DDoS 高仿 IP</li>
      <li class="cate_item">短信服务</li>
      <li class="cate_item">负载均衡 SLB</li>
      <li class="cate_item">轻量应用服务器</li>
      <li class="cate_item">块存储</li>
    </ul>
    <ul class="cate_part_col">
      <li class="cate_item">云服务器 ECS</li>
      <li class="cate_item">轻量应用服务器</li>
      <li class="cate_item">GPU 云服务器</li>
      <li class="cate_item">FPGA 云服务器</li>
    </ul>
    <ul class="cate_part_col">
      <li class="cate_item">DDos 高仿 IP</li>
      <li class="cate_item">Web 应用防火墙</li>
      <li class="cate_item">云安全中心(姿势感知)</li>
      <li class="cate_item">云安全中心(安骑士)</li>
      <li class="cate_item">云防火墙</li>
      <li class="cate_item">堡垒机</li>
      <li class="cate_item">网站威胁扫描系统</li>
    </ul>
    <ul class="cate_part_col">
      <li class="cate_item">MaxCompute</li>
      <li class="cate_item">E-MapReduce</li>
      <li class="cate_item">实时计算</li>
    </ul>
    <ul class="cate_part_col">
      <li class="cate_item">录音文件识别</li>
      <li class="cate_item">实时语音转写</li>
      <li class="cate_item">一句话识别</li>
      <li class="cate_item">语义合成</li>
      <li class="cate_item">语音合成声音定制</li>
    </ul>
    <ul class="cate_part_col">
      <li class="cate_item">域名注册</li>
      <li class="cate_item">域名交易</li>
      <li class="cate_item">网站建设</li>
      <li class="cate_item">云虚拟主机</li>
      <li class="cate_item">海外云虚拟主机</li>
      <li class="cate_item">云解析 DNS</li>
      <li class="cate_item">弹性 Web 托管</li>
      <li class="cate_item">备案</li>
    </ul>
    <ul class="cate_part_col">
      <li class="cate_item">物联网设备接入</li>
      <li class="cate_item">物联网设备管理</li>
      <li class="cate_item">物联网数据分析</li>
      <li class="cate_item">物联网一站式开发</li>
    </ul>
    </ul>
    <ul class="cate_part_col">
      <li class="cate_item">混合备份服务</li>
      <li class="cate_item">混合云容灾服务</li>
      <li class="cate_item">数据库备份 DBS</li>
      <li class="cate_item">数据传输 DTS</li>
      <li class="cate_item">迁移工具</li>
    </ul>
  </div>
</div>

CSS:

查看内容
* {
   margin: 0;
   padding: 0;
}

*, *::before, *::after {
   box-sizing: border-box;
}

body {
   font: 12px/1.5 "Microsoft YaHei", tahoma, arial, "Hiragino Sans GB", sans-serif;
}

li, a {
   color: #626262;
   text-decoration: none;
}

li {
   list-style: none;
   font-size: 16px;
}

#cate_wrapper {
   width: 610px;
   height: 300px;
   margin: 60px auto;
}

/* 主菜单 */
.cate_menu {
   float: left;
   width: 210px;
   height: 300px;
   padding: 6px 0;
   background: #272b2e;
}

.cate_menu_item {
   position: relative;
   box-sizing: content-box;
   height: 24px;
   line-height: 24px;
   padding: 6px 20px;
   font-size: 14px;
   transition: background-color .2s ease;
}

.cate_menu_item i {
   position: absolute;
   top: 5px;
   right: 20px;
   line-height: 24px;
   vertical-align: middle;
   font-style: normal;
   font-size: 24px;
   color: #fff;
}

.cate_menu_item {
   vertical-align: middle;
   font-size: 16px;
   color: #fff;
}

.cate_menu_item:hover,
.cate_menu_item:hover i {
   color: #00c1de;
}

/* 子菜单 */
.cate_part {
   float: left;
   width: 400px;
   height: 100%;
   background: #303538;
}

.cate_part_col {
   display: none;
   width: 100%;
   height: 100%;
   padding: 6px 10px;
}

.cate_part_col_show {
   display: block;
}

.cate_item {
   line-height: 1.6;
   font-size: 14px;
   color: #fff;
}

HTML 和 CSS 这里就不再说了,重点是 JS 代码。

首先来实现菜单最基本的切换效果:

var aMenu_items = document.querySelectorAll('.cate_menu_item'); // 主菜单项
var aPart_items = document.querySelectorAll('.cate_part_col');  // 子菜单项

for (let i = 0; i < aMenu_items.length; i++) {
  aMenu_items[i].onmouseenter = function () {
    toggleSubMenu(i);
  };
}

/**
 * 切换子菜单
 * @param {Number} i 索引
 */
function toggleSubMenu(i) {
  for (let j = 0; j < aMenu_items.length; j++) {
    aPart_items[j].className = 'cate_part_col';
  }

  aPart_items[i].classList.toggle('cate_part_col_show');
}

这样当鼠标移入某个菜单项时,子菜单的内容就会随之切换。

下面要实现的效果是:鼠标移向子菜单时,即使经过其他主菜单项也不会进行切换。

var oSubMenu = document.getElementById('cate_part'); // 子菜单

var timer = null;
var isMouseInSub = false; // 鼠标是否在子菜单中

oSubMenu.onmouseenter = () => isMouseInSub = true;
oSubMenu.onmouseleave = () => isMouseInSub = false;

for (let i = 0; i < aMenu_items.length; i++) {
  aMenu_items[i].onmouseenter = function () {
    if (timer) clearTimeout(timer);

    // 鼠标已经在子菜单中,直接返回函数,否则就切换子菜单
    timer = setTimeout(function () {
      if (isMouseInSub) return;

      toggleSubMenu(i);
      timer = null;
    }, 600);
  };
}

效果如下:

test

但是这里又来了一个问题:虽然实现了鼠标移动到子菜单时,就算经过了其他主菜单项,也不会切换子菜单,但是鼠标在主菜单之间切换时,受定时器的影响,也会延迟切换。如图:

test

如何解决这个问题就是今天的重点。

思考一下,怎么才能知道用户是想切换菜单,还是选择子菜单中的商品呢?

看一下下面这张图你可能就会明白了:

test

如图,用户鼠标当前所在位置和子菜单的左上角、左下角形成了一个三角形,即图中红色的三角形。用户下一次移动鼠标时,鼠标的位置不是在三角形内,就是在三角形外。而当用户向三角形内移动鼠标时,用户往往想要去选择子菜单中的商品,而不是切换菜单。反之,用户就是想要切换子菜单。

整理下思路就是:1、当判断出用户想要选择商品,延迟切换子菜单。如果最后鼠标落在子菜单中,证明用户确实是要选择商品。如果最后鼠标落在主菜单上,就切换对应的子菜单。2、当判断出用户想要切换子菜单,直接无延迟切换就行了。

但是问题又来了,怎么判断当前鼠标的位置在三角形内呢?

所以现在要解决的就是:如何判断一个点在不在三角形内。方法也很简单,不过需要用到一些关于向量点乘的知识。来看一张图:

test

P 点与 A、B、C 三个点形成的向量,分别两两进行点乘,当点乘结果同号(可正可负)时,则证明 P 点在三角形内,否则证明 P 点在三角形外。

代码实现如下:

// 通过点 d1、d2 得出向量 d1•d2
function vector(d1, d2) {
  return {
    x: d2.x - d1.x,
    y: d2.y - d1.y,
  }
}

// 两个向量进行点乘
function dotMul(v1, v2) {
  return (v1.x * v2 .y - v2.x * v1.y) > 0;
}

// p 点与三角形的两个点形成向量,并进行点乘
function vectorResult(d1, d2, p) {
  return dotMul(vector(d1, p), vector(d2, p));
}

// 判断一个点是否在三角形内
function isInTriangle(a, b, c, p) {
  var t = vectorResult(a, b, p);

  // 判断 ap, bp, cp 三个向量,两两点乘是否同号
  if (t !== vectorResult(b, c, p)) return false;
  if (t !== vectorResult(c, a, p)) return false;

  return true;
}

这样,给 isInTriangle 函数传入三角形三个点的坐标和其它任意一个点的坐标,就可以判断出这个点在不在三角形内。

下面是获取鼠标上一次和当前的坐标:

var oMenu = document.getElementById('cate_menu'); // 主菜单
var mousePos = []; // 存储鼠标坐标

function mouseMoveHandler(e) {
  mousePos.push({
    x: e.clientX,
    y: e.clientY,
  });

  // 只保存两次移动的坐标,即当前和上一次鼠标的坐标
  if (mousePos.length > 2) mousePos.shift();
}

// 鼠标移入菜单时,保存鼠标的坐标
oMenu.onmouseenter = function () {
  document.addEventListener('mousemove', mouseMoveHandler);
};

oMenu.onmouseleave = function () {
  document.removeEventListener('mousemove', mouseMoveHandler);
};

上面的代码实现了,只要鼠标在菜单上移动,其坐标就会被保存到数组中。由于限制了数组中最多只能保存两个点的坐标,所以就储存了鼠标最后两次的坐标,即鼠标上一次和当前的坐标。

下面把这些代码应用起来:

for (let i = 0; i < aMenu_items.length; i++) {
  aMenu_items[i].onmouseenter = function () {
    if (timer) clearTimeout(timer);

    var curMousePos = mousePos[1]; // 当前鼠标的位置
    var preMousePos = mousePos[0]; // 上次鼠标的位置

    if (!!curMousePos) {
      // 子菜单是否需要延迟
      var delay = needDelay(oSubMenu, preMousePos, curMousePos);

      if (delay) {
        timer = setTimeout(function () {
          if (isMouseInSub) return;

          toggleSubMenu(i);
          timer = null;
        }, 600);
      } else {
        toggleSubMenu(i);
      }
    }
  };
}

/**
 * 判断子菜单是否需要延迟
 * @param {HTMLElement} elem 子菜单的 HTML 元素
 * @param {Object} prePos 上一次鼠标的位置
 * @param {Object} curPos 当前鼠标的位置
 */
function needDelay(elem, prePos, curPos) {
  // 子菜单左上角和左下角的坐标
  var pos1 = { x: elem.offsetLeft, y: elem.offsetTop };
  var pos2 = { x: elem.offsetLeft, y: elem.offsetTop + elem.offsetHeight };

  return isInTriangle(pos1, pos2, prePos, curPos);
}

到此就完美实现了想要的效果。:tada:

完整的 JS 代码如下:

查看内容
window.onload = function () {
  var oMenu = document.getElementById('cate_menu');
  var oSubMenu = document.getElementById('cate_part');
  var aMenu_items = document.querySelectorAll('.cate_menu_item');
  var aPart_items = document.querySelectorAll('.cate_part_col');

  var timer = null;
  var isMouseInSub = false; // 鼠标是否在子菜单中
  var mousePos = [];        // 存储鼠标坐标

  oSubMenu.onmouseenter = () => isMouseInSub = true;
  oSubMenu.onmouseleave = () => isMouseInSub = false;

  for (let i = 0; i < aMenu_items.length; i++) {
    aMenu_items[i].onmouseenter = function () {
      if (timer) clearTimeout(timer);

      var curMousePos = mousePos[1]; // 当前鼠标的位置
      var preMousePos = mousePos[0]; // 上次鼠标的位置

      if (!!curMousePos) {
        // 子菜单需要延迟
        var delay = needDelay(oSubMenu, preMousePos, curMousePos);

        if (delay) {
          timer = setTimeout(function () {
            if (isMouseInSub) return;

            toggleSubMenu(i);
            timer = null;
          }, 600);
        } else {
          toggleSubMenu(i);
        }
      }
    };
  }

  function mouseMoveHandler(e) {
    mousePos.push({
      x: e.clientX,
      y: e.clientY,
    });

    // 只保存两次移动的坐标,即当前和上一次鼠标的坐标
    if (mousePos.length > 2) mousePos.shift();
  }

  oMenu.onmouseenter = function () {
    document.addEventListener('mousemove', mouseMoveHandler);
  };

  oMenu.onmouseleave = function () {
    document.removeEventListener('mousemove', mouseMoveHandler);
  };

  /**
   * 判断子菜单是否需要延迟
   * @param {HTMLElement} elem 子菜单的 HTML 元素
   * @param {Object} prePos 上一次鼠标的位置
   * @param {Object} curPos 当前鼠标的位置
   */
  function needDelay(elem, prePos, curPos) {
    // 子菜单左上角和左下角的坐标
    var pos1 = { x: elem.offsetLeft, y: elem.offsetTop };
    var pos2 = { x: elem.offsetLeft, y: elem.offsetTop + elem.offsetHeight };

    return isInTriangle(pos1, pos2, prePos, curPos);
  }

  /**
   * 切换子菜单
   * @param {Number} i 索引
   */
  function toggleSubMenu(i) {
    for (let j = 0; j < aMenu_items.length; j++) {
      aPart_items[j].className = 'cate_part_col';
    }

    aPart_items[i].classList.toggle('cate_part_col_show');
  }

  // ===============================================
  // 判断一个点是否在三角形内
  // ===============================================

  // 获取两个点的向量
  function vector(a, b) {
    return {
      x: b.x - a.x,
      y: b.y - a.y,
    }
  }

  // 两个向量进行点乘
  function dotMul(v1, v2) {
    return (v1.x * v2 .y - v2.x * v1.y) > 0;
  }
  
  // 三个点构造两个向量进行点乘运算
  function vectorResult(a, b, p) {
    return dotMul(vector(a, p), vector(b, p));
  }

  // 判断一个点是否在三角形内
  function isInTriangle(a, b, c, p) {
    var t = vectorResult(a, b, p);

    if (t !== vectorResult(b, c, p)) return false;
    if (t !== vectorResult(c, a, p)) return false;

    return true;
  }
  // ===============================================
};

Demo 体验地址:https://liuyib.github.io/demo/note/aliyun-menu-list/

liuyib avatar Apr 19 '19 05:04 liuyib