分类与标签合并页:从两个空页面到一个内容索引
博客的「分类」和「标签」两个页面,一直是两块没人管的空地。
分类页打开是这样:
目前共计 9 个分类
- 技术分享 15
- 算法竞赛 6
标签页是 NexT 原生的 tagcloud,59 个标签,字号全都差不多,挤成一坨。
问题不只是「空」。更根本的是这两个页面的内容高度重叠——分类是树状的,标签是网状的,但它们指向的其实是同一批 21 篇文章。摆两个菜单项让人二选一,是把同一个问题拆成了两半。
这篇把两页合并成一个内容索引,顺手记录几个踩坑。
一、合并后的结构
新的 /topics/ 页按四段组织,每段回答一个不同的问题:
| 分段 | 回答的问题 | 设计要点 |
|---|---|---|
| 概览横幅 | 这站有多少东西? | 21 篇 / 9 分类 / 59 标签 / 32 天跨度 |
| 分类树 | 这站聊什么方向? | 树状卡片,子分类缩进 + 层级竖线 |
| 标签云 | 哪些话题被反复提? | 5 档字号,按文章数分级 |
| 最近更新 | 从哪篇看起? | 日期 + 标题 + 分类胶囊 |
分类和标签的分工在文案里讲清楚了:「分类看主题脉络(树状、有层级),标签看交叉话题(网状、扁平)」。这样读者用哪种方式找文章都是通的,不用先理解 Hexo 的数据模型。
二、两个页面的 URL 怎么办
合并页面最大的隐患是旧 URL 404。/categories/ 和 /tags/ 很可能已经有搜索引擎收录和外部引用,直接删掉会掉权重。
做法是留一个跳转页:
1 | <meta http-equiv="refresh" content="0; url=/topics/"> |
四个细节:
meta refresh+location.replace双保险,前者对不支持 JS 的爬虫有效location.replace而不是location.href—— 前者不往历史记录里塞一条,回退键不会把用户卡在跳转页里canonical指向新页,把权重集中过去noindex, follow告诉爬虫「别索引这个跳转壳,但顺着里面的链接继续爬」
跳转页用 layout: false 完全脱离 NexT 布局,755 字节,比渲染一整套主题再跳转快得多。
三、为什么在构建期渲染而不是前端
分类树是层级结构,前端拿不到结构化数据。最直觉的做法是让页面自己发请求读 JSON,但那样首屏会有明显的空白。
我的选择是在 after_render:html filter 里直接把 HTML 算好塞进去(和这个博客的「首页统计胶囊」是同一个套路):
1 | hexo.extend.filter.register('after_render:html', function (result) { |
几个细节:
result.indexOf('topics-hero') === -1提前返回 —— 200 多个页面绝大多数没有占位符,省掉一次正则扫描- 过滤
c.length > 0—— Hexo 会给 front-matter 里写了但没用到的分类建对象,不过滤会渲染出空分类 - 标签排序加
localeCompare兜底 —— 只按文章数排的话,同数的标签顺序取决于数据顺序,每次构建 diff 都有噪音
首屏即内容,没有 loading 状态,pjax 切换也是瞬时的。
四、颜色:每个分类一个稳定色相
「技术分享」和「算法竞赛」长得不一样,读者扫一眼就能区分开,这是分类页该有的视觉锚点。
实现上有个不明显的选择:色相由分类名散列决定,而不是随便分配:
1 | function hueOf(name) { |
好处是零维护——以后新增分类,不用去配置文件里挑颜色,它自己就有。
五、踩的三个坑
坑一:Warehouse 不是数组
写「最近更新」时,我需要取每篇文章的顶级分类:
1 | const topCat = p.categories[0].categories ? p.categories[0].categories[0].name : p.categories[0].name; |
这段代码跑出来的结果是整个页面 0 字节,而构建日志干干净净:
1 | INFO 216 files generated in 7.4 s |
Hexo 会吞掉 filter 里抛的异常。排查过程很曲折——先怀疑 p.categories 是空数组,加了日志才发现是类数组对象:
1 | typeof cats=object isArray=false len=2 |
Array.from() 对一个只暴露 data / length 的对象迭代出的是 undefined,然后 .categories 就炸了。
正确写法:
1 | const cats = p.categories && p.categories.toArray ? p.categories.toArray() : []; |
顺手记一条自检铁律(这个博客踩过好几次):
改完scripts/下的 filter,除了看构建输出,必须stat -c%s public/topics/index.html确认不是 0。
坑二:stylus 会预求值 CSS 变量
想让分类卡片带自己的色相,第一反应是写:
1 | .cat-icon { |
stylus 直接编译失败:
1 | TypeError: expected "hue" to be a unit, but got call:var(--cat-hue) |
stylus 认为 hsla() 是它自己的内置函数,会在编译期求值。传进去的 var(--cat-hue) 不是它认识的单位,整个表达式就崩了。
而且这次崩得比 0 字节更隐蔽——整份 styles.styl 编译失败,main.css 产出 0 字节,全站所有页面裸奔,而构建依然打印「216 files generated」。
这和记忆里那条「stylus 不认 CSS
min()」是同一个病的不同症状:
凡是 stylus 有同名内置函数的关键字,都会在编译期被求值,不能塞 CSS 变量。min()、hsla()、rgba()、calc()全都在列。
解法是绕开所有内置函数——在 JS 里把完整颜色算好,作为 inline custom property 注入:
1 | function catVars(name) { |
CSS 里只写 var(--cat-soft),纯 CSS 变量,stylus 不介入求值。
顺带说,深色模式的两档颜色提前算好是个额外好处:body.darkmode 分支里就只剩纯粹的 var() 替换,不用再写一遍 HSL 计算。
坑三:auto-fill 撑出的空列
第一版分类树用 grid-template-columns: repeat(auto-fill, 1fr),结果只有 2 个顶层分类时,容器宽度被硬撑成 3 列,右侧空出一大块。
1 | .cat-tree { |
固定列宽 + justify-content: start:卡片按实际数量靠左排,剩余空间自然留白但不成列。
六、几个可复用的经验
1. Array.from() 不等于「类数组」安全。 只要对象只暴露 data / length 而没有 Symbol.iterator,Array.from 就会产出一堆 undefined。Hexo 的 Warehouse 一律用 .toArray()。
2. 构建期渲染的第一原则是「产物非 0 字节」。 构建日志永远好看,产物大小才是唯一可信的信号。改完 filter 必查。
3. 合并页面时先想清楚旧 URL。 跳转页 + canonical + noindex, follow 三件套,比直接 404 优雅。
4. 视觉编码要稳定。 色相由分类名散列得出,意味着新增分类零维护、重新构建结果不变。
5. 合并不等于删信息。 分类和标签合并进一页,但两者的分工要在文案里讲明白——否则只是把两个空页面换成一个看不懂的页面。
七、最终效果
- 菜单从 9 项减到 8 项,「分类」「标签」变成一个「分类标签」
- 概览横幅给出全局体量,分类树给方向,标签云给话题,最近更新给出入口
- 分类卡片按名散列色相,子分类带层级竖线和彩色圆点
- 标签云 5 档字号,一眼看出哪些是被反复写的主题
- 浅色 / 深色 / 移动端三种形态均已截图验证
关于「内容索引」这个定位:分类和标签本质上都是给同一批文章建的不同索引。与其让读者在两棵不相干的树里找同一篇文章,不如把它们并排放到一个页面上,让读者自己决定用哪条路径切入。工具的价值不在于分类得多细,而在于找东西的路径有多短。