需求聽起來很小:首頁的文章卡片,能不能像日期和分類那樣,順便顯示每篇的閱讀次數?
我原本以為是在模板裡多加一個 span 的事。實際上這個需求逼我把整個計數系統從第三方換成自建——因為不蒜子在架構上就做不到。這篇記錄我怎麼確認它做不到、怎麼設計替代品,以及一個「批次查詢」的端點如何讓 10 張卡片只用一個請求。
一、先確認:不蒜子真的做不到嗎?
之前的統計功能我用不蒜子,它在文章頁顯示單篇閱讀數運作良好。所以第一個問題是:能不能在首頁一次顯示十篇的數字?
與其猜,我把它的腳本抓下來看——壓縮後只有 1939 位元組,一次讀完:
1 | bszCaller.fetch("//busuanzi.ibruce.info/busuanzi?jsonpCallback=BusuanziCallback", |
整支腳本的行為就這樣:
- 發一次 JSONP 請求,網址裡沒有任何參數能指定要查哪一頁
- 伺服器靠
Referer判斷「你現在在哪一頁」(腳本設了referrerPolicy: no-referrer-when-downgrade就是為了確保 Referer 送得出去) - 回傳固定三個值:
site_pv、page_pv、site_uv - 寫進固定 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 | [[durable_objects.bindings]] |
新增 DO 類別要加一個新的 migration tag(v2),不能改舊的——這是 Cloudflare 的遷移機制,跟資料庫 migration 一樣是往前追加。
四、關鍵設計:兩種讀取方式
整個系統的核心就是這兩個端點的分工:
1 | 文章頁 POST /view {path, visitor} → {views, uniques, sitePv, siteUv} |
文章頁:一個請求做完兩件事。 記錄這次瀏覽(寫入)並且回傳這頁需要的所有數字(讀取)。既然都要寫入了,順便把結果帶回來,就不必再發第二個請求去讀。
列表頁:一個請求拿回所有卡片。 前端先掃描頁面上所有帶 data-views-path 的元素、收集路徑,打包成一次查詢:
1 | var els = document.querySelectorAll('[data-views-path]'); |
實測首頁 10 張卡片——只發一個請求。如果做成一張卡一個請求,就是 10 倍的請求數、10 倍的額度消耗,還會有 10 個 TCP 往返的延遲。
伺服器端用一次 SQL 撈完:
1 | countFor(paths) { |
參數化查詢(? 佔位符)而不是字串拼接——paths 來自使用者可控的網址,這是基本防線。另外限制最多 100 個路徑,避免有人用超長查詢字串打你。
五、兩個容易忽略的細節
路徑正規化:/a 和 /a/ 必須是同一篇
網站內連結有時帶尾斜線、有時不帶,location.pathname 也可能因設定而異。不處理的話,同一篇文章會被拆成兩個計數器,數字永遠對不起來。
正規化寫在伺服器端,因為它是唯一的權威來源:
1 | function normalizePath(value) { |
前端也放一份簡化版,只是為了送出前先統一格式——但真正的權威在伺服器。前端的正規化壞掉頂多多一次無效查詢,伺服器的正規化壞掉才會造成資料分裂。
訪客識別:localStorage 還是 sessionStorage?
這兩個計數器用不同的儲存,而且是刻意的:
| 計數器 | 用哪個 | 為什麼 |
|---|---|---|
| 在線人數 | sessionStorage |
「現在幾個人」只關心當下,識別碼不需要活過這個分頁 |
| 訪客數(UV) | localStorage |
「不重複訪客」要跨工作階段才有意義——同一個人明天再來不該算成新訪客 |
同一個網站、兩個計數器、兩種相反的選擇,因為它們問的問題不同。
無痕模式取不到 localStorage 時要能繼續運作:
1 | var vid; |
伺服器端看到空的 visitor 就只加 pv、不碰 uv。降級而不是崩潰。
六、驗證流程:先本機、再正式
Worker 改完我沒有直接部署,而是分三段驗證。這個順序很重要——部署到正式環境之後才發現邏輯錯誤,資料就已經髒了。
第一段:本機模擬。 wrangler dev --local 會在本機跑一個完整的 Workers 執行環境,含 Durable Objects 和 SQLite:
1 | npx wrangler dev --port 8788 --local |
然後用 curl 把每個行為都測過:
1 | # 路徑正規化:帶斜線與不帶斜線應該累加到同一個計數器 |
第二段:部署後煙霧測試。 部署完等二十秒(新版本要傳播),對正式端點重跑一次核心路徑。
這裡有個前一次踩過的經驗:剛部署完馬上打,可能拿到 1042 或 1101 錯誤。那是傳播延遲,不是程式壞了——當初我還掛了
wrangler tail準備抓例外,結果 log 顯示請求全部正常。
第三段:瀏覽器實測。 前面兩段測的是 Worker,最後這段才驗證「前端有沒有正確接上」。我用瀏覽器開首頁,檢查三件事:
1 | // 這頁到底發了幾個請求給 Worker? |
以及所有卡片是否都拿到數字並顯示。這一段不能省:Worker 測到爛不代表模板選對了元素、屬性名稱沒拼錯。
七、失敗處理:統計掛掉不能影響閱讀
所有計數元素預設是隱藏的(hidden 屬性),拿到數字才顯示:
1 | <span class="meta-views" data-views-path="/2026/08/09/xxx/" hidden> |
1 | fetch(...).then(paint).catch(function () {}); // 失敗就靜默,維持隱藏 |
Worker 掛掉、額度用盡、網路不通——讀者看到的是「沒有那行字」,而不是「閱讀 – 次」這種殘缺顯示。統計是錦上添花的功能,絕不能因為它壞掉而破壞版面。
這個原則跟當初用不蒜子時一樣,只是那時是靠它的 hides(),現在是自己的 catch。
八、代價與成本
數字從零開始。 不蒜子的累計數據沒有 API 可以匯出,換系統就是歸零。我的站上線才兩天、損失趨近於零,但如果你的部落格已經跑了三年,這會是個真實的決策點——那些數字對你有多重要?
保留退路。 我沒有把不蒜子的程式碼刪掉,而是做成設定開關:
1 | site_stats: |
模板兩種都還支援。自建的東西出問題時,改一行設定就能退回去——自建系統應該永遠留一條退路,尤其是剛上線、還沒經過時間考驗的時候。
額度:文章頁一次請求、列表頁一次請求。Cloudflare 免費方案是每日 10 萬次 Worker 請求與 10 萬次 DO 請求,離用完非常遠(細算過程在另一篇)。SQLite 儲存免費額度 5 GB,而一筆瀏覽紀錄只有幾十位元組。
小結
1 | 需求 首頁卡片顯示閱讀數 |
回頭看,這件事的分水嶺是花五分鐘讀那 1.9 KB 的原始碼。在那之前我可能還會嘗試各種 workaround;讀完之後「不可能」變成一個確定的事實,決策就從「還能怎麼弄」變成「換掉它值不值得」——後者是個容易回答的問題。
確認一件事做不到,跟找到做法一樣有價值。
留言