如何统计页面的 PV&UV?
PV
(page view) 是页面浏览量,UV
(Unique visitor)用户访问量。PV
只要访问一次页面就算一次,UV
同一天内多次访问只算一次。
对于前端来说,只要每次进入页面上报一次 PV
就行,UV
的统计放在服务端来做,主要是分析上报的数据来统计得出 UV
。
需要的数据
对于 PV
,我们需要采集的核心数据指标其实就三个:
- 页面地址;
- 访问时间;
- 用户信息。
因为 PV&UV
的数据是采集的用户行为信息,如果要更进一步地做用户行为分析,我们还需要采集如下指标:
- 设备信息;
- 网络信息(比如
IP
,用于做地域分布分析)。
下面以具体代码为例,看下应该怎么样采集到 PV
信息。首先封装一个数据上报函数:
const reportPV = () => {
const data = {
time: Date.now(),
pageUrl: document.location.href,
deviceInfo: window.navigator.userAgent,
userId: 'xxxx',
extra: {}
};
navigator.sendBeacon(
url: 'https://xxx.com',
new Blob([JSON.stringify(data)], { type: 'application/json' })
);
}
使用 sendBeacon
上报数据的好处是,即使页面被意外关闭,也仍然能够将数据进行上报,能保证不丢失数据。
export const pageVisit = () => {
window.addEventListener('load', reportPV);
};
对于 IP
信息的采集,需要通过服务端获取,以 egg
为例,可以通过 request
获取:
const { ctx } = this;
const { ip } = ctx.request;
区分单页应用和多页应用
对于多页应用,可以在页面 onload
事件中进行 PV
上报。但是对于单页应用,只有一次 onload
事件,后续页面的切换是通过路由实现的,因此需要在路由切换时进行 PV
上报。
对于多页应用,需要监听 hash
或者 history
路由的变化,然后进行 PV
上报。
hash 路由
如果是使用 hash
路由,可以通过监听 hashchange
事件来进行 PV
上报。
window.addEventListener('hashchange', reportPV);
history 路由
如果是使用 history
路由,会比 hash
路由稍微复杂一些,window
没有提供可以监听 history 路由的方法,所以需要重写 pushState
和 replaceState
事件来进行 PV
上报。
首先需要创建一个统一的重写事件函数:
function createHistoryEvent(type) {
const origin = history[type];
return function () {
const res = origin.apply(this, arguments);
const event = new Event(type);
window.dispatchEvent(event);
return res;
};
}
在这里,我们可以通过 dispatchEvent
来触发一个自定义事件,然后监听这个事件来进行 PV
上报。首先定义一个监听器函数:
function onEvents(events, onReport) {
events.forEach(function(event) {
window.addEventListener(event, function(evt) {
var target = evt.target;
var href = target.location.href;
var origin = target.location.origin;
onReport({
newURL: href,
oldURL: origin
});
});
});
}
有了这个事件重写函数,就可以重写 pushState
和 replaceState
事件,然后监听 popstate
事件来进行 PV
上报:
onEvents(['pushState', 'replaceState'], reportPV)
window.history.pushState = createHistoryEvent('pushState')
window.history.replaceState = createHistoryEvent('replaceState')
动态路由导致 PV 不准确问题
在单页应用开发中,同一个页面有时候会采用动态路由的方式,路由发生变化则数据变化,但其实还是同一个页面。比如一个在线考试页面,动态路由里会存在题目 ID
等信息,当切换题目的时候,路由发生变化,如果单纯地在路由变化时上报 PV
,这会导致 PV
数据偏大,PV/UV
之比会达到不合理的程度,那这样的数据就是不准确的。要解决这个问题,就需要拿到页面链接,还原路由定义,并缓存前一次的页面路由,当 url
发生变化的时候,如果属于同一个动态路由,就不上报 PV
数据。
let lastUrl; // 上一次路由
// 识别动态路由
const parseDynamicUrl = (url = '') => {
// 使用正则表达式匹配 URL 中的数字,并替换为 "[id]"
const parsedUrl = url.replace(/\/\d+/g, '/[id]');
return parsedUrl;
};
const reportPV = (url = '') => {
const route = url.split('?')?.[0];
const currentUrl = parseDynamicUrl(route);
if (currentUrl === lastUrl) return;
lastUrl = currentUrl;
// ...省略代码
}
这个是为了规避特定场景下的动态路由导致的 PV
偏大的问题,但是还是会带来新的问题。比如动态路由就是为了满足不同页面的内容的呈现,例如有一个新闻资讯网站,动态路由里存放的是每篇文章的 ID
,当用户打开单页应用后没有关闭过,在这期间阅读了很多篇文章,但是由于缓存了前一次的动态路由,即使后面再怎么切换文章,路由都会被认为是同一个,会导致从始至终只上报了一次 PV
数据,此时数据会比实际的少很多,也是不准确的。
UV 统计
如前所述,我们只上报 PV,UV 数据统计放在服务端做。在上报 PV 的时候,我们传递了用户信息,UV 是根据 PV 进行过滤得出的。上报的 PV 数据我存储在 ElasticSearch
上,然后通过 ES
的 terms
聚合来进行 UV 统计。
首先需要创建一个存放 pv 日志的索引,暂且叫做 monitor_pv
,对应的 elasticsearch
字段映射类型如下:
// PV 字段映射
export const pageVisitMappings = {
// dynamic: false,
properties: {
apikey: {
type: 'text',
fields: {
keyword: {
type: 'keyword',
ignore_above: 256,
},
},
},
deviceInfo: {
type: 'text',
},
breadcrumb: {
type: 'text',
},
sdkVersion: {
type: 'text',
},
time: {
type: 'date',
format: 'epoch_millis',
},
userId: {
type: 'text',
fields: {
keyword: {
type: 'keyword',
ignore_above: 256,
},
}, // 聚合查询必须是关键字类型,不然没法对文本类型聚合
},
uuid: {
type: 'text',
},
pageUrl: {
type: 'text',
},
ip: {
type: 'text',
},
},
};
通过 elasticsearch
查询 pv
的时候,我们可以根据 userId
进一步聚合,得到 uv
数据。注意,在 elasticsearch
里面,文本类型字段是无法完成聚合操作的,需要定义为关键字类型。查询条件可以这么写:
let uvAggs = {};
if (type === 'uv') {
uvAggs = {
unique_visitors: {
cardinality: {
field: 'userId.keyword',
},
},
};
}
const params = {
index: esIndex.pv,
body: {
size: 0, // 不返回文档,只返回聚合结果
query: {
bool,
},
aggs: {
daily_count: {
date_histogram: {
field: 'time',
interval: '1d',
},
aggs: uvAggs,
},
},
},
};
关于 elasticsearch
的具体操作可以查阅官方文档,这里我就一笔带过了,主要就是为了描述通过 cardinality
聚合来进行 UV
的按天统计。值得注意的是,按天聚合得到的 UV 之和会比直接计算的时间区间 UV 总数要多,这是由 于 UV 在不同的时间范围内被计算两次导致的。
比如说,用户 A 在8月1日访问了你的网站,用户 A 和用户 B 在8月2日访问了你的网站。如果你按天计算 UV,那么8月1日的 UV 是1,8月2日的 UV 是2,所以 UV 的总和是3。但如果你计算这两天的总 UV,那么结果是2,因为用户 A 在这两天都访问了你的网站,但在计算总 UV 时只被计算一次。
这就是为什么按天计算的 UV 之和可能会大于总的 UV。如果你想要得到总的 UV,你应该在你想要的整个时间范围内进行一次 UV 聚合,而不是将每天的 UV 加起来。