【记录】如何在 Next.js 中优雅的调用 Matomo 统计
2025/6/13
本文最后修改于 2 天前,请注意文章内容的时效性。
今天查看博客的 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
加载前,完成 setTrackerUrl
和 setSiteId
两部分。
但是这里就犯了难了,Next.js 并没有提供类似的办法。因此我们只能尝试利用一下 React 的 Effect 机制,看看能不能实现了:
首先设置一个只会执行一次的 Effect,在里面写上setTrackerUrl
和 setSiteId
部分:
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"]);
}, []);
然后,为了保证 setTrackerUrl
和 setSiteId
部分完成后才会加载 matomo.js
,我们添加一个 State 用来指示是否设置完成:
const [initialized, setInitialized] = useState(false);
// 在上面的 Effect 中
setInitialized(true);
然后限制只在 initialized
为 true
时才加载脚本:
{
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 机制的一些了解吧,顺便优化了一下自己的网站(点头)。
加载评论中……