Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
liuyib opened this issue Apr 19, 2019 · 0 comments
Open

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

liuyib opened this issue Apr 19, 2019 · 0 comments

Comments

@liuyib
Copy link
Owner

liuyib commented Apr 19, 2019

前言

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

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 liuyib changed the title 实现基于用户行为预测的无延迟二级菜单 原生JS实现基于用户行为预测的无延迟二级菜单 Apr 19, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant