35

需求与目标

在电商的大屏主页上,一般都会有一个显眼的品类导航栏,作为整个商城的重要分流入口,客户体验就必须要做到自然、极致。细心的用户可能会发现,在jd.com或者tmall.com等大型网站中,当鼠标在一级导航栏中垂直移动时,二级菜单可以无延迟的响应展示。神奇的是,当用户将鼠标悬浮在某一级菜单,想去点击对应的二级菜单区域时,即使这时鼠标掠过其他一级菜单,也并没有切换到其他二级菜单,似乎这样的菜单栏很懂你,可以准确预测到你的行为,高大上的叫法是基于用户行为预测的切换技术,我称之为“智能”导航栏,效果如下。

图片描述

在动手实践之前,我们再来明确一下目标效果:

  1. 鼠标正常切换一级菜单时,二级菜单无延迟响应;
  2. 鼠标快速移动到二级子菜单时,要求一级菜单无冗余切换;

知识准备

先来把需要用到的知识点划出来。如果完成这样一个小的需求,还能把辐射出的知识点都搞清楚,做到查漏补缺,再把相同的技术衍生到其他的场景,举一反三,那么这样的实践才是充分的、有价值的。

  1. 事件代理与事件委托
  2. mouseenter和mouseover的区别;
  3. debounce(防抖)和throttle(节流)
  4. 用向量叉乘判断点在三角形内;(本实践中选择算法4,用叉乘符号相同判断)
  5. 如何高效判断两个数字符号异同;
  6. h5语义化标签--dl dt dd标签元素的语法结构与使用;

对于以上我梳理出来的的知识点,其中第2、第5、第6点比较简单,几句话就可以说清楚,其余三点拿出一条就可以端端正正的写出一篇文章,所以我已把我私藏的优质链接附上,如果你对于某些点比较模糊,请点击跳转学习。

实践讲解

我会采用渐进增强的方式来进行讲解,完整的示例代码请进codepen

基础实现

首先对于文档结构,遵循语义化的原则,左侧的一级菜单用ul li组合.

<ul>
    <li data-id="a">
        <span> 一级导航1 </span>
    </li>
    <li data-id="b">
        <span> 一级导航2 </span>
    </li>
    ···
</ul>

右侧的子菜单,用dl dt dd标签来表达,因为他们最常用在一个标题下有若干对应列表项的菜单场景。如需进一步了解请点击

<div id="sub" class="none">
    <div id="a" class="sub_content none">
        <dl>
            <dt>
                <a href="#"> 二级菜单1 </a>
            </dt>
            <dd>
                <a href="#"> 三级菜单 </a>
                <a href="#"> 三级菜单 </a>
                <a href="#"> 三级菜单 </a>
                <a href="#"> 三级菜单 </a>
                <a href="#"> 三级菜单 </a>
            </dd>
        </dl>
        <dl>
            <dt>
                <a href="#"> 二级菜单1 </a>
            </dt>
            <dd>
                <a href="#"> 三级菜单 </a>
                <a href="#"> 三级菜单 </a>
                <a href="#"> 三级菜单 </a>
                <a href="#"> 三级菜单 </a>
                <a href="#"> 三级菜单 </a>
            </dd>
        </dl>
        ···
    </div>
    <div id="b" class="sub_content none">
        <dl>
            <dt>
                <a href="#"> 二级菜单2 </a>
            </dt>
            <dd>
                <a href="#"> 三级菜单 </a>
                <a href="#"> 三级菜单 </a>
                <a href="#"> 三级菜单 </a>
                <a href="#"> 三级菜单 </a>
                <a href="#"> 三级菜单 </a>
            </dd>
        </dl>
        ···
    </div>
    ···
</div>

接下来,添加js交互。通过鼠标在左侧不同li的悬浮,来激活显示右侧不同的.sub_content块,其中通过一级菜单的data-id属性与其id值作为钩子来进行联动。

这里我们遇到选择绑定mouseenter还是mouseover事件,其二者的区别可概括为:

  1. 使用mouseover/mouseout时,在鼠标指针经过绑定元素或者经过任何其子元素时,都会触发 mouseover 事件。如果鼠标移动到其子元素上,而没有离开绑定元素,也会触发绑定元素的mouseout事件;
  2. 使用mouseenter/mouseleave时,只有在鼠标指针经过绑定元素时(不包括鼠标指针经过任何子元素),才会触发

mouseenter 事件。如果鼠标没有离开绑定元素,在其子元素上任意移动,也不会触发mouseleave事件;

为了助于理解,我做了一个示例,请参考mouseenter/mouseover

通过比较,显然我们只需要给各li绑定mouseenter/mouseout事件即可。

var sub = $("#sub"); // 子级菜单包裹层
var activeRow, //  已激活的一级菜单
    activeMenu; //  已激活的子级菜单
    
$("#wrap").on("mouseenter", function() {
    // 显示子菜单
    sub.removeClass("none");
})
.on("mouseleave", function() {
    // 隐藏子菜单
    sub.addClass("none");
    // 重置两个已激活变量
    if (activeRow) {
        activeRow.removeClass("active");
        activeRow = null;
    }
    if (activeMenu) {
        activeMenu.addClass("none");
        activeMenu = null;
    }
})
.on("mouseenter", "li", function(e) {
    if (!activeRow) {
        activeRow = $(e.target).addClass("active");
    activeMenu = $("#" + activeRow.data("id"));
        activeMenu.removeClass("none");
        return;
    }
    // 若有已激活菜单,先还原之
    activeRow.removeClass("active");
    activeMenu.addClass("none");

    activeRow = $(e.target);
    activeRow.addClass("active");
    activeMenu = $("#" + activeRow.data("id"));
    activeMenu.removeClass("none");
});

以上便实现了基本效果,需要注意的是,在知识准备一节中所提到的事件代理的运用,是优化DOM性能的一种很好的实践,同时写法又不失优雅。

然而这个版本在体验上是有问题的,用户为了选择子菜单,必须要谨慎的让鼠标在当前所选一级菜单的范围内,以折线路径移动到子菜单,才可以进一步选择,如下图。

图片描述

很显然,用户希望在选择某一级菜单下的子菜单时,想要以斜向最短路径移动鼠标,而其他掠过的一级菜单也并不会激活。下面我们来对此做出改进。

解决斜向移动问题

当鼠标移动时,频繁的触发每一个一级菜单所绑定的mouseenter事件是问题的关键。因此我们很自然的想到延时触发,又为避免频繁触发,引入防抖/节流。每次触发一级菜单时,并不让他立即执行展示子菜单的逻辑,而是延后300ms,直到最后一次触发后300ms,判断鼠标的位置是否在子菜单区域内,如果在,便可直接return不做任何切换菜单操作,如下。

.on("mouseenter", "li", function(e) {
    if (!activeRow) {
        active(e.target);// 一个激活对应子菜单的函数
        return;
    }
    if (timer) {
        clearTimeout(timer);
    }

    timer = setTimeout(function() {
        if (mouseInSub) {
            return;
        }
        activeRow.removeClass("active");
        activeMenu.addClass("none");
        active(e.target);

        timer = null;
    }, 300);
});

由此,因为每一次切换一级菜单,都会有一个延迟300ms触发的效果,所以当用户在一级菜单区域中上下移动时,或者真的想去快速切换菜单时,这样粗糙的延时处理在解决了斜向移动的问题后,又引入了新的问题,如下图。

解决斜方移动问题加入延时显示过慢.gif-1382.7kB

那如何做到当用户真的想要快速切换一级菜单时,子级菜单快速响应,而只有当用户想去选择子级菜单时,才会去运用延时触发,进而可以斜向移动。至此,如果你的知识领域只局限于编程或者计算机科学,那么要解决这个问题着实困难。这里我们需要些跨学科的启发式思维,根据用户行为抽象出一个数学模型,进而实现对于用户切换菜单的预测

进一步改善

事实上,我们可以根据用户鼠标的移动轨迹抽象出这样一个三角形(如下图),构成它的三个点分别是,子级菜单容器的左上顶点(top),及其左下顶点(bottom),另外一个是用户鼠标刚刚移动经过的点(pre)。处在三角形内的cur点代表用户鼠标当前的位置。其中pre和cur之间的距离取决于鼠标移动每次触发mousemove事件的粒度,通常会很短很短,这里图例为了方便观察,做了合理放大。

三角形示例.png-108.2kB

这样的一个三角形有何意义呢?在通常的用户行为中,我们是否可以认为当鼠标在三角形内时,便可以判定用户有选择子级菜单的倾向,当鼠标在三角形外时,此时用户更倾向于快速切换一级菜单。这样在用户不断的移动鼠标时,也同时会不断的形成多个这样的三角形,此时,解决问题的突破口就转化成,不断监听鼠标位置,并判断当前点是否在刚刚经过的点和子级菜单左侧上下两顶点所形成的三角形中

不断监听鼠标位置,我们可以通过mousemove轻松解决,只需要注意绑定和解绑的时机,让其只在菜单范围内触发,因为持续的监听与触发对于浏览器来讲开销不小。而判断一个点是否在一个三角形内,这个问题需要用到知识准备一节中的第四点,我们选择用向量叉乘符号相同来判断一个点在一个三角形中。至于数学上的证明,不在本文讨论范围内,此处我们只需要知道该结论是严密的即可。

接下来我们用代码来模拟实现向量及其叉乘:

// 向量是终点坐标减去起点坐标
function vector(a, b) {
    return {
        x: b.x - a.x,
        y: b.y - a.y
    }
}

// 向量的叉乘
function vectorPro(v1, v2) {
    return v1.x * v2.y - v1.y * v2.x;
}

然后我们利用上边的两个辅助函数来判断一个点是否在某个三角形内,函数的入参是四个已知的点,最终返回的结果是,所形成的三个向量叉乘后是否两两符号相同,相同即点在三角形内,反之亦反。

// 判断点是否在三角形内
function isPointInTranjgle(p, a, b, c) {
    var pa = vector(p, a);
    var pb = vector(p, b);
    var pc = vector(p, c);

    var t1 = vectorPro(pa, pb);
    var t2 = vectorPro(pb, pc);
    var t3 = vectorPro(pc, pa);

    return sameSign(t1, t2) && sameSign(t2, t3);
}

// 用位运算高效判断符号相同
function sameSign(a, b) {
    return (a ^ b) >= 0;
}

这里需要留意sameSign这个用于判断两个值的符号是否相同的辅助函数,判断符号相同的方法有很多,但此处巧妙的利用了计算机二进制的最高位--符号位。将两个值按位异或,符号位不同取1,相同取0,所以如果最终符号位为1,即结果值整体小于0,则代表两值符号不同,反之亦反。位运算的执行效率是要比我们直接操作非二进制数的执行效率高,所以应用于此处大量频繁地判断符号异同的场景,对于性能优化是很有帮助的。

最终,我们利用上边准备好的辅助函数,通过跟踪鼠标的位置信息,判断当前是否需要启用延时器,选择性的实施上一节的优化方案,这样便实现了最终需求。(完整示例代码codepen

// 是否需要延迟
function needDelay(ele, curMouse, prevMouse) {
    if (!curMouse || !prevMouse) {
        return;
    }
    var offset = ele.offset();// offset() 方法返回或设置匹配元素相对于文档的偏移(位置)

    // 左上点
    var topleft = {
        x: offset.left,
        y: offset.top
    };
    // 左下点
    var leftbottom = {
        x: offset.left,
        y: offset.top + ele.height()
    };

    return isPointInTranjgle(curMouse, prevMouse, topleft, leftbottom);
}

启发

通过本例实践,给我最深刻的体会便是,高数为提高生产力所带来的价值,哈哈···

恕敝人浅薄,第一次看到这个实例时的那种激动现在依然犹存,再加之前些天翻看了几页深度学习领域的一本经典教材,有大半的篇幅讲所用到的数学知识,不禁感叹数学原来是这么玩儿的,可惜了···

以碾压式的高度和视野去看待问题,可以让无解变有解,唯一解变多解,这才是我心目中的高手。

如果这篇文章可以让你在coding本身、或者向量(数学)对于其他类似场景(点线面)的应用有所启发,甚至有对于教育引导方面的外延思考,我觉得我写这篇文章的目的便达到了。


mikeywang
247 声望12 粉丝

一个商科范儿的coder~~