首页 / 资讯中心 / 文章详情

ASP三级菜单源码:parentid递归渲染与IIS部署避坑

ASP三级菜单源码:parentid递归渲染与IIS部署避坑 ★ FEATURED ARTICLE
简介这份压缩包提供一套基于 ASP 的三级菜单实现代码面向需要为网站搭建多层级导航栏的开发者尤其适合目录结构复杂的电子商务站点做产品分类展示。代码以服务端 ASP 动态生成菜单结构为核心配合 HTML 无序列表组织层级、CSS 控制样式与布局、JavaScript 处理展开和折叠交互菜单项可由数据库数据驱动并与站点实际页面内容保持对应。压缩包为 rar 格式体积约 1.81MB除主源码目录外还包含 html 示例页面、txt 使用声明与 url 快捷方式等辅助文件便于快速查看效果并了解授权条款。已有 193 人学习。读者可从中获得可直接运行的三级菜单原型、电商产品多级分类的参考写法与二次定制所需的代码结构并据此调整样式和数据来源同时可留意浏览器兼容、服务端请求性能以及菜单结构对搜索引擎抓取的影响减少从零摸索的成本。1. 三级导航的表结构不过三列渲染代码不过三十行接手一套老后台导航要三级前端框架一个都引不进来——整站模板全是 .asp 文件。ASP三级菜单源码这类资源就是给这种场景准备的不依赖任何第三方库VBScript 里用一个递归函数把一张带 parentid 的菜单表渲染成三层 ul/li顺带处理当前页高亮和面包屑。它适合还在维护经典 ASP 站点的开发者、企业内部小型管理系统以及需要一次性交付静态结构的老项目改造。下面按表结构、递归核心、IIS 跑通、二次开发这条线把这份 asp源码 拆开看它能不能直接落到你手上的项目里。2. 目录与表结构三级菜单的骨架长什么样2.1 目录结构五个文件撑起整套导航拿到一份 ASP 菜单源码我第一件事不是看代码是先看目录。老项目的目录结构基本等于它的设计思路——文件怎么分就说明作者把哪些东西当成可复用的、哪些当成一次性的。这份源码的文件布局是这样的menu_demo/ ├── conn.asp 数据库连接串 全局常量 ├── inc/ │ ├── menu.asp 建树 递归渲染被所有页面 include │ └── breadcrumb.asp 面包屑按需 include ├── data/ │ └── menu.mdb Access 库含 sys_menu 一张表 ├── index.asp 首页验证一级菜单 ├── news/ │ ├── list.asp 二级页面验证相对路径 │ └── detail.asp 三级页面验证面包屑回溯 └── admin/ └── menu_edit.asp 后台增删改改完清菜单缓存inc/menu.asp是绝对核心它同时承担两件事把数据库里的平铺记录组织成树以及把树渲染成 HTML。这两件事分开写会更干净一个menu_data.asp负责数据、一个menu_view.asp负责渲染但老项目里合并的概率很高因为它只被!--#include file../inc/menu.asp--引用一次减少 include 层级就减少一次路径出错的机会。news/list.asp和news/detail.asp这两层目录是有意设计的。经典 ASP 里一个高频翻车点是菜单链接写成相对路径news/list.asp在index.asp里点着没问题进到news/detail.asp再点就变成news/news/list.asp直接 404。这两个文件就是用来暴露这个问题的跑通的时候别跳过。admin/menu_edit.asp负责后台维护它的关键不是表单本身而是改完数据之后有没有清缓存。这份源码默认把菜单缓存到 Application 里后台保存成功后必须手动置空否则前台看到的一直是旧菜单——这个坑在第 5 章会单独说。2.2 sys_menu 表parentid 一个字段决定所有层级菜单表是整个功能的地基字段设计错了后面代码怎么写都别扭。这份源码用的是最经典的邻接表adjacency list模型一张表存所有层级用parentid指向自己的父节点顶级节点统一填 0。Access 下建表语句大致是这样CREATE TABLE sys_menu ( id AUTOINCREMENT PRIMARY KEY, -- 主键自增 parentid INTEGER NOT NULL DEFAULT 0, -- 0 表示一级菜单 menuname VARCHAR(50) NOT NULL, -- 显示文字 menuurl VARCHAR(200), -- 链接地址 menu_code VARCHAR(50), -- 页面标识用于高亮比 URL 匹配可靠 sortid INTEGER DEFAULT 100, -- 同级排序小的在前 target VARCHAR(10) DEFAULT _self,-- 打开方式 visible INTEGER DEFAULT 1 -- 1 显示 0 隐藏 );字段逐个说清楚因为改的时候基本都会动它们。parentid是灵魂索引必须建CREATE INDEX idx_parent ON sys_menu(parentid, sortid)否则每层递归都全表扫一遍三级菜单在几百条数据时就能看出来卡。sortid是同级排序不要用id排序代替它——业务上经常需要把某个菜单往前挪用 id 排就得改主键那是灾难。menu_code是我建议加的字段源码里如果只有menuurl高亮只能靠 URL 字符串包含判断容易误判/news/会同时命中/news/list.asp和/news/detail.asp有个稳定的 code 就干净多了。visible用于临时下线某个菜单而不删数据比删了再建安全。提示Access 的自增关键字是AUTOINCREMENTSQL Server 是IDENTITY(1,1)MySQL 是AUTO_INCREMENT。迁移库的时候这行必须改别指望脚本能自动适配。初始化数据建议直接铺满三级方便验证INSERT INTO sys_menu (parentid, menuname, menuurl, menu_code, sortid, target, visible) VALUES (0, 首页, /index.asp, home, 10, _self, 1), (0, 产品中心, /product/, product, 20, _self, 1), (1, 硬件产品, /product/hw.asp, p_hw, 10, _self, 1), (1, 软件产品, /product/sw.asp, p_sw, 20, _self, 1), (3, 工业机型, /product/hw_a.asp,p_hw_a, 10, _self, 1), (3, 商用机型, /product/hw_b.asp,p_hw_b, 20, _self, 1), (0, 关于我们, /about.asp, about, 30, _self, 1);注意menuurl一律用/开头的站点绝对路径这是绕开相对路径 404 最省事的办法代价是站点不能部署在虚拟子目录下——如果你确实要放子目录就得在menuurl前面拼一个Application(SiteRoot)常量这个改造第 6 章会说。另外这里没有写「一级id / 二级id / 三级id」三列平铺的设计。平铺的好处是查询极简一次WHERE lv1? AND lv2?就出结果不用递归坏处是加一层就得改表结构、改所有 SQL。三级菜单这种需求今天说三级明年很可能就变四级所以parentid的通用性更值钱。2.3 三条实现路线嵌套循环、递归、GetRows 建树同一份菜单表经典 ASP 里有三种常见写法性能差得挺明显选错路线后面全是补丁。第一种是逐层嵌套循环先SELECT * FROM sys_menu WHERE parentid0打开一级 Recordset循环里再对每条一级记录执行一次SELECT * FROM sys_menu WHERE parentid当前 id二级里再来一次。代码最短新手最容易写出来但查询次数是 1 N N×M一个 3 层各 10 条的菜单要发 111 次查询。Access 单机还扛得住SQL Server 上就是明显的 N1 问题页面响应能从 30ms 涨到 400ms。第二种是单 Recordset 递归一次把全表取回来在 VBScript 里写递归函数每次从同一个 Recordset 里Filter出子集。写法优雅但这里有个大坑——ADO 的 Recordset 默认是服务端游标你在递归里MoveNext遍历子集递归返回后父层的游标位置已经被搅乱了二层菜单只能显示一条。这个坑我在第 5 章会详细展开实际项目里踩过不止一次。第三种是GetRows 转数组 字典分组一次查询rs.GetRows()把结果集拍成二维数组然后遍历一遍数组按parentid把行号分到字典里渲染时只查字典。查询次数恒等于 1时间复杂度 O(n)内存占用就是那点菜单数据几百条完全无压力。这份 ASP三级菜单源码用的就是这条路也是我最推荐的。路线查询次数代码量扩展性适用数据量逐层嵌套循环1NN×M最少差改层级要重写20 条以内单 Recordset 递归1中等中但游标易出错不建议GetRows 字典1稍多好层级只受渲染限数千条无压力3. 核心代码拆解一个请求从进页面到输出三级 ul 的完整链路3.1 第一步GetRows 把结果集拍成二维数组整个链路的起点是取数。这段代码看着平淡但有两个细节决定后面顺不顺% 一次取全表按 parentid、sortid 排好直接转成二维数组 Function GetMenuArray() Dim rs, arr Set rs Server.CreateObject(ADODB.Recordset) rs.Open SELECT id,parentid,menuname,menuurl,target FROM sys_menu _ WHERE visible1 ORDER BY sortid ASC, id ASC, Conn, 1, 1 If rs.EOF Then GetMenuArray Array() 空表也要返回数组避免调用方判断 EOF Else GetMenuArray rs.GetRows() 返回 arr(字段下标, 行下标) End If rs.Close Set rs Nothing End Function %rs.Open的第三、四个参数1, 1是adOpenKeyset和adLockReadOnly的数值写法经典 ASP 里习惯这么写等价于ADODB.CursorTypeEnum.adOpenKeyset。用只读游标是因为菜单表在这种场景下纯读不需要悲观锁锁设置越低并发越好。GetRows()返回的是一个二维数组下标顺序是arr(列, 行)这点必须记牢——很多人第一次用会写成arr(行, 列)然后UBound(arr, 2)拿到的是行数却当成列数用报「下标越界」还找不到原因。上面 SELECT 的字段顺序决定了列下标id0、parentid1、menuname2、menuurl3、target4。这个顺序是硬约定后面所有代码都按它取值所以改 SELECT 字段顺序等于改全站要么别动要么抽成常量。WHERE visible1和ORDER BY sortid必须在 SQL 里做不要取回来在 VBScript 里过滤排序。数据量小的时候看不出差别几百条菜单时 VBScript 冒泡排序和数据库索引排序的差距就出来了而且 SQL 里过滤能少占内存。注意GetRows()之后要立刻rs.Close不要留着 Recordset 开着继续别的事情。经典 ASP 页面里如果出现未关闭的 Recordset 加事务很容易在 Access 上把 .ldb 锁文件卡死表现为「数据库已被其他用户独占打开」。3.2 第二步字典分组把 O(n²) 降到 O(n)拿到数组之后下一步是建立「父级 id → 子行号列表」的索引。这一步是整个方案性能的关键没有它每次递归都要把整个数组扫一遍找子节点复杂度直接退化成 O(n²)% 建立索引parentid - 行号,行号,行号 Function GetMenuIndex(arr) Dim dict, i, k Set dict Server.CreateObject(Scripting.Dictionary) If Not IsArray(arr) Then Set GetMenuIndex dict Exit Function End If If UBound(arr, 2) 0 Then 空数组UBound 是 -1 Set GetMenuIndex dict Exit Function End If For i 0 To UBound(arr, 2) k CStr(arr(1, i)) 第 2 列是 parentid If dict.Exists(k) Then dict(k) dict(k) , i Else dict(k) CStr(i) End If Next Set GetMenuIndex dict End Function %字典的 key 用CStr(parentid)不要直接用数值。VBScript 的Dictionary在比较 key 时会做隐式转型混着0整数和0字符串存会得到两个不同的 key然后你怎么查都查不到一级菜单——这种 bug 只在你新增了某条数据之后突然冒出来特别难定位。统一转字符串一劳永逸。value 我用了逗号拼接的字符串而不是数组。原因很实际VBScript 里往Dictionary里存动态数组取出来ReDim Preserve再塞回去代码又长又容易出错而dict(key) dict(key) , i一行就完事取的时候一个Split还原。代价是字符串拼接但菜单量级下几千条以内可以忽略不计。如果你的菜单上万条那就得换成数组方案或者干脆做静态化那是另一个话题了。空数组的判断也别省。GetRows()在rs.EOF时你如果直接调用会报错所以上面GetMenuArray已经判了 EOF 返回Array()但Array()的UBound(arr, 2)是 -1 而不是报错所以GetMenuIndex里还要再判一次UBound(arr,2) 0。这两层防御少任何一层空菜单表都会给你一个 500。3.3 第三步递归渲染与当前页高亮有了数组和索引渲染就是从parentid0开始递归往下走% arr: GetRows 数组dict: parentid 索引pid: 当前父级lv: 当前层级 Sub RenderMenu(arr, dict, pid, lv) Dim ids, i, row, cls, key pair CStr(pid) If Not dict.Exists(pair) Then Exit Sub If lv 3 Then Exit Sub 熔断脏数据成环时不至于把页面撑爆 ids Split(dict(pair), ,) Response.Write ul classmenu-lv lv vbCrLf For i 0 To UBound(ids) row CLng(ids(i)) cls 用 menu_code 比对更稳这里演示用 URL 包含判断的常见写法 If InStr(LCase(Request.ServerVariables(URL)), LCase(arr(3, row))) 0 Then cls classon End If Response.Write li cls Response.Write a href arr(3, row) target arr(4, row) Response.Write arr(2, row) Response.Write /a vbCrLf RenderMenu arr, dict, arr(0, row), lv 1 递归下一层层级 1 Response.Write /li vbCrLf Next Response.Write /ul vbCrLf End Sub 调用入口只写这一行页面里所有菜单都由它输出 Dim mArr, mIdx mArr GetMenuArray() Set mIdx GetMenuIndex(mArr) RenderMenu mArr, mIdx, 0, 1 %逐块说明。lv 3 Then Exit Sub是熔断开关正常情况下三层菜单走完lv最多到 4如果数据里出现id5 的 parentid5这种自引用或者 A→B→A 的环没有这行就是无限递归IIS 工作进程直接把 CPU 拉满然后站点挂掉。这个判断不是防御性编程的客套是真的会救命的。高亮那一段InStr(...URL..., arr(3,row))是最常见的写法但它有缺陷一级菜单/product/在访问/product/hw.asp时也会命中导致父级和子级同时高亮。所以我在表里留了menu_code字段正解是每个页面顶部声明Dim PageCode : PageCode p_hw然后If arr(5,row) PageCode Then cls classon直接精确匹配没有歧义。源码如果只给了 URL 版本建议你自己按这个思路换掉改动量很小。输出classmenu-lv1/2/3是为了让 CSS 能按层级做样式比如一级横排、二三级下拉。这部分千万别在 VBScript 里拼style属性样式写在 CSS 文件里服务端只负责结构改颜色的时候你就不用去翻 .asp 了。递归里传的是arr(0, row)也就是当前节点的 id 作为下一层的 parentidlv 1同步递进。Sub递归在 VBScript 里是完全合法的不需要Call前缀但如果你想更明确写Call RenderMenu(...)也行。3.4 面包屑从当前节点往上回溯菜单是往下走面包屑是往上走正好反过来。给定当前页面 id反复查父节点直到parentid0% 从当前节点向上回溯拼出「首页 产品中心 硬件产品」 Function GetBreadcrumb(arr, dict, curId) Dim path, cur, i, row, found path cur CLng(curId) Do While cur 0 If Not dict.Exists(CStr(cur)) Then 找当前节点本身用 id 索引会更快这里演示用线性查找 End If found -1 For i 0 To UBound(arr, 2) If CLng(arr(0, i)) cur Then found i Exit For End If Next If found -1 Then Exit Do 断链直接退出别死循环 If path Then path arr(2, found) Else path a href arr(3, found) arr(2, found) /a gt; path End If cur CLng(arr(1, found)) 上移到父节点 Loop GetBreadcrumb path End Function %这段逻辑顺序要注意往path里拼的时候是往前拼因为回溯是从最深的子节点开始的拼出来才是「首页 产品中心 硬件产品」的顺序如果写成往后追加得到的就是反的。If found -1 Then Exit Do是防止脏数据。如果某条记录的parentid指向了一个已经被删除的 id或者visible0被过滤掉了回溯就断链了没有这行会一直循环下去。这在后台做过删除操作的老站点里非常常见——删父级菜单的时候忘了处理子级数据就残了。这段代码有个明显的性能问题每条记录都线性扫一遍数组三层面包屑就是 3×n 次比较。作者写成这样是因为简单但要优化很容易在建索引的时候顺手再建一个id → 行号的字典回溯时直接dictId(cur)拿行号复杂度降到 O(层级)。我在实际项目里都是两个字典一起建反正遍历一次数组能同时填两个。4. 部署跑通在 IIS 上把这份 asp源码 点起来4.1 环境三件套IIS、ASP 扩展、32 位应用池经典 ASP 在 Windows 上跑环境就三件事但每一件都能卡住人。第一步开 IIS 并启用 ASP。控制面板 → 启用或关闭 Windows 功能 → Internet 信息服务 → 万维网服务 → 应用程序开发功能勾上「ASP」。很多人只装了 IIS 主体没勾 ASP访问 .asp 文件直接 404 或者下载文件就是这个原因。装完在 IIS 管理器里能看到「ASP」图标才算成功。第二步应用池必须开 32 位。这是 Access 版本最容易踩的坑64 位 Windows 上的 IIS 默认跑 64 位应用池而Microsoft.Jet.OLEDB.4.0只有 32 位版本一连接就报「未找到提供程序。该程序可能未正确安装」错误码0x800A0E7A。解决办法是在应用池的「高级设置」里把「启用 32 位应用程序」设为True。如果不想动应用池也可以改用Microsoft.ACE.OLEDB.12.0并装 64 位 Access 数据库引擎但那个包体积不小还得注意 Office 版本冲突老项目里我更倾向于直接开 32 位。第三步目录权限。给data/目录加IIS_IUSRS的修改权限注意是修改不是读取。Access 打开 mdb 时会在同目录生成.ldb锁文件只有读权限就会报「Operation must use an updateable query」或者更隐晦的「无法打开注册表项临时全局变量」。加权限之后记得重启应用池权限缓存有时候不刷新。启动站点后先访问index.asp能出一级菜单再进news/list.asp看二级是否正常展开最后news/detail.asp验证面包屑。这三步过了说明取数、递归、路径拼接都没问题。4.2 conn.asp连接串的两种写法conn.asp是唯一需要按环境改的文件Access 和 SQL Server 的写法差别不小% Access 版本32 位应用池 Dim Conn, ConnStr ConnStr ProviderMicrosoft.Jet.OLEDB.4.0;Data Source _ Server.MapPath(/data/menu.mdb) ; SQL Server 版本二选一 ConnStr ProviderSQLOLEDB;Data Source127.0.0.1;Initial CatalogMenuDB; _ User IDsa;Password你的密码; Set Conn Server.CreateObject(ADODB.Connection) Conn.Open ConnStr 连接打开失败时给出可读提示别让用户面对一个空白的 500 页 If Err.Number 0 Then Response.Write 数据库连接失败 Err.Description Response.End End If %Server.MapPath(/data/menu.mdb)里的/是相对站点根目录不是相对当前文件。用相对路径data/menu.mdb的话admin/目录下的页面会去找admin/data/menu.mdb然后报找不到文件——这个错误信息很直白但第一次遇到还是会愣一下。Conn.Open之后判Err.Number是个实用习惯。经典 ASP 默认不显示详细错误IIS 那边一关「发送错误到浏览器」你看到的就是一片空白或者一个通用 500 页面什么线索都没有。加上这段至少能知道是连接串错了还是文件路径错了。连接用完要不要Conn.Close在页面末尾关掉是好习惯但经典 ASP 页面执行完会回收不关也不会立刻泄漏。真正需要注意的是不要在循环里反复Open/Close那样 Access 的文件句柄开销很大。一次请求一个连接全程复用这是这份源码的做法也是我推荐的。4.3 缓存与刷新Application 里存渲染好的 HTML菜单是典型的「读多写极少」数据每个页面请求都查一次库、建一次树、递归一次属于纯浪费。优化手段是把结果缓存起来这份源码用的是Application对象% 优先读缓存命中就直接输出不碰数据库 Dim menuHtml menuHtml Application(MenuHtml) If IsEmpty(menuHtml) Or Len(menuHtml ) 0 Then Dim cArr, cIdx, buf cArr GetMenuArray() Set cIdx GetMenuIndex(cArr) 用临时缓冲收集输出避免直接写进 Response 后没法存进 Application buf buf buf RenderMenuToString(cArr, cIdx, 0, 1) Application.Lock Application(MenuHtml) buf Application.UnLock menuHtml buf End If Response.Write menuHtml %这里的取舍要说清楚。理论上可以直接把GetRows()的数组塞进Application下次取出来再建索引省掉数据库查询实际项目里我更喜欢缓存最终 HTML 字符串原因有两个一是Dictionary是 COM 对象跨会话放在Application里涉及套间apartment问题多用户并发时偶发「对象不能跨线程访问」的错误二是字符串是纯粹的值怎么存都不会出事。代价是高亮和面包屑没法做进缓存。高亮的解法是把判断挪到前端输出时给每个li加>% 递归生成带缩进的下拉选项excludeId 用于编辑时排除自己和子孙 Sub RenderOptions(arr, dict, pid, lv, excludeId, curParentId) Dim ids, i, row If Not dict.Exists(CStr(pid)) Then Exit Sub If lv 10 Then Exit Sub ids Split(dict(CStr(pid)), ,) For i 0 To UBound(ids) row CLng(ids(i)) If CLng(arr(0, row)) CLng(excludeId) Then Response.Write option value arr(0, row) If CLng(arr(0, row)) CLng(curParentId) Then Response.Write selected Response.Write String(lv - 1, ) ├ arr(2, row) /option RenderOptions arr, dict, arr(0, row), lv 1, excludeId, curParentId End If Next End Sub %String(lv - 1, )是全角空格缩进├是层级符号两个一叠加下拉框里就能看出树形结构了。excludeId这个参数是关键编辑某个菜单时不能把「父级」设成它自己或它的子孙否则保存下去就是一个环直接触发第 5 章那条第 2 个坑。传当前 id 进去递归时整棵子树跳过选项里自然就没有非法候选。拖拽排序听起来要上 JS 库其实最小实现只要配合sortid就够了前端拖拽结束后把同级的 id 顺序拼成12,8,15,3提交后端按顺序写回sortid 序号×10。用 10 的倍数留出手工插入的余地以后想在中间塞一条取前后两个值的平均即可不用整体重排。这块我不建议引入重型组件老项目里 jQuery UI sortable 十几行就能搞定反倒是接一个现代框架进来要重配构建得不偿失。改完怎么验证我有一个固定套路造一棵深度 5、每层 3 个节点的测试菜单跑一遍渲染看嵌套是否闭合再把某个节点parentid手动改成它自己的 id确认页面不卡死而是被熔断截断最后把某个中间节点的visible改成 0确认面包屑不会因为断链而无限循环。这三个用例覆盖了 90% 的线上事故。这套验证流程是从一次事故之后固定下来的。当时一个后台的菜单表被运营误删了父级记录子级全部变成孤儿面包屑里的Do While没有断链判断整个站点打开就是超时重启应用池也只能撑几十秒。从那以后我每次交付带递归的菜单代码都强制走一遍「造环 / 断链 / 隐藏父级」这三个破坏性测试代码里也一定留着层级上限和found -1的判断。希望你把这套源码拿去改的时候也顺手把这两个保险装上——省下的排查时间比写代码的时间值钱多了希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站