我想在部落格上顯示三個數字:全站總瀏覽數、訪客人數、每篇文章的閱讀次數,外加一個比較有趣的 即時在線人數。問題是靜態網站沒有後端,這些數字沒地方算、沒地方存。這篇記錄我怎麼用「第三方服務 + 自製 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 | <span id="busuanzi_container_site_pv" style="display:none"> |
單篇文章閱讀數(放在文章的日期旁邊):
1 | <span id="busuanzi_container_page_pv" style="display:none"> |
注意那個 style="display:none"——這是刻意的設計。容器預設隱藏,不蒜子成功回傳數字後才把它顯示出來。這樣萬一服務掛掉或載入失敗,讀者看到的是「什麼都沒有」,而不是「總瀏覽 (空白) 次」這種殘缺的版面。外部依賴的失敗要靜默,不要留疤。
不蒜子的取捨
它的優點就是「零成本、零設定」,缺點也很明確,選之前要知道:
- 它是個人維護的免費服務,偶爾會慢、偶爾會掛。不影響網站本身,但數字會消失。
- 數字從你裝上去那天開始算,沒有歷史資料可以匯入。我的舊部落格數據就這樣歸零了,只能接受。
- 資料在別人手上,拿不回來也沒有 API。
如果這些對你來說不可接受,就得走自架路線(例如 Umami、或下面那種自己寫 Worker 的做法)。但對一個新開的部落格來說,我覺得用五分鐘換三個數字是划算的。
三、即時在線人數:自己寫一個 Cloudflare Worker
「現在有幾個人在看這個網站」——這個數字不蒜子沒有,而且它的本質跟累計計數不同:它是狀態,不是累加。有人來要加、有人走要減,而「走」這件事瀏覽器根本不會通知你(關掉分頁就沒了,beforeunload 不可靠)。
設計:心跳 + 逾時淘汰
我用的是最經典的解法——心跳(heartbeat):
1 | 瀏覽器每 30 秒回報一次「我還在」 |
70 秒這個閾值是刻意的:它是心跳間隔的兩倍多一點,容許漏掉一次心跳(網路抖動、分頁被瀏覽器凍結)而不會誤判離線,但又不會讓已離開的人在名單上賴太久。
為什麼需要 Durable Objects
一般的 Cloudflare Worker 是無狀態的——每次請求可能跑在不同的機器上,記憶體不共用。你在一個 Worker 實例裡記下的訪客名單,下一個請求可能看不到。
Durable Objects(DO) 解決的正是這件事:它保證「同一個 ID 的 DO 只有唯一一個實例」,所有請求都路由到它。因為在線人數是單一的全域狀態,我只需要一個 DO 實例(名字叫 global),所有心跳都往它送。
完整程式碼
workers/online-counter/src/index.js:
1 | const ALLOWED_ORIGINS = [ |
wrangler.toml:
1 | name = "murmur-online-counter" |
程式很短,但有幾個決定值得說明:
清理過期訪客寫在每次請求裡,不用定時任務。 DO 支援 alarm(定時喚醒),但沒必要——反正每次心跳進來都要回傳人數,順手掃一遍 Map 就好。這讓程式少了一整套排程邏輯。
狀態只放記憶體,不寫儲存。 在線人數是「當下」的資料,掉了也無所謂——DO 若因長時間沒流量被回收,重建後最多一個心跳週期(30 秒)就恢復正確。為了這種資料付出寫入成本不值得。
CORS 用白名單,不是 *。 只允許我自己的網域來源。這不是什麼高安全需求,但沒理由讓任意網站拿我的 Worker 當計數器。
訪客 id 限制長度(≤ 64 字元)。 來自客戶端的東西一律不信任——沒有這行,有人可以塞一個超長字串進來灌爆記憶體。
前端:sessionStorage 產生匿名 id
1 | var id = sessionStorage.getItem('fp-visitor-id'); |
用 sessionStorage 而非 localStorage 是刻意的隱私選擇:識別碼只活在這個分頁的這次瀏覽,關掉分頁就沒了,不會在使用者的裝置上留下長期的追蹤識別碼。在線人數本來就只關心當下,不需要知道「今天這個人昨天是不是也來過」。
代價是 sessionStorage 以分頁為單位:同一個人開三個分頁看你的網站,會產生三個 id、算成三個人。如果你希望多分頁算一個人,把 sessionStorage 換成 localStorage 即可——但那個 id 就會長期留在使用者的瀏覽器裡,是隱私與精確度的取捨。
錯誤處理一樣走「靜默失敗」原則:catch 什麼都不做,Worker 掛掉時那個數字就是不顯示,網站其他部分毫髮無傷。
部署
1 | cd workers/online-counter |
部署完會印出網址(https://<name>.<subdomain>.workers.dev)。剛部署完馬上測試可能會拿到 1042 或 1101 錯誤,這是傳播延遲,等一兩分鐘就好——我第一次就被這個嚇到,還掛了 wrangler tail 準備抓例外,結果 log 顯示請求全部正常。
測試指令:
1 | curl -X POST https://<你的worker>.workers.dev/heartbeat \ |
四、成本:會不會爆免費額度?
這是我做完之後第一個擔心的問題,算完發現差得很遠:
| 項目 | 免費額度 | 實際用量 |
|---|---|---|
| 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 | # _config.<theme>.yml |
然後在 layout.ejs 的頁尾後面掛上這個 partial,文章頁的 meta 區塊再加上單篇閱讀數的元素。三個功能各自獨立——不想要在線人數就把 online_api 留空,不想要不蒜子就把 busuanzi 設 false,彼此不互相依賴。
小結
1 | 不蒜子 → 累計型數字(總瀏覽、訪客、單篇閱讀):零成本、零維護、可接受偶爾掛掉 |
貫穿這三個選擇的原則是依數字的性質選工具,以及所有外部依賴都要能靜默失敗——統計是錦上添花的功能,絕不能因為它掛掉而影響閱讀體驗。頁尾那幾個小小的數字,背後其實有這些考量。
留言