一、前言

Butterfly 默认归档能够按时间显示文章,但文章变多后会遇到三个问题:分页把完整时间线切开、无法在当前页搜索全部文章、年份和月份缺少统一的折叠与数量反馈。本篇从关闭归档分页开始,逐步建立年份、月份、稳定编号、全文筛选和随机阅读。

本文只接管 /archives/ 主归档页。年度归档、月度归档、分类和标签分页继续由 Hexo 原生成器处理。

二、先看归档页怎样逐步成形

项目文件分工如下:

Hexo 博客根目录
├─ _config.yml # 关闭主归档分页
├─ scripts/blog-archives.js # 分组、编号并生成 HTML
├─ source/js/blog-archives.js # 搜索、折叠和随机阅读
└─ source/css/blog-archives.css # 归档布局和响应式样式

文章归档重构流程

三、第一步:关闭归档分页

打开站点 _config.yml,找到归档生成器配置:

archive_generator:
per_page: 0
yearly: true
monthly: true

per_page: 0 的作用是让主归档页面在构建时拿到全部文章。若仍保留每页十篇,浏览器端搜索只能搜索当前分页,无法实现真正的完整归档。

修改后执行:

hexo clean
hexo generate

确认 /archives/ 中已经包含全部文章,再继续编写接管脚本。

四、第二步:只识别主归档页

新建 scripts/blog-archives.js,先写基础工具:

'use strict'

const collectionItems = collection => {
if (!collection) return []
if (Array.isArray(collection.data)) return collection.data
if (Array.isArray(collection)) return collection
return typeof collection.toArray === 'function'
? collection.toArray()
: []
}

const dateValue = value => Number(value?.valueOf?.() || 0)
const sortedPosts = posts =>
[...posts].sort((a, b) => dateValue(b.date) - dateValue(a.date))

过滤器中要严格限制页面:

hexo.extend.filter.register('after_render:html', (html, locals) => {
const page = locals?.page
const isMainArchive = page?.archive === true &&
!page.year && !page.month && Number(page.current || 1) === 1

if (!isMainArchive) return html

const posts = sortedPosts(collectionItems(page.posts))
return injectAssets(replaceMainContent(html, archiveMarkup(posts)))
}, 40)

这样 /archives/2026//archives/2026/07/ 不会被重复接管。

五、第三步:统一上海时区

文章日期可能来自不同环境。如果构建服务器使用 UTC,本地使用上海时区,临近午夜的文章可能被分进不同月份。站点 _config.yml 应设置:

timezone: Asia/Shanghai

格式化时优先使用 Hexo 日期对象:

const formatDate = (value, pattern = 'YYYY.MM.DD') => {
if (value && typeof value.format === 'function') {
return value.format(pattern)
}
// 普通 Date 的回退处理
}

六、第四步:按年份和月份分组

使用两层 Map 保存结构:

const groupPosts = posts => {
const years = new Map()

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

if (!years.has(year)) years.set(year, new Map())
const months = years.get(year)
if (!months.has(month)) months.set(month, [])
months.get(month).push(post)
})

return years
}

最终 HTML 使用原生 <details>

<details class="blog-archive-year" open>
<summary>2026 <span>8 篇</span></summary>
<details class="blog-archive-month" open>
<summary>七月 <span>8 篇</span></summary>
<div class="blog-archive-list">...</div>
</details>
</details>

原生折叠不依赖 JavaScript,即使运行时脚本加载失败,文章仍然可以访问。

七、第五步:生成稳定文章编号

归档展示顺序是新文章在前,但编号最好从最早文章的 #001 开始:

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 serialFor = post => {
const value = serials.get(String(post.path)) || 0
return `#${String(value).padStart(3, '0')}`
}

只要不修改旧文章的发布日期,新增文章不会改变旧文章编号。

八、第六步:给每篇文章写入搜索文本

构建时把标题、分类、标签、日期和编号合成搜索字段:

const searchText = [
post.title,
formatDate(post.date),
...collectionItems(post.categories).map(item => item.name),
...collectionItems(post.tags).map(item => item.name),
serialFor(post)
].join(' ').toLocaleLowerCase('zh-CN')

再写入文章元素:

<article
class="blog-archive-entry"
data-archive-entry
data-archive-search="butterfly 建站笔记 hexo 2026.07.18 #019">
...
</article>

浏览器搜索时不需要读取 search.xml,也不需要请求服务器。

九、第七步:计算年份活跃度

每个年份标题旁可以显示十二个月的文章密度:

const counts = Array.from({ length: 12 }, () => 0)

yearPosts.forEach(post => {
const month = Number(formatDate(post.date, 'MM')) - 1
if (month >= 0 && month < 12) counts[month] += 1
})

const max = Math.max(1, ...counts)
const bars = counts.map(count => ({
count,
ratio: count / max
}))

空月份仍保留位置,访客可以直观看到这一年的发布节奏。

十、第八步:加入搜索、折叠和随机阅读

新建 source/js/blog-archives.js

(() => {
const normalize = value =>
String(value || '').trim().toLocaleLowerCase('zh-CN')

const initArchive = root => {
if (!root || root.dataset.archiveBound === 'true') return
root.dataset.archiveBound = 'true'

const search = root.querySelector('[data-archive-search-input]')
const entries = [...root.querySelectorAll('[data-archive-entry]')]

const refresh = () => {
const query = normalize(search?.value)

entries.forEach(entry => {
entry.hidden = Boolean(query) &&
!normalize(entry.dataset.archiveSearch).includes(query)
})

updateMonthVisibility(root)
updateYearVisibility(root)
}

search?.addEventListener('input', refresh)
refresh()
}
})()

过滤文章后必须继续更新父级月份和年份:一个月份中所有文章都隐藏时,该月份也应隐藏;一个年份没有可见月份时,年份也应隐藏。

随机阅读按钮只从当前可见文章中选择:

const visible = entries.filter(entry => !entry.hidden)
const target = visible[Math.floor(Math.random() * visible.length)]
const link = target?.querySelector('a[href]')
if (link) location.href = link.href

十一、第九步:编写局部样式

新建 source/css/blog-archives.css

.blog-archives {
--archive-accent: var(--theme-color, #d48726);
color: var(--font-color);
}

.blog-archive-entry {
display: grid;
grid-template-columns: 168px minmax(0, 1fr) auto;
align-items: center;
gap: 22px;
border-bottom: 1px solid rgba(127, 127, 127, 0.16);
}

.blog-archive-entry[hidden],
.blog-archive-month[hidden],
.blog-archive-year[hidden] {
display: none;
}

@media (max-width: 768px) {
.blog-archive-entry {
grid-template-columns: 92px minmax(0, 1fr);
gap: 12px;
}
}

所有类名都使用 blog-archive 前缀,防止影响普通文章卡片。

十二、第十步:按顺序排列系列文章

归档默认按 date 倒序。若希望页面从上到下显示“一、二、三……八”,第一篇必须拥有八篇中最大的时间,后续依次递减:

# 第一篇
date: 2026-07-18 23:50:00

# 第二篇
date: 2026-07-18 23:40:00

# 第三篇
date: 2026-07-18 23:30:00

后续每篇继续减少十分钟。这些 date 在本系列中承担展示排序键的作用。

十三、第十一步:构建和浏览器验证

npm run clean
npm run build
npm run verify
hexo server

打开:

http://localhost:4000/archives/

检查:

  1. 页面显示全部文章且数量正确;
  2. 年份、月份数量与文章相符;
  3. 搜索标题、标签、分类和编号都能命中;
  4. 空搜索结果会显示提示;
  5. 展开全部、折叠全部和随机阅读可用;
  6. 第一至第八篇从上到下顺序正确;
  7. 手机端没有横向溢出;
  8. PJAX 往返后不会重复绑定事件。

十四、常见问题

1. 搜索只能找到当前十篇文章

archive_generator.per_page 仍在分页。改为 0,执行 hexo clean 后重新生成。

2. 某篇文章被分进错误月份

检查 _config.ymltimezone: Asia/Shanghai,并确认文章日期带有有效时间。

3. 系列顺序仍然混乱

检查八篇 date 是否真正按第一篇最大、随后依次递减;不要只修改 updated,归档排序使用的是 date

4. 搜索后出现空年份

过滤完文章后没有同步更新月份和年份容器。依次执行 updateMonthVisibility()updateYearVisibility()

十五、维护入口

  • 排序与生成结构:scripts/blog-archives.js
  • 搜索和折叠交互:source/js/blog-archives.js
  • 页面样式:source/css/blog-archives.css
  • 分页和时区:_config.yml

十六、结语

完整归档的关键是先让构建阶段拿到全部文章,再把年月关系、稳定编号和搜索字段一次写进 HTML。浏览器只做轻量过滤和展开状态管理,既适合静态博客,也不会引入额外服务端依赖。