---
title: 如何统计页面的 PV&UV？
author: 休言
date: 2024-08-06
url: https://www.fechuck.com/2024/pv.html
---

# 如何统计页面的 PV&UV？

`PV`(page view) 是页面浏览量，`UV`(Unique visitor)用户访问量。`PV` 只要访问一次页面就算一次，`UV` 同一天内多次访问只算一次。

对于前端来说，只要每次进入页面上报一次 `PV` 就行，`UV` 的统计放在服务端来做，主要是分析上报的数据来统计得出 `UV`。

## 需要的数据

对于 `PV`，我们需要采集的核心数据指标其实就三个：

- 页面地址；
- 访问时间；
- 用户信息。

因为 `PV&UV` 的数据是采集的用户行为信息，如果要更进一步地做用户行为分析，我们还需要采集如下指标：

- 设备信息；
- 网络信息（比如 `IP`，用于做地域分布分析）。

下面以具体代码为例，看下应该怎么样采集到 `PV` 信息。首先封装一个数据上报函数：

```js
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` 上报数据的好处是，即使页面被意外关闭，也仍然能够将数据进行上报，能保证不丢失数据。

```js
export const pageVisit = () => {
  window.addEventListener('load', reportPV);
};
```

对于 `IP` 信息的采集，需要通过服务端获取，以 `egg` 为例，可以通过 `request` 获取：

```js
const { ctx } = this;
const { ip } = ctx.request;
```

## 区分单页应用和多页应用

对于多页应用，可以在页面 `onload` 事件中进行 `PV` 上报。但是对于单页应用，只有一次 `onload` 事件，后续页面的切换是通过路由实现的，因此需要在路由切换时进行 `PV` 上报。

对于多页应用，需要监听 `hash` 或者 `history` 路由的变化，然后进行 `PV` 上报。

### hash 路由

如果是使用 `hash` 路由，可以通过监听 `hashchange` 事件来进行 `PV` 上报。

```js
window.addEventListener('hashchange', reportPV);
```

### history 路由

如果是使用 `history` 路由，会比 `hash` 路由稍微复杂一些，`window` 没有提供可以监听 history 路由的方法，所以需要重写 `pushState` 和 `replaceState` 事件来进行 `PV` 上报。

首先需要创建一个统一的重写事件函数：

```js
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` 上报。首先定义一个监听器函数：

```js
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` 上报：

```js
onEvents(['pushState', 'replaceState'], reportPV)

window.history.pushState = createHistoryEvent('pushState')
window.history.replaceState = createHistoryEvent('replaceState')
```

### 动态路由导致 PV 不准确问题

在单页应用开发中，同一个页面有时候会采用动态路由的方式，路由发生变化则数据变化，但其实还是同一个页面。比如一个在线考试页面，动态路由里会存在题目 `ID` 等信息，当切换题目的时候，路由发生变化，如果单纯地在路由变化时上报 `PV`，这会导致 `PV` 数据偏大，`PV/UV` 之比会达到不合理的程度，那这样的数据就是不准确的。要解决这个问题，就需要拿到页面链接，还原路由定义，并缓存前一次的页面路由，当 `url` 发生变化的时候，如果属于同一个动态路由，就不上报 `PV` 数据。

```js
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` 字段映射类型如下：

```js
// 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` 里面，文本类型字段是无法完成聚合操作的，需要定义为关键字类型。查询条件可以这么写：

```js
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 加起来。
