1.0.0 • Published 7 months ago

xiao5-tracker v1.0.0

Weekly downloads
-
License
ISC
Repository
-
Last release
7 months ago

前端实现埋点&监控

实现埋点功能的意义主要体现在以下几个方面:

  1. 数据采集:埋点是数据采集领域(尤其是用户行为数据采集领域)的术语,它针对特定用户行为或事件进行捕获、处理和发送的相关技术及其实施过程。通过埋点,可以收集到用户在应用中的所有行为数据,例如页面浏览、按钮点击、表单提交等。
  2. 数据分析:采集的数据可以帮助业务人员分析网站或者App的使用情况、用户行为习惯等,是后续建立用户画像、用户行为路径等数据产品的基础。通过数据分析,企业可以更好地了解用户需求,优化产品和服务。
  3. 改进决策:通过对埋点数据的分析,企业可以了解用户的真实需求和行为习惯,从而做出更符合市场和用户需求的决策,提高产品和服务的质量和竞争力。
  4. 优化运营:通过埋点数据,企业可以了解用户的兴趣和行为,从而更好地定位目标用户群体,优化运营策略,提高运营效率和收益。
  5. 预测趋势:通过对埋点数据的分析,企业可以预测市场和用户的未来趋势,从而提前做好准备,把握市场机遇,赢得竞争优势。

总之,实现埋点功能可以帮助企业更好地了解用户需求和行为习惯,优化产品和服务,改进决策,优化运营并预测趋势,具有重要的意义和作用。

常见的埋点包括:pv【PageView】上报(包括history上报、hash上报)、uv【UserView】上报、dom事件上报、js报错上报(包括常规错误上报、Promise报错上报)

下面我们通过使用nodejsTypeScriptrollup等技术栈实现一个简易的埋点上报的sdk,并发布npm。

通过这篇文章你可以学习到:埋点&监控、区分js模块化、打包工具rollup、API之History、JS二进制、sendBeacon发送post请求等知识。

一、前置知识

1. 区分JS模块化

因为要在nodejs环境下并使用rollup打包输出支持不同规范的模块,因此了解JS模块化相关知识是必要的。

主流模块化规范有:

  • CommonJS规范

  • AMD规范

  • CMD规范
  • ESM规范
  • UMD规范

这里建议看一下这边博客:前端模块化详解(完整版),里面详细讲解了主流模块

下面我们对主流模块做个总结,如下:

序号模块化规范备注
1CommonJSCommonJS规范主要用于服务端编程,加载模块是同步的,这并不适合在浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的,因此有了AMDCMD解决方案
2AMDAMD规范在浏览器环境中异步加载模块,而且可以并行加载多个模块。不过,AMD规范开发成本高,代码的阅读和书写比较困难,模块定义方式的语义不顺畅。
3CMDCMD规范整合了CommonJSAMD规范的特点, CMD规范与AMD规范很相似,都用于浏览器编程,依赖就近,延迟执行,可以很容易在Node.js中运行。不过,依赖SPM打包,模块的加载逻辑偏重
4UMDUMDAMDCommonJS两者的结合,这个模式中加入了当前存在哪种规范的判断,所以能够“通用”,它兼容了AMDCommonJS,同时还支持老式的“全局”变量规范
5ESMES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案

2. rollup

要发布npm包,打包库是必然的,而rollupwebpack更适合打包库,因此学习rollup也是必要的。

建议看一下这篇博客:安装 Rollup 以及 Rollup 和 Webpack 的区别

这里总结一下rollupwebpack的区别:

  1. webpack 由于年代相对久远,在 commonjs 后且 esMoudles 之前,所以通过 webpack 通过自己来实现 commonjs 等语法,rollup 则可以通过配置打包成想要的语法,比如 esm
  2. 所以说 rollup 很适合打包成 ,而 webpack 比较适合用来做来打包应用
  3. 由于rollup不能够直接读取node_modules中的依赖项,需要引入加载npm模块的插件:rollup-plugin-node-resolve
  4. 由于rollup默认只支持esm模块打包,所以需要引入插件来支持cjs模块:rollup-plugin-commonjs
  5. 由于 rollup 通过可以 esm 模块开发和打包,所以支持 tree-shaking 模式
  6. vite 就是 rollup 开发而来的

3. History

实现Page View埋点往往需要使用HistoryAPI,因为它可以帮助我们更好地控制页面的状态和导航。

在SPA中,页面的状态通常由内部状态管理,而不是通过URL来表现。因此,传统的PV埋点方法(例如通过document.referrer)可能无法正确计算PV。

使用History API可以让我们更精细地控制页面的导航和状态。我们可以使用history.pushState()方法将新的状态添加到历史记录中,并更新URL,但不会触发页面刷新。这样,我们可以在用户与页面交互时跟踪其导航路径,并计算PV。

另外,当用户点击浏览器的后退按钮时,我们可以使用popstate事件来获取上一个历史记录状态,并根据需要进行处理。这可以帮助我们处理用户在SPA中的导航,并提供更准确的PV数据。

History 接口允许操作浏览器的曾经在标签页或者框架里访问的会话历史记录,它的实例方法back()forward()go()大家应该都比较熟悉就不一一介绍了,这里主要介绍一下pushState()replaceState()以及popstate事件,注意:popstate事件并么有使用驼峰命名方式。

3.1 history.pushState()

在HTML文档中,history.pushState()方法向浏览器的会话历史栈增加了一个条目。该方法是异步的。为 popstate 事件增加监听器,以确定导航何时完成。state 参数将在其中可用。

语法:history.pushState(state, title, url)

其中,state 对象是一个 JavaScript 对象,其与通过 pushState() 创建的新历史条目相关联。每当用户导航到新的 state,都会触发 popstate 事件,并且该事件的 state 属性包含历史条目 state 对象的副本。

title标题,由于历史原因是个必填项。url可以是相对路径也可以是绝对路径,浏览器会跳转到对应页面。

对比window.loacation

从某种程度来说,调用 pushState() 类似于 window.location = "#foo"window.location.hash将变成#foohash一般情况下为url后#及其后面一部分组成,这一部分为网页的位置,也称为为锚点),它们都会在当前的文档中创建和激活一个新的历史条目。但是 pushState() 有以下优势:

  • 新的 URL 可以是任何和当前 URL 同源的 URL。然而,如果你仅修改 hash,将其设置到 window.location,将使你留在同一文档中。【pushState即使是和当前同源的url也能加一条历史到历史栈】
  • 改变页面的 URL 是可选的。相反,设置 window.location = "#foo"; 仅仅会在当前 hash 不是 #foo 情况下,创建一条新的历史条目。
  • 你可以使用你的新历史条目关联任意数据。使用基于 hash 的方式,你需要将所有相关的数据编码为一个短字符串。

更多内容查看MDN——pushState()方法

3.2 history.replaceState()

replaceState()方法使用state objects, title,和 URL 作为参数,修改当前历史记录实体,如果你想更新当前的 state 对象或者当前历史实体的 URL 来响应用户的的动作的话这个方法将会非常有用。

语法:history.replaceState(stateObj, title[, url])

更多内容查看MDN——replaceState()方法

3.3 popstate事件

每当激活同一文档中不同的历史记录条目时,popstate 事件就会在对应的 window 对象上触发。如果当前处于激活状态的历史记录条目是由 history.pushState() 方法创建的或者是由 history.replaceState() 方法修改的,则 popstate 事件的 state 属性包含了这个历史记录条目的 state 对象的一个拷贝。

调用 history.pushState() 或者 history.replaceState() 不会触发 popstate 事件。popstate 事件只会在浏览器某些行为下触发,比如点击后退按钮(或者在 JavaScript 中调用 history.back() 方法)。即,在同一文档的两个历史记录条目之间导航会触发该事件。

更多内容查看MDN——popstate事件

4. JS二进制

JavaScript 提供了一些 API 来处理文件或原始文件数据,例如:FileBlobFileReaderArrayBufferbase64等。它们之间的关系如下:

img

以下是我对谈谈JS二进制:File、Blob、FileReader、ArrayBuffer、Base64这篇文章的一点小结,详细内容请看原文,建议把原文的代码都敲一遍

4.1 Blob

Blob(binary large object),即二进制大对象,它是 JavaScript 中的一个对象,表示原始的类似文件的数据。

Blob对象是一个只读不可修改的二进制文件,它的数据可以按文本或二进制的格式进行读取。

创建Blob对象

new Blob(array, option)

其中

  • array:由 ArrayBufferArrayBufferViewBlobDOMString 等对象构成的,将会被放进 Blob
  • options:可选的 BlobPropertyBag 字典,它可能会指定如下两个属性。
    • type:默认值为 "",表示放入到blob中的数组内容的MIME类型。
    • endings:默认值为"transparent",用于指定包含行结束符\n的字符串如何被写入,不常用。

常见MIME类型如下:

img

示例:以下实现了实例化了一个Blob对象并使用URL.createObjectUrl()方法将其转化为一个URL。

image-20231029160617822

点击这个url可以看到如下结果:

image-20231029161308175

4.2 File

文件(File)接口提供有关文件的信息,并允许网页中的 JavaScript 访问其内容。实际上,File 对象是特殊类型的 Blob,且可以用在任意的 Blob 类型的 context 中。Blob 的属性和方法都可以用于 File 对象。

注意:File 对象中只存在于浏览器环境中,在 Node.js 环境中不存在。

在 JavaScript 中,主要有两种方法来获取 File 对象:

  • <input> 元素上选择文件后返回的 FileList 对象
  • 文件拖放操作生成的 DataTransfer 对象;

下面实现一下拖放文件生成dataTransfer对象并输出结果:

<div style="width: 500px;height: 500px;background-color: gray;" id="dorp-zone"></div>
<script>
    const dorpZone = document.getElementById('dorp-zone');
    dorpZone.addEventListener('drop', (e) => {
        // 阻止默认事件 如放置文件将显示在浏览器新建窗口中
        e.preventDefault();
        console.log(e);
        const file = e.dataTransfer.files[0];
        console.log(file);
    })
    dorpZone.addEventListener('dragover', (e) => {
        e.preventDefault();
    })
</script>

结果如下:

image-20231029162540689

4.3 FileReader

FileReader 是一个异步 API,用于读取文件并提取其内容以供进一步使用。FileReader 可以将 Blob 读取为不同的格式。

注意:FileReader 仅用于以安全的方式从用户(远程)系统读取文件内容,不能用于从文件系统中按路径名简单地读取文件

创建FIleReader对象

const reader = new FileReader()

FileReader对象常用的属性如下:

  • error:表示在读取文件时发生的错误

  • result:文件内容。该属性仅在读取操作完成后才有效,数据的格式取决于使用哪个方法来启动读取操作。

  • readyState:表示FileReader状态的数字。取值如下:

    常量名描述
    EMPTY0还没有加载任何数据
    LOADING1数据正在被加载
    DONE2已完成全部的读取请求

FileReader对象提供了以下方法来加载文件:

  • readAsArrayBuffer():读取指定 Blob 中的内容,完成之后,result 属性中保存的将是被读取文件的 ArrayBuffer 数据对象
  • readAsBinaryString():读取指定 Blob 中的内容,完成之后,result 属性中将包含所读取文件的原始二进制数据
  • readAsDataURL():读取指定 Blob 中的内容,完成之后,result 属性中将包含一个data: URL 格式的 Base64 字符串以表示所读取文件的内容
  • readAsText():读取指定 Blob 中的内容,完成之后,result 属性中将包含一个字符串以表示所读取的文件内容

FileReader对象常用事件如下:

  • abort:该事件在读取操作被中断时触发
  • error:该事件在读取操作发生错误时触发
  • load:该事件在读取操作完成时触发
  • progress:该事件在读取 Blob 时触发

示例:以下实现了上传图片并转成base64的url以展示在页面

<input type="file" id="fileInput">
<img id="img" src="" alt="">
<script>
    const fileInput = document.getElementById('fileInput');
    const img = document.getElementById('img');
    const reader = new FileReader();
    fileInput.addEventListener('change', (e) => {
        console.log(e);
        const file = e.target.files[0];
        // reader.readAsText(file);  // 把图片文件转成字符串会是一大堆乱码
        reader.readAsDataURL(file);  // 转成base64的url
        reader.onload = (e) => {
            img.src = e.target.result;
            console.log(e.target.result);
        }
    })
</script>

结果如下:

image-20231029173703140

4.4 ArrayBuffer

ArrayBuffer对象用来表示通用的、固定长度的原始二进制数据缓冲区ArrayBuffer 的内容不能直接操作,只能通过 DataView 对象或 TypedArrray 对象来访问。这些对象用于读取和写入缓冲区内容。

ArrayBuffer 本身就是一个黑盒,不能直接读写所存储的数据,需要借助以下视图对象来读写:

  • TypedArray:用来生成内存的视图,通过9个构造函数,可以生成9种数据格式的视图。
  • DataViews:用来生成内存的视图,可以自定义格式和字节序。

4.5 Object URL

Object URL(MDN定义名称)又称Blob URL(W3C定义名称),是HTML5中的新标准。它是一个用来表示File ObjectBlob Object 的URL。在网页中,我们可能会看到过这种形式的 Blob URL

blob:https://zhuanlan.zhihu.com/47cca259-d9cd-41dc-b2a9-319c1db26f32

其实 Blob URL/Object URL 是一种伪协议,允许将 BlobFile 对象用作图像、二进制数据下载链接等的 URL 源

对于 Blob/File 对象,可以使用 URL构造函数的 createObjectURL() 方法创建将给出的对象的 URL。这个 URL 对象表示指定的 File 对象或 Blob 对象。我们可以在<img><script> 标签中或者 <a><link> 标签的 href 属性中使用这个 URL。

4.6 Base64

Base64 是一种基于64个可打印字符来表示二进制数据的表示方法。Base64编码普遍应用于需要通过被设计为处理文本数据的媒介上储存和传输二进制数据而需要编码该二进制数据的场景。这样是为了保证数据的完整并且不用在传输过程中修改这些数据

JavaScript 中,有两个函数被分别用来处理解码和编码 base64字符串:

  • atob():解码,解码一个 Base64 字符串;
  • btoa():编码,从一个字符串或者二进制数据编码一个 Base64 字符串。
btoa("JavaScript")       // 'SmF2YVNjcmlwdA=='
atob('SmF2YVNjcmlwdA==') // 'JavaScript'

应用场景如:使用toDataURL()方法把 canvas 画布内容生成 base64 编码格式的图片:

const canvas = document.getElementById('canvas'); 
const ctx = canvas.getContext("2d");
const dataUrl = canvas.toDataURL();

除此之外,还可以使用readAsDataURL()方法把上传的文件转为base64格式的data URI,可看4.3节的实例。

4.7 格式转换

看完这些基本的概念,下面就来看看常用格式之间是如何转换的。

(1)ArrayBuffer → blob

const blob = new Blob([new Uint8Array(buffer, byteOffset, length)]);

(2)ArrayBuffer → base64

const base64 = btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)));

(3)base64 → blob

const base64toBlob = (base64Data, contentType, sliceSize) => {
  const byteCharacters = atob(base64Data);
  const byteArrays = [];

  for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
    const slice = byteCharacters.slice(offset, offset + sliceSize);

    const byteNumbers = new Array(slice.length);
    for (let i = 0; i < slice.length; i++) {
      byteNumbers[i] = slice.charCodeAt(i);
    }

    const byteArray = new Uint8Array(byteNumbers);
    byteArrays.push(byteArray);
  }

  const blob = new Blob(byteArrays, {type: contentType});
  return blob;
}

(4)blob → ArrayBuffer

function blobToArrayBuffer(blob) { 
  return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onload = () => resolve(reader.result);
      reader.onerror = () => reject;
      reader.readAsArrayBuffer(blob);
  });
}

(5)blob → base64

function blobToBase64(blob) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onloadend = () => resolve(reader.result);
    reader.readAsDataURL(blob);
  });
}

(6)blob → Object URL

const objectUrl = URL.createObjectURL(blob);

5. sendBeacon发送请求

XMLHttpRequest 是一种用于发送 HTTP 请求的 API,它需要设置请求头、处理响应等,比较麻烦,而且它会在主线程中创建一个新的 HTTP 请求,可能会阻塞主线程。当需要发送的数据量比较大时,使用 XMLHttpRequest 是可行的,但在埋点场景下,通常需要发送的数据量很小,而且需要以非阻塞的方式发送,这时 navigator.sendBeacon() 就更合适,因此我们有必要学习navigator.sendBeacon发送请求,它能够更有效地处理小数据量的后台传输。

navigator.sendBeacon() 用于将数据以非阻塞(后台)方式发送到服务器。此方法主要用于在网页会话期间定期发送小数据包,而不会影响页面的加载或用户交互。即使页面卸载(关闭)也会发送请求,解决了使用XMLHttpRequest发送同步请求而迫使用户代理延迟卸载文档的问题

语法:navigator.sendBeacon(url, data);

更多内容查看:

  1. MDN——Navigator.sendBeacon()
  2. navigator.sendBeacon

二、功能实现

文件目录结构:

-dist	        打包生成的文件夹,生成的子文件后缀都是根据rollup.config.js配置生成的
--index.cjs.js
--index.d.js
--index.ems.js
--index.js
-node_modules
--....
-src
--core           包的主文件,实现主要功能
---index.ts
--types          类型定义文件,定义了需要使用的类型
---index.ts
--utils          工具方法文件夹
---pv.ts         因为pv埋点要用的pushState()、replaceState()方法是没有原生事件的,所以这里写了一个触发这两方法时派发的事件
-index.html      测试埋点效果用的html文件
-package.json
-rollup.config.js打包配置文件
-tsconfig.json   ts配置文件

1. 安装依赖与配置打包命令

package.json

{
  "name": "tracker",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "rollup -c"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "rollup": "^4.1.4",
    "rollup-plugin-dts": "^6.1.0",
    "rollup-plugin-typescript2": "^0.36.0",
    "typescript": "^5.2.2"
  },
  "dependencies": {
    "ts-node": "^10.9.1"
  }
}

2. rollup打包配置

rollup.config.js

import path from 'path';
import ts from 'rollup-plugin-typescript2';
import dts from 'rollup-plugin-dts';
const __dirname = path.resolve()
export default [
    {
        input: './src/core/index.ts',  // 入口文件
        output: [  // 输出文件
            {
                file: path.resolve(__dirname, './dist/index.ems.js'),
                format: 'es',  // 输出格式支持es规范,即import export
            },
            {
                file: path.resolve(__dirname, './dist/index.cjs.js'),
                format: 'cjs',  // 输出格式支持cjs规范,即require exports
            },
            {
                file: path.resolve(__dirname, './dist/index.js'),
                format: 'umd',  // 输出格式支持umd规范 即通用模块规范(amd和cjs的结合) amd(异步require) cmd() global都支持
                name: 'tracker'
            }
        ],
        plugins: [
            ts()  // 使用了这个 文件就支持ts 就会读取tsconfig.json
        ]
    },
    {  // 生成index.d.ts文件
        input: './src/core/index.ts',
        output: [  // 输出声明文件
            {
                file: path.resolve(__dirname, './dist/index.d.ts'),
                format: 'es'
            }
        ],
        // 该插件可以帮助我们自动生成 .d.ts 文件(TypeScript类型声明文件)
        plugins: [
            dts()
        ]
    }
]

// package.json中配置脚本 "build": "rollup -c" 运行脚本后就可以读取rollup.config.js文件
// tsconfig.json中配置 "module": "ESNext" ESNext泛指它永远指向下一个版本 如当前最新版本是ES2021 那么ESNext指的即是2022年6月要发布的标准

3. 类型定义

src/types/index.ts

/**
 * 这是一个默认值的接口,用于埋点类Tracker传递初识化时配置默认值
 * @uuid 做uv的 uv标识
 * @requestUrl 接口地址
 * @historyTracker history上报 单页面应用时 一种模式是hash一种模式是history
 * @hashTracker hash上报
 * @domTracker 携带Tracker-key 点击事件上报
 * @sdkVersionsdk sdk版本上报
 * @extra 透传字段 用户可以自定义一些参数 也可以上报这些
 * @jsError js 和 promise 报错异常上报
 */
export interface DefaultOptions {
    uuid: string | undefined,
    requestUrl: string | undefined,
    historyTracker: boolean,
    hashTracker: boolean,
    domTracker: boolean,
    sdkVersion: string | number,
    extra: Record<string, any> | undefined,
    jsError: boolean,
}

/**
 * @Options 继承于DefaultOptions
 * @Pirtial Partial实现将<>内的所有属性设置为可选。
 * 因此以下继承的默认参数都将是可传可不传的的
 */

export interface Options extends Partial<DefaultOptions> {
    requestUrl: string  // 这里又重写了requestUrl属性 意味着其他属性都是可传 但requestUrl必传
}

/**
 * version枚举
 */
export enum TrackerConfig {
    version = '1.0.0',
}

4. 实现pv操作事件监听

因为pv埋点要用的pushState()replaceState()方法是没有原生事件可以监听的,所以这里写了一个触发这两方法时派发同名事件的工具方法,通过监听派发的方法来实现pv埋点。

src/utils/pv.ts

/**
 * 因为pv埋点要用的pushState()、replaceState()方法是没有原生事件的,所以这里写了一个触发这两方法时派发的事件
 * PV: 页面访问量,即PageView,用户每次对网站的访问均被记录
 * 主要监听了history 和 hash
 * @type history内的方法名
 */
// 这里通过定义一个继承History对象泛型来限制传入的type
export const createHistoryEvent = <T extends keyof History>(type: T) => {
    
    const origin = history[type];  // 通过索引签名的方式拿到history内通过参数type传来的方法
    /**
     * 返回高阶函数
     * 这里的this是假参数 通过声明类型来欺骗编译器 以免下面使用this会有提示
     */
    return function (this: any) {
        // 使用apply()触发方法
        const res = origin.apply(this, arguments);
        /**
         * 使用Event创建自定义事件
         * @dispatchEvent 派发事件
         * @addEventListener 监听事件
         * @removeEventListener 删除事件
         * 其实就是 发布订阅模式
         */
        const e = new Event(type);
        // 派发参数传的type事件
        window.dispatchEvent(e);
        // 返回history[type]方法的返回结果
        return res;
    }
}
// 这里传入history.pushState
// createHistoryEvent('pushState')

5. 写一个简单接口测验埋点结果

这里新建了一个文件夹,使用Node.js写一个简易的接口http://localhost:9000/tracker模拟真实接口来校验埋点传参结果。

const express = require('express');
const cors = require('cors');

const app = express();
// 使用工具库cors解决跨域问题
app.use(cors());
app.use(express.urlencoded({ extended: false }));

app.post('/tracker', (req, res) => {
    console.log(req.body);  // 请求传来的参数
    res.send('ok')
})

app.listen(9000, () => {
    console.log('server is running on port 9000, success');
})

运行接口如下:

image-20231029184031903

6. 核心代码

src/core/index.ts

import { DefaultOptions, TrackerConfig, Options } from "../types/index";
import { createHistoryEvent } from "../utils/pv";

// 鼠标事件列表
const mouseEventList: string[] = [
  "click",
  "dblclick",
  "contextmenu",
  "mousedown",
  "mouseup",
  "mouseenter",
  "mouseout",
];

export default class Tracker {
  // 暴露一个上报类
  public data: Options;
  constructor(options: Options) {
    // 传的默认参数
    // 传的参数覆盖默认兜底的参数
    this.data = Object.assign(this.initDef(), options);
    // 根据参数触发监听对应内容
    this.installTracker();
  }

  // 兜底逻辑:返回一些默认参数 里面也可以增加一些初始化的操作 因为构造函数内会执行这个兜底方法
  private initDef(): DefaultOptions {
    // history.pushState() 方法向浏览器的会话历史栈增加了一个条目。
    // replaceState()方法使用state objects, title,和 URL 作为参数,修改当前历史记录实体,如果你想更新当前的 state 对象或者当前历史实体的 URL 来响应用户的的动作的话这个方法将会非常有用。
    // history.replaceState(stateObj, "", "bar2.html");执行后会替换到bar2.html但不会加载bar2.html页面
    window.history["pushState"] = createHistoryEvent("pushState");
    window.history["replaceState"] = createHistoryEvent("replaceState");
    return <DefaultOptions>{
      sdkVersion: TrackerConfig.version,
      historyTracker: false,
      hashTracker: false,
      domTracker: false,
      jsError: false,
    };
  }

  /**
   * 捕获事件的监听器
   * @param mouseEventList 要监听的事件列表 是个字符串数组
   * @param targetKey 一个关键字 一般传给后台 要给后台协商
   * @param data 数据 可填可不填
   */
  private captureEvents<T>(eventList: string[], targetKey: string, data?: T) {
    eventList.forEach((event) => {
      // 监听
      window.addEventListener(event, (e: any) => {
        // 回调
        console.log("监听到了");
        // 调用接口实现上报
        this.reportTracker({
          event,
          targetKey,
          data,
        });
      });
    });
  }

  // 手动上报
  public sendTracker<T>(data: T) {
    // 调用接口实现上报
    this.reportTracker(data);
  }

  // 埋点触发器 根据传来的监听配置来判断监听哪些内容
  private installTracker() {
    // history上报
    if (this.data.historyTracker) {
      // 传入history上报需要监听的事件列表
      this.captureEvents(
        ["pushState", "replaceState", "popstate"],
        "pv-history"
      ); // 注意popstate是小写
    }
    // hash上报
    if (this.data.hashTracker) {
      this.captureEvents(["hashchange"], "pv-hash");
    }
    // dom上报
    if (this.data.domTracker) {
      this.domReport("dom");
    }
    //  js报错上报
    if (this.data.jsError) {
      this.jsError();
    }
  }

  // 接口实现上报
  private reportTracker<T>(data: T) {
    const params = Object.assign(this.data, data, {
      time: new Date().getTime(),
    });
    let headers = {
      type: "application/x-www-form-urlencoded",
    };
    let blob = new Blob([JSON.stringify(params)], headers);
    navigator.sendBeacon(this.data.requestUrl, blob);
  }
  // uuid
  public setUserId<T extends DefaultOptions["uuid"]>(uuid: T) {
    this.data.uuid = uuid;
  }

  // 透传字段
  public setExtra<T extends DefaultOptions["extra"]>(extra: T) {
    this.data.extra = extra;
  }

  // dom事件上报
  private domReport(targetKey: string) {
    mouseEventList.forEach((ev) => {
      window.addEventListener(ev, (e) => {
        // console.log(e.target);
        const target = e.target as HTMLElement;
        if (target.getAttribute("target-key")) {
          console.log("监听到带有target-key属性元素的dom事件");
          this.reportTracker({
            event: ev,
            targetKey,
          });
        }
        console.log("未监听到带有target-key属性元素的dom事件");
        // let activeElement = document.activeElement;
        // if (activeElement?.getAttribute("target-key")) {
        //   console.log("监听到dom事件");
        // }
      });
    });
  }

  // 常规报错上报
  private errorEvent() {
    window.addEventListener("error", (event) => {
      console.log(event.message, "常规报错");
      this.reportTracker({
        event: "error",
        targetKey: "message",
        message: event.message,
      });
    });
  }
  // Promise报错上报
  private promiseReject() {
    window.addEventListener("unhandledrejection", (event) => {
      event.promise.catch((error) => {
        console.log(error, "promise报错");
        this.reportTracker({
          event: "unhandledrejection",
          targetKey: "message",
          reason: error,
        });
      });
    });
  }

  // js报错 包括常规报错和Promise报错
  private jsError() {
    this.errorEvent();
    this.promiseReject();
  }
}

7. 校验结果

我们在index.html实例化埋点类tracker并开启history、hash埋点、dom事件埋点、js报错埋点。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <script src="./dist/index.js"></script>

    <!-- 给按钮添加自定义属性,如果有就dom上报 无则不上报 -->
    <button target-key="btn">按钮</button>
    <button>无添加</button>
    <script>
        new tracker({
            requestUrl: 'http://localhost:9000/tracker', // 请求的埋点接口
            historyTracker: true,
            hashTracker: true,
            domTracker: true,
            jsError: true
        })
        // console.log(sfafa);
    </script>
</body>

</html>

右键Open with Live Server运行html文件。

点击按钮按钮,可以看到触发如下四次接口,而点击无添加按钮并不会触发。

image-20231029190035605

查看传参如下:

image-20231029190321597

证明dom事件传参ok。

在控制台输入:history.pushState('state', 'title', 'a')跳转http://127.0.0.1:5501/a页并触发pv监听,结果如下

image-20231029190526684

同样使用replaceState或者点击前进后退按钮一样会触发,则表面埋点是没有问题的。

在html页面的脚本内添加:console.log(sfafa);,由于sfafa未定义因此会触发js报错,通过这种方法我们来测试js报错埋点:

image-20231029190924783

参考

小满埋点SDK从0开发并且发布npm (完结)

小满 前端埋点SDK 带你 从0 开发 并且发布npm

前端模块化详解(完整版)

安装 Rollup 以及 Rollup 和 Webpack 的区别

navigator.sendBeacon

谈谈JS二进制:File、Blob、FileReader、ArrayBuffer、Base64

1.0.0

7 months ago