说实话,“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 先想清楚菜单数据结构
做导航子分类之前,我建议先把这个菜单在脑海里想象成一个多级列表:
- 前端
- JavaScript
- CSS
- 后端
- Python
- PHP
- 随笔
- 生活记录
- 技术感想
这个结构对应到代码上就是一个二维数组:每个分类有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 class="menu-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 class="menu-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 class="menu"> <li><a 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 class="crumbs">首页 / ' . 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 class="menu-sub">切换display。
HTML结构如下:
<li class="has-child"> <a href="前端链接">前端</a> <button class="sub-toggle" aria-label="展开子菜单"></button> <ul class="menu-sub">...</ul> </li>JS就一小段原生代码:
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'); } });配合CSS:
@media (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; } }这么做之后,移动端的一级分类点击行为是:先跳到分类页,点击按钮则展开子分类列表。如果你的需求是“点击一级分类只在导航内展开、不进列表页”,那就要给一级分类的链接加href="javascript: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、parent,slug完全落空。有些表结构里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主题开发里遇到的分类相关问题,基本都能直接搞定。