前言
這篇就是你現在在博客裡看到的「每篇文章都有自己瀏覽量」的完整實作紀錄,順便當作以後想重構或搬家時的備忘錄。
最終效果有兩個部分:
- 文章內頁 Meta 區塊:顯示 「 瀏覽量 X 」
- 首頁文章卡片:每張卡片右下角顯示對應文章的 「 瀏覽量 」
底層全部基於 Umami Cloud,再透過 Cloudflare Pages Function 代理 /api/umami,避免第三方追蹤腳本被瀏覽器或廣告阻擋器直接擋掉。
一、整體架構概念
整體流程可以拆成三層:
- 資料來源:Umami Cloud
- 在 Umami 後台建立站點,拿到
websiteId與 分享 Token (shareToken)。 - 透過 Umami 的
/websites/{websiteId}/metrics端點,查詢「路徑級別」的瀏覽數據。
- 在 Umami 後台建立站點,拿到
- 後端代理:Cloudflare Pages Functions
- 建立
functions/api/umami/[[path]].ts,將/api/umami/*代理到cloud.umami.is。 - 好處是瀏覽器只看到「同網域請求」,大幅降低被阻擋機率。
- 建立
- 前端渲染:Astro + 原生 JS
- 文章詳情頁:在
PostMeta.astro內嵌一段小腳本,針對單一路徑查詢瀏覽量。 - 首頁卡片:在
PostPage.astro裡一次打 Umami/metrics,回傳所有路徑,再對應到每張文章卡片。
- 文章詳情頁:在
你現在在畫面上看到的數字,就是這三層配合的結果。
二、Umami 代理:/api/umami 的設計
首先是 Cloudflare Functions 這一層,對應檔案是:
/** * Cloudflare Functions - Umami API 反向代理 * ... */這隻函數的關鍵點:
- 入口路徑:所有打到
/api/umami/*的請求都會進來這裡。 - 路由分流:
websites/*→ 轉發到https://cloud.umami.is/analytics/us/api/websites/*(統計查詢,如metrics、stats)script.js→ 轉發到https://cloud.umami.is/script.js(追蹤腳本)api/*→ 轉發到https://cloud.umami.is/api/*(事件上報)
- 統一 CORS:
- 回傳時加上
Access-Control-Allow-Origin: *等 header,方便前端直接fetch。
- 回傳時加上
這樣前端只需要打 /api/umami/...,不用關心實際 Umami Cloud 的網域與 CORS 細節。
三、文章內頁:單篇文章的瀏覽量
文章詳情頁使用的是 src/pages/posts/[...slug].astro,在這個模板裡,我把當前文章的實際路徑傳給 PostMeta:
<PostMetadata published={entry.data.published} updated={entry.data.updated} tags={entry.data.tags} category={entry.data.category} postPath={Astro.url.pathname} .../>重點是這個 postPath={Astro.url.pathname} —— 它會變成 /posts/xxx/ 這種實際路徑,後面拿來跟 Umami 數據對齊。
3.1 PostMeta.astro:顯示與更新瀏覽量
src/components/PostMeta.astro 新增了一個 postPath 屬性,並在模板中多渲染一塊「單篇文章瀏覽量」:
const shouldShowPostViews = typeof postPath === "string" && postPath.length > 0;
<!-- 單篇文章瀏覽量 -->{shouldShowPostViews && ( <div class="flex items-center umami-post-views" data-post-path={postPath}> <div class="meta-icon"> <Icon name="material-symbols:bar-chart-rounded" class="text-xl"></Icon> </div> <span class="text-50 text-sm font-medium page-views-display">瀏覽量 0</span> </div>)}接著在同一檔案底部塞了一段 inline script,專門負責:
- 正規化路徑(確保前後斜線統一)。
- 呼叫 Umami
/metrics,加上x-umami-share-tokenheader。 - 從回傳資料中找到對應路徑,抓出
pageviews。 - 把數字寫回
.page-views-display:
const UMAMI_CONFIG = Object.freeze({ baseUrl: "/api/umami", websiteId: "29fdf136-182c-41a4-9d06-9bfab393e177", shareToken: "..." // Umami 分享 Token});
const buildMetricsUrl = (path) => { const params = new URLSearchParams({ type: "path", startAt: "0", endAt: String(Date.now()), timezone: "Asia/Shanghai", }); return `${UMAMI_CONFIG.baseUrl}/websites/${UMAMI_CONFIG.websiteId}/metrics?${params.toString()}`;};
const fetchPostViews = async (path) => { const response = await fetch(buildMetricsUrl(path), { headers: { accept: "application/json", "x-umami-share-token": UMAMI_CONFIG.shareToken, }, }); // 解析 data / data.data / rows,找到對應 path 的 pageviews};
const initPostViews = async () => { const container = document.querySelector(".umami-post-views"); const normalizedPath = normalizePath(postPath || container.dataset.postPath); const pageviews = await fetchPostViews(normalizedPath); applyViewsText(container, pageviews ?? 0);};同時也掛了 Swup 事件:
const { swup } = window;if (swup?.hooks?.on) { swup.hooks.on("page:view", () => { void initPostViews(); });}這樣就算是透過 Swup 無刷新切頁,瀏覽量也會跟著重新抓一次。
四、首頁文章卡片:批次更新所有文章的瀏覽量
文章列表頁(首頁、分頁)使用的是 PostPage.astro + PostCard.astro,做法跟內頁有點不一樣:
- 文章內頁:一次只查一篇(單一路徑)。
- 首頁卡片:一次打
/metrics,拿到所有路徑的統計,最後在前端透過 Map 對應。
4.1 卡片模板:每張卡片有自己的路徑
在 PostCard.astro 裡,底部多了一個小按鈕用來顯示瀏覽數:
<div class="post-preview-views btn-regular ..." data-post-path={url} aria-label="文章瀏覽量"> <Icon name="material-symbols:bar-chart-rounded" class="text-sm" /> <span class="post-preview-views-text">0</span></div>這裡的 data-post-path={url} 會是 /posts/xxx/,後面統一丟給 Umami。
4.2 列表腳本:一次請求,全部回填
真正打 API 的是 PostPage.astro 底部那段 inline script:
const UMAMI_CONFIG = Object.freeze({ baseUrl: "/api/umami", websiteId: "29fdf136-182c-41a4-9d06-9bfab393e177", shareToken: "..."});
const buildMetricsUrl = () => { const params = new URLSearchParams({ type: "path", startAt: "0", endAt: String(Date.now()), timezone: "Asia/Shanghai", }); return `${UMAMI_CONFIG.baseUrl}/websites/${UMAMI_CONFIG.websiteId}/metrics?${params.toString()}`;};
const fetchPathViewsMap = async () => { const response = await fetch(buildMetricsUrl(), { headers: { accept: "application/json", "x-umami-share-token": UMAMI_CONFIG.shareToken, }, });
const data = await response.json(); const rows = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : []; const viewsMap = new Map();
rows.forEach((row) => { const rowPath = normalizePath(String(row?.x ?? row?.url ?? row?.pathname ?? row?.path ?? "")); const value = Number(row?.y ?? row?.value ?? row?.pageviews ?? 0); if (!rowPath || !Number.isFinite(value)) return; viewsMap.set(rowPath, Math.max(0, Math.round(value))); });
return viewsMap;};
const updatePreviewViews = async () => { const pathViewsMap = await fetchPathViewsMap(); const nodes = Array.from(document.querySelectorAll(".post-preview-views"));
await Promise.all( nodes.map(async (node) => { const textNode = node.querySelector(".post-preview-views-text"); const path = normalizePath(node.dataset.postPath); const value = pathViewsMap?.get(path) ?? 0; if (textNode) textNode.textContent = String(value); }), );};同樣也綁定了 Swup 的 page:view,確保切換分頁後瀏覽量會重新更新。
五、錯誤處理與降級策略
為了避免 Umami 掛掉時整個 UI 看起來壞掉,我在前端邏輯裡做了幾個保護:
- 請求失敗 / 回傳結構異常:一律當成
0,頁面會顯示「瀏覽量 0」,不會跳錯或閃爍。 - 路徑對不起來:透過
normalizePath/normalizeComparablePath統一移除多餘斜線,盡可能匹配/posts/foo與/posts/foo/這種差異。 - Swup 切頁:所有初始化函數都用「可重入」設計,切頁多次呼叫也不會重複綁定事件或爆錯。
整體來說,只要:
- Umami 的
websiteId/ share token 正確無誤。 - Cloudflare Functions 的
/api/umami代理部署成功。
那麼不管是首頁卡片還是文章詳情頁,都會自動顯示對應文章的瀏覽量,不需要在 Markdown 裡額外標註任何東西。
六、如果要搬家或複用這套方案?
如果未來想在其他 Astro / 靜態博客裡複用這組邏輯,可以照這個 checklist 走:
- 在 Umami 建站,拿到新的
websiteId與 share token。 - 部署一個對應的
/api/umami代理(Cloudflare、Vercel edge functions 皆可)。 - 在文章模板裡,加上一個類似
postPath={Astro.url.pathname}的欄位,並渲染一個容器(例如.umami-post-views)。 - 在首頁卡片模板裡,為每張卡片加上
data-post-path,並用一支批次查詢腳本回填數字。
本篇等於是這套「單篇文章瀏覽量系統」的總說明,之後要改 Umami、換追蹤工具,或開新站時,都可以直接拿這篇當操作手冊用。