想给之前上线的工具站做个博客数据统计: blogs。
理所当然用到了document.referer,最方便的获取上一个页面的方式,结果发现获取到的都是第一次 load 的页面,而不是导航前的那个页面。
为什么会这样?
Next.js 在生产环境中默认采用前端导航(Client-side Navigation)的方式。这意味着页面加载完之后,在网站内部点击链接时,浏览器并不会执行一次完整的页面刷新。相反,Next.js 会在客户端通过 JavaScript 异步加载新页面的数据和组件,然后更新 DOM。
这种类似 单页应用(SPA) 的行为导致了一个关键问题:
document.referrer 的值在页面首次加载后保持不变。当通过内部链接从blog/zh/1导航到 blog/zh/post/[slug] 时,由于没有发生完整的页面重载,document.referrer 的值仍然会是最初访问网站的那个值,而不是blog/zh/1。
那要如何处理这个问题呢,其实也很简单,因为我们是可以拿到导航变化的,我们把上一页存下来再拿出来就好了。
nextjs 官方也提到了这种做法:Router events
简单来说,首先准备个 utils 用来存取 session storage(也可以是其他地方,看需求):
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 
 | const REFERRER_KEY = "internal_referrer";
 export const getReferrer = (): string | null => {
 if (typeof window === "undefined") {
 return null;
 }
 return sessionStorage.getItem(REFERRER_KEY);
 };
 
 export const setReferrer = (value: string): void => {
 if (typeof window !== "undefined") {
 sessionStorage.setItem(REFERRER_KEY, value);
 }
 };
 
 | 
然后增加一个client component,在 path 和 search 变化的时候存进去:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 
 | "use client";
 import { useEffect } from "react";
 import { usePathname, useSearchParams } from "next/navigation";
 import { setReferrer } from "@/lib/storage";
 
 export function NavigationEvents() {
 const pathname = usePathname();
 const searchParams = useSearchParams();
 
 useEffect(() => {
 let url = pathname;
 if (searchParams.toString()) {
 url = `${pathname}?${searchParams.toString()}`;
 }
 console.log("nav change:", url);
 setReferrer(url);
 }, [pathname, searchParams]);
 
 return null;
 }
 
 | 
然后把这个组件放到 layout 里去:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 
 | export default async function RootLayout({children,
 params,
 }: {
 children: React.ReactNode;
 params: Promise<{ locale: string }>;
 }) {
 ...
 
 return (
 <html lang={locale} suppressHydrationWarning>
 <body>
 ...
 <Suspense fallback={null}>
 <NavigationEvents />
 </Suspense>
 ...
 </body>
 </html>
 );
 }
 
 
 | 
完成了,后续其他client component要使用就直接用 storage 获取就行了,但是注意如果他为空再去获取一下document.referer,主要用在第一次的时候。
比如这个组件:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 
 | export function ViewCounter({ slug, language }: ViewCounterProps) {const searchParams = useSearchParams();
 
 useEffect(() => {
 if (typeof window !== "undefined") {
 const internalReferrer = getReferrer();
 const externalReferrer = document.referrer;
 
 let referrerToSend = "";
 
 if (internalReferrer) {
 referrerToSend = internalReferrer;
 } else if (externalReferrer) {
 referrerToSend = externalReferrer;
 }
 
 addBlogViewCount(slug, language, referrerToSend, searchParams.toString());
 }
 }, [slug, language, searchParams]);
 
 return null;
 }
 
 | 
这里可能会有人奇怪了,这里记录的不是这次的 path 吗,怎么就变成上次了,那是因为react的useEffect执行是从子组件到父组件的,子组件会拿到更新前(也就是上一个页面)的值。
详细点:
- 当用户从页面 A 导航到页面 B 时,页面 B 的组件会 - mount(挂载),同时- NavigationEvents组件会因为- pathname改变而- re-render。
 
- 页面 B 的 - useEffect和- NavigationEvents的- useEffect都会被调度执行。
 
- 在页面 B 的 - useEffect执行时,它从- localStorage读取- referrer。此时,- NavigationEvents中更新- localStorage的- useEffect还未执行。
 
- 因此,页面 B 读取到的是上一个页面(页面 A)导航完成时存入的值。 
- 在页面 B 的 - useEffect执行之后,- NavigationEvents的- useEffect才执行,将当前的- pathname(页面 B 的路径)存入- localStorage,为下一次导航做准备。