ได้แรงบันดาลใจจากบล็อกของ Lee ที่แสดงจำนวน views ของแต่ละบทความ ผมจึงอยากทำแบบเดียวกันสำหรับเพจของตัวเอง ผมใช้ Next.js 13 พร้อม app router ใหม่ แต่แทนที่จะเก็บ page views ใน MySQL จะใช้ Upstash Redis แทน
นี่คือตัวอย่างสิ่งที่เราจะสร้าง แต่ละการ์ดบนหน้าแรกจะแสดงจำนวน views

ทำไมถึงเลือก Redis?
Redis มาพร้อมกับ 2 คำสั่งยอดเยี่ยมที่ทำให้การ deduplicate และการเพิ่มตัวนับเป็นเรื่องง่าย
เพื่อให้ตัวนับแม่นยำขึ้น ผมอยาก debounce การเพิ่มตัวนับ หากผู้ใช้ refresh หน้า ตัวนับควรเพิ่มแค่ครั้งเดียว ทำได้ง่ายด้วยคำสั่ง SET ของ Redis ที่มีตัวเลือก NX (set key เฉพาะเมื่อยังไม่มี) และ EX (หมดอายุหลังจากวินาทีที่กำหนด) รวมทั้งสองตัวเลือกนี้ เราสามารถรับรองได้ว่าผู้ใช้คนเดียวไม่จะเพิ่มตัวนับซ้ำภายในเวลาที่กำหนด (เช่น 24 ชั่วโมง)
คำสั่งที่สองคือ INCR ซึ่งเพิ่มค่าของ key ที่กำหนดทีละ 1 แบบ atomic
ตั้งค่า Redis
การตั้งค่าฐานข้อมูลบน Upstash ง่ายและมี free tier 10,000 requests ต่อวัน! สร้างฐานข้อมูลใหม่ ที่นี่ ใช้เวลาเพียงไม่กี่วินาที หลังจากนั้นเลื่อนลงและคัดลอก REST connection secrets ไปไว้ในไฟล์ .env.local:
# .env.local
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
Next.js
ตอนนี้มี Redis database แล้ว เริ่ม implement counter ได้ ติดตั้ง @upstash/redis ก่อน:
pnpm add @upstash/redis
เพื่อเก็บ page views เราต้องการ 2 คอมโพเนนต์: API route และ client component เริ่มจาก API route ก่อน
/api/incr.ts
Upstash และ @upstash/redis เข้ากันได้กับ Vercel edge functions ก่อนอื่น import ทุกอย่างที่ต้องการ setup redis และตั้งค่า runtime เป็น edge
สร้างไฟล์ /api/incr.ts ใส่โค้ดดังนี้:
// /api/incr.ts
import { NextRequest, NextResponse } from "next/server";
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();
export const config = {
runtime: "edge",
};
ต่อไป ต้องการ slug หรือ identifier ใน request body หากไม่มี ส่ง status 400 กลับไป
// /api/incr.ts
export default async function incr(req: NextRequest): Promise<NextResponse> {
const body = await req.json();
const slug = body.slug as string | undefined;
if (!slug) {
return new NextResponse("Slug not found", { status: 400 });
}
// more to come here
}
จากนั้นต้องการ IP address ของผู้ใช้ ทำได้ผ่าน req.ip จะ hash IP ด้วยอัลกอริทึม SHA-256 และเก็บในฐานข้อมูล วิธีนี้ไม่ต้องเก็บ IP โดยตรง ซึ่งอาจเป็นประเด็นความปลอดภัย
// /api/incr.ts
const ip = req.ip;
// Hash the IP and turn it into a hex string
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(ip));
const hash = Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
ตอนนี้ใช้คำสั่ง Redis แรก รวม SET กับ NX และ EX เพื่อเช็คว่า IP เฉพาะได้ดูหน้านี้ภายใน 24 ชั่วโมงที่ผ่านมาหรือยัง:
// /api/incr.ts
const isNew = await redis.set(["deduplicate", hash, slug].join(":"), true, {
nx: true,
ex: 24 * 60 * 60,
});
if (!isNew) {
new NextResponse(null, { status: 202 });
}
สิ่งสุดท้ายคือเพิ่มตัวนับสำหรับ slug ที่กำหนด ใช้คำสั่ง INCR:
// /api/incr.ts
await redis.incr(["pageviews", "projects", slug].join(":"));
return new NextResponse(null, { status: 202 });
สำหรับอ้างอิง โค้ดเต็มดูได้ ที่นี่
/app/[slug]/view.tsx
ต่อไป สร้าง client component เล็กๆ ที่ส่ง request ไปยัง api route ที่สร้างไว้ทุกครั้งที่ mount คอมโพเนนต์นี้สามารถ embed ในหน้าไหนก็ได้ที่ต้องการติดตาม
// /app/[slug]/view.tsx
"use client";
import { useEffect } from "react";
export const ReportView: React.FC<{ slug: string }> = ({ slug }) => {
useEffect(() => {
fetch("/api/incr", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ slug }),
});
}, [slug]);
return null;
};
/app/[slug]/page.tsx
สิ่งสุดท้ายคือเพิ่มคอมโพเนนต์ ReportView ลงในหน้าที่ต้องการติดตาม:
// /app/[slug]/page.tsx
import { ReportView } from "./view";
type Props = {
params: { slug: string; };
};
export default function Page({ params }: Props) {
return (
<div>
<ReportView slug={params.slug} />
{/* Add your page content here */}
</div>
);
}
ตอนนี้ การเข้าชม /app/[slug] ทุกครั้งจะถูกติดตามและตัวนับจะเพิ่มสำหรับแต่ละผู้เยี่ยมชมที่ไม่ซ้ำกันใน 24 ชั่วโมงล่าสุด
แสดงจำนวน Views
ติดตาม views แล้วแต่เราก็อยากแสดงออกมาด้วย มาดูวิธีทำกัน
เพื่อแสดงจำนวน views ต้องดึงข้อมูลจากฐานข้อมูล ใช้คำสั่ง GET ควรเพิ่ม config revalidate ให้ page component ด้วย เพื่อให้หน้า revalidate ทุก 60 วินาที ไม่ใช่ทุก request
// /app/[slug]/page.tsx
type Props = {
params: { slug: string; };
};
export const revalidate = 60
export default function Page({ params }: Props) {
const views = await redis.get<number>(["pageviews", "projects", params.slug].join(":")) ?? 0
return ...
}
สรุป
ดูตัวอย่างเต็มได้ที่ chronark.com โค้ดอยู่บน GitHub โดยเฉพาะส่วนต่อไปนี้:
- api route
- tracking component
- loading the number of views
สนใจ page view analytics เพิ่มเติม? แจ้งเราทราบทาง Twitter หรือ Discord นะครับ