一、前言

博客维护一段时间后,页面样式、主题版本、导航结构和部署方式都会不断变化。如果这些修改只留在 Git 提交里,访客看不到,自己过几个月也很难快速回忆。于是我为 Butterfly 博客增加了一个独立的“更新记录”页面:它按年份折叠、按日期排列,并用不同颜色区分“新增、优化、修复、升级”等类型。

这篇文章不再只解释设计思路,而是从一个空页面开始,带你依次创建页面入口、YAML 数据、Hexo 渲染脚本和 CSS 样式。完成后,你会得到一个固定地址为 /updates/、可以长期维护的更新记录页。

下面的交互演示器用于直观呈现“文件怎样逐步增加、页面怎样逐步成形”。它不会在浏览器中真正运行 Hexo;真正的生成与检查仍需在 Hexo 项目目录执行本文给出的命令。

二、先看最终效果与实现流程

点击演示器中的“上一步”“下一步”或左侧步骤,可以从空白页面一直看到最终时间线。右上角还可以切换桌面/手机宽度和浅色/深色预览。

整个功能由四个核心文件组成:

Hexo 博客根目录
├─ source/
│ ├─ updates/index.md # 固定页面入口
│ ├─ _data/updates.yml # 更新记录数据
│ └─ css/blog-updates.css # 页面样式
└─ scripts/blog-updates.js # 读取数据并生成 HTML

更新记录页实现流程

它们之间的关系很简单:updates.yml 保存内容,blog-updates.js 把内容转换成 HTML,blog-updates.css 负责外观,updates/index.md 则提供最终访问地址。

三、开始前的准备

本文默认你已经有一个能够正常运行的 Hexo + Butterfly 博客。先进入博客根目录,确认可以看到 _config.ymlsourcethemesnode_modules 等文件。

建议先运行一次原站构建:

hexo clean
hexo generate

如果原站本身就有报错,应当先处理原有问题,再开始本次修改。本文只编辑源码,不要直接修改自动生成的 public/,也不要修改 node_modules/hexo-theme-butterfly/ 中的主题依赖文件。

四、第一步:创建更新记录页面入口

source 目录中新建 updates 文件夹,再在其中新建 index.md

source/
└─ updates/
└─ index.md

写入以下内容:

---
title: 更新记录
layout: page
date: 2026-07-16 20:00:00
updated: 2026-07-16 20:00:00
permalink: /updates/
comments: false
aside: false
toc: false
---
{% blog_updates %}

这里需要注意四项配置:

  • layout: page:把它作为独立页面,而不是普通文章;
  • permalink: /updates/:固定访问地址;
  • aside: false:关闭侧栏,让时间线有更完整的横向空间;
  • {% blog_updates %}:这是稍后注册的 Hexo 自定义标签,最终内容会出现在这里。

此时立即执行构建会提示 blog_updates 标签尚未注册,这是正常现象,因为渲染脚本还没有创建。

五、第二步:用 YAML 保存更新数据

source/_data 目录中新建 updates.yml。如果 _data 不存在,就先创建该文件夹。

intro: 记录博客功能、界面与部署方式的重要变化。
source_note: 时间线只保留已经完成并可以核实的重要修改。

years:
- year: 2026
open: true
entries:
- date: 2026.07.16
items:
- type: 新增
text: 上线更新记录页面。
- type: 升级
text: Butterfly 主题升级至 5.6.0

- date: 2026.07.15
items:
- type: 优化
text: 调整首页文章摘要与页脚可读性。

- year: 2025
open: false
entries:
- date: 2025.08.01
items:
- type: 上线
text: 发布首篇文章,博客开始记录。

这份数据分为三层:

  1. years 是年份列表,open: true 表示该年份默认展开;
  2. entries 是某一年的日期节点;
  3. items 是当天发生的具体变化,每项包含 typetext

YAML 对缩进敏感,请统一使用空格,不要混入 Tab。建议一个年份只设为 open: true,否则页面首次打开时会同时展开过多内容。

六、第三步:注册 Hexo 自定义标签

Hexo 会自动执行项目根目录 scripts 中的 JavaScript。新建 scripts/blog-updates.js,先写入类型映射和 HTML 转义函数:

'use strict'

const STYLE_URL = '/css/blog-updates.css?v=20260716-1'

const TYPE_CLASS = Object.freeze({
上线: 'launch',
新增: 'new',
优化: 'optimize',
修复: 'fix',
迁移: 'migrate',
升级: 'change',
调整: 'change'
})

const escapeHtml = value => String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')

TYPE_CLASS 决定不同更新类型使用哪一种徽标颜色。escapeHtml 则会在文本进入页面前处理特殊字符,避免一条记录意外破坏 HTML 结构。

接着加入单条更新、日期节点和年份的渲染函数:

const updateItemMarkup = item => {
const type = String(item?.type || '更新')
const typeClass = TYPE_CLASS[type] || 'change'

return `<li>
<span class="blog-update-badge blog-update-badge--${typeClass}">${escapeHtml(type)}</span>
<span>${escapeHtml(item?.text || '')}</span>
</li>`
}

const entryMarkup = entry => {
const date = String(entry?.date || '')
const dateTime = date.replaceAll('.', '-')
const items = Array.isArray(entry?.items) ? entry.items : []

return `<li class="blog-update-entry">
<span class="blog-update-entry__dot" aria-hidden="true"></span>
<time class="blog-update-entry__date" datetime="${escapeHtml(dateTime)}">${escapeHtml(date)}</time>
<div class="blog-update-entry__card">
<ul>${items.map(updateItemMarkup).join('')}</ul>
</div>
</li>`
}

const yearMarkup = yearData => {
const year = String(yearData?.year || '')
const entries = Array.isArray(yearData?.entries) ? [...yearData.entries] : []
entries.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')))
const open = yearData?.open === true ? ' open' : ''

return `<details class="blog-update-year"${open}>
<summary>
<span class="blog-update-year__title">${escapeHtml(year)}</span>
<span class="blog-update-year__count">${entries.length} 个节点</span>
<span class="blog-update-year__chevron" aria-hidden="true"></span>
</summary>
<div class="blog-update-year__body">
<ol class="blog-update-timeline">${entries.map(entryMarkup).join('')}</ol>
</div>
</details>`
}

年份折叠使用浏览器原生的 <details><summary>,所以不需要额外编写点击事件;键盘操作和展开状态也由浏览器处理。

最后读取 updates.yml、计算统计数字并注册标签:

const updatesMarkup = () => {
const data = hexo.locals.get('data')?.updates || {}
const years = Array.isArray(data.years) ? [...data.years] : []
years.sort((a, b) => Number(b.year || 0) - Number(a.year || 0))

if (years.length === 0) {
return '<section class="blog-updates"><p class="blog-updates__empty">暂时还没有更新记录。</p></section>'
}

const entryCount = years.reduce((total, year) =>
total + (Array.isArray(year.entries) ? year.entries.length : 0), 0)

const itemCount = years.reduce((total, year) => total + (
Array.isArray(year.entries)
? year.entries.reduce((sum, entry) =>
sum + (Array.isArray(entry.items) ? entry.items.length : 0), 0)
: 0
), 0)

return `<section class="blog-updates" aria-label="博客更新记录">
<header class="blog-updates__header">
<div>
<p class="blog-updates__eyebrow">CHANGELOG</p>
<h1>更新记录</h1>
<p class="blog-updates__intro">${escapeHtml(data.intro || '')}</p>
</div>
<div class="blog-updates__summary" aria-label="更新时间线概览">
<span><strong>${years.length}</strong> 个年份</span>
<span><strong>${entryCount}</strong> 个节点</span>
<span><strong>${itemCount}</strong> 项变化</span>
</div>
</header>
<p class="blog-updates__source">${escapeHtml(data.source_note || '')}</p>
<div class="blog-updates__years">${years.map(yearMarkup).join('')}</div>
</section>`
}

hexo.extend.tag.register('blog_updates', updatesMarkup)

现在,Hexo 遇到 {% blog_updates %} 时,就会执行 updatesMarkup() 并输出时间线结构。

七、第四步:只给更新记录页加载样式

仍在 scripts/blog-updates.js 末尾加入下面的过滤器:

const normalizeRoot = root => {
const value = String(root || '/')
return value.endsWith('/') ? value : `${value}/`
}

hexo.extend.filter.register('after_render:html', html => {
if (!html.includes('class="blog-updates"') || !html.includes('</head>')) return html

const root = normalizeRoot(hexo.config.root)
const stylesheet = `<link id="blog-updates-style" rel="stylesheet" href="${root}${STYLE_URL.replace(/^\//, '')}">`
return html.replace('</head>', `${stylesheet}</head>`)
}, 30)

这个过滤器会检查生成后的 HTML:只有页面里确实出现 .blog-updates 时,才插入 CSS。其他文章不需要加载这份专用样式。

当你以后修改 CSS,而浏览器或 CDN 仍显示旧效果时,可以把 v=20260716-1 改成新的版本号,例如 v=20260808-1,让缓存重新获取文件。

八、第五步:编写时间线样式

新建 source/css/blog-updates.css。下面先给出决定布局和视觉效果的完整核心样式;类型颜色与响应式规则紧随其后。

.blog-updates {
--updates-accent: var(--theme-color, #1677b3);
--updates-line: rgba(22, 119, 179, 0.24);
--updates-soft: rgba(22, 119, 179, 0.08);
--updates-border: rgba(127, 127, 127, 0.18);
color: var(--font-color);
}

.blog-updates__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 28px;
margin-bottom: 18px;
padding: 4px 2px 18px;
border-bottom: 1px solid var(--updates-border);
}

.blog-updates__eyebrow {
margin: 0 0 4px;
color: var(--updates-accent);
font-weight: 700;
font-size: 0.72rem;
letter-spacing: 0.18em;
}

#article-container .blog-updates__header h1 {
margin: 0;
border: 0;
font-size: clamp(1.8rem, 4vw, 2.55rem);
}

.blog-updates__summary {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 8px;
}

.blog-updates__summary span {
padding: 8px 11px;
border: 1px solid var(--updates-border);
border-radius: 999px;
background: var(--card-bg);
white-space: nowrap;
font-size: 0.78rem;
}

.blog-updates__years {
display: grid;
gap: 18px;
}

#article-container details.blog-update-year {
margin: 0;
overflow: hidden;
border: 1px solid var(--updates-border);
border-radius: 15px;
background: var(--card-bg);
box-shadow: 0 8px 24px rgba(31, 45, 61, 0.06);
}

#article-container details.blog-update-year > summary {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: linear-gradient(120deg, var(--updates-accent), #42b7c8);
color: #fff;
cursor: pointer;
list-style: none;
}

#article-container details.blog-update-year > summary::-webkit-details-marker {
display: none;
}

.blog-update-year__count {
margin-right: auto;
padding: 3px 9px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
font-size: 0.74rem;
}

.blog-update-year__body {
padding: 24px 22px 8px;
}

.blog-update-timeline {
position: relative;
margin: 0;
padding: 0 0 0 28px;
list-style: none;
}

.blog-update-timeline::before {
position: absolute;
top: 7px;
bottom: 23px;
left: 6px;
width: 2px;
background: var(--updates-line);
content: '';
}

.blog-update-entry {
position: relative;
margin: 0 0 24px;
}

.blog-update-entry__dot {
position: absolute;
top: 7px;
left: -28px;
width: 14px;
height: 14px;
border: 3px solid var(--card-bg);
border-radius: 50%;
background: var(--updates-accent);
box-shadow: 0 0 0 2px var(--updates-line);
}

.blog-update-entry__date {
display: inline-flex;
margin-bottom: 9px;
color: var(--updates-accent);
font-weight: 700;
}

.blog-update-entry__card {
padding: 15px 17px;
border: 1px solid var(--updates-border);
border-radius: 12px;
background: rgba(127, 127, 127, 0.035);
}

#article-container .blog-update-entry__card ul {
margin: 0;
padding: 0;
list-style: none;
}

#article-container .blog-update-entry__card li {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 0;
line-height: 1.75;
}

继续添加徽标颜色、深色模式和手机适配:

.blog-update-badge {
flex: 0 0 auto;
min-width: 42px;
margin-top: 0.25em;
padding: 1px 7px;
border-radius: 6px;
color: #fff;
text-align: center;
font-weight: 650;
font-size: 0.7rem;
}

.blog-update-badge--launch { background: #d65a7a; }
.blog-update-badge--new { background: #22a06b; }
.blog-update-badge--optimize { background: #1677b3; }
.blog-update-badge--fix { background: #d97706; }
.blog-update-badge--migrate { background: #7c5ce6; }
.blog-update-badge--change { background: #64748b; }

[data-theme='dark'] .blog-updates {
--updates-line: rgba(66, 183, 200, 0.34);
--updates-soft: rgba(66, 183, 200, 0.1);
--updates-border: rgba(255, 255, 255, 0.12);
}

@media (max-width: 768px) {
.blog-updates__header {
align-items: flex-start;
flex-direction: column;
gap: 16px;
}

#article-container details.blog-update-year > summary {
padding: 14px 16px;
}

.blog-update-year__body {
padding: 20px 14px 4px;
}

.blog-update-timeline {
padding-left: 25px;
}

.blog-update-entry__dot {
left: -25px;
}
}

@media (prefers-reduced-motion: reduce) {
.blog-update-year__chevron,
.blog-update-entry__dot,
.blog-update-entry__card {
transition: none;
}
}

这里优先使用 Butterfly 已有的 --theme-color--font-color--card-bg,因此更新页会自动跟随主题色与深色模式,而不是维护一套互不相干的配色。

九、第六步:把更新记录加入导航栏

打开 Butterfly 的站点级配置 _config.butterfly.yml,在 menu 中找到你希望放置更新记录的位置。例如放进“博客”子菜单:

menu:
主页: / || fas fa-home
博客:
友链: /links/ || fas fa-link
留言板: /comment/ || fas fa-comments
关于笔者: /about/ || fas fa-user
更新记录: /updates/ || fas fa-clock-rotate-left

如果你的导航结构与示例不同,只需保留这一项即可:

更新记录: /updates/ || fas fa-clock-rotate-left

不要去修改主题依赖目录内的 _config.yml,否则重新安装或升级主题时很容易丢失修改。

十、第七步:生成并在浏览器中预览

回到博客根目录,执行:

hexo clean
hexo generate
hexo server

终端出现类似下面的提示后,在浏览器打开 http://localhost:4000/updates/

INFO  Hexo is running at http://localhost:4000/

请逐项检查:

  1. /updates/ 可以正常打开;
  2. 最新年份默认展开,旧年份可以点击折叠;
  3. 统计数字与 YAML 中的年份、日期节点和记录数量一致;
  4. 导航栏可以进入更新记录页;
  5. 手机宽度下没有横向滚动;
  6. 深色模式下文字、边框和卡片仍清晰可读。

如果项目已经提供构建脚本,也可以使用:

npm run clean
npm run build
npm run verify

构建命令即使退出码为 0,也要继续检查输出中是否出现 ERRORFATAL,避免把被忽略的生成错误带到线上。

十一、日后怎样增加一条记录

更新功能上线后,日常维护只需要编辑 source/_data/updates.yml。例如在 2026 年新增一个日期节点:

- year: 2026
open: true
entries:
- date: 2026.08.08
items:
- type: 优化
text: 重写 Butterfly 建站笔记,并加入交互式搭建演示。

同一天有多项变化,就继续向 items 追加。到了新的一年,新建一个年份块,把旧年份改为 open: false,把新年份设为 open: true

更新记录不必复制每一条 Git 提交。一次功能可能经历多次尝试、回退和修正,时间线上应当保留最终完成、访客能感知或对维护有意义的结果。

十二、常见问题排查

1. 提示 blog_updates 标签不存在

确认 blog-updates.js 位于项目根目录的 scripts/,而不是 source/js/。保存后重新执行 hexo clean && hexo generate

2. 页面有内容但没有样式

确认 source/css/blog-updates.css 存在,并检查生成页面的 <head> 中是否出现 id="blog-updates-style"<link>。若 CDN 缓存了旧文件,修改 STYLE_URL 的版本号。

3. YAML 修改后页面不更新

先检查缩进与冒号后面的空格,再执行 hexo clean 清理缓存。日期建议统一写成 YYYY.MM.DD,这样字符串倒序排列就与时间顺序一致。

4. 页面出现原始 HTML 字符或布局被破坏

不要删除 escapeHtml。如果记录需要放链接,不要直接把任意 HTML 写入 YAML;应当为链接设计单独字段,并在渲染脚本中进行协议校验后再输出。

十三、结语

到这里,一个从数据到页面完整闭环的更新记录功能就完成了:

编辑 updates.yml

Hexo 执行 blog-updates.js

生成可折叠时间线 HTML

按需加载 blog-updates.css

访问 /updates/ 查看结果

这种做法的关键不是“多写一个页面”,而是把内容、结构和样式分开:以后增加记录只改 YAML,调整结构只改脚本,修改外观只改 CSS。下一篇将继续用相同的教程方式,拆解 Butterfly 分类与标签内容地图的搭建过程。