不用急着想复杂了,其实 Typecho 的“导航子分类”核心就两件事:后台把层级建对,前端把树形结构输出出来。但很多朋友第一次接触时,总对着“父级分类”的下拉框发呆,或者在 header 里换了四五种写法,子分类就是不出来,其实问题往往不是代码,而是分类层级本身没建好。
这篇就把导航子分类从后台创建到前端输出的完整路径走一遍,包括三种实现方式的取舍、模板代码怎么写、hover 下拉怎么调、以及最容易被忽略的表单字段和路由小坑,适合刚好卡在分类导航上、想动手改主题的新手,也适合想优化导航结构的老用户做一次排查。
1. 先搞懂 Typecho 的分类数据,再谈导航
1.1 分类不是“文件夹”,是一堆带 parent 字段的记录
很多人上手 Typecho 时下意识拿 WordPress 的思维来想:分类就是文件夹,子分类就是嵌套文件夹,导航菜单相当于文件夹的快捷方式。实际上 Typecho 的分类长远简化得多——所有分类都放在 typecho_metas 这一张表里,一字排开,彼此之间靠一个 parent 字段来标记上下级关系。
你打开数据库看一眼就能明白:
| 字段 | 含义 | 典型值 |
|---|---|---|
mid |
分类的唯一 ID | 1, 2, 3 |
name |
分类显示名 | 技术笔记 |
slug |
别名,用于 URL | tech |
type |
类型 | category |
description |
描述 | 可留空 |
count |
该分类下文章数 | 12 |
order |
排序权重 | 0, 1, 2 |
parent |
父分类 ID | 0 表示顶级分类,非 0 表示挂在哪个父分类下 |
也就是说,导航里要出现子分类,前提条件是分类记录的 parent 字段指向了正确的父分类 mid。后台“管理分类”见面里那个“父级分类”下拉框,填的就是这个字段。
我见过一种非常典型的错误:后台创建了一堆分类,看起来子分类在缩进上是对的(Typecho 后台会用 — 前缀缩进显示),但导航代码里怎么判断这个分类有没有父级时,一查 parent 全是 0。为什么会这样?因为很多人是在“管理分类”界面通过“分类名称”下加缩进来误以为建立了层级,但那只是显示上缩进,实际 parent 没填对。
1.2 “导航子分类”到底意味着什么
在 Typecho 语境下,“导航”一般指两类位置:
- 站点顶部导航栏(header 里的菜单),常见形态是顶级分类一排平铺,鼠标悬浮或点击时展开下拉子分类;
- 侧边栏分类导航,常见形态是树状列表,父分类带展开箭头,点开显示下面的子分类。
这两类需求拿到的数据是同一份,但输出方式不同。顶部导航通常只需要取 parent = 0 的顶级分类作为直接显示项,再根据交互去取子分类;侧边栏则更适合把整个分类树一次性取出来,递归输出成嵌套列表。
在动手之前先想清楚你要的是哪一种,不然你会很容易出现“侧边栏能放出子分类,顶部导航死活不显示”的情况——因为它们的判断逻辑本来就应该不一样。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 后台准备:把层级关系先建对
2.1 创建父分类与子分类的标准操作
Typecho 后台路径是 “管理” → “分类”,你会看到一个“新增分类”表单,里面有“名称”“别名”“父级分类”“描述”几个字段。建父子结构的保守做法是分两步:
- 先建父分类。比如我要做一个“前端开发”的大类,名称填“前端开发”,别名建议填
frontend,父级分类保持“无”。保存后这条记录的parent是 0。 - 再建子分类。比如“JavaScript”“CSS”“工程化”,每个分类创建时,“父级分类”下拉框里直接选“前端开发”。保存后它们的
parent就等于“前端开发”的mid。
如果你在开始建站时已经创建了一堆平铺分类,后面想调整成父子结构,不需要删掉重建,直接在“管理分类”列表里点对应分类的“编辑”,把“父级分类”改成目标父级即可,文章关系会自动保留,不用动文章本身。
有个细节值得注意:Typecho 后台为了直观,子分类名称前面会显示 — 的缩进标记。这个标记不代表数据正确,只是展示用的。检查等级是否真正建立,最靠谱的途径是点击分类进入编辑页,确认“父级分类”下拉框的值。或者直接跑一段 SQL 到 phpMyAdmin 里: SELECT mid, name, parent FROM typecho_metas WHERE type = 'category' ORDER BY order,看到 parent 字段值就知道结构对不对了。
2.2 排序、计数和别名的细节
后台每个表单都是一个字段一个坑,这里挑三个最容易忽略的:
- 排序(order):Typecho 默认出来的分类列表是乱的,除非你给每条分类指定
order。导航输出时如果想控制顺序,就在后台把每个分类的“排序”数字填好,数值按从小到大排。我习惯按层级各排各的:顶级分类之间用 0、1、2、3,子分类之间也用 0、1、2、3,这样模板里按order字段排序时以为顺序正确。 - 别名(slug):别名会体现在 URL 上,比如
https://你的域名/category/frontend/。创建后尽量别改,因为改别名可能影响旧链接的收录和分享。如果你要造一个比较复杂的层级 URL,Typecho 默认并不支持parent/sub/这种嵌套路径,子分类的访问路径固定是/category/子分类别名/,这是平台特性,不要为这个去折腾伪静态规则,除非你有充分理由。 - 描述(description):子分类通常会继承父分类的个性化设置,但 Typecho 的分类描述只在个别模板里展示,不影响导航结构。就算你不填写描述,也不影响分类显示,所以追求整洁的话可以直接空着。
建好分类后别忘了到“设置” → “永久链接”里确认分类路径规则已经保存。不然前端输出的分类链接可能是带参数的长链接,虽然导航本身能用,但看着心虚,后期也不好优化。
3. 前端实现:三种把子分类挂上导航的方式
3.1 路线一:模板函数遍历法(最通用,推荐先掌握)
Typecho 官方内容封装里给我们留了一个很方便的查询入口:Widget_Metas_Category_List。在主题模板里通过 $this->widget() 调用它,就可以拿到全部分类数据。
典型写法如下:
php复制<?php $this->widget('Widget_Metas_Category_List')->to($categories); ?>
<?php if ($categories->have()): ?>
<ul>
<?php while ($categories->next()): ?>
<li>
<a href="<?php $categories->permalink(); ?>"><?php $categories->name(); ?></a>
</li>
<?php endwhile; ?>
</ul>
<?php endif; ?>
这是平铺输出。如果想做子分类导航,需要先分出父级和子级:
php复制<?php
$this->widget('Widget_Metas_Category_List')->to($categories);
$parents = array();
$children = array();
while ($categories->next()) {
$mid = $categories->mid;
$parent = $categories->parent;
$item = array(
'mid' => $mid,
'name' => $categories->name,
'slug' => $categories->slug,
'permalink' => $categories->permalink,
'count' => $categories->count,
'order' => $categories->order
);
if ($parent == 0) {
$parents[$mid] = $item;
$parents[$mid]['children'] = array();
} else {
$children[$parent][] = $item;
}
}
foreach ($children as $pid => $items) {
if (isset($parents[$pid])) {
$parents[$pid]['children'] = $items;
}
}
?>
这个数组结构做出来后,顶部导航的输出就顺理成章了。外层循环输出一级分类,内层循环输出子分类:
php复制<nav class="site-nav">
<ul class="nav-list">
<?php $countParents = count($parents); ?>
<?php foreach ($parents as $parent): ?>
<li class="has-child <?php echo !empty($parent['children']) ? 'dropdown' : ''; ?>">
<a href="<?php echo $parent['permalink']; ?>"><?php echo $parent['name']; ?></a>
<?php if (!empty($parent['children'])): ?>
<ul class="sub-nav">
<?php foreach ($parent['children'] as $child): ?>
<li><a href="<?php echo $child['permalink']; ?>"><?php echo $child['name']; ?></a></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</nav>
为什么说这种方法通用?因为不管你的主题是自己写的还是改的第三方主题,只要找到 header 里的导航模板区域,把这块代码放进去就能用,不依赖任何插件。唯一的软性门槛是你得能看懂 PHP 的数组和循环,不算大事。
3.2 路线二:后台菜单插件法(适合想做可视化管理的)
Typecho 默认没有 WordPress 那种“菜单管理器”,但这不是死局,插件市场里有若干支持“导航菜单”的插件,它们的底层原理其实也是把菜单项存到类型为 menu 的 metas 表里,前端再读出来输出。用这类插件的好处是可以完全脱离 PHP 代码,在后台往菜单里加分类、页面、自定义链接,拖拽排序后生成导航。
但我的建议是:除非你经常需要让“非技术人员”去后台维护导航,否则不必为此引入插件。原因有三个:
- 插件本身需要适配当前主题,有些插件输出的是固定 HTML 结构,你想改样式得顺着插件的钩子走,反而受约束。
- 多一个插件就多一份维护和升级成本,尤其 Typecho 更新节奏不快,插件长期不更新的可能性是有的。
- 大部分站点的导航结构是稳定的,改起来不频繁,模板函数改一次一劳永逸。
如果你确实要用插件,选型时重点看三点:是否支持多级分类、是否支持输出自定义链接、对当前 Typecho 版本的兼容性是否有人维护。装好后在后台对照插件说明新建菜单,把“分类”对应的子分类拉进去,再要求主题调用插件提供的菜单输出函数。
我不在文章里点名推荐某个具体插件,一是这类项目迭代快,今天好用的明天未必有人维护;二是插件好不好用很大程度取决于你的具体主题结构,自己装一个试五个分钟比看我推荐更靠谱。
3.3 路线三:侧边栏树状导航法(适合“栏目页”型站点)
有一些站点,比如资源下载站、教程站,侧边栏本身就是导航主力。这种需求下,把整个分类树一次性渲染成嵌套列表,比“顶级分类 + 下拉”更直观。
用相同的数据准备逻辑,改一下输出层即可:
php复制<?php
function renderCategoryTree($items, $depth = 0) {
if (empty($items) || $depth > 3) {
return;
}
echo '<ul class="category-tree">';
foreach ($items as $item) {
echo '<li>';
echo '<a href="' . $item['permalink'] . '">' . $item['name'] . '</a>';
if (!empty($item['children'])) {
renderCategoryTree($item['children'], $depth + 1);
}
echo '</li>';
}
echo '</ul>';
}
renderCategoryTree($parents);
?>
这个函数里用了递归,深度限制在 3 是为了防一手误操作造成无限层级。Typecho 官方支持几级分类没有硬性限制,但站点逻辑上超过三级基本就乱套了,所以加个深度保险是值得的。
侧边栏里放递归树就没必要给每个父分类都加 hover 下拉了,更常见的是再做一层折叠交互,父分类后面加一个展开按钮,点击后显示子分类。这个交互实现放在第 3.4 节讲。
3.4 下拉与折叠的交互处理
HTML 结构只是骨架,视觉上的下拉和折叠是另一环。这里分享我实测下来比较稳妥的一种方案,不依赖 jQuery,原生 JS 就能搞定。
顶部导航的 hover 下拉做法有很多,本地思路是:父级 li 加 position: relative;,子列表 .sub-nav 加 position: absolute;、opacity: 0、visibility: hidden,鼠标悬停到父级 li 时切换为 opacity: 1、visibility: visible。纯 CSS 实现其实就够了:
css复制.nav-list > li { position: relative; }
.dropdown .sub-nav {
position: absolute;
top: 100%;
left: 0;
min-width: 140px;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
background: #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.dropdown:hover .sub-nav {
opacity: 1;
visibility: visible;
}
如果是触屏设备,hover 不太灵,那就用点击切换,给父级加一个 toggle 事件:
javascript复制document.querySelectorAll('.dropdown > a').forEach(function (link) {
link.addEventListener('click', function (e) {
if (window.innerWidth <= 768) {
e.preventDefault();
this.parentElement.classList.toggle('open');
}
});
});
对应 CSS 要加上 .dropdown.open .sub-nav { opacity: 1; visibility: visible; } 作为移动端覆盖样式。
侧边栏的折叠更简单,li 里的子 ul 默认 display: none,点击父分类后的按钮切换 display: block。注意别误把父分类链接本身的跳转也拦截掉,一般做法是展开按钮独立一个元素,父分类标题保持可点击跳转。
4. 实操中绕不开的几道坎
4.1 子分类全部取不到,问题多半不在代码
我调试过不少主题代码,见到最高频的故障就是“子分类在导航里始终不出来”。排查路径不是一上来改模板,而是先回后台确认两件事:
- 后台“管理分类”页面里,子分类的“父级分类”下拉框是不是真的选到了对应父分类。如果没有,存进去的
parent就是 0,前端无论如何都判断不出来。 - 检查一下
typecho_metas表里type字段是不是category。有些插件或者手动 SQL 操作可能把 type 写成别的值,导致分类 widget 直接不返回它。
有一个比较隐蔽的坑:如果你在后台修改过分类的“父级分类”后,Typecho 后台列表的缩进显示有时会有缓存,看起来位置没变,其实数据已经改了。遇到这种显示不一致的情况,直接刷新页面或清掉浏览器缓存再看,以编辑页下拉框的值为准。
如果上面都没问题,模板里也有输出代码,但还是空白,那就要检查 widget 调用方式了。一个常见的误用是 $this->widget('Widget_Metas_Category_List') 这个写在 header.php 时,当前页面的请求对象并不是全局 $this,导致拿不到数据。写成独立的 Typecho_Widget::widget('Widget_Metas_Category_List') 再 to 到一个变量,通常能解决。
php复制<?php
$categories = Typecho_Widget::widget('Widget_Metas_Category_List');
$categories->to($categories);
?>
注意上面变量的变量名可以换成 $catList 之类的,避免和 $this 混淆。
4.2 后台“父级分类”下拉框只有一层,怎么选三级分类
Typecho 后台的“父级分类”下拉框默认只会列出直接父级,不会无限递归地缩进展示所有层级。也就是说,如果你想建“前端” > “框架” > “Vue”这样的三层结构,在给“Vue”选父级时,下拉框里能看到“前端”和“框架”,选“框架”即可。
但如果你发现下拉框里根本没有子分类选项,那是因为 Typecho 后台默认下拉框仅展示当前层级下的直接子级,而不是全量树。其实它展示的是全量列表,只不过用缩进表示层级。如果你有一些分类始终没在下拉框里出现,去数据库检查一下这些分类的 parent 是否已经指向别处,或者看它们是不是被某个插件过滤掉了。
如果你觉得后台下拉框看不清楚,还有一个土办法:先建平铺分类,全部保存完成后,再逐个编辑分类修改父级。虽然操作次数多,但每个编辑页的下拉框都是全量列表,层级关系一目了然,不容易选错。
4.3 导航下拉被其他元素遮挡
position: absolute 的下拉菜单被后面的轮播图、内容区遮住,这是经典的层级问题。解决办法是给下拉菜单所在容器加更高的 z-index,不是给菜单项加,而是给它相对定位的那个父容器加。
css复制.site-nav { position: relative; z-index: 999; }
.sub-nav { z-index: 1000; }
如果父容器没设 position: relative 以外的定位方式,z-index 可能不生效,检查一下父容器有没有 position: relative 即可。
还有一种容易被忽略的情况:父容器设置了 overflow: hidden,子菜单一旦超过父容器边界,就被裁掉了。header 里为了撑满宽度一般在子项处设置宽度,但如果你在 header 的某一层套了 overflow: hidden,下拉菜单超出 header 高度后就会被隐藏。这种情况排查时优先看 header 及中间层级的 CSS。
4.4 分类链接 404 或者指向错误
分类导航能用,但点进子分类后 404,十有八九是永久链接和伪静态配置的问题。Typecho 的 “设置 → 永久链接” 里有分类路径的自定义选项,例如 category 或 archives,改过之后记得同步更新服务器上的伪静态规则。
如果你的站点程序放在子目录,或者套了二级域名,分类链接还可能出现多重前缀。导航里输出的是平台生成的永久链接,不要手动拼接字符串去猜 URL,用 $categories->permalink 输出,它会自动带上站点地址和路径规则。
遇到 404 时,先访问一次 /index.php?category=分类别名 这样的入口地址,如果这种带参数能访问,那就是伪静态没生效;如果带参数也 404,那就是分类别名或者数据本身有问题。
4.5 子分类文章数显示不正确
有些模板在分类导航旁边会展示文章数,比如“前端开发(23)”。这个 count 是平台在登记文章时实时统计的,发布、删除文章都会自动更新,一般不会错。但如果你手动改过数据库里的 count 字段,或者用了一些批量导入文章的脚本,可能出现与实际不符的情况。
解决办法是后台“管理 → 分类”页面重新保存一次每个分类,让平台重新统计。批量处理的话,直接去数据库执行:
sql复制UPDATE typecho_metas SET count = (SELECT COUNT(*) FROM typecho_contents WHERE type = 'post' AND category = typecho_metas.mid) WHERE type = 'category';
这句 SQL 的前提是文章表使用的分类字段是你的中间表结构,Typecho 默认文章表并没有直接的 category 字段,而是存在关系表 typecho_relationships 里,所以上面语句只是示意,实际要联表统计:
sql复制UPDATE typecho_metas
SET count = (
SELECT COUNT(*) FROM typecho_relationships
WHERE typecho_relationships.mid = typecho_metas.mid
)
WHERE type = 'category';
建议实际操作前先备份表,别在正式环境裸跑。
5. 进阶:导航里加“当前分类高亮”和面包屑
5.1 当前分类高亮判断
导航里有一个很提升体验的小细节:用户当前浏览的是哪个分类,导航里对应项就高亮出来。实现的思路是拿到当前请求对应的分类 mid,和导航数组里的 mid 做比较。
在分类页模板里,当前分类可以这样取:
php复制<?php
$currentMid = 0;
if ($this->is('category')) {
$currentMid = $this->get('categoryId');
if (!$currentMid) {
$currentMid = $this->category;
}
}
?>
拿到 currentMid 后,在循环输出导航时比对:
php复制<?php foreach ($parents as $parent): ?>
<?php if ($parent['mid'] == $currentMid): ?>active<?php endif; ?>
<?php endforeach; ?>
更进阶一点,当用户正在浏览某个子分类页面时,父分类也应该高亮,这时判断条件就得改成“当前分类的 parent 等于父分类的 mid”或者在子分类数组中匹配到了 currentMid。这个逻辑虽然不复杂,但写的时候容易忽略,建议在本地多测两个层级再去上线。
5.2 面包屑导航:子分类页面里带上父链路
子分类页面的面包屑也是导航的一部分。Typecho 在分类页模板里可以用 $this->category 拿到当前分类对象,但默认字段里不带父级分类信息。想显示“首页 / 前端开发 / JavaScript / 当前文章”这种链路,就得自己往父级追。
可以封装一个小函数,根据当前分类的 parent 不断向上查:
php复制<?php
function getCategoryChain($mid) {
$chain = array();
$db = Typecho_Db::get();
while ($mid > 0) {
$row = $db->fetchRow($db->select()->from('table.metas')
->where('mid = ? AND type = ?', $mid, 'category'));
if (!$row) {
break;
}
$chain[] = array(
'mid' => $row['mid'],
'name' => $row['name'],
'slug' => $row['slug']
);
$mid = $row['parent'];
}
return array_reverse($chain);
}
?>
这个函数只在分类页里调用,性能上没问题。最关键的点是查询时加了 type = 'category' 条件,避免误查标签或者其他 meta 类型,不然 parent 字段的值指向错误对象时,很容易循环出莫名其妙的结果。
6. 一些我踩过才懂的经验
第一,分类结构的规划永远比模板代码更重要。你代码写得再漂亮,如果后台层级是乱的,导航出来也是乱的。我见过最多的情况是子分类东一个西一个,parent 指向混乱,后面要花大量时间去清理。建站初期先把分类体系在纸上列一遍,想清楚站点的信息架构,再动手在后台建,能省下后面几个月的心力。
第二,不要为了“看起来层级深”去硬建分类。有些内容明明只有 5 篇,也要挂个二级分类,结果导航里全是下拉菜单,用户根本点不过来。信息架构上有个通俗的原则:一级分类控制在 5 到 7 个以内,二级分类尽量少而精,超过两层的导航就该考虑是不是分类方式本身有问题。
第三,给导航的每个链接都留一个明确的点击目标。很多模板里父分类和子分类长得一样,用户分不清哪个能点、点了去哪。建议父分类链接如果本身没有独立文章,可以做成下拉触发按钮,或者明确让它跳转到分类列表页;子分类则老老实实链接到分类页。设计上可以用颜色、加粗、箭头图标等区分,总之别让用户迷路。
第四,改动主题模板前,一定先备份原文件,并记录改动了哪个文件。不同主题的导航代码位置不一样,有的在 header.php,有的在 menu.php,还有的写在 functions.php 里通过钩子输出。修改后如果结构乱了,能立刻恢复原状,避免线上站点长时间处于异常状态。
在正式部署到多站点或者生产环境前,我一般的顺序是:后台建好分类 → 本地模板改完 → 在一台测试环境确认父子层级、下拉交互、高亮都正常 → 再推到正式环境。Typecho 本身轻量,能在半小时内完成全流程验证。还是那句话,先把数据结构想明白,再动手折腾代码,一切都会顺很多。
