架站筆記

從不蒜子換成自建計數器:為了讓首頁卡片顯示閱讀數

2026-08-10 #Hexo#教學#Cloudflare Workers#Durable Objects#SQLite

需求聽起來很小:首頁的文章卡片,能不能像日期和分類那樣,順便顯示每篇的閱讀次數?

我原本以為是在模板裡多加一個 span 的事。實際上這個需求逼我把整個計數系統從第三方換成自建——因為不蒜子在架構上就做不到。這篇記錄我怎麼確認它做不到、怎麼設計替代品,以及一個「批次查詢」的端點如何讓 10 張卡片只用一個請求。

一、先確認:不蒜子真的做不到嗎?

之前的統計功能我用不蒜子,它在文章頁顯示單篇閱讀數運作良好。所以第一個問題是:能不能在首頁一次顯示十篇的數字?

與其猜,我把它的腳本抓下來看——壓縮後只有 1939 位元組,一次讀完:

1
2
3
4
5
6
7
8
9
10
11
12
13
bszCaller.fetch("//busuanzi.ibruce.info/busuanzi?jsonpCallback=BusuanziCallback",
function (a) { bszTag.texts(a); bszTag.shows(); });

bszTag = {
bszs: ["site_pv", "page_pv", "site_uv"],
texts: function (a) {
this.bszs.map(function (b) {
var c = document.getElementById("busuanzi_value_" + b);
c && (c.innerHTML = a[b]);
});
},
...
};

整支腳本的行為就這樣:

  1. 一次 JSONP 請求,網址裡沒有任何參數能指定要查哪一頁
  2. 伺服器靠 Referer 判斷「你現在在哪一頁」(腳本設了 referrerPolicy: no-referrer-when-downgrade 就是為了確保 Referer 送得出去)
  3. 回傳固定三個值site_pvpage_pvsite_uv
  4. 寫進固定 id 的元素:busuanzi_value_page_pv

於是有兩個各自獨立的死結:它只知道當前這一頁(沒有 API 能查別的網址),而且id 是全域唯一的——就算能查,一個頁面上也放不了十個不同的計數器。

確認做不到,比猜做不到重要。 花五分鐘讀 1.9 KB 的原始碼,換來的是「不用再嘗試各種 hack」的確定性。

二、方案評估

方案 能否批次 代價
不蒜子 ✕ 架構上不行
Waline ✅ 原生支援 data-path 批次 要部署後端 + 資料庫(Vercel + MongoDB)
自建 Cloudflare Worker ✅ 自己定端點 多一段自己維護的程式碼

我選自建,決定性的理由是:我已經有一個 Worker 了。之前為了即時在線人數寫的那個 Durable Object 就跑在 Cloudflare 上,而且當初的 wrangler.toml 已經宣告了 SQLite 儲存。擴充它比多養一套 Waline 加資料庫划算得多,而且能順便把不蒜子整個換掉——全站瀏覽、訪客數、單篇閱讀、首頁卡片全部一套供應,資料在自己的帳號裡。

三、設計:兩個 Durable Object,刻意不合併

擴充時第一個決定是:在線人數和瀏覽數要不要共用同一個 DO?

分開。 因為兩者的資料性質完全相反:

在線人數 瀏覽數
語意 「現在」的快照 永久累計
儲存 記憶體即可 必須持久化(SQLite)
掉了會怎樣 一個心跳週期內自動恢復 資料永久消失
寫入頻率 每訪客每 30 秒 每次瀏覽一次

把「掉了無所謂」的資料和「掉了就完了」的資料放在同一個物件裡,等於讓前者的高頻寫入去騷擾後者的持久化。分成兩個 DO,各自用最適合的儲存策略:

1
2
3
4
5
6
7
8
9
10
11
[[durable_objects.bindings]]
name = "COUNTER"
class_name = "OnlineCounter" # 記憶體

[[durable_objects.bindings]]
name = "VIEWS"
class_name = "ViewCounter" # SQLite

[[migrations]]
tag = "v2"
new_sqlite_classes = ["ViewCounter"]

新增 DO 類別要加一個新的 migration tag(v2),不能改舊的——這是 Cloudflare 的遷移機制,跟資料庫 migration 一樣是往前追加。

四、關鍵設計:兩種讀取方式

整個系統的核心就是這兩個端點的分工:

1
2
文章頁  POST /view   {path, visitor}   → {views, uniques, sitePv, siteUv}
列表頁 GET /views?paths=/a/,/b/,/c/ → {views: {"/a/": 12, ...}, sitePv, siteUv}

文章頁:一個請求做完兩件事。 記錄這次瀏覽(寫入)並且回傳這頁需要的所有數字(讀取)。既然都要寫入了,順便把結果帶回來,就不必再發第二個請求去讀。

列表頁:一個請求拿回所有卡片。 前端先掃描頁面上所有帶 data-views-path 的元素、收集路徑,打包成一次查詢:

1
2
3
4
5
6
7
var els = document.querySelectorAll('[data-views-path]');
var paths = [];
els.forEach(function (el) {
var p = norm(el.getAttribute('data-views-path'));
if (paths.indexOf(p) === -1) paths.push(p); // 去重
});
fetch(api + '/views?paths=' + encodeURIComponent(paths.join(',')));

實測首頁 10 張卡片——只發一個請求。如果做成一張卡一個請求,就是 10 倍的請求數、10 倍的額度消耗,還會有 10 個 TCP 往返的延遲。

伺服器端用一次 SQL 撈完:

1
2
3
4
5
6
7
8
9
10
countFor(paths) {
const marks = paths.map(() => '?').join(',');
const rows = this.sql
.exec(`SELECT path, pv FROM views WHERE path IN (${marks})`, ...paths)
.toArray();
const out = {};
for (const p of paths) out[p] = 0; // 沒被看過的頁補 0,前端才不用處理 undefined
for (const r of rows) out[r.path] = Number(r.pv);
return out;
}

參數化查詢(? 佔位符)而不是字串拼接——paths 來自使用者可控的網址,這是基本防線。另外限制最多 100 個路徑,避免有人用超長查詢字串打你。

五、兩個容易忽略的細節

路徑正規化:/a/a/ 必須是同一篇

網站內連結有時帶尾斜線、有時不帶,location.pathname 也可能因設定而異。不處理的話,同一篇文章會被拆成兩個計數器,數字永遠對不起來。

正規化寫在伺服器端,因為它是唯一的權威來源:

1
2
3
4
5
6
7
8
9
10
11
12
function normalizePath(value) {
if (typeof value !== 'string') return '';
let p = value.trim();
if (!p || p.length > 512) return '';
try {
if (/^https?:\/\//i.test(p)) p = new URL(p).pathname; // 給完整網址也接受
} catch { return ''; }
p = p.split('?')[0].split('#')[0]; // 丟掉 query 與 hash
if (!p.startsWith('/')) p = '/' + p;
if (!p.endsWith('/')) p += '/'; // 統一補尾斜線
return p;
}

前端也放一份簡化版,只是為了送出前先統一格式——但真正的權威在伺服器。前端的正規化壞掉頂多多一次無效查詢,伺服器的正規化壞掉才會造成資料分裂。

訪客識別:localStorage 還是 sessionStorage?

這兩個計數器用不同的儲存,而且是刻意的:

計數器 用哪個 為什麼
在線人數 sessionStorage 「現在幾個人」只關心當下,識別碼不需要活過這個分頁
訪客數(UV) localStorage 「不重複訪客」要跨工作階段才有意義——同一個人明天再來不該算成新訪客

同一個網站、兩個計數器、兩種相反的選擇,因為它們問的問題不同。

無痕模式取不到 localStorage 時要能繼續運作:

1
2
3
4
5
var vid;
try {
vid = localStorage.getItem('fp-vid');
if (!vid) { vid = crypto.randomUUID(); localStorage.setItem('fp-vid', vid); }
} catch (e) { vid = '' } // 取不到就送空字串:瀏覽數照算,只是不去重

伺服器端看到空的 visitor 就只加 pv、不碰 uv。降級而不是崩潰

六、驗證流程:先本機、再正式

Worker 改完我沒有直接部署,而是分三段驗證。這個順序很重要——部署到正式環境之後才發現邏輯錯誤,資料就已經髒了。

第一段:本機模擬。 wrangler dev --local 會在本機跑一個完整的 Workers 執行環境,含 Durable Objects 和 SQLite:

1
npx wrangler dev --port 8788 --local

然後用 curl 把每個行為都測過:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 路徑正規化:帶斜線與不帶斜線應該累加到同一個計數器
curl -X POST $B/view -d '{"path":"/a/","visitor":"v1"}' # {"views":1,"uniques":1}
curl -X POST $B/view -d '{"path":"/a","visitor":"v1"}' # {"views":2,"uniques":1} ✅

# UV 去重:同訪客不重複計,新訪客才加
curl -X POST $B/view -d '{"path":"/a/","visitor":"v2"}' # {"views":3,"uniques":2} ✅

# 批次查詢:沒被看過的頁要回 0,不是消失
curl "$B/views?paths=/a/,/b/,/never/"
# {"views":{"/a/":3,"/b/":1,"/never/":0}} ✅

# 壞輸入
curl -o /dev/null -w "%{http_code}" -X POST $B/view -d '{"path":""}' # 400 ✅

第二段:部署後煙霧測試。 部署完等二十秒(新版本要傳播),對正式端點重跑一次核心路徑。

這裡有個前一次踩過的經驗:剛部署完馬上打,可能拿到 1042 或 1101 錯誤。那是傳播延遲,不是程式壞了——當初我還掛了 wrangler tail 準備抓例外,結果 log 顯示請求全部正常。

第三段:瀏覽器實測。 前面兩段測的是 Worker,最後這段才驗證「前端有沒有正確接上」。我用瀏覽器開首頁,檢查三件事:

1
2
3
4
5
// 這頁到底發了幾個請求給 Worker?
performance.getEntriesByType('resource')
.filter(e => e.name.includes('workers.dev'))
.map(e => e.name)
// → ["/views?paths=...十個路徑...", "/heartbeat"] ← 批次生效,只有一個查詢請求

以及所有卡片是否都拿到數字並顯示。這一段不能省:Worker 測到爛不代表模板選對了元素、屬性名稱沒拼錯。

七、失敗處理:統計掛掉不能影響閱讀

所有計數元素預設是隱藏的(hidden 屬性),拿到數字才顯示:

1
2
3
<span class="meta-views" data-views-path="/2026/08/09/xxx/" hidden>
閱讀 <span data-views-value></span>
</span>
1
fetch(...).then(paint).catch(function () {});   // 失敗就靜默,維持隱藏

Worker 掛掉、額度用盡、網路不通——讀者看到的是「沒有那行字」,而不是「閱讀 – 次」這種殘缺顯示。統計是錦上添花的功能,絕不能因為它壞掉而破壞版面。

這個原則跟當初用不蒜子時一樣,只是那時是靠它的 hides(),現在是自己的 catch

八、代價與成本

數字從零開始。 不蒜子的累計數據沒有 API 可以匯出,換系統就是歸零。我的站上線才兩天、損失趨近於零,但如果你的部落格已經跑了三年,這會是個真實的決策點——那些數字對你有多重要?

保留退路。 我沒有把不蒜子的程式碼刪掉,而是做成設定開關:

1
2
3
4
5
site_stats:
enable: true
provider: self # self(自建)| busuanzi(切回第三方)
api: 'https://xxx.workers.dev'
online: true

模板兩種都還支援。自建的東西出問題時,改一行設定就能退回去——自建系統應該永遠留一條退路,尤其是剛上線、還沒經過時間考驗的時候。

額度:文章頁一次請求、列表頁一次請求。Cloudflare 免費方案是每日 10 萬次 Worker 請求與 10 萬次 DO 請求,離用完非常遠(細算過程在另一篇)。SQLite 儲存免費額度 5 GB,而一筆瀏覽紀錄只有幾十位元組。

小結

1
2
3
4
5
6
需求    首頁卡片顯示閱讀數
阻礙 不蒜子只知道「當前頁」,且 id 全域固定 → 架構上不可能
方案 擴充既有的 Cloudflare Worker,加一個 SQLite 的 ViewCounter
關鍵 批次端點——10 張卡片一個請求,而不是 10 個
細節 路徑正規化在伺服器端;UV 用 localStorage、在線用 sessionStorage
驗證 wrangler dev --local → 部署煙霧測試 → 瀏覽器實測

回頭看,這件事的分水嶺是花五分鐘讀那 1.9 KB 的原始碼。在那之前我可能還會嘗試各種 workaround;讀完之後「不可能」變成一個確定的事實,決策就從「還能怎麼弄」變成「換掉它值不值得」——後者是個容易回答的問題。

確認一件事做不到,跟找到做法一樣有價值。

這個部落格的其他架站記錄:建站流程留言系統統計功能Mermaid 圖表主題客製圖片優化

Creative Commons 姓名標示 非商業性

本文採用 CC BY-NC 4.0 授權

歡迎轉載與引用,請標明出處(從不蒜子換成自建計數器:為了讓首頁卡片顯示閱讀數 — mur mur);禁止用於商業用途。

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

留言
分享

留言