这篇文章记录 Nova 主题的实现思路。Nova 是我为这个博客定制的 Hexo 主题,目标是把学术个人主页和博客合二为一——进来先看到个人介绍,再从里面跳转到博客内容。

整体架构

Hexo 的工作流程很简单:

1
Markdown 文章 → 渲染器 → layout 模板 → 静态 HTML

每个页面对应一个 layout,Hexo 按页面类型在 layout/ 目录下找对应的 .ejs 文件。Nova 主题的 layout 分工如下:

文件 负责的页面
layout.ejs 所有页面的外壳(<html>/<head>/<body>
index.ejs 首页学术主页
archive.ejs /blog/ 博客列表
post.ejs 文章详情页
page.ejs 关于、友链等静态页

首页的路由问题

这是整个主题里最绕的地方。

Hexo 有一个内置的 index_generator 插件,它会把所有文章聚合,生成 index.html,用的是 index.ejs。默认行为是渲染文章列表——但我们想要的首页是学术主页,不是文章列表。

解决方案是:index.ejs 直接写学术主页的完整 HTML,完全绕过文章列表逻辑

1
2
3
/          → index.ejs       → 学术主页(两栏布局)
/blog/ → archive.ejs → 博客卡片列表
/posts/xxx → post.ejs → 文章详情

/blog/ 对应的是 source/blog/index.md,里面只有一行 front-matter:

1
2
3
---
layout: archive
---

这样 Hexo 就会用 archive.ejs 渲染它,而不是默认的 index.ejs

两栏学术布局

index.ejs 的 HTML 结构:

1
2
3
4
5
6
7
8
.academic-wrapper  (CSS Grid: 280px + 1fr)
├── .academic-sidebar (position: sticky)
│ └── 头像、姓名、机构、社交链接
└── .academic-content (右侧滚动区)
├── Biography
├── 技术栈
├── 教育经历
└── 最新文章

CSS 核心只有两行:

1
2
3
4
5
6
7
8
.academic-wrapper {
display: grid;
grid-template-columns: 280px 1fr;
}
.profile-sticky {
position: sticky;
top: calc(var(--nav-height) + 2rem);
}

左栏固定不动,右栏随页面滚动。响应式处理也很简单,小屏幕下把 grid 改成单列就行。

数据配置

所有个人信息存在 themes/nova/_config.yml,在 EJS 里通过 theme.* 访问:

1
2
3
4
5
6
7
8
9
10
11
12
13
profile:
name: Gu EnHao
name_cn: 古恩豪
avatar: /images/avatar.jpg
bio: 新疆大学软件工程在读...

education:
- school: 西湖大学
school_en: Westlake University
school_logo: /images/logos/westlake.png
period: 2025.12 2026.03
degree: 访问学生
dept: 自然语言处理实验室

模板里这样用:

1
2
3
4
5
6
7
8
<h1><%= theme.profile.name %></h1>

<% theme.education.forEach(edu => { %>
<div class="ac-edu-item">
<img src="<%- url_for(edu.school_logo) %>" alt="<%= edu.school %>">
<span><%= edu.school %></span>
</div>
<% }) %>

文章数据来自 site.posts(Hexo 全局对象),不需要手动配置。

暗色模式(无闪烁)

这是个经典的 FOUC(Flash of Unstyled Content)问题。如果在 JS 里切换主题,页面加载时会先白屏再变暗,体验很差。

解决方案是在 <head> 里放一段同步执行的内联脚本,在浏览器渲染任何内容之前就把主题设好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script>
(function(){
var t = localStorage.getItem('nova-theme');
if (t) document.documentElement.setAttribute('data-theme', t);
// 禁用所有 transition,防止初始化时的动画闪烁
document.documentElement.classList.add('no-transition');
window.addEventListener('DOMContentLoaded', function(){
requestAnimationFrame(function(){
requestAnimationFrame(function(){
document.documentElement.classList.remove('no-transition');
});
});
});
})();
</script>
<style>.no-transition, .no-transition * { transition: none !important; }</style>

requestAnimationFrame 嵌套两层是为了确保浏览器完成一帧渲染后再恢复 transition,避免初始化动画。

CSS 主题切换靠 [data-theme] 属性选择器 + CSS 变量:

1
2
3
4
5
6
7
8
9
:root {
--bg: #ffffff;
--text: #1a1a1a;
--accent: #3b82f6;
}
[data-theme="dark"] {
--bg: #121212;
--text: #e5e5e5;
}

TOC 目录的坑

Hexo 内置的 toc() helper 对中文标题生成的锚点有问题,href 经常是空的,点了没反应。

所以 TOC 完全用 JS 动态构建,读取页面里实际存在的标题元素的 id

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function buildToc() {
const tocList = document.getElementById('tocList');
const headings = document.querySelectorAll('.post-content h1, .post-content h2, .post-content h3');

headings.forEach(h => {
// Hexo 把 id 放在 <h2> 内部的 <span> 上,不是 <h2> 本身
const idEl = h.id ? h : h.querySelector('[id]');
if (!idEl) return;

const a = document.createElement('a');
a.href = '#' + idEl.id;
a.textContent = h.textContent.trim();
tocList.appendChild(a);
});
}

滚动高亮也是 JS 实现的,监听 scroll 事件,找到当前视口内最近的标题,给对应的 TOC 链接加 active class。

博客卡片的渐变色条

每张卡片顶部有一条 4px 的彩色渐变,6 张一循环,用 nth-child + ::before 伪元素实现:

1
2
3
4
5
6
7
8
9
.post-card::before {
content: '';
display: block;
height: 4px;
}
.post-card:nth-child(6n+1)::before { background: linear-gradient(90deg, #3b82f6, #06b6d4); }
.post-card:nth-child(6n+2)::before { background: linear-gradient(90deg, #8b5cf6, #ec4899); }
.post-card:nth-child(6n+3)::before { background: linear-gradient(90deg, #10b981, #3b82f6); }
/* ... */

不需要额外 HTML,纯 CSS 搞定。

总结

整个主题的核心思路:

  • 路由:用 Hexo 的 layout 系统做页面分发,index.ejs 劫持首页渲染学术主页
  • 数据:个人信息放 _config.yml,文章数据用 Hexo 全局 site.posts
  • 主题切换:CSS 变量 + [data-theme] 属性,内联脚本防 FOUC
  • 交互:JS 只处理 TOC、暗色模式切换、导航滚动阴影,没有引入任何框架

主题已开源在 hexo-theme-nova,欢迎 star。