架站筆記

幫 Hexo 靜態部落格加上統計:不蒜子 + 自製 Cloudflare Worker 算即時在線人數

2026-08-09 #Hexo#教學#Cloudflare Workers#Durable Objects#不蒜子

我想在部落格上顯示三個數字:全站總瀏覽數訪客人數每篇文章的閱讀次數,外加一個比較有趣的 即時在線人數。問題是靜態網站沒有後端,這些數字沒地方算、沒地方存。這篇記錄我怎麼用「第三方服務 + 自製 Worker」的混合方案解決,包含那個在線人數 Worker 的完整程式碼與設計取捨。

一、靜態網站為什麼不能自己算

Hexo 產生的是純 HTML,部署後就是一堆躺在 CDN 上的檔案。要統計人數,至少需要三件事:接收事件、累加計數、讀取結果——這三件事都需要一個活著的伺服器。這跟上一篇講留言功能遇到的是同一種困境:靜態網站的動態需求,本質上都在問「後端由誰當」。

我最後採用混合方案,因為這四個數字的性質其實不同:

數字 方案 理由
全站總瀏覽數 不蒜子 累計型數字,掛了頂多數字不顯示,無傷大雅
訪客人數(UV) 不蒜子 同上
每篇文章閱讀數 不蒜子 同上
即時在線人數 自製 Cloudflare Worker 需要「當下狀態」,第三方服務沒有現成的

順帶一提,這三個「顯示在頁面上的數字」跟 Google Analytics 是兩回事——GA 的數據在 Google 後台給你看,不會出現在頁面上;不蒜子的數字則是給讀者看的。兩者互不衝突,我兩個都裝。

二、不蒜子:五分鐘搞定三個數字

不蒜子(bùsuànzǐ)是個極簡的免費計數服務:引入一支 script,在頁面放幾個特定 id 的元素,數字就會自己填進去。 沒有註冊、沒有 API key、沒有後台。

引入腳本:

1
<script async src="https://busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js"></script>

然後在想顯示數字的地方放對應的元素——全站統計(放在頁尾):

1
2
3
4
5
6
<span id="busuanzi_container_site_pv" style="display:none">
總瀏覽 <span id="busuanzi_value_site_pv"></span>
</span>
<span id="busuanzi_container_site_uv" style="display:none">
訪客 <span id="busuanzi_value_site_uv"></span>
</span>

單篇文章閱讀數(放在文章的日期旁邊):

1
2
3
<span id="busuanzi_container_page_pv" style="display:none">
閱讀 <span id="busuanzi_value_page_pv"></span>
</span>

注意那個 style="display:none"——這是刻意的設計。容器預設隱藏,不蒜子成功回傳數字後才把它顯示出來。這樣萬一服務掛掉或載入失敗,讀者看到的是「什麼都沒有」,而不是「總瀏覽 (空白) 次」這種殘缺的版面。外部依賴的失敗要靜默,不要留疤。

不蒜子的取捨

它的優點就是「零成本、零設定」,缺點也很明確,選之前要知道:

  • 它是個人維護的免費服務,偶爾會慢、偶爾會掛。不影響網站本身,但數字會消失。
  • 數字從你裝上去那天開始算,沒有歷史資料可以匯入。我的舊部落格數據就這樣歸零了,只能接受。
  • 資料在別人手上,拿不回來也沒有 API。

如果這些對你來說不可接受,就得走自架路線(例如 Umami、或下面那種自己寫 Worker 的做法)。但對一個新開的部落格來說,我覺得用五分鐘換三個數字是划算的。

三、即時在線人數:自己寫一個 Cloudflare Worker

「現在有幾個人在看這個網站」——這個數字不蒜子沒有,而且它的本質跟累計計數不同:它是狀態,不是累加。有人來要加、有人走要減,而「走」這件事瀏覽器根本不會通知你(關掉分頁就沒了,beforeunload 不可靠)。

設計:心跳 + 逾時淘汰

我用的是最經典的解法——心跳(heartbeat)

1
2
3
4
瀏覽器每 30 秒回報一次「我還在」
伺服器記下每個訪客最後回報的時間
超過 70 秒沒回報 → 視為離線,從名單移除
回傳名單人數 = 在線人數

70 秒這個閾值是刻意的:它是心跳間隔的兩倍多一點,容許漏掉一次心跳(網路抖動、分頁被瀏覽器凍結)而不會誤判離線,但又不會讓已離開的人在名單上賴太久。

為什麼需要 Durable Objects

一般的 Cloudflare Worker 是無狀態的——每次請求可能跑在不同的機器上,記憶體不共用。你在一個 Worker 實例裡記下的訪客名單,下一個請求可能看不到。

Durable Objects(DO) 解決的正是這件事:它保證「同一個 ID 的 DO 只有唯一一個實例」,所有請求都路由到它。因為在線人數是單一的全域狀態,我只需要一個 DO 實例(名字叫 global),所有心跳都往它送。

完整程式碼

workers/online-counter/src/index.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
const ALLOWED_ORIGINS = [
'https://murmurpaper.heitang.info',
'https://murmurpaper.pages.dev',
'http://localhost:4000',
];

const STALE_MS = 70_000; // 70 秒沒心跳就算離線

export class OnlineCounter {
constructor() {
this.seen = new Map(); // 訪客 id -> 最後心跳時間
}

async fetch(request) {
const now = Date.now();
// 每次請求順便清掉過期的訪客——不需要額外的定時任務
for (const [id, t] of this.seen) {
if (now - t > STALE_MS) this.seen.delete(id);
}
if (request.method === 'POST') {
const body = await request.json().catch(() => ({}));
const id = body && body.id;
if (typeof id === 'string' && id.length > 0 && id.length <= 64) {
this.seen.set(id, now);
}
}
return Response.json({ online: this.seen.size });
}
}

export default {
async fetch(request, env) {
const origin = request.headers.get('Origin') || '';
const cors = {
'Access-Control-Allow-Origin':
ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0],
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Cache-Control': 'no-store',
};
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: cors });
}
const stub = env.COUNTER.get(env.COUNTER.idFromName('global'));
const res = await stub.fetch(request);
return new Response(await res.text(), {
status: res.status,
headers: { 'Content-Type': 'application/json', ...cors },
});
},
};

wrangler.toml

1
2
3
4
5
6
7
8
9
10
11
name = "murmur-online-counter"
main = "src/index.js"
compatibility_date = "2026-08-01"

[[durable_objects.bindings]]
name = "COUNTER"
class_name = "OnlineCounter"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["OnlineCounter"]

程式很短,但有幾個決定值得說明:

清理過期訪客寫在每次請求裡,不用定時任務。 DO 支援 alarm(定時喚醒),但沒必要——反正每次心跳進來都要回傳人數,順手掃一遍 Map 就好。這讓程式少了一整套排程邏輯。

狀態只放記憶體,不寫儲存。 在線人數是「當下」的資料,掉了也無所謂——DO 若因長時間沒流量被回收,重建後最多一個心跳週期(30 秒)就恢復正確。為了這種資料付出寫入成本不值得。

CORS 用白名單,不是 * 只允許我自己的網域來源。這不是什麼高安全需求,但沒理由讓任意網站拿我的 Worker 當計數器。

訪客 id 限制長度(≤ 64 字元)。 來自客戶端的東西一律不信任——沒有這行,有人可以塞一個超長字串進來灌爆記憶體。

前端:sessionStorage 產生匿名 id

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
var id = sessionStorage.getItem('fp-visitor-id');
if (!id) {
id = (window.crypto && crypto.randomUUID)
? crypto.randomUUID()
: String(Date.now()) + Math.random().toString(16).slice(2);
sessionStorage.setItem('fp-visitor-id', id);
}

function beat() {
fetch(api + '/heartbeat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: id }),
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (typeof d.online === 'number') {
el.textContent = d.online;
wrap.style.display = ''; // 成功才顯示
}
})
.catch(function () {}); // 失敗靜默,維持隱藏
}

beat();
setInterval(beat, 30000);

sessionStorage 而非 localStorage 是刻意的隱私選擇:識別碼只活在這個分頁的這次瀏覽,關掉分頁就沒了,不會在使用者的裝置上留下長期的追蹤識別碼。在線人數本來就只關心當下,不需要知道「今天這個人昨天是不是也來過」。

代價是 sessionStorage 以分頁為單位:同一個人開三個分頁看你的網站,會產生三個 id、算成三個人。如果你希望多分頁算一個人,把 sessionStorage 換成 localStorage 即可——但那個 id 就會長期留在使用者的瀏覽器裡,是隱私與精確度的取捨。

錯誤處理一樣走「靜默失敗」原則:catch 什麼都不做,Worker 掛掉時那個數字就是不顯示,網站其他部分毫髮無傷。

部署

1
2
3
cd workers/online-counter
npx wrangler login # 瀏覽器授權
npx wrangler deploy

部署完會印出網址(https://<name>.<subdomain>.workers.dev)。剛部署完馬上測試可能會拿到 1042 或 1101 錯誤,這是傳播延遲,等一兩分鐘就好——我第一次就被這個嚇到,還掛了 wrangler tail 準備抓例外,結果 log 顯示請求全部正常。

測試指令:

1
2
3
4
5
6
7
curl -X POST https://<你的worker>.workers.dev/heartbeat \
-H "Content-Type: application/json" -d '{"id":"visitor-a"}'
# {"online":1}

curl -X POST https://<你的worker>.workers.dev/heartbeat \
-H "Content-Type: application/json" -d '{"id":"visitor-b"}'
# {"online":2} ← 兩個不同訪客,計數正確

四、成本:會不會爆免費額度?

這是我做完之後第一個擔心的問題,算完發現差得很遠:

項目 免費額度 實際用量
Worker 請求 100,000 次/日 一天要有 1 萬人次、每人停留 5 分鐘才用完
DO 請求 100,000 次/日 同上(各自獨立計算)
DO 運算時間 13,000 GB-秒/日 數學上不可能超過
DO 儲存 5 GB 0(只用記憶體)

運算時間那項值得特別說:這個設計只有一個 DO 實例(128 MB),就算它 24 小時全程醒著,也只消耗 86,400 秒 × 0.125 GB = 10,800 GB-秒,天生低於 13,000 的上限。單一 DO 架構自帶成本天花板——這是設計時沒想到、事後發現很安心的性質。

真的超過會怎樣?請求會失敗,前端的 catch 靜默吞掉,數字消失而已,部落格本體完全不受影響(Pages 的靜態檔案服務不計流量)。額度每天 UTC 00:00(台灣早上 8 點)重置。

五、接進 Hexo 主題

我的主題(FlatPaper)沒有內建這些,所以自己寫了一個 partial _partial/site-stats.ejs,用設定檔開關控制:

1
2
3
4
5
# _config.<theme>.yml
site_stats:
enable: true
busuanzi: true # 全站瀏覽/訪客數 + 每篇閱讀次數
online_api: 'https://<你的worker>.workers.dev' # 留空則不顯示在線人數

然後在 layout.ejs 的頁尾後面掛上這個 partial,文章頁的 meta 區塊再加上單篇閱讀數的元素。三個功能各自獨立——不想要在線人數就把 online_api 留空,不想要不蒜子就把 busuanzi 設 false,彼此不互相依賴。

小結

1
2
3
不蒜子       → 累計型數字(總瀏覽、訪客、單篇閱讀):零成本、零維護、可接受偶爾掛掉
自製 Worker → 狀態型數字(即時在線):心跳制 + Durable Objects,免費額度綽綽有餘
GA → 完整分析:數據在後台,跟頁面顯示是兩件事

貫穿這三個選擇的原則是依數字的性質選工具,以及所有外部依賴都要能靜默失敗——統計是錦上添花的功能,絕不能因為它掛掉而影響閱讀體驗。頁尾那幾個小小的數字,背後其實有這些考量。

如果你也在用 Hexo,這個部落格的建站流程留言系統也都記錄過了。

Creative Commons 姓名標示 非商業性

本文採用 CC BY-NC 4.0 授權

歡迎轉載與引用,請標明出處(幫 Hexo 靜態部落格加上統計:不蒜子 + 自製 Cloudflare Worker 算即時在線人數 — mur mur);禁止用於商業用途。

商業使用或合作提案,歡迎來信洽談:[email protected]

留言
分享

留言