一、前言
博客维护一段时间后,页面样式、主题版本、导航结构和部署方式都会不断变化。如果这些修改只留在 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.yml、source、themes 或 node_modules 等文件。
建议先运行一次原站构建:
如果原站本身就有报错,应当先处理原有问题,再开始本次修改。本文只编辑源码,不要直接修改自动生成的 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: 发布首篇文章,博客开始记录。
|
这份数据分为三层:
years 是年份列表,open: true 表示该年份默认展开;
entries 是某一年的日期节点;
items 是当天发生的具体变化,每项包含 type 和 text。
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('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", ''')
|
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/
|
请逐项检查:
/updates/ 可以正常打开;
- 最新年份默认展开,旧年份可以点击折叠;
- 统计数字与 YAML 中的年份、日期节点和记录数量一致;
- 导航栏可以进入更新记录页;
- 手机宽度下没有横向滚动;
- 深色模式下文字、边框和卡片仍清晰可读。
如果项目已经提供构建脚本,也可以使用:
npm run clean npm run build npm run verify
|
构建命令即使退出码为 0,也要继续检查输出中是否出现 ERROR 或 FATAL,避免把被忽略的生成错误带到线上。
十一、日后怎样增加一条记录
更新功能上线后,日常维护只需要编辑 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 分类与标签内容地图的搭建过程。