Next.js 如何在客户端导航时获取上一页(referer)

想给之前上线的工具站做个博客数据统计: 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(也可以是其他地方,看需求):

1
2
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 变化的时候存进去:

1
2
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 里去:

1
2
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,主要用在第一次的时候。

比如这个组件:

1
2
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 吗,怎么就变成上次了,那是因为reactuseEffect执行是从子组件到父组件的,子组件会拿到更新前(也就是上一个页面)的值。
详细点:

  • 当用户从页面 A 导航到页面 B 时,页面 B 的组件会 mount(挂载),同时 NavigationEvents 组件会因为 pathname 改变而 re-render

  • 页面 B 的 useEffectNavigationEventsuseEffect 都会被调度执行。

  • 在页面 B 的 useEffect 执行时,它从 localStorage 读取 referrer。此时,NavigationEvents更新 localStorageuseEffect 还未执行

  • 因此,页面 B 读取到的是上一个页面(页面 A)导航完成时存入的值。

  • 在页面 B 的 useEffect 执行之后,NavigationEventsuseEffect 才执行,将当前的 pathname(页面 B 的路径)存入 localStorage,为下一次导航做准备。