一、前言

文章数量、标签热度和分类分布都存在于 Hexo 数据中,但普通页面无法直观看出发布节奏。本篇从一个空白 /charts/ 页面开始,在构建阶段统计月份、标签和分类,再使用站内 ECharts 生成折线图、横向柱状图和分类图。

二、交互演示:数据怎样一步步变成图表

Hexo 博客根目录
├─ source/charts/index.md # 统计页入口
├─ scripts/blog-charts.js # 构建期统计和 HTML
├─ source/js/blog-charts-runtime.js # ECharts 初始化与主题同步
├─ source/css/blog-charts.css # 页面和图表容器样式
└─ node_modules/echarts/ # 本地依赖

博客统计页数据流程

三、第一步:安装并锁定 ECharts

在博客根目录安装:

npm install echarts --save

确认 package.jsonpackage-lock.json 都发生变化。不要只引用公共 CDN,否则离线预览、国内网络和第三方可用性都会成为额外故障点。

四、第二步:创建统计页入口

新建 source/charts/index.md

---
title: 文章统计
layout: page
permalink: /charts/
comments: false
aside: false
toc: false
---
{% blog_charts %}

统计页拥有固定地址,不会进入首页文章列表。

五、第三步:读取文章、标签和分类

新建 scripts/blog-charts.js

'use strict'

const collectionItems = collection => {
if (!collection) return []
if (Array.isArray(collection.data)) return collection.data
if (Array.isArray(collection)) return collection
return typeof collection.toArray === 'function'
? collection.toArray()
: []
}

const buildChartData = () => {
const posts = collectionItems(hexo.locals.get('posts'))
const tags = collectionItems(hexo.locals.get('tags'))
const categories = collectionItems(hexo.locals.get('categories'))

return { posts, tags, categories }
}

这一步只把 Hexo 查询集合转成普通数组,后续统计才不会依赖内部实现。

六、第四步:生成连续月份数据

先累计实际出现的月份:

const monthCounts = new Map()

posts.forEach(post => {
const key = formatDate(post.date, 'YYYY-MM')
monthCounts.set(key, (monthCounts.get(key) || 0) + 1)
})

只有这一步还不够。如果某个月没有文章,它会直接从横轴消失。需要根据最早和最晚文章建立连续月份:

const monthly = monthRange(firstPost.date, latestPost.date).map(month => ({
month,
count: monthCounts.get(month) || 0
}))

最终得到:

[
{ "month": "2026-05", "count": 0 },
{ "month": "2026-06", "count": 2 },
{ "month": "2026-07", "count": 8 }
]

七、第五步:统计标签 Top 10

const topTags = tags
.map(tag => ({
name: String(tag.name),
value: Number(tag.length || collectionItems(tag.posts).length)
}))
.filter(item => item.value > 0)
.sort((a, b) => b.value - a.value ||
a.name.localeCompare(b.name, 'zh-CN'))
.slice(0, 10)

第二排序条件很重要:文章数相同的标签按中文名称稳定排序,避免每次构建顺序变化。

八、第六步:构建分类层级

分类可能有父子关系,不能只把它当作一维数组:

const categoryNode = category => ({
name: String(category.name),
value: Number(category.length || 0),
children: collectionItems(category.children).map(categoryNode)
})

const categoryTree = rootCategories.map(categoryNode)

如果只需要扁平玫瑰图,也可以把叶子节点展开后统计;保留树结构则可以使用旭日图展示上下级关系。

九、第七步:安全地把 JSON 写进 HTML

不要直接把任意 JSON 拼进 <script>。至少转义可能提前结束标签的字符:

const safeJson = value => JSON.stringify(value)
.replaceAll('<', '\\u003c')
.replaceAll('>', '\\u003e')
.replaceAll('&', '\\u0026')

const dataMarkup = data => `
<script type="application/json" id="blog-charts-data">
${safeJson(data)}
</script>`

运行时读取:

const dataNode = document.querySelector('#blog-charts-data')
const data = JSON.parse(dataNode?.textContent || '{}')

application/json 不会被浏览器当作 JavaScript 执行。

十、 第八步:把 ECharts 文件复制到站内

构建脚本从已安装依赖中读取发布文件,并生成:

public/js/vendor/echarts.min.js

脚本中可以使用 Hexo 生成器:

hexo.extend.generator.register('blog-charts-vendor', () => ({
path: 'js/vendor/echarts.min.js',
data: () => fs.createReadStream(
require.resolve('echarts/dist/echarts.min.js')
)
}))

这样 npm ci 后每次构建都能得到相同路径,不需要手工复制第三方文件。

十一、第九步:生成三个图表容器

自定义标签输出头部、统计概览和容器:

<section class="blog-charts" data-blog-charts>
<header class="blog-charts__header">...</header>
<div class="blog-charts__grid">
<section class="blog-chart-card is-wide">
<h2>文章发布趋势</h2>
<div data-chart="monthly"></div>
</section>
<section class="blog-chart-card">
<h2>标签 Top 10</h2>
<div data-chart="tags"></div>
</section>
<section class="blog-chart-card">
<h2>分类分布</h2>
<div data-chart="categories"></div>
</section>
</div>
</section>

并注册标签:

hexo.extend.tag.register('blog_charts', chartsMarkup)

十二、第十步:初始化折线图

新建 source/js/blog-charts-runtime.js

const monthlyOption = (data, colors) => ({
tooltip: { trigger: 'axis' },
grid: { left: 46, right: 22, top: 30, bottom: 38 },
xAxis: {
type: 'category',
data: data.map(item => item.month)
},
yAxis: {
type: 'value',
minInterval: 1
},
series: [{
type: 'line',
smooth: true,
symbolSize: 8,
data: data.map(item => item.count),
areaStyle: { opacity: 0.12 },
lineStyle: { width: 3, color: colors.accent }
}]
})

minInterval: 1 避免文章数量轴出现 0.5 篇。

十三、第十一步:生成标签柱状图与分类图

标签使用横向柱状图,长名称更容易阅读:

const tagOption = data => ({
grid: { left: 82, right: 24, top: 20, bottom: 24 },
xAxis: { type: 'value', minInterval: 1 },
yAxis: {
type: 'category',
inverse: true,
data: data.map(item => item.name)
},
series: [{
type: 'bar',
data: data.map(item => item.value),
barMaxWidth: 18
}]
})

分类层级使用 sunburst,扁平分类也可以替换为 pie

const categoryOption = data => ({
series: [{
type: 'sunburst',
radius: ['18%', '88%'],
data,
sort: null,
emphasis: { focus: 'ancestor' }
}]
})

十四、第十二步:同步深色模式和窗口尺寸

读取主题变量:

const themeColors = () => {
const styles = getComputedStyle(document.documentElement)
return {
text: styles.getPropertyValue('--font-color').trim(),
accent: styles.getPropertyValue('--theme-color').trim() || '#16a4b8'
}
}

窗口变化时调用 resize()

window.addEventListener('resize', () => {
charts.forEach(chart => chart.resize())
})

Butterfly 切换主题后,重新生成 option 并调用 setOption(),不要只改变外围 CSS。

十五、第十三步:加入统计页样式

新建 source/css/blog-charts.css

.blog-charts__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}

.blog-chart-card {
min-width: 0;
padding: 20px;
border: 1px solid rgba(127, 127, 127, 0.18);
border-radius: 16px;
background: var(--card-bg);
}

.blog-chart-card.is-wide {
grid-column: 1 / -1;
}

[data-chart] {
width: 100%;
height: 340px;
}

@media (max-width: 768px) {
.blog-charts__grid { grid-template-columns: 1fr; }
[data-chart] { height: 300px; }
}

十六、第十四步:构建和验证

npm run clean
npm run build
npm run verify
hexo server

打开 http://localhost:4000/charts/,检查:

  1. 月份折线图包含空月份;
  2. 标签只显示前十且顺序稳定;
  3. 分类图数值总和与文章分类统计一致;
  4. echarts.min.js 来自站内路径;
  5. 深色模式切换后坐标轴和提示框可读;
  6. 手机端图表没有溢出;
  7. PJAX 重新进入页面不会重复创建实例;
  8. 控制台没有 echarts is not defined

十七、常见问题

1. 图表容器是空白

检查容器是否有明确高度,并确认 ECharts 脚本先于运行时脚本加载。

2. 构建成功但统计仍是旧数据

执行 hexo clean,检查构建脚本是否在每次 before_generate 清空缓存。

3. 深色模式只有卡片变暗,图表文字仍是黑色

ECharts 绘制在 Canvas 中,必须重新设置 option,单独修改 CSS 无效。

4. 手机旋转后图表被压缩

监听 resize 并对每个实例调用 chart.resize(),同时保证父容器没有固定像素宽度。

十八、维护入口与结语

  • 统计口径和 JSON:scripts/blog-charts.js
  • 图表 option 与主题同步:source/js/blog-charts-runtime.js
  • 布局:source/css/blog-charts.css
  • 页面入口:source/charts/index.md

这套方案把计算放在 Hexo 构建期,把绘图留给浏览器。页面不需要数据库和统计接口,部署后仍然只是静态文件,却能展示持续更新的博客结构。