秋实-Allenyou 的小窝

稻花香里说丰年,听取蛙声一片

【记录】如何在 Next.js 中优雅的调用 Matomo 统计

2025/6/13

今天查看博客的 Matomo 统计,发现几乎所有访客都只访问了一个页面。这并不太符合常理。

虽然我文章写的依托但是总有人会点进去看一眼的吧

由此,我回想起以前使用 Wordpress Argon 主题时,需要额外在 PJAX 回调中加入统计代码这件事,怀疑是我的调用方法有问题,于是便开启了长达两小时的踩坑之旅。

成因

我的博客基于 Next.js App Router 开发,而 Next.js App Router 引入的一大重要特性便是 Layout。

在 Next.js App Router 中,不同页面可以共享相同的 Layout。这意味着开发者可以以一种更符合直觉的方式组织、复用代码。

在此基础上,Next.js 提供了 <Link /> 组件,通过使用该组件替换 <a /> 链接,即可实现站内的无刷新导航。

这也就意味着,Next.js 自带了一套类似 PJAX 的实现。自然,这也为统计代码带来了使用 PJAX 时一样的问题。

引入 Matomo 统计

Matomo 的统计代码通常是长这个样子:

<!-- Matomo -->
<script>
	var _paq = (window._paq = window._paq || []);
	/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
	_paq.push(["trackPageView"]);
	_paq.push(["enableLinkTracking"]);
	(function () {
		var u = "【你的 Matomo URL】";
		_paq.push(["setTrackerUrl", u + "matomo.php"]);
		_paq.push(["setSiteId", "【你的站点 ID】"]);
		var d = document,
			g = d.createElement("script"),
			s = d.getElementsByTagName("script")[0];
		g.async = true;
		g.src = u + "matomo.js";
		s.parentNode.insertBefore(g, s);
	})();
</script>
<!-- End Matomo Code -->

通常,只需把如上所述的代码放置在页面的 </head> 标签前即可。

而在 Next.js 中, <script /> 标签实际上是 JSX 的语法糖,我们需要使用 dangerouslySetInnerHTML 属性,在 src/app/layout.tsx</head> 标签前插入如下代码:

<script
	dangerouslySetInnerHTML={{
		__html: `
      var _paq = (window._paq = window._paq || []);
	    _paq.push(["trackPageView"]);
	    _paq.push(["enableLinkTracking"]);
	    (function () {
	    	var u = "【你的 Matomo URL】";
	    	_paq.push(["setTrackerUrl", u + "matomo.php"]);
	    	_paq.push(["setSiteId", "【你的站点 ID】"]);
	    	var d = document,
	    		g = d.createElement("script"),
	    		s = d.getElementsByTagName("script")[0];
	    	g.async = true;
	    	g.src = u + "matomo.js";
	    	s.parentNode.insertBefore(g, s);
	    })();
    `,
	}}
/>

但是很显然,这非常不优雅。

优雅的写法

Next.js 提供了 <Script /> 组件作为替代,可以实现外部 JavaScript 脚本的 LazyLoad 等,同时也支持内联脚本,避免使用 dangerouslySetInnerHTML

因此我们可以把上面的代码改成下面这样:

<Script strategy="afterInteractive">
	{`var _paq = (window._paq = window._paq || []);_paq.push(["trackPageView"]);_paq.push(["enableLinkTracking"]);(function () {var u = "【你的 Matomo URL】";_paq.push(["setTrackerUrl", u + "matomo.php"]);_paq.push(["setSiteId", "【你的站点 ID】"]);var d = document,g = d.createElement("script"),s = d.getElementsByTagName("script")[0];g.async = true;g.src = u + "matomo.js";s.parentNode.insertBefore(g, s);})();`}
</Script>

事实上,我的站点中很长一段时间都采取上述解决方法。但这种方法只会在初次打开页面时生效,因为 Next.js 的策略是,通过 <Link /> 组件在页面之间导航时,为了实现无刷新加载,Layout 中公共的部分不会被重新渲染。这也就意味着,这里的代码只会被执行一次。因此我们需要更好的办法。

解决 PJAX 问题

一般而言为了在 PJAX 生效时,能正常更新统计数据,我们通常需要在 PJAX 回调函数中添加以下代码:

// 示例 PJAX 回调函数
function pjax_callback() {
	var _paq = (window._paq = window._paq || []);
	_paq.push(["setCustomUrl", document.URL]); // 更新页面 URL
	_paq.push(["setDocumentTitle", document.title]); // 更新页面标题
	_paq.push(["trackPageView"]); // 重新触发统计
}

因此,一个很自然的想法是找到 Next.js 的导航回调,并在回调中添加如上内容。但是翻阅文档后发现,Next.js 并没有提供这个功能。

考虑 PJAX 通常的实现方法,我们猜测监听 popstate 事件可以实现。但是不幸的是,在开发环境中测试时,如果在 DevTools 设置 popstate 事件监听器断点,导航时并不会触发断点。因此,我们不得不考虑一些更 “React” 的写法。

这里我们注意到,Next.js 提供了一个 Hook usePathname(),返回当前路由的 pathname 部分。那么,将其作为 useEffect 的依赖项,即可实现当 pathname 更改时自动触发更新。因此我们添加如下代码:

const pathname = usePathname();
useEffect(() => {
	const _paq = ((window as unknown as { _paq?: string[][] })._paq =
		(window as unknown as { _paq?: string[][] })._paq || []); // 这一段是为了糊弄过 TypeScript 的神秘类型体操
	_paq.push(["setCustomUrl", document.URL]);
	_paq.push(["setDocumentTitle", document.title]);
	_paq.push(["trackPageView"]);
}, [pathname]);

这样就实现了……吗?

优化

我们回看加载 Matomo 统计代码的部分:

<Script strategy="afterInteractive">
	{`var _paq = (window._paq = window._paq || []);_paq.push(["trackPageView"]);_paq.push(["enableLinkTracking"]);(function () {var u = "【你的 Matomo URL】";_paq.push(["setTrackerUrl", u + "matomo.php"]);_paq.push(["setSiteId", "【你的站点 ID】"]);var d = document,g = d.createElement("script"),s = d.getElementsByTagName("script")[0];g.async = true;g.src = u + "matomo.js";s.parentNode.insertBefore(g, s);})();`}
</Script>

可以发现,这段代码其实仍然非常不优雅—— JavaScript 代码甚至是直接以字符串形式传入的!同时,这里也没有很好的利用 Next.js 的特性,实现更好的代码注入方式。

翻查文档,我们发现 Next.js 支持为 <Script /> 组件设置 onReady 属性,对应的事件处理器将在脚本加载完成时、每次组件重新挂载时被调用。因此,我们可以重写出如下代码:

<Script
	strategy="afterInteractive"
	src="【你的 Matomo URL】/matomo.js"
	onReady={() => {
		const _paq = ((window as unknown as { _paq?: string[][] })._paq =
			(window as unknown as { _paq?: string[][] })._paq || []);
		_paq.push(["setTrackerUrl", "【你的 Matomo URL】/matomo.php"]);
		_paq.push(["setSiteId", "4"]);
		_paq.push(["setCustomUrl", document.URL]);
		_paq.push(["setDocumentTitle", document.title]);
		_paq.push(["enableLinkTracking"]);
		_paq.push(["trackPageView"]);
	}}
/>

看上去非常优雅,我们刷新页面,查看结果——它报错了!

_paq.push() was used but Matomo tracker was not initialized before the matomo.js file was loaded.

赶紧查查文档,发现应该在 matomo.js 加载前,完成 setTrackerUrlsetSiteId 两部分。

但是这里就犯了难了,Next.js 并没有提供类似的办法。因此我们只能尝试利用一下 React 的 Effect 机制,看看能不能实现了:

首先设置一个只会执行一次的 Effect,在里面写上setTrackerUrlsetSiteId 部分:

useEffect(() => {
	const _paq = ((window as unknown as { _paq?: string[][] })._paq =
		(window as unknown as { _paq?: string[][] })._paq || []);
	_paq.push(["setTrackerUrl", "【你的 Matomo URL】/matomo.php"]);
	_paq.push(["setSiteId", "4"]);
}, []);

然后,为了保证 setTrackerUrlsetSiteId 部分完成后才会加载 matomo.js,我们添加一个 State 用来指示是否设置完成:

const [initialized, setInitialized] = useState(false);

// 在上面的 Effect 中
setInitialized(true);

然后限制只在 initializedtrue 时才加载脚本:

{
	initialized && (
		<Script
			strategy="afterInteractive"
			src="【你的 Matomo URL】/matomo.js"
			onReady={() => {
				const _paq = ((window as unknown as { _paq?: string[][] })._paq =
					(window as unknown as { _paq?: string[][] })._paq || []);
				_paq.push(["setCustomUrl", document.URL]);
				_paq.push(["setDocumentTitle", document.title]);
				_paq.push(["enableLinkTracking"]);
				_paq.push(["trackPageView"]);
			}}
		/>
	);
}

顺便,也给上面在 PJAX 时更新统计信息的 Effect 加上 initialized 的判断:

useEffect(() => {
	if (!initialized) {
		return;
	}
	const _paq = ((window as unknown as { _paq?: string[][] })._paq =
		(window as unknown as { _paq?: string[][] })._paq || []);
	_paq.push(["setCustomUrl", document.URL]);
	_paq.push(["setDocumentTitle", document.title]);
	_paq.push(["trackPageView"]);
}, [initialized, pathname]);

由于 React 的机制,在 initialized 被更改时,会重新执行上面的 Effect,因此会导致一次多余的统计。因此我们可以将脚本加载完成时的统计触发删掉:

{
	initialized && (
		<Script
			strategy="afterInteractive"
			src="【你的 Matomo URL】/matomo.js"
			onLoad={() => {
				const _paq = ((window as unknown as { _paq?: string[][] })._paq =
					(window as unknown as { _paq?: string[][] })._paq || []);
				_paq.push(["enableLinkTracking"]);
			}}
		/>
	);
}

这样就大功告成啦!

最后成品

"use client";

import { usePathname } from "next/navigation";
import Script from "next/script";
import { useEffect, useState } from "react";

export function Matomo() {
	const [initialized, setInitialized] = useState(false);
	useEffect(() => {
		const _paq = ((window as unknown as { _paq?: string[][] })._paq =
			(window as unknown as { _paq?: string[][] })._paq || []);
		_paq.push(["setTrackerUrl", "【你的 Matomo URL】/matomo.php"]);
		_paq.push(["setSiteId", "4"]);
		setInitialized(true);
	}, []);
	const pathname = usePathname();
	useEffect(() => {
		if (!initialized) {
			return;
		}
		const _paq = ((window as unknown as { _paq?: string[][] })._paq =
			(window as unknown as { _paq?: string[][] })._paq || []);
		_paq.push(["setCustomUrl", document.URL]);
		_paq.push(["setDocumentTitle", document.title]);
		_paq.push(["trackPageView"]);
	}, [initialized, pathname]);
	return (
		<>
			{initialized && (
				<Script
					strategy="afterInteractive"
					src="【你的 Matomo URL】/matomo.js"
					onLoad={() => {
						const _paq = ((window as unknown as { _paq?: string[][] })._paq =
							(window as unknown as { _paq?: string[][] })._paq || []);
						_paq.push(["enableLinkTracking"]);
					}}
				/>
			)}
		</>
	);
}

调用时,只需要在 RootLayout 中插入 <Matomo /> 即可。

后记

其实 npm 上已经有开源的库可以实现了。但是这些库大多年久失修(Last Updated:7 months),而且没有很好的发挥 Next.js 和 React 的特性。

这次实践也算是加深了一下对 React Effect 和 Re-render 机制的一些了解吧,顺便优化了一下自己的网站(点头)。

【记录】如何在 Next.js 中优雅的调用 Matomo 统计

https://www.allenyou.wang/post/29

本文作者

秋实-Allenyou

授权协议

CC BY-NC-SA 4.0

加载评论中……