Hexo 博客体验升级:SEO 优化 + 用户体验增强 + 品牌视觉重构

前言

上一篇《Hexo 博客三项基础修复》解决了 RSS、Sitemap、评论系统的高优先级硬性问题,博客具备了稳固的基础设施。但 Lighthouse 审查还暴露了更多问题:

类别 问题数 严重程度
SEO 4 项 🟡 中
体验 7 项 🟡 中
品牌 2 项 🟡 中

其中 SEO 问题影响搜索引擎发现和社交分享展示,体验问题影响读者停留和互动,品牌问题则关系到博客的识别度和专业性。

本文将依次记录这三个方面共 13 项优化的完整过程。


一、SEO 优化

1.1 Open Graph / Twitter Card

问题: 文章分享到微信、Twitter 时没有标题、描述、缩略图,只有光秃秃的 URL。

方案: NexT 主题已集成 Hexo 内置的 open_graph() 方法,在 head-unique.swig 中调用:

1
{{ open_graph() }}

它自动从页面 front-matter 提取 title、description、image 生成 OG 标签:

1
2
3
<meta property="og:title" content="文章标题">
<meta property="og:description" content="文章摘要">
<meta property="og:image" content="文章首图 URL">

无需手动配置。如果需要自定义,Hexo 支持在 _config.yml 中传参:

1
2
3
4
open_graph:
twitter_card: summary_large_image
twitter_id: "@your_id"
fb_admins: your_id

1.2 JSON-LD 结构化数据

问题: 搜索引擎不理解页面的结构和内容类型,无法生成 Rich Snippet(富摘要)。文章在搜索结果页只能显示标题和链接,没有作者、发布时间等增强信息。

方案: 编写了一个 json-ld.js helper,在 head-unique.swig 中调用 {{ json_ld() }},根据页面类型输出三组结构化数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// themes/next/scripts/helpers/json-ld.js — 核心逻辑
hexo.extend.helper.register('json_ld', function() {
var siteTitle = this.config.title;
var siteUrl = this.config.url;
var blocks = [];

if (this.page.__index) {
// 首页:WebSite + SearchAction
blocks.push({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: siteTitle,
url: siteUrl,
potentialAction: {
'@type': 'SearchAction',
target: siteUrl + '/search.json?q={search_term_string}',
'query-input': 'required name=search_term_string'
}
});
}

if (this.page.layout === 'post') {
// 文章页:BlogPosting + 作者 + Publisher + 关键词 + 发布日期
blocks.push({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: this.page.title,
description: this.page.description,
datePublished: this.page.date.format(),
dateModified: (this.page.updated || this.page.date).format(),
author: { '@type': 'Person', name: this.config.author },
publisher: { '@type': 'Organization', name: this.config.title }
});
}

// 所有页面:面包屑导航
blocks.push({ /* BreadcrumbList */ });

return blocks.map(function(b) {
return '<script type="application/ld+json">' +
JSON.stringify(b) + '</script>';
}).join('\n');
});

完成后用 Google 的 Rich Results Test 验证,确认 BlogPostingBreadcrumbList 结构正确。

1.3 统计系统接入

问题: 博客上线后没有任何访问数据,无法了解读者来源、热门内容。

方案: NexT 主题内置了 Google Analytics 和百度统计的注入模板,只需配置 tracking_id

1
2
3
4
5
6
# themes/next/_config.yml
google_analytics:
tracking_id: G-XXXXXXXXXX
only_pageview: false

baidu_analytics: your_baidu_hm_code

配置后自动在 <head> 中注入 gtag.js 脚本。only_pageview: false 表示加载完整 gtag,支持事件追踪和用户分析。如果只想统计 PV,设为 true 会改用 sendBeacon 轻量推送。

1.4 自动描述生成

问题: 91 篇文章,手动写 description 太耗时。没有 description 的页面在搜索引擎中摘要为空,影响点击率。

方案: 写了一个 auto-description.js 过滤器,在 after_post_render 阶段自动提取前 200 字符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// themes/next/scripts/filters/auto-description.js
hexo.extend.filter.register('after_post_render', function(data) {
if (data.description && data.description.trim()) return data;

var plainText = (data.content || data._content || '')
.replace(/<[^>]+>/g, '')
.replace(/```[\s\S]*?```/g, '')
.replace(/[#*>`\-|~_\n\r\t\[\]]/g, ' ')
.replace(/\s{2,}/g, ' ')
.trim();

var description = plainText.substring(0, 200).trim();
if (description) {
data.description = description + (plainText.length > 200 ? '...' : '');
}
return data;
});

规则:

  • 已手动填写 description 的文章不覆盖
  • 去掉 HTML 标签和代码块
  • 去掉 Markdown 语法符号
  • 截取前 200 字符,超出加 ...

这样所有页面都自动有了 SEO 友好的元描述。


二、用户体验优化

2.1 自定义 404 页面

问题: GitHub Pages 默认 404 页面太简陋,访客遇到死链直接离开。

方案: 创建 source/404.md,使用 NexT 页面模板渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
---
title: 页面未找到
layout: page
permalink: /404.html
---
<div class="error-page">
<div class="error-code">404</div>
<div class="error-message">鱼跑路了...</div>
<p>您要找的页面可能已经被鱼叼走了</p>
<div class="error-actions">
<a href="/" class="btn-error">回到首页</a>
<a href="/archives/" class="btn-error">浏览文章</a>
<a href="/fish/" class="btn-error">看鱼去</a>
</div>
</div>

加上品牌色样式的按钮(#37c6c0),hover 时上浮:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// source/_data/styles.styl
.error-page
text-align: center
padding: 60px 20px
.error-code
font-size: 100px
color: #37c6c0
.btn-error
display: inline-block
padding: 10px 24px
border-radius: 24px
background: #37c6c0
color: #fff
&:hover
transform: translateY(-2px)

2.2 阅读时间估算

问题: 读者打开文章时不知道大概需要多久能读完,容易没有心理预期。

方案: 安装 hexo-symbols-count-time 插件,自动计算字数和预估阅读时间:

1
npm install hexo-symbols-count-time --save

主题配置中启用:

1
2
3
4
5
# themes/next/_config.yml
symbols_count_time:
separated_meta: true
item_text_post: true
item_text_total: false

效果:文章标题下方显示 📄 字数: 2,350 / 🕐 阅读时间 ≈ 12 分钟。底部全站统计显示博客总字数。

2.3 相关文章推荐

问题: 读者看完一篇文章后,不知道还有什么相关内容可以看,缺少停留引导。

方案: 在 after_post_render 过滤器中实现标签匹配算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// themes/next/scripts/filters/related-posts.js
hexo.extend.filter.register('after_post_render', function(data) {
if (!data.tags || !data.tags.length) return data;

var allPosts = hexo.locals.get('posts');
var currentTags = data.tags.map(function(t) {
return ((t && t.name) || t || '').toLowerCase();
});

var scored = [];
allPosts.data.forEach(function(p) {
if (p._id === data._id) return;

var score = 0;
var postTags = (p.tags && p.tags.data) || p.tags || [];
for (var i = 0; i < postTags.length; i++) {
var tag = postTags[i];
if (!tag) continue;
var tagName = ((tag.name || tag) + '').toLowerCase();
if (currentTags.indexOf(tagName) >= 0) score++;
}

if (score > 0) scored.push({ post: p, score: score });
});

scored.sort(function(a, b) {
return b.score - a.score || b.post.date - a.post.date;
});

// 取 top 6 渲染卡片
var html = '<div class="related-posts">...';
scored.slice(0, 6).forEach(function(item) { /* 渲染标题+日期+摘要 */ });
data.content += html;
});

卡片展示效果:使用 CSS Grid 自适应布局,悬停时边框变为品牌色并轻微上浮。

2.4 文章系列导航

问题: 像「C# 学习笔记」这种系列文章有 22 篇,读者不知道当前读到第几篇、前后是什么。

方案: 通过自定义标签 series:xxx 标记同系列文章,按日期排序,自动生成导航:

1
2
3
4
5
# 文章 front-matter 示例
tags:
- series:C#学习笔记
- C#
- 委托

过滤器实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// themes/next/scripts/filters/series-nav.js
hexo.extend.filter.register('after_post_render', function(data) {
if (!data.tags || !data.tags.length) return data;

var seriesTagObj = null;
data.tags.forEach(function(t) {
if (t && t.name && String(t.name).startsWith('series:')) {
seriesTagObj = t;
}
});

if (!seriesTagObj) return data;

var seriesName = String(seriesTagObj.name).replace('series:', '');
var seriesPosts = seriesTagObj.posts.toArray().sort(function(a, b) {
return a.date - b.date;
});

var currentIndex = -1;
for (var i = 0; i < seriesPosts.length; i++) {
if (seriesPosts[i]._id === data._id) {
currentIndex = i;
break;
}
}

// 渲染:`C#学习笔记 系列` + `第 3/22 篇` + 上/下篇链接
data.content += navHtml;
});

踩坑记录data.tagsafter_post_render 阶段是 _Query 对象,不是普通数组,Array.from() 会返回全 undefined。必须使用 forEach() 原生迭代。另外,用 TagModel.findOne({name: tagName}) 查询含冒号的 tag name 会失败,需要直接用 tag 对象上携带的 .posts 属性。

2.5 置顶功能

问题: 首页按时间倒序显示文章,重要公告无法置顶。

方案: 前置过滤器和后置渲染器配合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// themes/next/scripts/filters/pinned-posts.js
// before_generate: 全局排序
hexo.extend.filter.register('before_generate', function() {
var posts = hexo.locals.get('posts');
posts.data.sort(function(a, b) {
var aTop = Number(a.top || a.sticky || 0);
var bTop = Number(b.top || b.sticky || 0);
if (aTop && !bTop) return -1;
if (!aTop && bTop) return 1;
if (aTop && bTop) return bTop - aTop;
return b.date - a.date;
});
});

// after_post_render: 置顶徽章
hexo.extend.filter.register('after_post_render', function(data) {
if (!data.top && !data.sticky) return data;
var notice = '<div class="post-pinned-notice">📌 置顶文章</div>';
data.content = notice + data.content;
});

在文章 front-matter 中加一行即可置顶:

1
top: 1   # 数字越大越靠前

2.6 标签云 / 分类可视化

问题: /tags/ 页面只有列表,看不出哪些标签文章多。

方案: NexT 主题已使用 Hexo 内置的 tagcloud() 方法,按文章数自动调整字号和颜色:

1
2
3
4
5
6
7
# themes/next/_config.yml
tagcloud:
min: 12 # 最少文章 → 12px
max: 30 # 最多文章 → 30px
start: "#ccc" # 最少 → 灰色
end: "#111" # 最多 → 黑色
amount: 200

渲染代码在 page.swig 中:

1
2
3
4
5
6
7
8
9
10
{%- if page.type === 'tags' %}
{{ tagcloud({
min_font : theme.tagcloud.min,
max_font : theme.tagcloud.max,
amount : theme.tagcloud.amount,
color : true,
start_color: theme.tagcloud.start,
end_color : theme.tagcloud.end
}) }}
{%- endif %}

分类页面同理,使用 list_categories()

2.7 滚动动画

问题: 页面切换和内容出现过于生硬。

方案: NexT 主题内置 Velocity.js 动画引擎,开箱即用:

1
2
3
4
5
6
7
8
9
10
# themes/next/_config.yml
motion:
enable: true
async: false
transition:
post_block: fadeIn
post_header: slideDownIn
post_body: slideDownIn
coll_header: slideLeftIn
sidebar: slideUpIn

效果:页面加载时文章块淡入、侧边栏滑入。async: false 确保动画按序播放。


三、品牌视觉重构

3.1 品牌色系统

问题: 博客颜色来自 NexT 默认灰蓝,到处都是标准 Bootstrap 风格,没有辨识度。

方案: 在 source/_data/variables.styl 中定义品牌色体系,覆盖 NexT 内置变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// source/_data/variables.styl
// 品牌色
$brand-primary = #37c6c0 // 青蓝 — 海洋/钓鱼主题
$brand-secondary = #ff6b6b // 暖橙红 — 活力点缀
$brand-accent = #ffd93d // 金黄 — 强调
$brand-success = #6bcb77 // 绿 — 正面反馈
$brand-link = #4d96ff // 蓝 — 可识别链接

// 覆盖 NexT 主题变量
$blue = $brand-primary
$blue-hover = darken($brand-primary, 10%)
$link-color = $brand-link
$link-hover-color = $brand-secondary
$btn-default-bg = transparent
$btn-default-color = $brand-primary
$btn-default-border = $brand-primary

// 圆角统一
$border-radius-inner = 20px 20px 20px 20px
$border-radius = 20px

这套变量通过 NexT 的 custom_file_path 注入机制自动生效。覆盖原理:source/_data/variables.styl 在主题 Stylus 编译开始时加载,后续所有引用 $blue$link-color 的地方都会解析为我们的品牌色。

实际影响:

  • 代码高亮的蓝色 → 青蓝 #37c6c0
  • 文章链接色 → #4d96ff,hover → #ff6b6b
  • 按钮边框和文字 → 品牌色
  • 全局圆角 → 20px(原本是直角)

踩坑记录:品牌色还在 styles.styl 中被多处直接引用(比如 404 页面的 .error-code、置顶标识 .post-pinned-notice、系列导航 .series-nav 的边框),这些不会自动被变量覆盖,需要手动更新。建议新样式统一使用变量,已有样式逐步迁移。

3.2 Hero 区域

问题: 首页打开直接是文章列表,没有品牌展示区。新访客无法快速了解”这个博客是做什么的”。

方案: 在首页顶部增加 Hero 展示区,包含四个部分:

① 模板 (themes/next/layout/_partials/hero.swig):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<div class="hero-section">
<div class="hero-content">
<h1 class="hero-title">🎣 ByteFisher</h1>
<div class="hero-subtitle">
<span class="hero-prefix">钓鱼爱好者的</span>
<span class="hero-typing" id="hero-typing"></span>
<span class="hero-cursor">|</span>
</div>
<p class="hero-desc">分享游戏开发技术与钓鱼乐趣</p>
<div class="hero-stats">
<div class="hero-stat">
<span class="hero-stat-value">{{ site.posts.length }}</span>
<span class="hero-stat-label">文章</span>
</div>
<div class="hero-stat">
<span class="hero-stat-value">{{ theme.fishAlbumCount or 0 }}</span>
<span class="hero-stat-label">鱼获</span>
</div>
<div class="hero-stat">
<span class="hero-stat-value">{{ theme.aigamesCount or 0 }}</span>
<span class="hero-stat-label">小游戏</span>
</div>
</div>
<div class="hero-actions">
<a href="/archives/" class="hero-btn hero-btn-primary">📚 浏览文章</a>
<a href="/fish/" class="hero-btn hero-btn-secondary">🎣 钓鱼相册</a>
<a href="/ai-games/" class="hero-btn hero-btn-secondary">🎮 玩小游戏</a>
</div>
</div>
</div>

② 引用 (themes/next/layout/index.swig):

1
2
3
4
{% block content %}
{% include '_partials/hero.swig' %}
{%- for post in page.posts.toArray() %}
...

③ 打字机动画 (source/_data/body-end.swig):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
(function() {
function initHeroTyping() {
var el = document.getElementById('hero-typing');
if (!el) return;
var texts = ['编程世界', '数字乐园', '钓鱼天地', '技术博客'];
var textIndex = 0, charIndex = 0, isDeleting = false;
function type() {
var current = texts[textIndex];
el.textContent = isDeleting
? current.substring(0, charIndex--)
: current.substring(0, charIndex++);
if (!isDeleting && charIndex === current.length) {
setTimeout(function() { isDeleting = true; }, 2000);
} else if (isDeleting && charIndex === 0) {
isDeleting = false;
textIndex = (textIndex + 1) % texts.length;
}
setTimeout(type, isDeleting ? 50 : 100);
}
type();
}
// DOM 就绪时启动
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initHeroTyping);
} else {
initHeroTyping();
}
})();

核心机制:四个标语循环切换,先逐字打出 → 停留 2 秒 → 逐字删除 → 切换到下一个。打字速度 100ms/字,删除速度 50ms/字,光标 0.8s 闪烁。

④ 样式 (source/_data/styles.styl):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
.hero-section
text-align: center
padding: 60px 20px 40px
background: linear-gradient(135deg, rgba(55,198,192,0.08), rgba(255,107,107,0.05))
border-radius: 20px
margin-bottom: 30px

.hero-title
font-size: 48px
font-weight: 800

.hero-stats
display: flex
justify-content: center
gap: 40px
.hero-stat-value
font-size: 32px
font-weight: 700
color: #37c6c0

.hero-actions
.hero-btn
border-radius: 40px
transition: all 0.3s
&:hover
transform: translateY(-2px)
.hero-btn-primary
background: linear-gradient(135deg, #37c6c0, #2da8a3)
color: #fff

@keyframes blink
0%, 50% { opacity: 1 }
51%, 100% { opacity: 0 }

关于数据统计theme.fishAlbumCounttheme.aigamesCountfish-albums.jsbefore_generate 阶段动态计算——前者统计 source/fish/ 下的子目录数,后者统计 source/ai-games/ 下的子目录数(排除 gamejs)。每次构建自动更新,无需手动维护。

效果:首页打开首先看到品牌名 → 打字机循环标语 → 文章/鱼获/小游戏实时统计 → 三个导航按钮,引导访客深入浏览。


四、总结

改造前后全景对比:

类别 改造前 改造后
SEO 无 OG/JSON-LD,无 Analytics,无 description 社交分享富卡片 + 结构化数据 + 自动描述 + 流量统计
体验 无 404 页面,无阅读时间,无相关文章/系列导航,无置顶 品牌化 404 + 阅读估算 + 相关推荐 + 系列导航 + 置顶公告
品牌 主题默认配色,首页直出文章列表 统一品牌色体系(5 色)+ Hero 展示区(打字机+统计+按钮)

全部 13 项优化涉及 15 个新增/修改文件,包括 6 个过滤器(related-posts.jsseries-nav.jspinned-posts.jsauto-description.jsfish-albums.jsjson-ld.js)、2 个模板(hero.swig404.md)和 3 个自定义样式/数据文件(variables.stylstyles.stylbody-end.swig)。

所有改动基于 Hexo 的插件和过滤器机制实现,不修改 NexT 主题核心文件,主题升级时只需重新注入自定义文件即可移植。