商城“智能”導航欄實踐(附詳細代碼)

需求與目標

在電商的大屏主頁上,一般都會有一個顯眼的品類導航欄,作爲整個商城的重要分流入口,客戶體驗就必須要做到自然、極致。細心的用戶可能會發現,在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組合。


 
  1. <ul>

  2.    <li data-id="a">

  3.        <span> 一級導航1 </span>

  4.    </li>

  5.    <li data-id="b">

  6.        <span> 一級導航2 </span>

  7.    </li>

  8.    ···

  9. </ul>

右側的子菜單,用 dl dt dd標籤來表達,因爲他們最常用在一個標題下有若干對應列表項的菜單場景。如需進一步瞭解請點擊。


 
  1. <div id="sub" class="none">

  2.    <div id="a" class="sub_content none">

  3.        <dl>

  4.            <dt>

  5.                <a href="#"> 二級菜單1 </a>

  6.            </dt>

  7.            <dd>

  8.                <a href="#"> 三級菜單 </a>

  9.                <a href="#"> 三級菜單 </a>

  10.                <a href="#"> 三級菜單 </a>

  11.                <a href="#"> 三級菜單 </a>

  12.                <a href="#"> 三級菜單 </a>

  13.            </dd>

  14.        </dl>

  15.        <dl>

  16.            <dt>

  17.                <a href="#"> 二級菜單1 </a>

  18.            </dt>

  19.            <dd>

  20.                <a href="#"> 三級菜單 </a>

  21.                <a href="#"> 三級菜單 </a>

  22.                <a href="#"> 三級菜單 </a>

  23.                <a href="#"> 三級菜單 </a>

  24.                <a href="#"> 三級菜單 </a>

  25.            </dd>

  26.        </dl>

  27.        ···

  28.    </div>

  29.    <div id="b" class="sub_content none">

  30.        <dl>

  31.            <dt>

  32.                <a href="#"> 二級菜單2 </a>

  33.            </dt>

  34.            <dd>

  35.                <a href="#"> 三級菜單 </a>

  36.                <a href="#"> 三級菜單 </a>

  37.                <a href="#"> 三級菜單 </a>

  38.                <a href="#"> 三級菜單 </a>

  39.                <a href="#"> 三級菜單 </a>

  40.            </dd>

  41.        </dl>

  42.        ···

  43.    </div>

  44.    ···

  45. </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事件即可。


 
  1. var sub = $("#sub"); // 子級菜單包裹層

  2. var activeRow, //  已激活的一級菜單

  3.    activeMenu; //  已激活的子級菜單

  4.  

  5. $("#wrap").on("mouseenter", function() {

  6.    // 顯示子菜單

  7.    sub.removeClass("none");

  8. })

  9. .on("mouseleave", function() {

  10.    // 隱藏子菜單

  11.    sub.addClass("none");

  12.    // 重置兩個已激活變量

  13.    if (activeRow) {

  14.        activeRow.removeClass("active");

  15.        activeRow = null;

  16.    }

  17.    if (activeMenu) {

  18.        activeMenu.addClass("none");

  19.        activeMenu = null;

  20.    }

  21. })

  22. .on("mouseenter", "li", function(e) {

  23.    if (!activeRow) {

  24.        activeRow = $(e.target).addClass("active");

  25.    activeMenu = $("#" + activeRow.data("id"));

  26.        activeMenu.removeClass("none");

  27.        return;

  28.    }

  29.    // 若有已激活菜單,先還原之

  30.    activeRow.removeClass("active");

  31.    activeMenu.addClass("none");

  32.  

  33.    activeRow = $(e.target);

  34.    activeRow.addClass("active");

  35.    activeMenu = $("#" + activeRow.data("id"));

  36.    activeMenu.removeClass("none");

  37. });

以上便實現了基本效果,需要注意的是,在知識準備一節中所提到的事件代理的運用,是優化DOM性能的一種很好的實踐,同時寫法又不失優雅。

然而這個版本在體驗上是有問題的,用戶爲了選擇子菜單,必須要謹慎的讓鼠標在當前所選一級菜單的範圍內,以折線路徑移動到子菜單,纔可以進一步選擇,如下圖。

 

很顯然,用戶希望在選擇某一級菜單下的子菜單時,想要以斜向最短路徑移動鼠標,而其他掠過的一級菜單也並不會激活。下面我們來對此做出改進。

解決斜向移動問題

當鼠標移動時,頻繁的觸發每一個一級菜單所綁定的mouseenter事件是問題的關鍵。因此我們很自然的想到延時觸發,又爲避免頻繁觸發,引入防抖/節流。每次觸發一級菜單時,並不讓他立即執行展示子菜單的邏輯,而是延後300ms,直到最後一次觸發後300ms,判斷鼠標的位置是否在子菜單區域內,如果在,便可直接return不做任何切換菜單操作,如下。


 
  1. .on("mouseenter", "li", function(e) {

  2.    if (!activeRow) {

  3.        active(e.target);// 一個激活對應子菜單的函數

  4.        return;

  5.    }

  6.    if (timer) {

  7.        clearTimeout(timer);

  8.    }

  9.  

  10.    timer = setTimeout(function() {

  11.        if (mouseInSub) {

  12.            return;

  13.        }

  14.        activeRow.removeClass("active");

  15.        activeMenu.addClass("none");

  16.        active(e.target);

  17.  

  18.        timer = null;

  19.    }, 300);

  20. });

由此,因爲每一次切換一級菜單,都會有一個延遲300ms觸發的效果,所以當用戶在一級菜單區域中上下移動時,或者真的想去快速切換菜單時,這樣粗糙的延時處理在解決了斜向移動的問題後,又引入了新的問題,如下圖。

 

 

 

 

那如何做到當用戶真的想要快速切換一級菜單時,子級菜單快速響應,而只有當用戶想去選擇子級菜單時,纔會去運用延時觸發,進而可以斜向移動。至此,如果你的知識領域只侷限於編程或者計算機科學,那麼要解決這個問題着實困難。這裏我們需要些跨學科的啓發式思維,根據用戶行爲抽象出一個數學模型,進而實現對於用戶切換菜單的預測

進一步改善

事實上,我們可以根據用戶鼠標的移動軌跡抽象出這樣一個三角形(如下圖),構成它的三個點分別是,子級菜單容器的左上頂點(top),及其左下頂點(bottom),另外一個是用戶鼠標剛剛移動經過的點(pre)。處在三角形內的cur點代表用戶鼠標當前的位置。其中pre和cur之間的距離取決於鼠標移動每次觸發mousemove事件的粒度,通常會很短很短,這裏圖例爲了方便觀察,做了合理放大。

 

 

 

這樣的一個三角形有何意義呢?在通常的用戶行爲中,我們是否可以認爲當鼠標在三角形內時,便可以判定用戶有選擇子級菜單的傾向,當鼠標在三角形外時,此時用戶更傾向於快速切換一級菜單。這樣在用戶不斷的移動鼠標時,也同時會不斷的形成多個這樣的三角形,此時,解決問題的突破口就轉化成,不斷監聽鼠標位置,並判斷當前點是否在剛剛經過的點和子級菜單左側上下兩頂點所形成的三角形中

不斷監聽鼠標位置,我們可以通過mousemove輕鬆解決,只需要注意綁定和解綁的時機,讓其只在菜單範圍內觸發,因爲持續的監聽與觸發對於瀏覽器來講開銷不小。而判斷一個點是否在一個三角形內,這個問題需要用到知識準備一節中的第四點,我們選擇用向量叉乘符號相同來判斷一個點在一個三角形中。至於數學上的證明,不在本文討論範圍內,此處我們只需要知道該結論是嚴密的即可。

接下來我們用代碼來模擬實現向量及其叉乘:


 
  1. // 向量是終點座標減去起點座標

  2. function vector(a, b) {

  3.    return {

  4.        x: b.x - a.x,

  5.        y: b.y - a.y

  6.    }

  7. }

  8.  

  9. // 向量的叉乘

  10. function vectorPro(v1, v2) {

  11.    return v1.x * v2.y - v1.y * v2.x;

  12. }

然後我們利用上邊的兩個輔助函數來判斷一個點是否在某個三角形內,函數的入參是四個已知的點,最終返回的結果是,所形成的三個向量叉乘後是否兩兩符號相同,相同即點在三角形內,反之亦反。


 
  1. // 判斷點是否在三角形內

  2. function isPointInTranjgle(p, a, b, c) {

  3.    var pa = vector(p, a);

  4.    var pb = vector(p, b);

  5.    var pc = vector(p, c);

  6.  

  7.    var t1 = vectorPro(pa, pb);

  8.    var t2 = vectorPro(pb, pc);

  9.    var t3 = vectorPro(pc, pa);

  10.  

  11.    return sameSign(t1, t2) && sameSign(t2, t3);

  12. }

  13.  

  14. // 用位運算高效判斷符號相同

  15. function sameSign(a, b) {

  16.    return (a ^ b) >= 0;

  17. }

這裏需要留意 sameSign這個用於判斷兩個值的符號是否相同的輔助函數,判斷符號相同的方法有很多,但此處巧妙的利用了計算機二進制的最高位 -- 符號位。將兩個值按位異或,符號位不同取1,相同取0,所以如果最終符號位爲1,即結果值整體小於0,則代表兩值符號不同,反之亦反。位運算的執行效率是要比我們直接操作非二進制數的執行效率高,所以應用於此處大量頻繁地判斷符號異同的場景,對於性能優化是很有幫助的。

最終,我們利用上邊準備好的輔助函數,通過跟蹤鼠標的位置信息,判斷當前是否需要啓用延時器,選擇性的實施上一節的優化方案,這樣便實現了最終需求。(完整示例代碼codepen)


 
  1. // 是否需要延遲

  2. function needDelay(ele, curMouse, prevMouse) {

  3.    if (!curMouse || !prevMouse) {

  4.        return;

  5.    }

  6.    var offset = ele.offset();// offset() 方法返回或設置匹配元素相對於文檔的偏移(位置)

  7.  

  8.    // 左上點

  9.    var topleft = {

  10.        x: offset.left,

  11.        y: offset.top

  12.    };

  13.    // 左下點

  14.    var leftbottom = {

  15.        x: offset.left,

  16.        y: offset.top + ele.height()

  17.    };

  18.  

  19.    return isPointInTranjgle(curMouse, prevMouse, topleft, leftbottom);

  20. }

啓發

通過本例實踐,給我最深刻的體會便是,高數爲提高生產力所帶來的價值,哈哈···

恕敝人淺薄,第一次看到這個實例時的那種激動現在依然猶存,再加之前些天翻看了幾頁深度學習領域的一本經典教材,有大半的篇幅講所用到的數學知識,不禁感嘆數學原來是這麼玩兒的,可惜了···

以碾壓式的高度和視野去看待問題,可以讓無解變有解,唯一解變多解,這纔是我心目中的高手。

如果這篇文章可以讓你在coding本身、或者向量(數學)對於其他類似場景(點線面)的應用有所啓發,甚至有對於教育引導方面的外延思考,我覺得我寫這篇文章的目的便達到了。

 

時間太久,忘了從哪裏轉的了,如有冒犯或原作者看到,請私信我刪除或添加原文鏈接,謝謝!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章