HEXO 开发笔记(6)自建主题:核心功能实现
创建于 2026-06-19
更新于 2026-06-19
科技
hexo
主题开发
JavaScript
5392 字 · 约 18 分钟

前言

本篇聚焦 DoraTiger 主题的核心功能实现:文章渲染管线、侧边栏双面板、分页系统、归档页面,以及内置的 PV/UV 统计计数器。这些功能构成了博客日常使用中最频繁交互的部分。

一、文章渲染管线

1.1 渲染流程

文章从 Markdown 到最终页面经过多个处理阶段:

text
1
2
3
4
5
6
7
Hexo 解析 Markdown → after_post_render 过滤器链 → code.js:代码块增强(行号 + 复制按钮 + 语言标签) → redirect.js:外链重定向注入 data-redirect 属性 → encrypt.js:文章 AES-256-GCM 加密(可选) → Pug 模板渲染 → post.pug:标题 / 元信息 / 内容 / 版权 / QR码 / 评论

1.2 代码块过滤器

<pre><code> 块增强为带语言标签、行号和复制按钮的容器:

javascript
1
2
3
4
5
6
7
8
9
10
// scripts/filters/lib/code.js const reg = /<pre><code(.*?)>([\s\S]*?)<\/code><\/pre>/g; data.content = data.content.replace(reg, (match, attrs, content) => { const lang = attrs.match(/class="language-(.*?)"/)?.[1] || "code"; const codeHeader = `<div class="code-header"> <span class="code-type">${lang}</span> <div class="code-copy">${copyButton}</div> </div>`; // ... 行号生成 });

1.3 复制按钮的 UI 调试

复制按钮本身逻辑不复杂,但对齐代码块的 UI 花了不少功夫。最初想做一个带动效的花哨设计 — 点击时有个旋转/缩放反馈,hover 时有渐变背景。来来回回调整了好几版 DOM 结构和 CSS 动画,最后发现效果反而不稳定,在不同代码块宽度下对齐经常出问题。

最终放弃了花里胡哨的 DOM 方案,改用JS 实现 — 直接操作 classListtextContent,不依赖复杂的 CSS 动画。效果虽然朴素,但稳定性好得多,各种代码块宽度下都能正确对齐。

javascript
1
2
3
4
5
6
7
// 简化后的复制逻辑 button.addEventListener('click', () => { navigator.clipboard.writeText(codeContent).then(() => { button.textContent = copiedText; setTimeout(() => { button.textContent = copyText; }, 2000); }); });

1.4 外链重定向

构建时给外链添加 data-redirect 属性,运行时拦截点击:

javascript
1
2
3
4
5
6
7
8
// scripts/filters/lib/redirect.js const shouldRedirect = (url) => { const host = new URL(url).hostname; if (host === siteHostname) return false; // 内链排除 if (method === "include") return hostMatches(host, include); if (hostMatches(host, exclude)) return false; // 黑名单排除 return true; };

支持两种模式:exclude(默认,所有外链重定向,黑名单除外)和 include(仅白名单域名重定向)。

二、侧边栏双面板

2.1 面板切换

pug
1
2
3
4
5
6
7
8
9
10
// layout/_include/sidebar.pug - let showToc = enableToc && is_post() if showToc #sidebar-toc != toc(page.content, {list_number: ...}) .sidebar-menu-item // 切换按钮 #sidebar-info.hide else #sidebar-info #sidebar-toc.hide

2.2 TOC 面板的反复适配

TOC 面板是调试次数最多的组件之一。它需要同时适配两个场景:

场景一:侧边栏折叠/展开

  • 侧边栏宽度从 300px 切换到 0 时,TOC 内容需要重新排版
  • 高嵌套目录(三级以上)在窄宽度下文字溢出

场景二:文章加密

  • 加密文章的 page.content 在构建时被替换为密文
  • toc() 函数无法从密文生成目录
  • 需要在加密前单独提取目录结构

这两个场景反复调整了好多次,也是为什么自建扩展比用第三方插件适配度最高的原因 — 可以精确控制每个边界情况。

2.3 滚动高亮

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
// source/js/utils/scroll.js // 滚动时自动高亮当前章节 updateActiveHeading() { const headings = document.querySelectorAll('.toc-item a'); const scrollTop = contentWrapper.scrollTop; // 找到当前视口顶部最近的标题 headings.forEach(link => { const target = document.querySelector(link.getAttribute('href')); if (target && target.offsetTop <= scrollTop + offset) { link.parentElement.classList.add('toc-active'); } }); }

三、分页系统

3.1 首页分页

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
// scripts/generators/lib/index.js const pagination = require("hexo-pagination"); module.exports = function (locals) { const posts = locals.posts.sort(this.config.index_generator.order_by); const stickyPosts = posts.data.sort((a, b) => (b.sticky || 0) - (a.sticky || 0)); return pagination(path, stickyPosts, { format: 'page/%d/', layout: ['index'], perPage: this.config.index_generator.per_page, }); };

置顶文章通过 sticky 字段排序,确保始终显示在首页最前。

3.2 文章前后导航

非分页页面(文章页)使用上一篇/下一篇文章导航,带 border-animation 效果。

四、归档/标签/分类页面

4.1 Timeline 样式归档

pug
1
2
3
4
5
6
7
8
9
// 按年份分组,年份 = 大圆点,文章 = 小圆点,竖线连接 each year in Object.keys(groupedPosts) .archive-year .archive-year-marker // 大圆点 .archive-year-title= year each post in groupedPosts[year] .archive-post-item .archive-post-marker // 小圆点 a(href=url_for(post.path))= post.title

4.2 标签云

pug
1
2
3
.tagcloud != tagcloud({min_font: 0.75, max_font: 2, amount: 100, color: true, start_color: '#A4D8FA', end_color: '#0790E8'})

颜色从浅蓝渐变到深蓝,字号从 0.75rem2rem

五、内置 PV/UV 统计计数器

5.1 从卜算子到自建

统计功能的演进路径:

text
1
2
3
4
5
6
localStorage(最早) → 不跨浏览器,换设备数据丢失 → 卜算子 busuanzi(第三方服务) → 服务不稳定,经常挂掉 → 自建 counter 服务 → 完全可控,支持 PV + UV

卜算子不好用了,逼得只能自己写。这也是选择一体化集成的又一个原因 — 外部服务不可控,不如自己掌握。

pug
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// layout/_include/footer.pug — inline IIFE (function() { function getVisitorId() { var match = document.cookie.match(/dtc_uid=([^;]+)/); if (match) return match[1]; var uid = crypto.randomUUID(); document.cookie = 'dtc_uid=' + uid + '; max-age=31536000; path=/; SameSite=Lax'; return uid; } var visitorId = getVisitorId(); fetch(api + '?page=' + encodeURIComponent(location.pathname) + '&uid=' + visitorId) .then(r => r.json()) .then(d => { sv.textContent = d.site_uv || d.site_pv || 0; pv.textContent = d.page_uv || d.page_pv || 0; }); })();

5.3 后端 counter 服务

doratiger-counter 服务(GitHub 仓库)使用内存布隆过滤器做 UV 去重:

  • PV:内存计数器,每 30 秒持久化到 SQLite
  • Site UV:布隆过滤器(100 万容量,7 个哈希函数)
  • Page UVmap[page]map[uid]struct{}
  • 安全:Origin 白名单校验,只允许来自博客域名的请求

布隆过滤器的选择是因为它在内存占用和查询速度之间取得了很好的平衡 — 100 万容量只需要约 120KB 内存,远小于存储每个 uidmap。对于个人博客的访问量级别,误判率可以接受。

六、文章附加功能

6.1 阅读时间估算

pug
1
2
3
4
5
// layout/_include/post.pug - let wpm = theme.post_extend?.reading_time?.wpm ?? 300 - let wordCount = page.content.replace(/<[^>]+>/g, '').replace(/\s+/g, '').length - let readMin = Math.max(1, Math.ceil(wordCount / wpm)) span= wordCount + ' 字 · 约 ' + readMin + ' 分钟'

WPM 参数可配置(默认 300),但中英文混排的字数统计有局限 — 去除 HTML 标签后按字符数计算,中文和英文都算一个字符,实际上中文的阅读速度比英文慢不少。300 这个值是参考了一些博客的通用设定,实际体验下来偏差不大,够用。

6.2 文章 QR 码

自行实现了 ISO 18004 标准的 QR 码生成器(纯 JS,无外部依赖),渲染为 SVG data URL。选择自己实现的原因和其他本地化组件一样 — 不需要在主题编译阶段引入第三方包,让 node 依赖尽量少,做到自包含。

6.3 其他功能

功能 实现
置顶标记 front-matter sticky: 100,样式 .post-item-header-sticky
版权声明 CC BY-NC-SA 4.0,可在配置中修改
打赏按钮 支付宝/微信收款码展示

类似的实现思路贯穿整个主题 — 很多插件功能都是参考学习后自行实现的,就是为了尽量减少第三方包的安装,保持主题的自包含性。

七、总结

本篇介绍了 DoraTiger 的核心功能实现,从文章渲染管线到内置统计计数器。自建扩展的代价是更多的开发和调试工作(比如 TOC 面板的反复适配、复制按钮的 UI 调试),但换来的是更高的适配度和完全的可控性。下一篇将聚焦安全功能和 SEO 优化。

参考

本文作者: 有次元袋的 tiger
本文链接: https://www.superheaoz.top/2026/06/1626/
版权声明: 本站点所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 我的个人天地
手机扫码阅读