在 Vue 3 项目里接入网站分析,通常做法是在 index.html 中放一段 Fathom 脚本。这样做对于多页面站点足够,但在单页应用中路由切换不会触发新的整页加载,统计到的访问量会明显偏低。更合理的做法是把 Fathom 的加载、页面追踪和事件追踪逻辑沉淀到独立模块中,通过插件和组合式函数供全局使用。下面会围绕这一工程化过程展开,并给出可直接运行的 Vue 3 与 TypeScript 代码。

为什么 Fathom 适合 Vue 3 项目
Fathom 是一款强调隐私保护的轻量级网站分析工具,它不依赖侵入性 Cookie,也不需要向用户展示 Cookie 同意横幅。对 Vue 3 这类前端项目来说,首屏加载速度是重要指标,Fathom 的脚本体积极小,并且支持 defer 异步加载,基本不会阻塞页面渲染。相比之下,一些传统分析工具脚本体积更大,还会因为合规配置增加额外的初始化成本。
在单页应用中,Fathom 默认只跟踪初次页面加载,后续通过 Vue Router 切换路由时并不会自动补报。这是很多接入方容易忽略的问题:后台看到的访问量远远低于真实页面浏览。因此工程化接入的核心目标之一,就是让路由变化能够准确地反映到 Fathom 的统计中,同时把逻辑封装得足够干净,不侵入业务组件。
Fathom 的事件模型也比较简单:页面浏览可以自动或手动触发,自定义转化目标通过 trackGoal 记录。这样的 API 设计很适合用 Vue 3 的组合式函数封装,不需要引入额外的状态管理,也不依赖全局事件总线。只要把加载、类型、路由同步和事件追踪拆开,就能保持分析层与业务层解耦。
封装 Fathom 插件:从加载脚本到全局注入
第一步是把脚本加载逻辑集中到一个 Vue 插件中。默认情况下,我们不在 index.html 里直接写 script 标签,而是通过插件动态创建 script 元素。这样做的好处是可以在开发环境完全禁用 Fathom,也可以根据环境变量切换站点 ID。插件安装时只做两件事:注入脚本,并通过 provide 暴露一个安全获取 Fathom 客户端的函数。
下面是一个基础的插件实现。它通过 data-auto 属性关闭 Fathom 的自动页面跟踪,把初次页面浏览也交给路由逻辑统一处理,从而避免重复统计。
// src/plugins/fathom.ts
export interface FathomOptions {
siteId: string
src?: string
enabled?: boolean
}
export const FathomSymbol = Symbol('fathom')
export const fathomPlugin = {
install(app, options: FathomOptions) {
if (options.enabled === false) return
const script = document.createElement('script')
script.src = options.src ?? 'https://cdn.usefathom.com/script.js'
script.setAttribute('data-site', options.siteId)
script.setAttribute('data-auto', 'false')
script.defer = true
document.head.appendChild(script)
const getFathom = () => (window as any).fathom
app.config.globalProperties.$fathom = getFathom
app.provide(FathomSymbol, getFathom)
}
}
这里把 window.fathom 的读取包装成一个函数,是因为 Fathom 脚本是异步加载的。如果插件安装阶段直接读取 window.fathom,拿到的很可能还是 undefined。通过函数推迟到实际调用时读取,可以保证拿到已经加载完成的客户端对象。插件同时通过 globalProperties 和 provide 暴露,既兼容选项式 API,也方便组合式 API 使用。
接下来封装 useFathom 组合式函数。组件不再直接接触全局 window,而是通过 inject 获取能力。这样在测试或非浏览器环境中,也能用空实现替换,不会因为 window 未定义而报错。
// src/composables/useFathom.ts
import { inject } from 'vue'
import { FathomSymbol } from '@/plugins/fathom'
export interface FathomClient {
trackPageview?: (opts?: Record<string, any>) => void
trackGoal?: (code: string, cents: number) => void
}
export function useFathom() {
const getFathom = inject<() => FathomClient | undefined>(FathomSymbol, () => undefined)
const client = () => getFathom() as FathomClient | undefined
function trackPageview(opts?: Record<string, any>) {
client()?.trackPageview?.(opts)
}
function trackGoal(code: string, cents = 0) {
client()?.trackGoal?.(code, cents)
}
return { trackPageview, trackGoal }
}
这个组合式函数返回两个语义化方法,组件调用时不需要关心 Fathom 是否已经加载完成。如果脚本尚未就绪,调用会被静默忽略;脚本加载完成后,后续调用会被正常记录。对于大多数分析场景,这种容错方式足够可靠,也能避免额外的队列或Promise复杂度。
结合 Vue Router 跟踪每次路由跳转
单页应用中的核心环节是在路由切换完成后调用 trackPageview。Vue Router 提供了 afterEach 钩子,每次导航完成都会触发,包括首次进入页面。由于我们在插件中已经用 data-auto="false" 关闭了 Fathom 的自动跟踪,因此 afterEach 里可以统一处理所有页面浏览,不用担心重复。
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from './routes'
const router = createRouter({
history: createWebHistory(),
routes
})
router.afterEach((to) => {
const w = window as any
if (typeof w.fathom?.trackPageview === 'function') {
w.fathom.trackPageview({
url: to.fullPath
})
}
})
export default router
这里使用 to.fullPath 可以把路径和查询参数一并传给 Fathom。但有些查询参数并不适合发送到分析后台,例如登录 token、临时票据或找回密码凭证。更稳妥的做法是先对查询参数做一层过滤,再拼接成最终上报地址。
function safePath(route) {
const query = { ...route.query }
delete query.token
delete query.ticket
const queryString = new URLSearchParams(query as Record<string, string>).toString()
return queryString ? `${route.path}?${queryString}` : route.path
}
将这段过滤逻辑提取到独立函数中,router.afterEach 只负责调用它。后续如果还有其他敏感参数,只需要在 safePath 里统一处理,不需要修改路由监听逻辑。这也符合工程化中“单一职责”的原则,让路由文件和隐私策略各自独立维护。
对于有权限控制的后台系统,可能只希望统计已登录用户的页面访问,或者只统计某些基础路由。此时可以在 afterEach 中加入条件判断,例如根据 to.meta.analytics 或用户登录状态决定是否上报。Fathom 本身不关心业务规则,一切控制权都在 Vue Router 这一层完成,灵活性足够。
用 trackGoal 做事件与转化追踪
页面浏览只是基础指标,很多业务真正关心的是注册、下单、订阅等转化事件。Fathom 提供 trackGoal 方法,第一个参数是目标代码,第二个参数是金额,单位为分。目标代码需要提前在 Fathom 后台创建,前端按照约定代码触发即可。
在实际项目中,不建议在组件里到处写 trackGoal 的原始调用。更合理的做法是封装一层语义化函数,把业务事件名与 Fathom 目标代码的映射集中管理。这样如果后续目标代码调整,只需要改一个文件。
// src/utils/analytics.ts
export function trackGoal(code: string, valueInCents = 0) {
const w = window as any
if (typeof w.fathom?.trackGoal === 'function') {
w.fathom.trackGoal(code, valueInCents)
}
}
export function trackSignup(plan: string) {
const code = plan === 'pro' ? 'SIGNUP_PRO' : 'SIGNUP_FREE'
trackGoal(code, plan === 'pro' ? 1999 : 0)
}
组件中只需要调用 trackSignup 或者直接的 trackGoal,不用接触全局对象。下面是一个 Vue 3 组件示例,在支付按钮点击后触发转化统计。
<script setup lang="ts">
import { trackGoal } from '@/utils/analytics'
function handleCheckout() {
// 处理支付逻辑
trackGoal('PURCHASE', 4990)
}
</script>
<template>
<button @click="handleCheckout">立即支付</button>
</template>
这里的 4990 表示 49.90 元。Fathom 以分作为金额单位,避免浮点数运算带来的精度问题。前端只需要传递整数值,后台会按照对应币种进行汇总展示。需要注意的是,不要在目标代码或金额中携带用户邮箱、订单号等敏感信息,这既不符合 Fathom 的隐私设计,也可能导致合规风险。
类型声明、环境隔离与调试建议
TypeScript 项目中,如果直接使用 window.fathom,类型检查会提示属性不存在。可以通过声明文件补充全局 Window 接口,让组件和工具函数获得完整的类型提示。声明文件放在 src/types 目录下,TypeScript 会自动加载。
// src/types/fathom.d.ts
export {}
declare global {
interface Window {
fathom?: {
trackPageview: (opts?: { url?: string }) => void
trackGoal: (code: string, cents: number) => void
}
}
}
环境隔离同样重要。开发阶段的 localhost 访问不应该进入生产数据,否则会干扰分析报告。在 main.ts 中,可以根据 Vite 的环境变量和站点 ID 是否存在来决定是否启用 Fathom。生产构建时设置好 VITE_FATHOM_SITE_ID,开发环境缺省即可自动关闭。
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { fathomPlugin } from './plugins/fathom'
const app = createApp(App)
app.use(fathomPlugin, {
siteId: import.meta.env.VITE_FATHOM_SITE_ID ?? '',
enabled: import.meta.env.PROD && import.meta.env.VITE_FATHOM_SITE_ID !== ''
})
app.use(router)
app.mount('#app')
调试时可以先在浏览器开发者工具的 Network 面板中确认 Fathom 脚本是否成功加载,并检查请求是否带有正确的 data-site 参数。然后在控制台执行 window.fathom,如果能拿到包含 trackPageview 和 trackGoal 的对象,说明脚本已经就绪。通过 Fathom 后台的实时面板,可以立即看到测试事件是否到达。若后台没有记录,也可能是隐私拦截插件阻止了请求,这类情况需要先在无插件环境验证。
整套方案的核心思想是把分析逻辑当作独立模块来维护,而不是分散在页面脚本和组件中。Fathom 本身足够轻量,工程化后可以进一步降低接入和维护成本。对大多数 Vue 3 项目来说,按照插件、组合式函数、路由同步和事件追踪四个层次划分,已经能够覆盖从基础页面统计到业务转化追踪的完整需求。