ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Typecho导航子分类实现:递归构建分类树与高亮面包屑全攻略

Typecho导航子分类实现:递归构建分类树与高亮面包屑全攻略 说实话“Typecho 导航子分类”这个问题我在好几个主题群里都被人问过。大部分人是想做一个类似电商或门户站点的导航栏顶部是“前端”“后端”“随笔”这类大分类鼠标移过去之后会展开“JavaScript”“PHP”“生活记录”这些小分类。但Typecho默认的导航输出非常简陋——要么你手动在导航栏写死几个链接要么就直接拿全部分类平铺出来根本没有“父子菜单”的概念。这篇文章我就把我在实际博客里反复调整过多次、最终稳定运行的方案完整写出来。1. Typecho为什么默认就做不出“导航子分类”1.1 路由层面的现实分类页URL没有层级要理解这个问题先要承认一个事实Typecho的分类体系本质上是一张“带parent字段的平面表”并不是真正意义上的树形结构。你在后台“分类管理”里给某个分类设置了“上级分类”它做的只是往metas表里写入一个parent值。真正到前台生成链接时一级分类和二级分类没有任何区别都是用同一个路由规则/category/[slug]/比如“前端”是顶级分类slug是frontend“前端”下面有个“JavaScript”子分类slug是js。那么它们的链接分别是/category/frontend//category/js/也就是说子分类的URL不会变成 /category/frontend/js/。这一点和WordPress的父子分类URL层级结构完全不同。很多第一次用Typecho的人会习惯性以为“设置了子分类URL自然就有层级”实际是没有的所以导航子分类的需求才需要我们自己动手在主题层解决。搞清楚这个底层机制之后思路就清晰了——我们要做的事情不是“改路由”而是在导航输出时把分类的父子关系体现成HTML结构里的嵌套列表。1.2 导航输出完全是主题的事Typecho之所以不给你默认的二级菜单是因为它不像某些商业CMS那样把“导航”当成一个内置功能来管理。Typecho的主题结构里导航栏通常就是header.php里那段代码输出逻辑完全由主题作者决定。我早期用过的几个Typecho主题header部分大概长这样nav ?php $this-widget(Widget_Metas_Category_List)-to($categories); ? ?php while ($categories-next()) : ? a href?php $categories-permalink(); ??php $categories-name(); ?/a ?php endwhile; ? /nav这段代码的作用是取出所有分类然后一个循环全部平铺输出。结果是你后台把“JavaScript”挂在“前端”下面导航栏依旧会把“前端”“JavaScript”“后端”全部并排显示层级关系完全消失。所以想让导航子分类出现必须自己写递归逻辑。这既是麻烦的地方也是Typecho灵活的地方——不依赖插件直接改主题就能达到任意想要的效果。1.3 先想清楚到底用哪种方案在动手之前先判断一下你自己的需求规模。据我观察想实现“导航子分类”的人大致有三类对应三种不同做法需求类型特点推荐方案栏目固定很少增删站点就几个大栏目子分类也基本不变直接在header里写死嵌套HTML最省事分类数量适中需要后台维护博客文章多分类会不断调整用PHP递归生成分类树推荐需要可视化拖拽排序对菜单顺序、外部链接有极高要求装导航菜单插件我自己最终选择的是“PHP递归生成分类树”。原因很简单分类是活的今天加一个“AI工具”明天把“Python”挪到“后端”下面如果写死HTML每次都要改模板文件。而自动递归方案只依赖分类表本身后台调好父子关系导航自动跟着变。另外如果你纯粹想做个带下拉菜单的导航不关心后台分类结构那也可以直接在导航里输出“自定义链接”“分类链接”的混排菜单。这种方案一般需要借助插件或者主题自带的导航管理功能不过可定制性反而不如自己写来得直接。2. 一次查询构建分类树导航菜单的递归输出2.1 先想清楚菜单数据结构做导航子分类之前我建议先把这个菜单在脑海里想象成一个多级列表前端JavaScriptCSS后端PythonPHP随笔生活记录技术感想这个结构对应到代码上就是一个二维数组每个分类有id、parent、name、slug、链接。我们要做的事情就是先把所有分类按照parent分组然后从顶层开始遍历每遇到一个有子分类的节点就继续递归往下走。这种做法的好处是分类表只需要查询一次剩下的都是PHP数组操作不会因为循环多层而反复访问数据库。2.2 从Widget_Metas_Category_List拉取全部分类Typecho里拉取全部分类最常用的方式是在模板中调用Widget_Metas_Category_List。我实测过这个widget默认会遍历出所有分类并且每个分类对象里包含mid、name、slug、parent、count这些关键字段。在functions.php或者模板直接就有的示例写法如下?php // 拉取所有分类 $categories $this-widget(Widget_Metas_Category_List)-stack; // 按parent分组 $tree array(); foreach ($categories as $cat) { $tree[$cat[parent]][] $cat; } // 递归输出函数 function tce_menu_category($tree, $parentId 0, $depth 0) { if (!isset($tree[$parentId])) { return; } echo $depth 0 ? ul classmenu-sub : ; foreach ($tree[$parentId] as $cat) { $hasChild !empty($tree[$cat[mid]]); echo li class . ($hasChild ? has-child : ) . ; echo a href . tce_category_url($cat) . . $cat[name] . /a; if ($hasChild) { echo ul classmenu-sub; tce_menu_category($tree, $cat[mid], $depth 1); echo /ul; } echo /li; } echo $depth 0 ? /ul : ; }这段代码的核心逻辑就是先用$tree[$parentId][] $cat把所有分类按上级分类分组然后从parent 0开始递归。每个分类如果有子分类就在自己的li里再嵌入一个ul。有一点要提醒你$this-widget(Widget_Metas_Category_List)-stack拿到的数组每个元素是分类对象但对象的属性可以用数组下标方式访问也可以用箭头方式访问。上面代码里我用的是数组下标这两种方式都可以跑通关键看你熟悉的写法。2.3 header模板中的输出位置与输出开关有了递归函数之后header.php里的改动就很简单了。原来平铺输出的代码可以直接替换为nav ?php $categories $this-widget(Widget_Metas_Category_List)-stack; $tree array(); foreach ($categories as $cat) { $tree[$cat[parent]][] $cat; } ? ul classmenu lia href?php $this-options-siteUrl(); ?首页/a/li ?php tce_menu_category($tree, 0); ? /ul /nav注意一点Widget_Metas_Category_List默认会输出“所有分类”包括那些你想在导航里隐藏的分类。比如你建了一个“友链”分类专门放友情链接文章并不想让它在导航里出现。对于这种情况我比较推荐在递归函数里加一个过滤参数比如按分类的slug排除或者直接约定只有设置了某个自定义字段的分类才进导航。如果不想引入自定义字段那么复杂简单一点的办法是在后台把所有隐藏分类的slug都列成一个数组递归的时候跳过$hidden array(friend-links, about-me);这样等于给导航保留了一个“黑名单”控制口后面想加隐藏分类往数组里加值即可。2.4 关于分类链接生成的一个安全函数分类链接的问题很容易被忽略。多数情况下分类的slug都是正常字符串直接用$cat[slug]拼链接没问题。但如果你是从旧系统、旧数据库导入的文章和分类或者早期分类slug留空过那么slug字段可能为空这时候生成的URL就会变成 /category/ 这样的无效链接。为了避免这种翻车我通常会在主题的functions.php里准备一个“安全生成分类链接”的函数function tce_category_url($cat) { if (!empty($cat[slug])) { return Typecho_Router::url(default, array(category $cat[slug])); } // slug为空时退回用mid保证页面至少能打开 return Typecho_Router::url(default, array(category $cat[mid])); }这种函数在主题内很多位置都能复用比如下面的面包屑、文章页的分类高亮。写一次省得以后到处复制粘贴又改不齐。3. 联动起来当前高亮与面包屑不能只做一半3.1 当前是哪一个分类的判断逻辑导航做出来了下一个问题接踵而至用户浏览某个子分类页面时导航里应该高亮对应的菜单。就拿我的站点举例用户进入“/category/js/”时导航里“前端”和“JavaScript”应该同时处于高亮状态这样用户才知道自己现在站在哪个栏目里。Typecho没有直接给你“当前分类id”这样一个全局变量但我们可以从请求参数里拿到分类slug。分类页的URL是 /category/slug/所以主题可以通过$this-request-get(category)来获取当前分类slug$currentSlug $this-request-get(category);但是等等——这个参数只有在分类列表页存在文章页、首页、独立页面拿到的是null。所以如果你要在文章页里高亮所属分类逻辑就更复杂一些需要从当前文章的分类数组里取出一个slug再去和导航比对。为了统一我一般在header.php里封装了这样一个判断逻辑// 分类列表页 if ($this-is(category)) { $currentSlug $this-request-get(category); } // 文章页取第一个分类作为当前栏目 if ($this-is(post) !empty($this-category)) { $currentSlug $this-category[slug]; }$this-category在Typecho的文章页里可用返回的是当前文章所属的第一个分类的信息数组。如果你的文章属于多个分类Typecho支持多分类取第一个作为高亮是实践中最常见的做法。拿到当前分类slug之后递归函数里就可以判断某个分类是否命中echo li class . ($cat[slug] $currentSlug ? active : ) . ;同时为了让父级菜单也高亮还需要再加一层判断当前分类的parent链上有没有当前这个导航项。这个放到下面讲。3.2 递归找出父级菜单并一并点亮先亮出结论想让父级菜单也高亮最直接的办法是构建一个“当前分类的祖先id数组”。比如当前分类“JavaScript”的mid是10它的parent是5“前端”的mid是5。那我就要知道当前分类的祖先中包含了5于是“前端”这个导航项也被标记为active。这个逻辑可以用一层循环来实现不需要递归$ancestorIds array(); $tempMid $currentMid; // 当前分类的mid while (!empty($currentCats[$tempMid])) { $parentId $currentCats[$tempMid][parent]; if ($parentId 0) { break; } $ancestorIds[] $parentId; $tempMid $parentId; }这里的$currentCats是“所有分类按mid索引”的数组方便根据当前id快速查到parent。有了$ancestorIds之后导航里的高亮判断就变成$isActive ($cat[mid] $currentMid) || in_array($cat[mid], $ancestorIds);这样做出来交互效果就是进入子分类页时父级菜单和子级菜单都有高亮背景。很多主题只高亮当前项父级“灰着”通常是因为没有做这一步祖先链查找。3.3 面包屑与SEO输出建议导航高亮做好之后还可以顺手把分类页和文章页的面包屑也联上。面包屑的核心价值有两个一是用户路径指引二是搜索引擎可以在页面里多拿到一组内部链接。Typecho分类页的面包屑生成逻辑可以写成这样?php $crumbs array(); $tempCat $currentCategoryInfo; // 当前分类数组 $tempMid $currentCategoryInfo[mid]; while ($tempMid 0) { $cat $categoriesByMid[$tempMid]; $crumbs[] a href . tce_category_url($cat) . . $cat[name] . /a; $tempMid $cat[parent]; } $crumbs array_reverse($crumbs); echo nav classcrumbs首页 / . implode( / , $crumbs) . /nav;在文章页里一般建议先输出文章所属的第一个分类作为面包屑的“栏目”层级再拼上文章标题。SEO方面我有一个实际经验导航子分类递归输出之后分类树的全部分类链接都会出现在每个页面的导航里这天然增加了内链数量对收录是有帮助的。但要注意如果网站分类数量非常多比如超过100个导航HTML会变大此时建议把导航改造成“只展示一级分类鼠标悬停加载子分类”的方式避免所有页面重复输出庞大的分类列表。4. 外观适配PC端下拉与移动端收纳4.1 一个纯CSS就够用的下拉菜单导航子分类的HTML结构一旦嵌套好了剩下的就是让子菜单看起来像“下拉菜单”。如果你不需要动画纯CSS就可以搞定。我给自己的一个后台管理后台主题写的样式大概是这样.menu { list-style: none; display: flex; gap: 20px; position: relative; } .menu li { position: relative; } .menu .menu-sub { display: none; position: absolute; top: 100%; left: 0; min-width: 160px; background: #fff; border: 1px solid #ddd; padding: 8px 0; z-index: 99; } .menu li:hover .menu-sub, .menu li:focus-within .menu-sub { display: block; }这里有个细节我踩过坑子菜单的left: 0很重要。如果父级菜单在页面的最右侧子菜单展开时容易超出屏幕出现横向滚动条。我后来是这样处理的如果是最后一个父级菜单给它的子菜单加一个right: 0让子菜单右对齐。如果你希望子菜单的层级从第二级开始水平往右展开而不只是垂直下拉那么可以进一步调整.menu .menu-sub .menu-sub { left: 100%; top: 0; }这个样式的意思是第三级菜单出现在第二级菜单的右侧。配合上一个小技巧整个多级分类导航在桌面上就能无缝铺开。4.2 移动端手风琴菜单的少量JS移动端是另一个战场。手机屏幕上没有“悬停”这个概念点击一级分类时如果直接展开下拉用户会非常迷惑。我在自己的主题里选择了最稳妥的方案“点击一级菜单标题展开子分类”。实现思路很简单在递归输出时给一级菜单里有子分类的li加一个button作为展开按钮点击时让相邻的ul classmenu-sub切换display。HTML结构如下li classhas-child a href前端链接前端/a button classsub-toggle aria-label展开子菜单/button ul classmenu-sub.../ul /liJS就一小段原生代码document.addEventListener(click, function (event) { var toggle event.target.closest(.sub-toggle); if (!toggle) { return; } var subMenu toggle.nextElementSibling; if (subMenu subMenu.classList.contains(menu-sub)) { subMenu.classList.toggle(open); toggle.classList.toggle(expanded); } });配合CSSmedia (max-width: 768px) { .menu .menu-sub { display: none; position: static; } .menu .menu-sub.open { display: block; } .menu li.has-child .sub-toggle { display: inline-block; margin-left: 6px; } }这么做之后移动端的一级分类点击行为是先跳到分类页点击按钮则展开子分类列表。如果你的需求是“点击一级分类只在导航内展开、不进列表页”那就要给一级分类的链接加hrefjavascript:void(0)或者用JS阻止默认跳转。不过我个人不推荐这样做——导航的一级分类还是应该保持可点击以免用户想直接进入整个栏目首页时找不到入口。4.3 几个能提升体验的细节细节一子菜单加序号索引。如果某个父分类下面的子分类很多可以在父分类后面显示数量比如“前端 (12)”。不过Typecho的分类对象里自带count字段直接用就行不需要额外查询。细节二给每个子菜单的标题加上小箭头。CSS用::after加一个▶或者旋转45度的伪元素角标都行这个纯粹是视觉反馈告诉大家这个菜单还可以继续展开。细节三键盘可达性。上面CSS里我已经写了focus-within这比单纯依赖hover要友好至少键盘用户用Tab键聚焦到子菜单里的链接时子菜单不会意外消失。如果想让体验更进一步可以给子菜单容器加tabindex-1并监听焦点事件。5. 上线之后容易翻车的几个点5.1 分类slug为空或重复带来的链接问题前面我提过一次slug为空的情况这里再详细展开。我处理过一个朋友的Typecho站点他的数据是从别的程序迁移过来的导入之后大量分类没有slug字段后台显示分类名旁边没有链接别名。结果我递归函数生成导航链接时全部指向 /category/点击进去直接404。当时排查了一下午最后发现是导入工具只导了mid、name、parentslug完全落空。有些表结构里slug字段是NULL我直接在循环里拼接字符串把NULL拼成了空字符串。这个坑推荐大家提前预防递归输出前先检查分类是否有slug没有slug就改用mid生成链接顺手在后台把所有缺少slug的分类都补上正式的拼音别名。另外Typecho后台并不强制分类slug唯一但如果你有两个分类用了相同slug导航里会生成两个一模一样的链接点击进去则显示其中一个分类的列表。所以迁移数据之后务必检查一遍分类slug是否重复。5.2 无限递归与层数失控递归菜单最大的隐忧就是数据异常导致无限递归或者层级过深导致导航HTML臃肿到无法直视。正常情况下你在后台选“上级分类”时只能选择“顶级分类”不能把子分类挂到更深的层级所以Typecho后台最多只能创建两层分类。但不要抱侥幸心理因为数据库一旦被导入过、插件修改过数据可能不干净。我给自己写的递归函数加了一个层数保护if ($depth 3) { return; }这样即使数据异常也只会输出三层菜单而不是无限嵌套避免页面卡死或者HTML结构烂得无可挽回。在实际使用中三层对绝大多数博客来说已经是视觉极限了。5.3 性能观察一次查询与页面缓存导航递归方案性能问题的关键点在于“全部分类”到底被查询了几次。我见过有些主题作者写出这样的代码在循环里调用$this-widget(Widget_Metas_Category_List)导致每个分类都触发一次数据库查询。分类数量少还好一旦到几十个页面加载会明显变慢尤其在没有开启任何缓存插件的Typecho默认状态下。我的做法是把-stack提前取出构建好数组之后再递归。这样整个导航只执行一次分类查询性能开销可以忽略。如果你用了Typecho的缓存插件还可以把递归输出的结果完全缓存下来比如把渲染出来的菜单HTML用Transient机制存一小时分类变动时清缓存重建。5.4 伪静态规则与分类页设置最后提醒一个和导航子分类没有直接关系、但经常一起出现的坑。Typecho默认的分类路由是 /category/slug/ 这种形式。如果你在后台把“分类页面”的地址格式改成了自定义格式那么使用Typecho_Router::url(default, array(category $slug))依旧能正确生成链接因为Typecho会按照当前站点的路由规则去生成不会硬编码。但如果你的服务器开启了伪静态rewrite规则并且规则是手工维护的新增了自定义路由格式时伪静态规则也要同步调整否则生成出来的地址能拼出来访问却会404。这个属于环境配置问题和代码无关但排查起来很费时间建议上线前把分类链接逐一点一遍。5.5 导航高亮在文章页偶尔失灵如果你把文章页的分类高亮也用上了可能会发现某些文章页导航没有任何高亮。原因是文章可能属于“未分类”或者没有明确的第一分类。Typecho的文章如果没设置分类$this-category就是空的取slug自然失败。对这种文章我习惯的做法是文章页里如果拿不到分类就让导航不做高亮或者在文章页底部输出一个“该文章未分类”的提示。千万不要强行指定某个默认分类去高亮那样会让导航状态失真。另外一个很隐蔽的问题Typecho文章页的$this-category取到的分类顺序是后台“分类管理”里的顺序不是你文章内容里手动选的分类顺序。比如你写文章时勾选了“Python”和“工作日志”想强调文章属于“工作日志”但系统取到的第一个分类可能是“Python”。如果你发现文章页高亮的总是不太对就是这个原因。文章分类顺序要完全按自己意图控制需要额外查文章与分类的关联表再排序这个在导航层面一般不值得纠结取第一个能用就行。结束语一个小小的延伸本来文章到这就该结束了但最后我忍不住再分享一个我后面加上的小改动算是对导航子分类方案的一个自然延伸我的博客里除了分类导航还有首页文章流。最初文章列表只显示正文片段后来我发现如果列表里也同步显示文章所属的父子分类链读者对内容的归属感会强很多。我就在文章列表模板里加了一个小函数读取当前文章的分类找到这个分类的父级链然后输出类似“前端 / JavaScript / 本文”这样的一段路径。这段逻辑和导航递归共用了同一个“祖先链查找”思路代码量很小但效果非常明显。如果你也已经把导航子分类做完了我建议可以顺着这个思路继续把“文章详情页的面包屑”和“列表页的分类筛选”一起做了。这些功能的核心都是同一件事把Typecho那张平面的分类表还原成真正分层级的分类树。把这一套吃透你在Typecho主题开发里遇到的分类相关问题基本都能直接搞定。
返回列表