前言

统计页的难点不是调用 echarts.init(),而是把 Hexo 的文章模型整理成图表需要的数据,并确保图表只在统计页加载、能适应深色模式、窗口变化和 PJAX 页面切换。

一、统计页由哪几层组成

Hexo 文章、标签、分类
↓ 构建时统计
window.__BLOG_CHARTS_DATA__
↓ 浏览器读取
ECharts option 配置

折线图 + 柱状图 + 玫瑰图

项目中对应四个部分:

文件 作用
source/charts/index.md 提供图表容器和统计卡片
scripts/blog-charts.js 构建时收集并序列化数据
source/js/blog-charts-runtime.js 浏览器端创建 ECharts 实例
source/css/blog-charts.css 卡片尺寸、网格和移动端布局

二、为什么选择构建时统计

Hexo 本身已经知道所有文章、标签和分类。如果浏览器打开页面后再请求接口,不但需要额外服务器,也会重复读取数据。

构建阶段直接获取本地模型:

const posts = hexo.locals.get('posts').sort('date', 1).toArray()
const tags = hexo.locals.get('tags').toArray()
const categories = hexo.locals.get('categories').toArray()

统计结果会随每次 hexo generate 自动更新,线上仍然是纯静态页面。

三、月份折线图的数据怎样准备

如果只统计实际出现过的月份,没有发文的月份会消失,折线图横轴就不连续。例如一月发文、三月发文,不能把二月直接跳过。

脚本先从第一篇文章月份循环到当前月份,每个月填 0:

const firstMonth = moment(posts[0].date).startOf('month')
const finalMonth = moment.max(
moment().startOf('month'),
moment(posts.at(-1).date).startOf('month')
)

for (
const cursor = firstMonth.clone();
cursor.isSameOrBefore(finalMonth, 'month');
cursor.add(1, 'month')
) {
monthCounts.set(cursor.format('YYYY-MM'), 0)
}

再遍历文章累加:

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

每个月还会附带归档地址:

{
month: '2026-07',
count: 6,
path: '/archives/2026/07/'
}

所以点击折线上的有效数据点,可以直接进入当月归档。

四、标签 Top 10 怎样产生

标签数据先转换成 ECharts 常见的 { name, value } 结构:

const tagData = tags
.map(tag => ({
name: tag.name,
value: tag.length,
path: toSitePath(root, tag.path)
}))
.sort((a, b) =>
b.value - a.value ||
a.name.localeCompare(b.name, 'zh-CN')
)

浏览器端读取图表容器的 data-length="10",再截取前十个:

const limit = Number(element.dataset.length) || 10
const tags = data.tags.slice(0, limit)

将数量统计和显示数量分开后,将来想改成 Top 15,只改页面属性即可。

五、分类为什么要先构建树

Hexo 分类可以有父子层级。一篇文章可能同时带有父分类和子分类。如果直接把所有分类都算一次,文章会被重复统计。

脚本先把分类转换为节点:

{
id,
parentId,
name,
children: [],
posts: []
}

随后根据 parentId 连接父子关系,并判断文章所带分类中哪些是最深层分类:

const deepestIds = assignedIds.filter(id =>
!assignedIds.some(otherId =>
otherId !== id && isAncestorOf(id, otherId)
)
)

通俗地说:如果文章同时属于“建站”和“建站/Hexo”,只把它放进更具体的“Hexo”节点。父分类数量再由子节点向上汇总。

即使当前博客分类层级较浅,这种处理也避免以后增加子分类时统计失真。

六、数据怎样安全交给浏览器

统计对象通过脚本写入页面:

window.__BLOG_CHARTS_DATA__ = {
summary: {},
months: [],
tags: [],
categories: []
}

不能直接把未经处理的 JSON 拼进 <script>。如果标题中包含 </script> 或特殊换行符,可能提前结束脚本。序列化函数会转义危险字符:

const serializeForScript = value => JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029')

这是把服务端数据嵌入 HTML 时很容易忽略的一步。

七、为什么把 ECharts 打包到本地

项目安装了:

"echarts": "^6.1.0"

Hexo 生成器把依赖中的压缩文件输出到站点:

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

这样版本由 package-lock.json 固定,不受公共 CDN 突然失效或升级影响。

同时,脚本只有确认页面包含三个图表容器时才注入 ECharts:

if (!chartIds.every(id => html.includes(`id="${id}"`))) {
return html
}

首页和普通文章不会下载体积较大的图表库。

八、折线图 option 怎样读

ECharts 的核心是 option。可以把它理解成“图表说明书”:

chart.setOption({
xAxis: {
type: 'category',
data: data.months.map(item => item.month)
},
yAxis: {
type: 'value',
minInterval: 1
},
series: [{
type: 'line',
smooth: 0.42,
data: data.months.map(item => ({
value: item.count,
path: item.path
}))
}]
}, { notMerge: true })
  • xAxis.data 决定横轴月份;
  • minInterval: 1 防止文章数量出现 0.5 这样的刻度;
  • smooth 控制平滑曲线程度;
  • series.data 是真正绘制的数据;
  • notMerge: true 表示深色模式重绘时使用完整新配置,不残留旧颜色。

面积渐变通过 echarts.graphic.LinearGradient 创建,平均值虚线由 markLine 自动计算。

九、柱状图怎样实现 Top 10

柱状图使用:

series: [{
type: 'bar',
barMaxWidth: 58,
showBackground: true,
data: tags.map(item => ({
name: item.name,
value: item.value,
path: item.path
}))
}]

标签较多时横轴文字旋转 24 度,柱顶显示文章数量。点击柱形后读取自定义的 path

chart.off('click')
chart.on('click', event => {
if (event.data?.path) {
window.location.href = event.data.path
}
})

off()on() 是为了防止主题切换重绘后重复注册点击事件。

十、分类玫瑰图不是普通饼图

分类图的基础类型仍是饼图:

series: [{
type: 'pie',
roseType: 'radius',
data: categoryData
}]

普通饼图主要通过扇区角度表现大小;roseType: 'radius' 还会让数量更多的分类拥有更长半径,视觉差异更明显。

提示框中的占比来自 ECharts 自动计算的 params.percent

formatter: params =>
`${params.name}<br>` +
`文章数量:${params.value} 篇<br>` +
`占比:${params.percent.toFixed(2)}%`

移动端会缩小半径、字号和引导线,防止标签互相覆盖。

十一、深色模式怎样同步

颜色不是写死一套,而是通过函数读取当前主题:

const isDark = () =>
document.documentElement.getAttribute('data-theme') === 'dark'

const getColor = () =>
isDark() ? 'rgba(255,255,255,.88)' : '#354052'

主题变化时重新执行三张图的 setOption()

new MutationObserver(mutations => {
if (mutations.some(item => item.attributeName === 'data-theme')) {
requestAnimationFrame(renderAll)
}
}).observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
})

同时接入 Butterfly 的 themeChange 全局事件,兼容主题自身的切换流程。

十二、窗口变化为什么需要 resize

ECharts 初始化时会记住容器尺寸。浏览器窗口变化后,仅靠 CSS 改宽度不够,还要调用:

chart.resize()

为了避免拖动窗口时连续执行几十次,使用 requestAnimationFrame 合并同一帧内的请求:

window.addEventListener('resize', () => {
cancelAnimationFrame(resizeFrame)
resizeFrame = requestAnimationFrame(() => {
chartInstances.forEach(chart => chart.resize())
})
}, { passive: true })

十三、构建检查覆盖了什么

验证脚本会确认:

  • /charts/ 已生成;
  • 页面包含统计数据和三个脚本;
  • ECharts 没有被加载到首页;
  • 分类图仍是 pie + roseType: radius
  • 日期范围格式正确;
  • 统计卡片链接到归档、分类和标签页。

这样调整图表代码时,不容易无意中破坏关键功能。

十四、维护入口

  • 修改卡片和图表容器:source/charts/index.md
  • 修改数据统计口径:scripts/blog-charts.js
  • 修改折线、柱形和玫瑰图:source/js/blog-charts-runtime.js
  • 修改尺寸与移动端布局:source/css/blog-charts.css

结语

这套统计页的完整链路是“Hexo 构建数据,页面携带数据,ECharts 负责可视化”。理解这三层后,继续增加年度热力图或分类趋势图也会容易很多。