一、前言
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 的作用是让主归档页面在构建时拿到全部文章。若仍保留每页十篇,浏览器端搜索只能搜索当前分页,无法实现真正的完整归档。
修改后执行:
确认 /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 应设置:
格式化时优先使用 Hexo 日期对象:
const formatDate = (value, pattern = 'YYYY.MM.DD') => { if (value && typeof value.format === 'function') { return value.format(pattern) } }
|
六、第四步:按年份和月份分组
使用两层 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/
|
检查:
- 页面显示全部文章且数量正确;
- 年份、月份数量与文章相符;
- 搜索标题、标签、分类和编号都能命中;
- 空搜索结果会显示提示;
- 展开全部、折叠全部和随机阅读可用;
- 第一至第八篇从上到下顺序正确;
- 手机端没有横向溢出;
- PJAX 往返后不会重复绑定事件。
十四、常见问题
1. 搜索只能找到当前十篇文章
archive_generator.per_page 仍在分页。改为 0,执行 hexo clean 后重新生成。
2. 某篇文章被分进错误月份
检查 _config.yml 的 timezone: 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。浏览器只做轻量过滤和展开状态管理,既适合静态博客,也不会引入额外服务端依赖。