秋实-Allenyou 的小窝

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

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

今天查看博客的 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

加载评论中……