一、前言
文章数量、标签热度和分类分布都存在于 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.json 和 package-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/,检查:
- 月份折线图包含空月份;
- 标签只显示前十且顺序稳定;
- 分类图数值总和与文章分类统计一致;
echarts.min.js 来自站内路径;
- 深色模式切换后坐标轴和提示框可读;
- 手机端图表没有溢出;
- PJAX 重新进入页面不会重复创建实例;
- 控制台没有
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 构建期,把绘图留给浏览器。页面不需要数据库和统计接口,部署后仍然只是静态文件,却能展示持续更新的博客结构。