note
note copied to clipboard
原生JS实现基于用户行为预测的无延迟二级菜单
前言
在很多电商网站中,一般都会有能够展开多级子菜单的菜单列表。例如:

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

当用户径直移动鼠标去选择商品时,对于交互体验好的菜单列表来说,会通过预测用户的行为,判断出用户是想选择商品,而不是切换菜单项。
解决问题
下面我们要实现的效果是:当用户选择商品时,即使经过其他菜单项,也不会切换菜单。而当用户想要切换菜单项时,可以进行无延迟切换。效果如下:

代码实现
HTML:
查看内容
<div id="cate_wrapper">
<ul id="cate_menu" class="cate_menu">
<li class="cate_menu_item">
精选<i>></i>
</li>
<li class="cate_menu_item">
云计算<i>></i>
</li>
<li class="cate_menu_item">
安全<i>></i>
</li>
<li class="cate_menu_item">
大数据<i>></i>
</li>
<li class="cate_menu_item">
人工智能<i>></i>
</li>
<li class="cate_menu_item">
企业应用<i>></i>
</li>
<li class="cate_menu_item">
物联网<i>></i>
</li>
<li class="cate_menu_item">
开发运维<i>></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);
};
}
效果如下:

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

如何解决这个问题就是今天的重点。
思考一下,怎么才能知道用户是想切换菜单,还是选择子菜单中的商品呢?
看一下下面这张图你可能就会明白了:

如图,用户鼠标当前所在位置和子菜单的左上角、左下角形成了一个三角形,即图中红色的三角形。用户下一次移动鼠标时,鼠标的位置不是在三角形内,就是在三角形外。而当用户向三角形内移动鼠标时,用户往往想要去选择子菜单中的商品,而不是切换菜单。反之,用户就是想要切换子菜单。
整理下思路就是:1、当判断出用户想要选择商品,延迟切换子菜单。如果最后鼠标落在子菜单中,证明用户确实是要选择商品。如果最后鼠标落在主菜单上,就切换对应的子菜单。2、当判断出用户想要切换子菜单,直接无延迟切换就行了。
但是问题又来了,怎么判断当前鼠标的位置在三角形内呢?
所以现在要解决的就是:如何判断一个点在不在三角形内。方法也很简单,不过需要用到一些关于向量点乘的知识。来看一张图:

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/