下拉菜單開發(fā):原生局限與增強(qiáng)方案實(shí)戰(zhàn))
1. 原生Bootstrap 4下拉菜單的邊界為什么嵌套之后直接“翻車”1.1 一級(jí)菜單在Bootstrap里到底是怎么“展開”的如果你只是用Bootstrap做過一次導(dǎo)航欄通常只會(huì)接觸到最基礎(chǔ)的用法navbar包裹菜單項(xiàng)某個(gè)li上加dropdown類鏈接寫上>nav classnavbar navbar-expand-lg navbar-dark bg-dark sticky-top a classnavbar-brand href#某后臺(tái)管理系統(tǒng)/a button classnavbar-toggler typebutton>/* 子級(jí)容器定位基準(zhǔn) */ .dropdown-submenu { position: relative; } /* 子級(jí)菜單默認(rèn)隱藏并相對(duì)父級(jí)右側(cè)偏移 */ .dropdown-submenu .dropdown-menu { top: 0; left: 100%; margin-left: -1px; margin-top: -6px; border-radius: 0 .25rem .25rem .25rem; visibility: hidden; opacity: 0; transition: opacity 0.15s ease-in-out; } /* 鼠標(biāo)懸停、鍵盤焦點(diǎn)進(jìn)入、手動(dòng)添加show類時(shí)展示 */ .dropdown-submenu:hover .dropdown-menu, .dropdown-submenu:focus-within .dropdown-menu, .dropdown-submenu.show .dropdown-menu { visibility: visible; opacity: 1; display: block; } /* 子級(jí)切換項(xiàng)的箭頭方向從向下改為向右 */ .dropdown-submenu .dropdown-toggle::after { transform: rotate(-90deg); margin-left: 0.5rem; } /* 反向展開當(dāng)子級(jí)容器需要靠左彈出時(shí)用 */ .dropdown-submenu.dropdown-menu-right .dropdown-menu { left: auto; right: 100%; border-radius: .25rem 0 .25rem .25rem; }這里有一點(diǎn)和原生Bootstrap不同我用了visibility加opacity的組合而不是只靠display: none。原因是display切換時(shí)如果菜單里還有focus狀態(tài)視覺上容易產(chǎn)生閃爍用visibility和opacity做過渡會(huì)更平滑。但要注意這個(gè)組合并不會(huì)真正“移除”菜單如果頁(yè)面里存在大量隱藏菜單建議再加上pointer-events: none展示時(shí)再恢復(fù)。.dropdown-submenu .dropdown-menu { pointer-events: none; } .dropdown-submenu:hover .dropdown-menu, .dropdown-submenu:focus-within .dropdown-menu, .dropdown-submenu.show .dropdown-menu { pointer-events: auto; }3.3 用JS處理父級(jí)點(diǎn)擊與多級(jí)開關(guān)有了HTML和CSS最后一步是用JavaScript把狀態(tài)串起來。我的實(shí)現(xiàn)里不依賴Bootstrap的Dropdown插件直接用jQuery監(jiān)聽代碼結(jié)構(gòu)很簡(jiǎn)單(function ($) { // 關(guān)閉導(dǎo)航范圍內(nèi)所有展開的菜單保留傳入的容器 function closeAllExcept(keepEl) { $(.navbar-nav .dropdown-menu, .navbar-nav .dropdown-submenu) .not(keepEl || ) .removeClass(show); } // 頂層菜單點(diǎn)擊 $(.navbar-nav .nav-item.dropdown .dropdown-toggle).on(click, function (e) { e.preventDefault(); e.stopPropagation(); var $li $(this).closest(.nav-item.dropdown); var $menu $li.children(.dropdown-menu); if ($li.hasClass(show)) { $li.removeClass(show); $menu.removeClass(show); return; } closeAllExcept(null); $li.addClass(show); $menu.addClass(show); $(this).attr(aria-expanded, true); }); // 子級(jí)切換項(xiàng)點(diǎn)擊 $(.navbar-nav .dropdown-submenu .dropdown-toggle).on(click, function (e) { e.preventDefault(); e.stopPropagation(); var $sub $(this).parent(.dropdown-submenu); var $subMenu $sub.children(.dropdown-menu); // 同一父級(jí)下的其他子級(jí)統(tǒng)一關(guān)閉 $sub.siblings(.dropdown-submenu).removeClass(show) .children(.dropdown-menu).removeClass(show); $sub.toggleClass(show); $subMenu.toggleClass(show); $(this).attr(aria-expanded, $sub.hasClass(show)); }); // 點(diǎn)擊頁(yè)面任意位置關(guān)閉 $(document).on(click, function (e) { if (!$(e.target).closest(.navbar-nav).length) { closeAllExcept(null); $(.navbar-nav .nav-item.dropdown).find(.dropdown-toggle).attr(aria-expanded, false); } }); // 按 Esc 鍵逐級(jí)關(guān)閉 $(document).on(keydown, function (e) { if (e.key Escape || e.keyCode 27) { $(.navbar-nav .dropdown-submenu.show .dropdown-menu.show) .last().closest(.dropdown-submenu).removeClass(show) .children(.dropdown-menu).removeClass(show); var $topOpen $(.navbar-nav .nav-item.dropdown.show); if ($topOpen.length !$topOpen.find(.dropdown-submenu.show).length) { $topOpen.removeClass(show) .children(.dropdown-menu).removeClass(show) .find(.dropdown-toggle).attr(aria-expanded, false); } } }); })(window.jQuery);這段腳本做了幾件事頂層點(diǎn)擊如果當(dāng)前已經(jīng)展開再點(diǎn)一次就收起否則關(guān)閉其他所有頂級(jí)菜單再展開當(dāng)前項(xiàng)。子級(jí)點(diǎn)擊切換當(dāng)前子級(jí)的show狀態(tài)同時(shí)把同一個(gè)父級(jí)下的其他兄弟子級(jí)關(guān)掉避免多個(gè)子菜單同時(shí)出現(xiàn)。外部關(guān)閉整個(gè)頁(yè)面任意一個(gè)地方點(diǎn)擊只要不在導(dǎo)航欄內(nèi)部就把所有菜單全部重置。Esc按鍵從最深一級(jí)逐層關(guān)閉最后關(guān)掉頂層。這個(gè)細(xì)節(jié)對(duì)后臺(tái)用戶來說很重要很多人習(xí)慣鍵盤操作。我在實(shí)際使用中還會(huì)補(bǔ)充一個(gè)很小的細(xì)節(jié)子級(jí)切換項(xiàng)點(diǎn)擊之前如果這個(gè)子級(jí)菜單里已經(jīng)有一個(gè)更高層級(jí)的子級(jí)菜單處于展開狀態(tài)可以先把它一起關(guān)掉。否則三層以上菜單在連續(xù)切換時(shí)會(huì)出現(xiàn)二級(jí)菜單已經(jīng)換了三級(jí)菜單還掛著舊內(nèi)容的情況。上面的siblings只處理了同級(jí)嚴(yán)格來說還需要遞歸清理后代。一個(gè)偷懶的寫法是點(diǎn)擊前先執(zhí)行$sub.closest(.dropdown-menu).find(.dropdown-submenu.show).removeClass(show) .children(.dropdown-menu).removeClass(show);再加到子級(jí)點(diǎn)擊處理開頭就能避免絕大部分三級(jí)菜單殘留。4. 實(shí)戰(zhàn)中繞不開的五個(gè)坑含修復(fù)代碼代碼能跑通只是第一步真正上生產(chǎn)環(huán)境后會(huì)遇到一堆單頁(yè)面 demo 里看不見的細(xì)節(jié)問題。下面按我踩到的順序列五個(gè)最常見的坑。4.1 第一坑點(diǎn)擊父級(jí)后子級(jí)菜單被“外部分發(fā)”邏輯誤關(guān)這個(gè)坑出現(xiàn)的頻率最高?,F(xiàn)象是一級(jí)菜單正常展開鼠標(biāo)移到二級(jí)菜單點(diǎn)擊二級(jí)菜單的父項(xiàng)剛彈出的三級(jí)菜單在零點(diǎn)幾秒內(nèi)又自動(dòng)收起。原因在于事件冒泡。當(dāng)我點(diǎn)擊子級(jí)切換項(xiàng)時(shí)事件先被我的子級(jí)點(diǎn)擊處理函數(shù)攔截e.stopPropagation()確實(shí)阻止了繼續(xù)冒泡到document但如果在代碼里還掛了別的全局監(jiān)聽器或者在某個(gè)地方?jīng)]有正確使用stopPropagation菜單就會(huì)被其他監(jiān)聽收掉。更常見的因素是我自己寫的“點(diǎn)擊外部關(guān)閉”邏輯比如判斷closest(.navbar-nav)寫錯(cuò)了選擇器層級(jí)導(dǎo)致子級(jí)菜單被判定為“外部”。修復(fù)辦法有兩個(gè)所有處理菜單的點(diǎn)擊函數(shù)里統(tǒng)一加上e.stopPropagation()這是最低成本手段在外部關(guān)閉判斷里不僅判斷navbar-nav也把已經(jīng)展開的菜單容器算作內(nèi)部區(qū)域$(document).on(click, function (e) { if (!$(e.target).closest(.navbar-nav, .dropdown-menu).length) { closeAllExcept(null); } });4.2 第二坑子菜單被后面區(qū)塊蓋住后臺(tái)頁(yè)面經(jīng)常會(huì)用card、panel這類容器它們自帶的z-index有時(shí)比導(dǎo)航欄還高。子級(jí)菜單明明已經(jīng)顯示但右邊或者下方的內(nèi)容卡片直接蓋住了它肉眼只能看到菜單被截?cái)嗟倪吘夵c(diǎn)都點(diǎn)不到。排查思路很簡(jiǎn)單打開瀏覽器的開發(fā)者工具看一下子級(jí)菜單計(jì)算樣式里的z-index層級(jí)和定位。一般來說導(dǎo)航欄本身會(huì)有sticky-top類它自帶position: sticky和較高的z-index但子級(jí)菜單的z-index沒有顯式設(shè)置很容易被卡片蓋住。我一般會(huì)在CSS里給所有導(dǎo)航下拉菜單一個(gè)足夠高的值.navbar-nav .dropdown-menu { z-index: 1060; } .navbar-nav .dropdown-submenu .dropdown-menu { z-index: 1070; }如果頁(yè)面里還有模態(tài)框模態(tài)框的默認(rèn)z-index是1040到1050左右給到1060以上基本安全。但別給得過高否則菜單會(huì)浮到小彈層上面去反而影響操作。4.3 第三坑hover路徑上的“懸??p隙”導(dǎo)致菜單閃斷純CSS方案里二級(jí)菜單展開后鼠標(biāo)從一個(gè)菜單項(xiàng)劃到另一個(gè)菜單項(xiàng)時(shí)如果兩級(jí)菜單之間有個(gè)小小的空白間隔鼠標(biāo)一旦經(jīng)過這個(gè)間隔hover狀態(tài)就會(huì)消失子級(jí)菜單立刻收起。鼠標(biāo)要在極短的時(shí)間內(nèi)再次進(jìn)入子級(jí)菜單否則就會(huì)遭遇“菜單閃一下就沒”的情況。我的方案雖然是點(diǎn)擊展開但桌面端鼠標(biāo)懸停依舊會(huì)觸發(fā).dropdown-submenu:hover。有次我把二級(jí)菜單的margin-top: -6px寫沒了二級(jí)菜單和一級(jí)菜單之間出現(xiàn)一條幾像素的透明縫隙鼠標(biāo)從一級(jí)劃到二級(jí)時(shí)經(jīng)過縫隙導(dǎo)致菜單收起反復(fù)橫跳非常影響心情。修復(fù)方式是在子級(jí)菜單的margin上做手腳讓子級(jí)菜單底部和父級(jí)緊密貼合同時(shí)給父級(jí)和子級(jí)之間留出一條“緩沖區(qū)”。常用做法是.dropdown-submenu .dropdown-menu { margin-top: -6px; }這條負(fù)值本質(zhì)上是在視覺上讓子級(jí)菜單“向上伸”一點(diǎn)鼠標(biāo)在父級(jí)底部到子級(jí)頂部之間的過渡就不會(huì)斷檔。4.4 第四坑響應(yīng)式折疊時(shí)菜單錯(cuò)位和閃動(dòng)導(dǎo)航欄在小屏設(shè)備上會(huì)變成折疊模式navbar-collapse里的內(nèi)容區(qū)域?qū)挾却蟾胖挥衅聊粚挾鹊陌俜种俅藭r(shí)如果子級(jí)菜單還保持絕對(duì)定位從左往右彈出大概率會(huì)超出屏幕范圍形成“菜單跑到屏幕外面去”的尷尬效果。處理方式很明確在折疊斷點(diǎn)以下把菜單定位方式改成靜態(tài)讓子級(jí)菜單順著文檔流直接排列在父級(jí)下面。以navbar-expand-lg為例斷點(diǎn)是992px所以小屏區(qū)間就是992px以下media (max-width: 991.98px) { .navbar-nav .dropdown-menu, .navbar-nav .dropdown-submenu .dropdown-menu { position: static !important; float: none; box-shadow: none; border: 0; margin: 0; border-radius: 0; padding-left: 1rem; display: none; } .navbar-nav .dropdown-menu.show { display: block; } }注意display: none和.show的配合右上角的展開動(dòng)畫在這個(gè)尺寸下直接去掉點(diǎn)擊展開時(shí)只做顯示和隱藏這樣不會(huì)有絕對(duì)定位導(dǎo)致的滾動(dòng)條。4.5 第五坑與第三方JS庫(kù)的沖突后臺(tái)管理系統(tǒng)里最常見的第三方腳本是各種統(tǒng)計(jì)埋點(diǎn)、表單校驗(yàn)、Tab切換和自定義滾動(dòng)條。它們有一個(gè)共同特點(diǎn)會(huì)在document或者body上綁定全局click事件。我的“點(diǎn)擊外部關(guān)閉”也是document級(jí)事件兩個(gè)事件都在文檔級(jí)處理處理順序不同就可能出現(xiàn)“外部點(diǎn)擊關(guān)閉”檢查通過了但另外一個(gè)庫(kù)把事件stopPropagation掉了導(dǎo)致我的關(guān)閉邏輯一直不執(zhí)行。排查方法在外部點(diǎn)擊回調(diào)里打印e.target確認(rèn)事件是否到達(dá)用瀏覽器Event Listeners面板看document上綁定了哪些全局監(jiān)聽如果確實(shí)是第三方庫(kù)的問題可以在我的處理函數(shù)里放一個(gè)延時(shí)關(guān)閉邏輯比如setTimeout避免和對(duì)方在同一次事件循環(huán)里打架。但必須提醒延時(shí)關(guān)閉會(huì)帶來菜單關(guān)閉不夠“跟手”的副作用。所以我的建議是優(yōu)先排查沖突源而不是依賴延時(shí)兜底。實(shí)在無法定位的時(shí)候再把setTimeout的時(shí)間控制在80ms以內(nèi)。5. 移動(dòng)端折疊與鍵盤可達(dá)性別讓多級(jí)菜單成為無障礙災(zāi)難多級(jí)菜單表面上只是“多點(diǎn)幾次”的問題但在折疊屏、手機(jī)和讀屏軟件的環(huán)境里它很容易變成一團(tuán)糟。我見過不少后臺(tái)系統(tǒng)多級(jí)菜單在PC上好好的拿到手機(jī)上一看菜單項(xiàng)根本點(diǎn)不開或者點(diǎn)開之后沒有焦點(diǎn)反饋鍵盤用戶完全無法操作。5.1 折疊態(tài)改造成手風(fēng)琴展開在手機(jī)端的折疊導(dǎo)航里如果我的JS還是按桌面邏輯監(jiān)聽頂級(jí)點(diǎn)擊那用戶點(diǎn)一下“系統(tǒng)配置”一級(jí)菜單展開再點(diǎn)一下“權(quán)限組”二級(jí)菜單展開再點(diǎn)“數(shù)據(jù)范圍”三級(jí)菜單展開。每次點(diǎn)擊菜單都會(huì)切換一次不會(huì)自動(dòng)關(guān)閉其他兄弟級(jí)菜單。用戶層級(jí)越多切換越吃力。所以我在折疊態(tài)單獨(dú)做了一套手風(fēng)琴邏輯點(diǎn)擊當(dāng)前層級(jí)的切換項(xiàng)會(huì)同時(shí)收起當(dāng)前層級(jí)下其他已展開的兄弟菜單。也就是說我在子級(jí)點(diǎn)擊處理函數(shù)里額外添加了一個(gè)對(duì)siblings的處理這個(gè)邏輯在桌面端和移動(dòng)端保持一致。只在移動(dòng)端禁用了hover展開避免觸摸屏上點(diǎn)擊時(shí)出現(xiàn)“hover閃爍”。media (hover: none) { .dropdown-submenu:hover .dropdown-menu { visibility: hidden; opacity: 0; display: none; } .dropdown-submenu.show .dropdown-menu { visibility: visible; opacity: 1; display: block; } }這個(gè)hover: none媒體查詢比單純判斷屏幕寬度更靠譜能兼顧觸屏筆記本這類特殊設(shè)備。5.2 鍵盤Tab導(dǎo)航與Esc關(guān)閉鍵盤用戶操作多級(jí)菜單主要靠Tab鍵在鏈接之間移動(dòng)按空格或回車觸發(fā)點(diǎn)擊再按Esc退出。Tab移動(dòng)時(shí)如果子級(jí)菜單沒有展開焦點(diǎn)跳到子級(jí)切換項(xiàng)上沒有意義如果打開了子級(jí)菜單但Tab鍵順序錯(cuò)亂也很容易讓用戶掉出菜單樹。我的做法是給所有菜單項(xiàng)保持原生的Tab順序同時(shí)確保焦點(diǎn)進(jìn)入dropdown-submenu時(shí)菜單能通過focus-within自動(dòng)展開。這比單純用hover更可靠.dropdown-submenu:focus-within .dropdown-menu { visibility: visible; opacity: 1; display: block; }Esc關(guān)閉邏輯前面已經(jīng)寫了注意一點(diǎn)要從最深層級(jí)開始逐層關(guān)閉否則用戶按一次Esc就想回到上一級(jí)結(jié)果整個(gè)導(dǎo)航全部關(guān)了體驗(yàn)很割裂。5.3 aria-expanded與讀屏提示補(bǔ)充讀屏軟件主要依賴aria-expanded和aria-haspopup這類屬性來判斷菜單狀態(tài)。我前面的JS代碼里已經(jīng)在點(diǎn)擊時(shí)更新了aria-expanded還需要在HTML初始化時(shí)給頂層菜單和子級(jí)切換項(xiàng)補(bǔ)上默認(rèn)值a classnav-link dropdown-toggle href# idsystemLink aria-expandedfalse aria-haspopuptrue另外子級(jí)菜單容器建議加上aria-label或rolemenu語義。Bootstrap 4本身會(huì)給.dropdown-menu設(shè)置標(biāo)準(zhǔn)角色但多級(jí)嵌套時(shí)我見過讀屏軟件把三層菜單混在一起讀的情況。給每層菜單加一個(gè)明確的aria-label讀屏?xí)黠@友好不少div classdropdown-menu aria-label權(quán)限組子菜單6. 讓多級(jí)導(dǎo)航“正式上線”的檢查清單壓測(cè)和自測(cè)階段我習(xí)慣拿一份固定的清單逐項(xiàng)過。很多東西看起來是小問題上線后運(yùn)營(yíng)同事或客戶一旦碰到反饋成本比我們自己修一遍高得多。6.1 上線前測(cè)試清單桌面端擴(kuò)大瀏覽器窗口一級(jí)、二級(jí)、三級(jí)菜單分別在1200px、1440px、1920px寬度下點(diǎn)擊展開確認(rèn)無橫向滾動(dòng)條、無遮擋縮窄瀏覽器到992px以下重啟折疊模式再逐級(jí)點(diǎn)擊確認(rèn)子菜單是靜態(tài)展開而不是浮層觸屏設(shè)備上確認(rèn)hover不干擾點(diǎn)擊連續(xù)點(diǎn)擊不同層級(jí)菜單后狀態(tài)正確用鍵盤Tab逐一走一遍所有菜單項(xiàng)確認(rèn)焦點(diǎn)到達(dá)子級(jí)時(shí)菜單自動(dòng)展開按Esc逐級(jí)關(guān)閉有效打開瀏覽器開發(fā)者工具模擬400px和320px寬度確認(rèn)菜單文字不擠壓、箭頭不換行檢查z-index把頁(yè)面里所有卡片、彈層、模態(tài)框和導(dǎo)航菜單的層級(jí)都過一遍清理所有第三方全局點(diǎn)擊監(jiān)聽確認(rèn)沒有腳本和菜單關(guān)閉邏輯沖突。6.2 維護(hù)時(shí)的幾個(gè)額外提醒這套多級(jí)導(dǎo)航不是改完就一勞永逸的后續(xù)維護(hù)會(huì)集中在兩類事情上一類是菜單層級(jí)變動(dòng)比如某個(gè)功能模塊下新增了二級(jí)頁(yè)面菜單結(jié)構(gòu)調(diào)整時(shí)一定要注意HTML里dropdown-submenu的層級(jí)嵌套是否正確嵌套錯(cuò)位會(huì)導(dǎo)致第三級(jí)菜單掛到錯(cuò)誤的二級(jí)下另一類是主題樣式調(diào)整很多后臺(tái)項(xiàng)目會(huì)定制配色或內(nèi)邊距一旦navbar的padding有變化子級(jí)菜單的margin-left: -1px、margin-top: -6px這些細(xì)微偏移就可能失調(diào)我在項(xiàng)目升級(jí)主題時(shí)就吃過這個(gè)虧。說到升級(jí)主題我還有一個(gè)建議把多級(jí)菜單的CSS和JS單獨(dú)拆成一個(gè)文件不要和Bootstrap主題樣式混在一起大改。拆開之后升級(jí)Bootstrap公共樣式的時(shí)候多級(jí)菜單的增強(qiáng)邏輯不會(huì)被覆蓋排查問題也能一眼找到對(duì)應(yīng)位置。這套方案我沿用下來的整體感受是結(jié)構(gòu)清晰、對(duì)外部依賴少、基礎(chǔ)功能穩(wěn)定后續(xù)擴(kuò)展比如加動(dòng)畫、加面包屑聯(lián)動(dòng)都還有余量。如果你只是做一個(gè)小站直接改改CSS和JS文件里的類名就夠了但如果是后臺(tái)系統(tǒng)我還是建議先把代碼規(guī)范定好這樣后面加入的同事改起來也會(huì)順手很多。