前言

默认归档页解决了“按时间列出文章”,但文章多起来后仍不方便查找。这次重构保留 Hexo 原有归档路由,在其上增加年份折叠、月份分组、搜索、随机阅读和全站编号。

一、为什么必须先关闭归档分页

归档页要按完整年份折叠,就必须一次拿到全部文章。如果 Hexo 每页只交给脚本十篇文章,那么一个年份可能被拆到多页,脚本无法计算准确的年度数量。

因此 _config.yml 中单独关闭主归档分页:

archive_generator:
per_page: 0

这不会关闭首页分页。首页仍可使用:

index_generator:
per_page: 10

两种配置服务于不同目标:主页需要控制首屏长度,归档页需要完整数据。

二、脚本只接管主归档页

Hexo 还会生成 /archives/2026//archives/2026/07/ 等子页面。自定义脚本只改造第一页主归档:

const isMainArchive =
page?.archive &&
!page.year &&
!page.month &&
!page.day &&
Number(page.current || 1) === 1

这样,月份统计图等地方跳到具体年月时,仍能使用 Hexo 原生年月归档,不会被整站替换逻辑误伤。

三、文章怎样分成年和月

构建阶段先按日期从新到旧排序,再用两个 Map 分组:

const yearMap = new Map()

posts.forEach(post => {
const year = formatDate(post.date, 'YYYY')
const month = formatDate(post.date, 'MM')

if (!yearMap.has(year)) {
yearMap.set(year, {
year,
posts: [],
months: new Map()
})
}

const yearData = yearMap.get(year)
yearData.posts.push(post)

if (!yearData.months.has(month)) {
yearData.months.set(month, [])
}
yearData.months.get(month).push(post)
})

最终数据结构类似:

2026
├─ 07
│ ├─ 文章 A
│ └─ 文章 B
└─ 01
└─ 文章 C

2025
└─ 12
└─ 文章 D

使用 Map 的好处是能保持插入顺序,也能用年份或月份直接找到分组。

四、全站文章编号怎样保持稳定

页面按新到旧展示,但编号希望从最早文章开始累加。脚本先复制一份文章并改成从旧到新,再建立路径到编号的映射:

const chronological = [...posts]
.sort((a, b) => dateValue(a.date) - dateValue(b.date))

const serials = new Map(
chronological.map((post, index) => [String(post.path), index + 1])
)

展示时通过文章路径取回编号,并补足位数:

const serialText = `#${String(serial).padStart(3, '0')}`

最早的文章是 #001。以后新增文章只会获得更大的编号,历史文章编号不会因为页面排序改变。

五、年份活跃度条怎样生成

每个年份标题右侧有十二格月份活跃度。脚本先创建长度为 12 的数组:

const monthCounts = Array.from(
{ length: 12 },
(_, index) => yearData.months
.get(String(index + 1).padStart(2, '0'))?.length || 0
)

随后用该年最高月份作为基准计算透明度:

const max = Math.max(1, ...monthCounts)
const level = count === 0 ? 0 : Math.max(0.22, count / max)

数值通过 CSS 变量传给样式:

<span style="--archive-month-level:0.75"></span>

CSS 再用这个变量控制颜色深浅。构建阶段负责“算数”,CSS 负责“画出来”。

六、搜索索引为什么写在 HTML 中

每篇文章卡片在生成时,把标题、分类、标签和日期拼成小型搜索索引:

const searchText = `${title} ${category} ${tags} ${date}`
.toLocaleLowerCase('zh-CN')

然后写入:

<article data-archive-search="文章标题 建站笔记 hexo 2026.07.16">
</article>

用户输入内容时,浏览器无需联网,只在现有 DOM 中匹配。文章数量不大时,这比引入完整搜索库更轻量。

七、过滤必须从文章向上更新

搜索不能只隐藏文章卡片,否则会留下空月份和空年份。正确顺序是:

匹配文章

没有可见文章的月份隐藏

没有可见月份的年份隐藏

更新“显示 n / 总数”

对应代码:

entries.forEach(entry => {
entry.hidden = !matched(entry)
})

months.forEach(month => {
month.hidden = !month.querySelector(
'.blog-archive-entry:not([hidden])'
)
})

years.forEach(year => {
year.hidden = !year.querySelector(
'.blog-archive-month:not([hidden])'
)
})

有搜索词时自动展开命中的年份;清空搜索后恢复为“仅展开最新年份”的初始状态。

八、展开全部与随机阅读

年份折叠使用浏览器原生 <details>。按钮只需要修改 open 属性:

const shouldOpen = !visibleYears.every(year => year.open)
visibleYears.forEach(year => {
year.open = shouldOpen
})

随机阅读只从当前可见文章中抽取,因此搜索后点击“随便看看”,候选范围会自动缩小:

const candidates = entries.filter(entry => !entry.hidden)
const selected = candidates[
Math.floor(Math.random() * candidates.length)
]
window.location.assign(selected.dataset.archiveUrl)

九、日期为什么要统一时区

Hexo 在不同时区构建时,靠近零点的文章可能被算到前一天或前一个月。本项目明确设置:

timezone: Asia/Shanghai

GitHub Actions 同样设置:

env:
TZ: Asia/Shanghai

源码解析时区和构建环境时区同时固定,年月分组才不会在本地与线上出现差异。

十、封面与分类标签怎样处理

文章有封面时输出懒加载图片;没有封面时显示分类图标占位。标签最多显示两个,多余标签以 +N 表示,避免在手机上把卡片撑得过高。

所有文章标题、分类和标签进入 HTML 前都会转义,内部路径统一通过 urlFor() 加上站点根路径,从而兼容以后把博客部署到子目录的情况。

十一、维护入口

  • 分页和时区:_config.yml
  • 年月分组、编号和页面结构:scripts/blog-archives.js
  • 搜索、折叠与随机阅读:source/js/blog-archives.js
  • 归档卡片与响应式:source/css/blog-archives.css

结语

归档页的核心是先保证数据完整,再按“年 → 月 → 文章”建立层级;浏览器端只负责在已经生成的结构上搜索和交互。