为 Hexo Fluid 接入 Waline 浏览量统计

前言

最近想给博客加上浏览量统计,一开始想着这不是很简单嘛,找个统计服务接进去就好了。Fluid 主题本身已经支持 Busuanzi、LeanCloud、OpenKounter 等统计源,其中 LeanCloud 本来也是很多 Hexo 主题用来做浏览量统计的老方案。

但看到 LeanCloud 的停服公告后,就感觉这个方案不太适合继续用了。公告里提到,LeanCloud 从 2026 年 1 月 12 日开始停止新用户注册和创建新应用,正式停止服务时间是 2027 年 1 月 12 日,到时会关闭应用访问、数据读写、API 调用、控制台使用等面向公众的服务。

啊这,浏览量统计这种东西虽然不是什么核心业务,但要是继续接 LeanCloud,后面迟早还得迁移一次,不如现在直接换掉。结果看了一圈发现,Fluid 暂时没有内置 Waline 浏览量统计。

不过 Fluid 已经有人给 OpenKounter 做过类似接入了,可以参考这个 PR:fluid-dev/hexo-theme-fluid#1237。Mintimate 那篇 OpenKounter 的文章里也有 Hexo 接入示例,看完后大概就知道应该往哪里下手了。

这次要用的是 Waline 的浏览量统计功能。Waline 官方提供了 pageviewCount,可以根据页面 path 记录浏览量。用来做文章阅读量很合适,但它不直接提供真正的全站 PV/UV 聚合统计,所以这里就用一个折中办法:

  • 文章浏览量:使用文章自己的 window.location.pathname
  • 站点 PV:使用一个虚拟路径 __site_pv,每次访问页面都自增。
  • 站点 UV:使用一个虚拟路径 __site_uv,并用浏览器 localStorage 做 24 小时去重。

这种 UV 统计是浏览器级别的近似统计。清理浏览器数据、换浏览器、换设备都会被当成新访客。它不能等同于严格意义上的独立访客统计。

一开始我直接修改了 Fluid 主题源码,让 post.meta.views.sourcefooter.statistics.source 多一个 waline 分支。功能能跑,但看着 themes/fluid 里多出来的改动,总感觉以后更新主题时会给自己挖坑。

所以最后还是换成了非侵入式方案:使用 Hexo 的 scripts/ 自动加载机制和 Fluid 的 theme_inject 注入点,把 HTML 片段和统计脚本都放在站点侧。这样主题源码保持干净,以后更新 Fluid 时也不用盯着冲突发呆。

准备 Waline 服务端

需要先准备一个可以访问的 Waline 服务端。这个服务端地址也就是 Waline 文档里说的 serverURL

如果还没有部署 Waline,可以直接看官方的快速上手文档。按照文档完成服务端部署、数据库配置、重新部署后,在 Vercel 控制台里点击 Visit 访问到的地址,就是后面要填到配置里的 server_url

1
https://your-domain.vercel.app

这里用 https://your-domain.vercel.app 当占位示例,实际使用时要换成自己的 Waline serverURL。这个地址会被前端脚本读取,所以不能当成密钥来隐藏。

先别急着改代码,可以用浏览器或者 curl 测一下 Waline 的文章统计接口是否正常。

1
curl 'https://your-domain.vercel.app/api/article?path=__site_pv&type=time&lang=zh-CN'

正常情况下会返回类似下面的内容:

1
{"errno":0,"errmsg":"","data":[{"time":4}]}

如果这里接口请求失败,那前端页面大概率也显示不出统计结果。先排查服务端部署、域名解析、HTTPS、CORS 这些地方,不然后面改半天代码也可能只是白忙活。

配置 Fluid

先打开 _config.fluid.yml,在 web_analytics 下面加上 Waline 浏览量相关配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
web_analytics:
enable: true
follow_dnt: true

waline:
# Waline 服务端地址,留空时不加载统计
server_url: https://your-domain.vercel.app
# 统计页面时使用的路径
path: window.location.pathname
# 如果部署后只想统计线上访问,可以设为 true
ignore_local: false
# 用于近似站点 PV 的虚拟路径
site_pv_path: __site_pv
# 用于近似站点 UV 的虚拟路径
site_uv_path: __site_uv
# UV 去重窗口,单位毫秒,86400000 是 24 小时
uv_duration: 86400000
# 是否显示页脚站点 PV/UV
site_stats: true

然后把文章浏览量功能打开,并把来源写成 waline

1
2
3
4
5
post:
meta:
views:
enable: true
source: "waline"

这里要注意一下,Fluid 原生并不认识 source: "waline"。所以这个配置不是让 Fluid 自己处理 Waline,而是给后面站点侧注入脚本当开关用。

页脚的原生统计建议关闭,避免生成一个空的原生统计块。页脚 Waline 统计会由后面的注入脚本追加进去。

1
2
3
4
5
6
footer:
statistics:
enable: false
source: "leancloud"
pv_format: "总访问量 {} 次"
uv_format: "总访客数 {} 人"

最后把前端统计脚本加到 custom_js

1
2
3
4
custom_js:
- /js/sakura.js
- /js/mouse_firework_load.js
- /js/waline-pageview.js

custom_js 的路径相对 source 目录,所以 /js/waline-pageview.js 对应的是 source/js/waline-pageview.js

配置改完后,对应的 git diff 大概是下面这样,可以用来快速核对有没有漏掉关键配置。嗯,比只看一大段 YAML 直观多了。

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
44
45
46
diff --git a/_config.fluid.yml b/_config.fluid.yml
--- a/_config.fluid.yml
+++ b/_config.fluid.yml
@@
custom_js:
- /js/sakura.js
- /js/mouse_firework_load.js
+- /js/waline-pageview.js
@@
web_analytics:
enable: true
follow_dnt: true
@@
+ waline:
+ # Waline 服务端地址,留空时不加载统计
+ server_url: https://your-domain.vercel.app
+ # 统计页面时使用的路径
+ path: window.location.pathname
+ # 如果部署后只想统计线上访问,可以设为 true
+ ignore_local: false
+ # 用于近似站点 PV 的虚拟路径
+ site_pv_path: __site_pv
+ # 用于近似站点 UV 的虚拟路径
+ site_uv_path: __site_uv
+ # UV 去重窗口,单位毫秒,86400000 是 24 小时
+ uv_duration: 86400000
+ # 是否显示页脚站点 PV/UV
+ site_stats: true
@@
footer:
statistics:
- enable: true
- source: "leancloud"
+ # 原生页脚统计关闭,Waline 页脚统计由 scripts/waline-pageview-inject.js 注入
+ enable: false
+ source: "leancloud"
pv_format: "总访问量 {} 次"
uv_format: "总访客数 {} 人"
@@
post:
meta:
views:
- enable: false
- source: "busuanzi"
+ enable: true
+ source: "waline"

这个 diff 是配置思路示例,不一定和你的 _config.fluid.yml 行号完全一致。如果你的博客原本没有开启 web_analytics.enable,还需要把它改成 true

迁移 LeanCloud 历史浏览量

如果之前已经用 Fluid 的 LeanCloud 浏览量统计跑了一段时间,那直接切到 Waline 后,历史浏览量不会自动过去。呜,这就有点难受了,毕竟旧数据丢了总觉得怪怪的。

先看一下 Fluid 原来的 LeanCloud 统计逻辑,它用的是 LeanCloud 里的 Counter Class,主要字段是:

  • target:统计目标。文章浏览量就是文章路径,比如 /2024/06/14/example/
  • time:访问次数。

另外,Fluid 原生页脚统计会用两个固定的 target

  • site-pv:站点 PV。
  • site-uv:站点 UV。

而 Waline 的浏览量数据会写到数据库里的 wl_counter 表,核心字段是:

  • url:统计目标,对应 LeanCloud 的 target
  • time:访问次数,对应 LeanCloud 的 time

所以迁移思路就很简单了:从 LeanCloud 导出 Counter 数据,把里面的 targettime 转成 Waline 数据库里的 urltime

下面的迁移方法默认 Waline 使用官方快速上手里常见的 Neon / PostgreSQL 部署方式,并且表名前缀还是默认的 wl_。如果你用的是 MySQL、SQLite 或者改过 Waline 数据表前缀,需要按自己的数据库改一下 SQL。

从 LeanCloud 导出 Counter

在 LeanCloud 控制台里打开之前用于浏览量统计的应用,进入数据存储,找到 Counter Class,然后导出数据。

Mintimate 那篇 OpenKounter 的文章在“数据迁移”部分也提到了类似思路:先从 LeanCloud 控制台下载原始数据,再导入到新的统计服务里。OpenKounter 有自己的导入入口,但 Waline 浏览量统计没有专门给 Fluid Counter 准备的导入按钮,所以这里需要自己转换一下。

导出的数据大概会包含这些字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[
{
"objectId": "xxxx",
"target": "/2024/06/14/example/",
"time": 123,
"createdAt": "2024-06-14T12:00:00.000Z",
"updatedAt": "2026-05-29T12:00:00.000Z"
},
{
"objectId": "yyyy",
"target": "site-pv",
"time": 4567
}
]

不同导出方式拿到的 JSON 外层结构可能不太一样,有的直接是数组,有的可能包在 results 里。下面这个转换脚本做了一点兼容。

如果导出的 Counter 里只有 site-pvsite-uv,没有文章路径,那就只能迁移页脚的全站 PV/UV。文章阅读量没有出现在导出数据里,自然也没法从 LeanCloud 迁过去。

LeanCloud 里偶尔可能出现多个相同 target 的记录,比如并发初始化时创建了重复的 site-pv。所以脚本会按 Waline 的 url 合并求和,最后每个 URL 只生成一条 SQL。

转换成 Waline SQL

新建一个临时脚本,比如 leancloud-counter-to-waline-sql.js。这个脚本只是迁移时用一下,不要放进博客根目录的 scripts/ 里,不然 Hexo 构建时会把它当插件加载。

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
'use strict';

const fs = require('fs');

const inputFile = process.argv[2];

if (!inputFile) {
console.error('用法: node leancloud-counter-to-waline-sql.js Counter.json > waline-counter.sql');
process.exit(1);
}

const input = JSON.parse(fs.readFileSync(inputFile, 'utf8'));

function getRows(data) {
if (Array.isArray(data)) return data;
if (Array.isArray(data.results)) return data.results;
if (Array.isArray(data.Counter)) return data.Counter;

const arrayValue = Object.values(data).find(Array.isArray);
return arrayValue || [];
}

function getValue(row, key) {
return row[key] ?? (row._serverData && row._serverData[key]);
}

function toWalineURL(target) {
if (target === 'site-pv') return '__site_pv';
if (target === 'site-uv') return '__site_uv';
return target;
}

function sqlString(value) {
return "'" + String(value).replace(/'/g, "''") + "'";
}

const counter = new Map();

for (const row of getRows(input)) {
const target = getValue(row, 'target');
const time = Number(getValue(row, 'time') || 0);

if (!target) continue;

const url = toWalineURL(target);
counter.set(url, (counter.get(url) || 0) + time);
}

const rows = Array.from(counter, ([url, time]) => ({ url, time }));
const urls = rows.map((row) => sqlString(row.url));

console.log('BEGIN;');

if (urls.length > 0) {
console.log('DELETE FROM wl_counter WHERE url IN (' + urls.join(', ') + ');');
}

for (const row of rows) {
console.log(
'INSERT INTO wl_counter (url, time, createdAt, updatedAt) VALUES (' +
sqlString(row.url) +
', ' +
row.time +
', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);'
);
}

console.log('COMMIT;');

然后运行:

1
node leancloud-counter-to-waline-sql.js Counter.json > waline-counter.sql

这样会生成一个 waline-counter.sql 文件。里面会先删除同名 url 的旧记录,再插入从 LeanCloud 转换来的数据。即使 LeanCloud 导出的 Counter 里有重复 target,也会先合并后再导入。

上面的脚本会把 LeanCloud 的 site-pv 转成本文配置里的 __site_pv,把 site-uv 转成 __site_uv。如果你不想转换,也可以把 _config.fluid.yml 里的 site_pv_pathsite_uv_path 改成 site-pvsite-uv

导入到 Waline 数据库

如果你用的是 Waline 快速上手里的 Neon,可以进入 Neon 控制台,打开 SQL Editor,把生成的 waline-counter.sql 粘进去运行。

导入完成后,可以用 SQL 查一下:

1
SELECT url, time FROM wl_counter ORDER BY time DESC LIMIT 10;

再打开博客页面,文章浏览量和页脚 PV/UV 应该就会从迁移后的数字继续往上加了。

导入前最好先备份 Waline 数据库,尤其是已经上线跑了一段时间的站点。DELETE FROM wl_counter WHERE url IN (...) 只会删除脚本里出现的路径,但手滑导错数据库还是很麻烦。

如果你要迁移的是评论数据,不是浏览量数据,那就不要用上面的 Counter 转换脚本了。Waline 官方有“从 Valine 迁移”和“数据迁移助手”,那个更适合处理 LeanCloud 里 Valine 风格的评论数据。

修改后的目录结构

配置文件改完后,还需要新增几个文件。最后的目录结构大概是这样:

1
2
3
4
5
6
7
8
9
10
blog/
├── _config.fluid.yml
├── scripts/
│ └── waline-pageview-inject.js
└── source/
├── _inject/
│ ├── waline-footer-statistics.ejs
│ └── waline-post-views.ejs
└── js/
└── waline-pageview.js

这几个目录不是随便放的,作用不太一样:

  • scripts/waline-pageview-inject.js 是 Hexo 构建时运行的脚本,用来告诉 Fluid 要把哪些 EJS 片段塞到页面里。
  • source/_inject/*.ejs 是给 Fluid 注入用的模板片段。放在 source/_inject 里,是为了和普通文章、图片、前端脚本分开。
  • source/js/waline-pageview.js 是浏览器里运行的前端脚本,要通过 custom_js 生成到站点里,所以要放在 source 下面。

不要把 waline-pageview.js 也放进 scripts/ 里。scripts/ 目录下的文件会被 Hexo 当成构建脚本执行,而不是作为前端静态资源发布出去。放错地方的话,浏览器就访问不到这个文件了。

注入 Fluid 页面位置

在博客根目录新建 scripts/waline-pageview-inject.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* global hexo */

'use strict';

const path = require('path');

hexo.extend.filter.register('theme_inject', function(injects) {
injects.postMetaTop.file(
'waline-post-views',
path.join(hexo.base_dir, 'source/_inject/waline-post-views.ejs'),
{},
{},
1
);

injects.footer.file(
'waline-footer-statistics',
path.join(hexo.base_dir, 'source/_inject/waline-footer-statistics.ejs'),
{},
{},
1
);
});

这个文件的作用很简单:在 Hexo 构建时告诉 Fluid,把我们自己的 EJS 片段注入到 postMetaTopfooter 这两个位置。

Hexo 会自动加载博客根目录 scripts/ 下的 JS 文件。这是 Hexo 官方支持的扩展方式,不需要额外在 package.json 中注册。

注入文章浏览量

新建 source/_inject/waline-post-views.ejs

1
2
3
4
5
6
7
8
9
10
11
12
<%
const walineAnalytics = theme.web_analytics && theme.web_analytics.waline ? theme.web_analytics.waline : {};
const viewsTexts = (theme.post.meta.views.format || __('post.meta.views')).split('{}');
%>
<% if (walineAnalytics.server_url && theme.post.meta.views.enable && theme.post.meta.views.source === 'waline' && viewsTexts.length >= 2) { %>
<div class="mt-1">
<span id="waline-page-views-container" class="post-meta" style="display: none">
<i class="iconfont icon-eye" aria-hidden="true"></i>
<%- viewsTexts[0] %><span class="waline-pageview-count"></span><%- viewsTexts[1] %>
</span>
</div>
<% } %>

这里做了几个判断,免得配置没开时还硬往页面里塞东西:

  • server_url 为空时不渲染。
  • post.meta.views.enable 必须为 true
  • post.meta.views.source 必须为 waline
  • 浏览量格式中必须包含 {} 占位符。

这是注入到 postMetaTop 的追加内容,不修改 Fluid 原来的 meta-top.ejs。所以它比侵入式修改更好维护,但布局上可能不会和原生分支完全一样。如果看着有点偏,可以通过外层 mt-1 或自定义 CSS 微调。

注入页脚 PV/UV

新建 source/_inject/waline-footer-statistics.ejs

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
<%
const walineAnalytics = theme.web_analytics && theme.web_analytics.waline ? theme.web_analytics.waline : {};
const pvTexts = (theme.footer.statistics.pv_format || __('footer.pv')).split('{}');
const uvTexts = (theme.footer.statistics.uv_format || __('footer.uv')).split('{}');
const showSiteStats = walineAnalytics.server_url && walineAnalytics.site_stats !== false;
%>
<% if (showSiteStats && (pvTexts.length >= 2 || uvTexts.length >= 2)) { %>
<div id="waline-footer-statistics" class="statistics">
<% if (pvTexts.length >= 2) { %>
<span id="waline-site-pv-container" style="display: none">
<%- pvTexts[0] %>
<span class="waline-site-pv-count" data-path="<%= walineAnalytics.site_pv_path || '__site_pv' %>"></span>
<%- pvTexts[1] %>
</span>
<% } %>
<% if (uvTexts.length >= 2) { %>
<span id="waline-site-uv-container" style="display: none">
<%- uvTexts[0] %>
<span class="waline-site-uv-count" data-path="<%= walineAnalytics.site_uv_path || '__site_uv' %>"></span>
<%- uvTexts[1] %>
</span>
<% } %>
</div>
<script>
(function() {
var stats = document.getElementById('waline-footer-statistics');
var footerInner = document.querySelector('footer > .footer-inner');
if (stats && footerInner) {
footerInner.appendChild(stats);
}
})();
</script>
<% } %>

这里有一个小细节:注入点 footer 在 Fluid 原生 .footer-inner 后面。如果直接输出一个新的 .footer-inner,字号和间距会和原主题不太一样,看起来就会有点怪。

所以这里先输出统计块,再用一小段脚本把它移动到原生 .footer-inner 里面,让它复用 Fluid 原来的 .statistics 样式。这样最后显示出来的效果会更像主题自带的页脚统计。

如果你以后换主题,或者 Fluid 修改了页脚结构,需要检查 footer > .footer-inner 这个选择器是否还存在。

编写前端统计脚本

新建 source/js/waline-pageview.js

这里没有直接动态导入 Waline 官方的 pageview.js,而是直接调用 Waline 服务端的 /api/article 接口。原因是有些浏览器或网络环境会拦截从 CDN 动态导入 ESM 模块,控制台里可能会出现类似 CORS 或模块来源不允许的错误。

既然最后都是请求 Waline 的文章统计接口,那就直接请求接口吧,这样还能少依赖一个外部 JS 文件。

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
/* global CONFIG, Fluid */

(function(window, document) {
'use strict';

var analytics = (CONFIG.web_analytics && CONFIG.web_analytics.waline) || {};
var serverURL = analytics.server_url || analytics.serverURL || '';

if (!serverURL) {
return;
}

function normalizeServerURL(url) {
var normalized = String(url || '').replace(/\/$/u, '');
return /^(https?:)?\/\//.test(normalized) ? normalized : 'https://' + normalized;
}

var apiURL = normalizeServerURL(serverURL).replace(/\/?$/, '/') + 'api/';
var headers = { 'Content-Type': 'application/json' };

function validHost() {
if (analytics.ignore_local) {
var hostname = window.location.hostname;
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]') {
return false;
}
}
return true;
}

function getPath() {
var pathConfig = analytics.path || 'window.location.pathname';
try {
return eval(pathConfig);
} catch (e) {
console.warn('Waline pageview: invalid path config, fallback to window.location.pathname');
return window.location.pathname;
}
}

function normalizePath(path) {
var normalized = String(path || window.location.pathname).replace(/\/*(index.html)?$/, '/');
try {
return decodeURI(normalized);
} catch (e) {
return normalized;
}
}

function unwrapResponse(data, label) {
if (data && typeof data === 'object' && data.errno) {
throw new TypeError(label + ' failed with ' + data.errno + ': ' + data.errmsg);
}
return data && data.data;
}

function getCounter(path) {
return fetch(
apiURL + 'article?path=' + encodeURIComponent(path) + '&type=time&lang=' + encodeURIComponent(analytics.lang || navigator.language)
)
.then(function(resp) {
return resp.json();
})
.then(function(data) {
return unwrapResponse(data, 'Get counter');
});
}

function incrementCounter(path) {
return fetch(apiURL + 'article?lang=' + encodeURIComponent(analytics.lang || navigator.language), {
method: 'POST',
headers: headers,
body: JSON.stringify({
path: path,
type: 'time',
action: 'inc'
})
})
.then(function(resp) {
return resp.json();
})
.then(function(data) {
return unwrapResponse(data, 'Update counter');
});
}

function getTime(data) {
var item = Array.isArray(data) ? data[0] : data;
return item && typeof item.time === 'number' ? item.time : null;
}

function renderCount(selector, data, containerSelector) {
var time = getTime(data);
if (time === null) {
return;
}

document.querySelectorAll(selector).forEach(function(ele) {
ele.innerText = time.toString();
});

var container = document.querySelector(containerSelector);
if (container) {
container.style.display = 'inline';
}
}

function validUV() {
var key = 'Waline_Site_UV_Flag';
var now = Date.now();
var duration = parseInt(analytics.uv_duration, 10) || 86400000;

try {
var flag = localStorage.getItem(key);
if (flag && now - parseInt(flag, 10) <= duration) {
return false;
}
localStorage.setItem(key, now.toString());
} catch (e) {
console.warn('Waline pageview: localStorage is not available');
}
return true;
}

function updateCount(selector, path, update, containerSelector) {
if (!document.querySelector(selector)) {
return;
}

var request = update ? incrementCounter(path) : getCounter(path);
request
.then(function(data) {
renderCount(selector, data, containerSelector);
})
.catch(function(error) {
console.error('Waline pageview error:', error);
});
}

var dnt = window.Fluid && Fluid.ctx && Fluid.ctx.dnt;
var enableIncr = CONFIG.web_analytics.enable && !dnt && validHost();
var pagePath = normalizePath(getPath());
var sitePVPath = analytics.site_pv_path || '__site_pv';
var siteUVPath = analytics.site_uv_path || '__site_uv';

updateCount('.waline-pageview-count', pagePath, enableIncr, '#waline-page-views-container');
updateCount('.waline-site-pv-count', sitePVPath, enableIncr, '#waline-site-pv-container');
if (document.querySelector('.waline-site-uv-count')) {
updateCount('.waline-site-uv-count', siteUVPath, enableIncr && validUV(), '#waline-site-uv-container');
}
})(window, document);

如果只想查询浏览量而不自增,可以把 _config.fluid.ymlweb_analytics.enable 设置为 false。这样脚本仍然会读取数值,但不会提交自增请求。

构建与检查

完成后运行:

1
hexo generate

如果构建成功,可以在生成的 HTML 中搜索下面这些关键字,确认节点和前端脚本都已经进页面了。

1
2
3
4
waline-pageview-count
waline-site-pv-count
waline-site-uv-count
/js/waline-pageview.js

也可以打开浏览器开发者工具,检查是否请求了:

1
https://你的-waline-服务端/api/article

如果页脚没有显示统计信息,优先看浏览器控制台和网络请求。常见原因是 server_url 填错、Waline 服务端不可访问、接口 CORS 配置异常,或者统计节点还没拿到数据所以保持 display: none

常见问题

为什么不用直接修改 Fluid 源码?

直接修改 fluid/layout/_partials/post/meta-top.ejsfluid/layout/_partials/footer/statistics.ejs 可以做得更像原生功能,但后续更新 Fluid 时容易产生冲突。

非侵入式方案把逻辑放到博客根目录:

1
2
3
4
scripts/waline-pageview-inject.js
source/_inject/waline-post-views.ejs
source/_inject/waline-footer-statistics.ejs
source/js/waline-pageview.js

这样主题源码保持干净,维护成本更低。

为什么 scripts/waline-pageview-inject.js 放在根目录?

这是 Hexo 官方支持的脚本扩展机制。把 JS 文件放到博客根目录的 scripts/ 中,Hexo 初始化时会自动加载它。

theme_inject 是 Hexo 原生的吗?

不是。theme_inject 是 Fluid 主题内部实现的注入机制。Fluid 的源码中可以看到它会执行 hexo.execFilterSync('theme_inject', injects),并提供 postMetaTopfooter 等注入点。

为什么不用 Waline 官方的 pageview.js

Waline 官方 pageview.js 是可用的,但在某些环境中从 CDN 动态导入 ESM 模块可能遇到 CORS 或浏览器策略拦截。这里直接调用 Waline 服务端 API,可以少依赖一个外部 JS 文件。

全站 PV/UV 准确吗?

PV 是一个虚拟路径 __site_pv 的访问次数,每次页面加载都会增加,适合作为站点总访问量的近似值。

UV 是虚拟路径 __site_uv 加本地 localStorage 去重,只能做到浏览器级别的近似 UV。

参考资料

此外,Fluid 当前主题源码里也能看到注入机制的实现:


为 Hexo Fluid 接入 Waline 浏览量统计
https://licyk.netlify.app/2026/05/29/add-waline-pageview-to-hexo-fluid/
作者
licyk
发布于
2026年5月29日
许可协议