菜單源碼解析:從數(shù)據(jù)結(jié)構(gòu)到遞歸渲染的完整實(shí)現(xiàn))
簡(jiǎn)介這份ASP三級(jí)菜單源碼面向需要構(gòu)建多層級(jí)網(wǎng)站導(dǎo)航的Web開(kāi)發(fā)者尤其適合電子商務(wù)類(lèi)站點(diǎn)處理復(fù)雜產(chǎn)品分類(lèi)場(chǎng)景可作為動(dòng)態(tài)菜單生成與樣式交互的參考實(shí)現(xiàn)。壓縮包為rar格式整體約1.81MB內(nèi)含示例頁(yè)面、說(shuō)明文檔與主源碼等文件涵蓋HTML頁(yè)面、文本說(shuō)明及ASP程序等類(lèi)型分別用于演示菜單效果、提供使用聲明與幫助入口以及承載三級(jí)菜單的核心服務(wù)端邏輯。目前已有192人學(xué)習(xí)下載可供對(duì)照參考。源碼結(jié)合HTML無(wú)序列表、CSS布局與JavaScript展開(kāi)折疊效果并由ASP根據(jù)數(shù)據(jù)動(dòng)態(tài)生成菜單項(xiàng)讀者可據(jù)此理解菜單結(jié)構(gòu)與頁(yè)面內(nèi)容的對(duì)應(yīng)關(guān)系并在此基礎(chǔ)上按自身設(shè)計(jì)風(fēng)格修改定制同時(shí)留意瀏覽器兼容、請(qǐng)求性能與搜索引擎抓取等實(shí)際問(wèn)題。1. 從一份 ASP 三級(jí)菜單源碼說(shuō)起老項(xiàng)目里最容易被低估的導(dǎo)航組件接手一套十幾年前的老后臺(tái)最先崩掉的往往不是業(yè)務(wù)邏輯而是導(dǎo)航。左側(cè)菜單點(diǎn)開(kāi)二級(jí)、再點(diǎn)開(kāi)三級(jí)刷新一下全收起來(lái)了用戶(hù)罵罵咧咧地重新找位置——這種場(chǎng)景在維護(hù)老系統(tǒng)時(shí)幾乎每周都遇到。這次拆的是一份 ASP 三級(jí)菜單源碼純 Classic ASP 實(shí)現(xiàn)不依賴(lài)任何前端框架靠服務(wù)端拼 HTML 加少量 JavaScript 控制展開(kāi)收起數(shù)據(jù)來(lái)源可以是數(shù)據(jù)庫(kù)表也可以是配置文件里的靜態(tài)數(shù)組。它解決的就是「多層級(jí)導(dǎo)航在無(wú)框架環(huán)境下怎么穩(wěn)定展開(kāi)、怎么記住當(dāng)前選中項(xiàng)」這個(gè)具體問(wèn)題。適合誰(shuí)還在維護(hù) ASP 老站點(diǎn)的開(kāi)發(fā)者、需要給遺留系統(tǒng)補(bǔ)導(dǎo)航的運(yùn)維、以及想搞明白無(wú)框架時(shí)代菜單邏輯的人。asp源碼這類(lèi)資源網(wǎng)上不少但能直接跑通、結(jié)構(gòu)清晰的不多這份算一個(gè)。2. 三級(jí)菜單的數(shù)據(jù)結(jié)構(gòu)從扁平表到樹(shù)形渲染的完整鏈路2.1 為什么菜單數(shù)據(jù)要存成「父 ID 層級(jí)」而不是嵌套數(shù)組先看數(shù)據(jù)層。三級(jí)菜單的本質(zhì)是一棵樹(shù)但存進(jìn)數(shù)據(jù)庫(kù)時(shí)幾乎沒(méi)人真去存嵌套結(jié)構(gòu)常見(jiàn)做法是一張扁平表每行一個(gè)菜單項(xiàng)用 ParentID 指向上級(jí)再用一個(gè) SortOrder 控制同級(jí)順序。這份源碼走的就是這條路表結(jié)構(gòu)大致是 MenuID、ParentName、MenuName、MenuUrl、ParentID、MenuLevel 這么幾個(gè)字段。為什么不直接在 ASP 里寫(xiě)死嵌套數(shù)組因?yàn)椴藛我芎笈_(tái)維護(hù)運(yùn)營(yíng)改個(gè)名字、加個(gè)子項(xiàng)總不能每次都改代碼。扁平表轉(zhuǎn)樹(shù)形渲染核心是「一次查詢(xún)、內(nèi)存分組」。ASP 里沒(méi)有現(xiàn)成的 groupBy我一般會(huì)先把整表讀進(jìn)一個(gè)二維數(shù)組或 Dictionary按 ParentID 分桶然后從根節(jié)點(diǎn)遞歸往下拼。這份源碼用的是 Dictionary 嵌套 Dictionary 的寫(xiě)法好處是查找 O(1)壞處是 Classic ASP 的 Dictionary 不支持直接存對(duì)象引用得靠字符串鍵拼路徑。下面這段是數(shù)據(jù)讀取和分桶的核心邏輯我按源碼結(jié)構(gòu)整理成了可直接對(duì)照的版本% 讀取全部菜單按 ParentID 分桶 Dim conn, rs, menuDict, sql Set menuDict Server.CreateObject(Scripting.Dictionary) sql SELECT MenuID, MenuName, MenuUrl, ParentID, MenuLevel, SortOrder _ FROM Sys_Menu ORDER BY ParentID, SortOrder Set conn Server.CreateObject(ADODB.Connection) conn.Open Application(ConnStr) Set rs conn.Execute(sql) Do While Not rs.EOF Dim pid, item pid Trim(rs(ParentID) ) If Not menuDict.Exists(pid) Then menuDict.Add pid, Server.CreateObject(Scripting.Dictionary) End If 用 MenuID 做二級(jí)鍵值為 名稱(chēng)|鏈接|層級(jí) 的拼接串 menuDict(pid).Add CStr(rs(MenuID)), _ rs(MenuName) | rs(MenuUrl) | rs(MenuLevel) rs.MoveNext Loop rs.Close : conn.Close Set rs Nothing : Set conn Nothing %邏輯說(shuō)明外層 Dictionary 的鍵是 ParentID內(nèi)層鍵是 MenuID值用豎線拼成字符串。這么寫(xiě)是因?yàn)?Classic ASP 的 Dictionary 存不了 Recordset 行對(duì)象只能降級(jí)成字符串。參數(shù)上要注意兩點(diǎn)ParentID 為 0 或空串代表根節(jié)點(diǎn)源碼里統(tǒng)一用 0 做根鍵SortOrder 必須在 SQL 里排好ASP 端不再二次排序否則同級(jí)順序會(huì)亂。層級(jí)字段 MenuLevel 其實(shí)可以不要靠遞歸深度就能算出來(lái)但保留它有個(gè)好處——渲染時(shí)可以直接按層級(jí)套不同的 CSS class省一次判斷。2.2 遞歸渲染函數(shù)三個(gè)參數(shù)決定菜單長(zhǎng)什么樣數(shù)據(jù)分好桶接下來(lái)是渲染。源碼里有一個(gè) RenderMenu 函數(shù)接收父 ID、當(dāng)前層級(jí)、當(dāng)前選中 ID 三個(gè)參數(shù)遞歸調(diào)用自己。這三個(gè)參數(shù)就是整個(gè)菜單的「控制面板」父 ID 決定從哪一層開(kāi)始拼當(dāng)前層級(jí)控制縮進(jìn)和樣式選中 ID 負(fù)責(zé)給當(dāng)前頁(yè)面對(duì)應(yīng)的菜單項(xiàng)加高亮。很多人改菜單改崩就是沒(méi)搞清這三個(gè)參數(shù)誰(shuí)管什么。% Sub RenderMenu(parentId, level, currentId) If Not menuDict.Exists(parentId) Then Exit Sub Dim key, parts, mid, name, url, cls Response.Write ul classmenu-lv level For Each key In menuDict(parentId).Keys parts Split(menuDict(parentId)(key), |) mid key : name parts(0) : url parts(1) cls If mid currentId Then cls classactive Response.Write li cls Response.Write a href url name /a 有子項(xiàng)才繼續(xù)遞歸層級(jí)上限 3 If menuDict.Exists(mid) And level 3 Then RenderMenu mid, level 1, currentId End If Response.Write /li Next Response.Write /ul End Sub %邏輯說(shuō)明level 3是硬上限防止數(shù)據(jù)里出現(xiàn)環(huán)導(dǎo)致無(wú)限遞歸——這是血淚經(jīng)驗(yàn)老系統(tǒng)里 ParentID 指回自己的臟數(shù)據(jù)太常見(jiàn)了。currentId 從 Request.QueryString 或 Session 里取源碼默認(rèn)用 QueryString 的 menuid 參數(shù)。參數(shù)怎么改想把三級(jí)擴(kuò)成四級(jí)把 3 改成 4 即可但 CSS 的.menu-lv4得同步補(bǔ)上想默認(rèn)全部展開(kāi)就在ul上加內(nèi)聯(lián) style 或者去掉 JS 的折疊邏輯。注意For Each key In menuDict(parentId).Keys這里遍歷的是內(nèi)層 Dictionary 的鍵順序取決于插入順序所以 SQL 的 ORDER BY 不能省。2.3 前端折疊與選中態(tài)保持二十行 JS 解決刷新丟狀態(tài)服務(wù)端渲染完菜單默認(rèn)是全部展開(kāi)的三級(jí)菜單全攤開(kāi)頁(yè)面會(huì)很長(zhǎng)所以源碼配了一小段 JS 做折疊。這段 JS 不依賴(lài) jQuery純 DOM 操作核心是給每個(gè)帶子菜單的li綁定點(diǎn)擊事件切換子ul的 display。真正容易翻車(chē)的是「刷新后選中態(tài)丟失」——用戶(hù)點(diǎn)進(jìn)三級(jí)頁(yè)面F5 一下菜單全收起來(lái)當(dāng)前項(xiàng)也找不到了。// 折疊控制 選中項(xiàng)自動(dòng)展開(kāi) document.addEventListener(DOMContentLoaded, function () { var items document.querySelectorAll(.menu-lv1 li, .menu-lv2 li); items.forEach(function (li) { var sub li.querySelector(ul); if (!sub) return; // 點(diǎn)擊父項(xiàng)文字切換展開(kāi)點(diǎn)擊鏈接本身不攔截 li.querySelector(a).addEventListener(click, function (e) { if (this.getAttribute(href) # || !this.getAttribute(href)) { e.preventDefault(); sub.style.display sub.style.display none ? block : none; } }); }); // 選中項(xiàng)的所有祖先 ul 強(qiáng)制展開(kāi) var active document.querySelector(li.active); if (active) { var p active.parentElement; while (p p ! document.body) { if (p.tagName UL) p.style.display block; p p.parentElement; } } });邏輯說(shuō)明第一段遍歷所有可能帶子菜單的 li只在 href 為空或#時(shí)才攔截點(diǎn)擊做折疊避免把正常跳轉(zhuǎn)吃掉——這是最常見(jiàn)的誤用很多人直接e.preventDefault()導(dǎo)致菜單點(diǎn)不動(dòng)。第二段從.active往上爬把沿途所有 ul 的 display 設(shè)為 block保證刷新后當(dāng)前路徑可見(jiàn)。參數(shù)上.menu-lv1、.menu-lv2這兩個(gè)選擇器要和 RenderMenu 輸出的 class 嚴(yán)格對(duì)應(yīng)改層級(jí)時(shí)兩邊一起改。這段 JS 放在/body前或 DOMContentLoaded 里都行源碼是后者。3. 把源碼跑起來(lái)IIS 配置、數(shù)據(jù)庫(kù)對(duì)接與靜態(tài)模式改造3.1 本地 IIS 跑通 Classic ASP 的四個(gè)必改項(xiàng)拿到 asp源碼 第一件事不是讀代碼是讓它先跑起來(lái)。Classic ASP 在現(xiàn)代 Windows 上默認(rèn)是關(guān)的IIS 里要手動(dòng)開(kāi)。我一般按這個(gè)順序走控制面板啟用「Internet Information Services」和「ASP」兩個(gè)功能IIS 管理器里把站點(diǎn)物理路徑指到源碼目錄應(yīng)用程序池的「托管管道模式」設(shè)為「經(jīng)典」或「集成」都行但 32 位應(yīng)用要開(kāi)「啟用 32 位應(yīng)用程序」最后在 ASP 配置里把「啟用父路徑」打開(kāi)因?yàn)槔洗a里!--#include file../conn.asp--這種相對(duì)路徑很常見(jiàn)。# 以管理員身份在 PowerShell 里快速檢查 ASP 功能是否已裝 Get-WindowsOptionalFeature -Online -FeatureName IIS-ASP # 若 State 為 Disabled用下面這條啟用需重啟 Enable-WindowsOptionalFeature -Online -FeatureName IIS-ASP -All命令說(shuō)明第一條只查狀態(tài)不改動(dòng)第二條才真正安裝。裝完 IIS 要重啟W3SVC服務(wù)。常見(jiàn)翻車(chē)點(diǎn)是 64 位系統(tǒng)上老組件比如某些 Access 驅(qū)動(dòng)只有 32 位版本應(yīng)用池沒(méi)開(kāi) 32 位就會(huì)報(bào)「未找到提供程序」。數(shù)據(jù)庫(kù)連接串在conn.asp里源碼默認(rèn)給的是 SQL Server 的 OLE DB 寫(xiě)法換成 Access 的話改成ProviderMicrosoft.Jet.OLEDB.4.0;Data Source...注意 Jet 引擎只認(rèn) 32 位。3.2 數(shù)據(jù)庫(kù)表建好之后菜單不顯示先查這三處表建完、數(shù)據(jù)插進(jìn)去頁(yè)面一片空白是最高頻的問(wèn)題。按我的排查順序先看menuDict.Exists(0)成不成立也就是根節(jié)點(diǎn)的 ParentID 到底存的是 0、空串還是 NULL——源碼按 0 匹配數(shù)據(jù)庫(kù)里存 NULL 就永遠(yuǎn)進(jìn)不去根桶。再看 SQL 有沒(méi)有被ORDER BY ParentID, SortOrder之外的字段干擾SortOrder 有重復(fù)會(huì)導(dǎo)致同級(jí)順序隨機(jī)。最后看 Response 有沒(méi)有被前面的 include 文件提前Response.End掉?,F(xiàn)象優(yōu)先檢查典型原因整棵菜單空白根節(jié)點(diǎn) ParentID 值存了 NULL 或空串與 0 不匹配只有一級(jí)沒(méi)有二三級(jí)內(nèi)層 Dictionary 鍵類(lèi)型MenuID 數(shù)字與字符串鍵混用同級(jí)順序亂SortOrder 唯一性排序字段重復(fù)或未排序中文亂碼文件編碼與 Response.Charset文件存成 UTF-8 但頁(yè)面聲明 GB2312表格里最后一條值得單獨(dú)說(shuō)Classic ASP 對(duì) UTF-8 支持很別扭源碼文件如果是 UTF-8 無(wú) BOM中文菜單名會(huì)變問(wèn)號(hào)。穩(wěn)妥做法是文件存成 ANSI/GB2312頁(yè)面頂部% CodePage936 LanguageVBScript %Response.Charset 設(shè)gb2312三處編碼一致才不會(huì)翻車(chē)。3.3 沒(méi)有數(shù)據(jù)庫(kù)也能用靜態(tài)數(shù)組模式改造很多人的場(chǎng)景根本不需要后臺(tái)維護(hù)菜單就是幾個(gè)固定頁(yè)面。這時(shí)候把數(shù)據(jù)庫(kù)那層抽掉直接用一個(gè)靜態(tài)二維數(shù)組喂給 RenderMenu 更省事。改造點(diǎn)只有一處把menuDict的構(gòu)建從 Recordset 循環(huán)換成手寫(xiě) Add。% 靜態(tài)模式id, 名稱(chēng), 鏈接, 父id Dim staticData, i, arr staticData Array( _ Array(1, 系統(tǒng)管理, #, 0), _ Array(11, 用戶(hù)管理, user_list.asp, 1), _ Array(111, 用戶(hù)列表, user_list.asp, 11), _ Array(112, 角色分配, role_assign.asp, 11), _ Array(2, 內(nèi)容管理, #, 0) _ ) For i 0 To UBound(staticData) arr staticData(i) If Not menuDict.Exists(arr(3)) Then menuDict.Add arr(3), Server.CreateObject(Scripting.Dictionary) End If menuDict(arr(3)).Add arr(0), arr(1) | arr(2) |1 Next %邏輯說(shuō)明數(shù)組每行四個(gè)元素順序是 ID、名稱(chēng)、鏈接、父 ID和數(shù)據(jù)庫(kù)字段一一對(duì)應(yīng)改菜單就是改這個(gè)數(shù)組不用碰渲染邏輯。參數(shù)上注意父 ID 必須出現(xiàn)在子項(xiàng)之前因?yàn)榉滞笆琼樞驁?zhí)行的父桶不存在時(shí)menuDict(arr(3))會(huì)報(bào)錯(cuò)——源碼里加了 Exists 判斷兜底。這種靜態(tài)模式適合菜單不超過(guò)三四十項(xiàng)的場(chǎng)景再多就該回到數(shù)據(jù)庫(kù)了。4. 避坑與排查這份源碼最容易翻車(chē)的五個(gè)地方4.1 遞歸沒(méi)有出口IIS 直接 500 并拖垮應(yīng)用池現(xiàn)象頁(yè)面轉(zhuǎn)圈十幾秒后報(bào)「Active Server Pages 錯(cuò)誤 0115」應(yīng)用池重啟。原因菜單表里某行的 ParentID 指向了自己的 MenuID或者兩個(gè)節(jié)點(diǎn)互相指向遞歸永遠(yuǎn)到不了level 3之外的出口。解決渲染前先做一次環(huán)檢測(cè)或者在 SQL 里加WHERE MenuID ParentID過(guò)濾自環(huán)更穩(wěn)的做法是給遞歸加一個(gè)全局計(jì)數(shù)器超過(guò) 100 次直接 Exit Sub 并寫(xiě)日志。我一般會(huì)在 RenderMenu 開(kāi)頭加If level 5 Then Exit Sub雙保險(xiǎn)。4.2 Dictionary 鍵大小寫(xiě)敏感導(dǎo)致子菜單掛不上現(xiàn)象一級(jí)菜單正常二級(jí)怎么都不出現(xiàn)但數(shù)據(jù)庫(kù)里數(shù)據(jù)明明是對(duì)的。原因Classic ASP 的 Scripting.Dictionary 默認(rèn)CompareMode是 BinaryCompare鍵 A1 和 a1 被當(dāng)成兩個(gè)不同的鍵而數(shù)據(jù)庫(kù)里 MenuID 有時(shí)是大寫(xiě)有時(shí)是小寫(xiě)分桶和查找對(duì)不上。解決創(chuàng)建 Dictionary 后立刻設(shè)menuDict.CompareMode 1TextCompare或者在 Add 之前統(tǒng)一UCase()。這個(gè)坑很隱蔽因?yàn)閳?bào)錯(cuò)都不報(bào)就是靜默不顯示。4.3 選中態(tài)用 Session 存多標(biāo)簽頁(yè)互相串現(xiàn)象用戶(hù)開(kāi)了兩個(gè)標(biāo)簽頁(yè)一個(gè)在用戶(hù)管理、一個(gè)在內(nèi)容管理切回來(lái)發(fā)現(xiàn)高亮跑到了另一個(gè)菜單。原因源碼默認(rèn)從 Session 取 currentIdSession 是會(huì)話級(jí)的多標(biāo)簽共享。解決改成優(yōu)先讀Request.QueryString(menuid)取不到再回落 Session或者在每個(gè)鏈接上帶?menuidxxx參數(shù)。參數(shù)說(shuō)明menuid 要和 MenuID 字段類(lèi)型一致數(shù)字型別加引號(hào)字符串型記得 Trim。4.4 中文菜單名在部分瀏覽器顯示成方塊現(xiàn)象Chrome 正常某老版本 IE 里菜單名全是方塊。原因文件編碼、CodePage、Response.Charset、meta charset 四處不一致最常見(jiàn)的是文件存了 UTF-8 但 CodePage 還是 65001 而數(shù)據(jù)庫(kù)連接串里沒(méi)指定字符集。解決統(tǒng)一走 GB2312 路線文件另存為 ANSI頁(yè)面頭CodePage936Response.Charsetgb2312meta charsetgb2312四處對(duì)齊。改完清一次瀏覽器緩存再驗(yàn)。4.5 靜態(tài)模式下數(shù)組順序?qū)戝e(cuò)父項(xiàng)在后導(dǎo)致整段不渲染現(xiàn)象靜態(tài)數(shù)組模式改造后某個(gè)二級(jí)菜單連同它的子項(xiàng)全部消失。原因子項(xiàng)在數(shù)組里排在了父項(xiàng)前面分桶時(shí)父桶還不存在menuDict(arr(3)).Add直接拋錯(cuò)而 VBScript 默認(rèn)On Error Resume Next沒(méi)開(kāi)整個(gè) Sub 中斷。解決數(shù)組嚴(yán)格按「父在前、子在后」排列或者在 Add 前加If Not menuDict.Exists(arr(3)) Then menuDict.Add arr(3), ...的兜底創(chuàng)建。后者更穩(wěn)代價(jià)是父項(xiàng)缺失時(shí)會(huì)生成一個(gè)空桶渲染時(shí)表現(xiàn)為一個(gè)空 ul需要額外判斷跳過(guò)。5. 進(jìn)階把三級(jí)菜單做成可配置組件并驗(yàn)證渲染結(jié)果源碼跑通只是起點(diǎn)真正省事的是把它抽成一個(gè)可復(fù)用的 include 組件。我的做法是把數(shù)據(jù)構(gòu)建、RenderMenu、JS 折疊三塊拆成menu_data.asp、menu_render.asp、menu.js頁(yè)面里只留一行!--#include filemenu_render.asp--加一次RenderMenu 0, 1, currentId調(diào)用。這樣換項(xiàng)目時(shí)只改數(shù)據(jù)源渲染邏輯不動(dòng)。驗(yàn)證渲染結(jié)果別靠肉眼數(shù)寫(xiě)個(gè)簡(jiǎn)單的自檢腳本把生成的 HTML 里li數(shù)量和數(shù)據(jù)庫(kù) count 對(duì)一下數(shù)量對(duì)不上就是分桶漏了。驗(yàn)證項(xiàng)方法通過(guò)標(biāo)準(zhǔn)節(jié)點(diǎn)總數(shù)正則數(shù)li出現(xiàn)次數(shù)等于表內(nèi)有效行數(shù)層級(jí)深度數(shù).menu-lv3是否存在有三級(jí)數(shù)據(jù)時(shí)必出現(xiàn)選中態(tài)唯一數(shù)classactive次數(shù)恰好 1 次空鏈接查href0 處這個(gè)自檢表我一般掛在測(cè)試環(huán)境的一個(gè)menu_check.asp里改完菜單跑一遍比點(diǎn)半天靠譜。另外提一個(gè)性能細(xì)節(jié)菜單數(shù)據(jù)每次請(qǐng)求都查庫(kù)其實(shí)沒(méi)必要Classic ASP 可以用Application對(duì)象緩存整個(gè) menuDict后臺(tái)改菜單時(shí)手動(dòng)Application.Contents.Remove(menuDict)刷新。緩存后首屏能省一次數(shù)據(jù)庫(kù)往返老服務(wù)器上體感很明顯。但要注意 Application 是進(jìn)程級(jí)的多站點(diǎn)共用一個(gè)應(yīng)用池時(shí)會(huì)串?dāng)?shù)據(jù)得用帶站點(diǎn)前綴的鍵名隔離。從那以后我每次接手老 ASP 項(xiàng)目第一件事就是把菜單組件單獨(dú)抽出來(lái)跑一遍自檢表確認(rèn)節(jié)點(diǎn)數(shù)、層級(jí)、選中態(tài)三項(xiàng)都對(duì)再動(dòng)業(yè)務(wù)代碼——菜單這種「看起來(lái)最簡(jiǎn)單」的組件恰恰是最容易在重構(gòu)時(shí)被改崩的地方。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取