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