前言

qx-tracker 是一个前端监控和埋点SDK,支持自定义埋点和监控。

埋点就是数据采集-数据处理-数据分析和挖掘,如用户停留时间,用户哪个按钮点的多等。使用ts在编译过程中发现问题,减少生产代码的错误。

本文重新解构一下项目实现,复习相关的API。

构建环境

使用 Rollup 构建项目,TypeScript 编写代码。基本模板为自编写的 rollup-template

package.json

两个npm命令:

  1. npm run build 构建生产环境代码,rollup 使用 rollup.config.prod.mjs 生产配置文件,并将环境变量 ENV 设置为 production
  2. npm run dev 构建开发环境代码,rollup 使用 rollup.config.dev.mjs 开发配置文件,并将环境变量 ENV 设置为 development-w 监听文件变化(热更新),-m 生成sourcemap源映射文件。
npm命令
1
2
3
4
"scripts": {
"build": "rollup --config rollup.config.prod.mjs --environment ENV:production",
"dev": "rollup --config rollup.config.dev.mjs -w -m --environment ENV:development"
},

最终打包的文件在 dist 目录下。

1
2
3
4
"main": "dist/index.cjs.js", // commonjs规范
"module": "dist/index.esm.js", // esm模块规范
"browser": "dist/index.js", // 浏览器环境,使用umd
"types": "dist/index.d.ts", // ts类型声明文件

rollup配置

一共有三个配置文件:

  1. rollup.config.common.mjs 公共配置文件,包含基础的公共配置,如 input、基础插件。
  2. rollup.config.dev.mjs 开发环境配置文件,继承公共配置文件,设置 output、增加插件。
  3. rollup.config.prod.mjs 生产环境配置文件,继承公共配置文件,设置 output、增加插件、打包类型声明文件。
rollup.config.common.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 默认情况下,Rollup 只能处理相对路径的导入,安装node-resolve才能在模块中导入第三方npm模块
import nodeResolve from '@rollup/plugin-node-resolve';
// 将 CommonJS 模块转换为 ES6 模块,因为 Rollup 默认只能处理 ES6 模块
import commonjs from '@rollup/plugin-commonjs';
// 在每次打包之前清空输出目录
import clear from 'rollup-plugin-clear';
// 编译ts,使 Rollup 能处理ts文件
import typescript from '@rollup/plugin-typescript';
// 在源代码中替换一些特定的字符串。
import replace from '@rollup/plugin-replace';

export default {
// 入口文件
input: {
// 名为index的入口文件
index: './src/core/index.ts',
},
// 插件配置
plugins: [
replace({
preventAssignment: true, // 阻止在赋值操作中进行不正确的替换。
__env__: JSON.stringify(process.env.ENV) // 替换为环境变量
}),
nodeResolve(), // 可以导入第三方npm模块
// 将 CommonJS 模块转换为 ES6 模块,只处理js和ts文件
commonjs({ extensions: ['.js', '.ts'] }),
clear({
// 需要清空的文件夹
targets: ['dist'],
// 在监视模式下进行汇总重新编译时是否清除目录
watch: false, // default: false
}),
typescript({}), // 编译ts
],
};
rollup.config.dev.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 在开发环境中启动一个 HTTP 服务器
import serve from 'rollup-plugin-serve';
// 在文件改变时自动刷新浏览器
import livereload from "rollup-plugin-livereload";
// 生成html文件,方便查看效果
import html from '@rollup/plugin-html';
// 公共配置
import common from './rollup.config.common.mjs';
// html模板
import { htmlDevTemple } from './html-temple.mjs';

// 使用Object.assign扩展公共配置
export default Object.assign({}, common, {
// 输出配置
output: [
{
dir: 'dist', // 输出目录
entryFileNames: '[name].js', // 输出文件名
format: 'umd', // 输出格式
name: "Tracker" // umd模块名称,作为全局变量名
}
],
plugins: [
...common.plugins, // 导入公共配置的插件
html(htmlDevTemple), // 生成html文件
serve({
port: 3000, // 端口
contentBase: 'dist', // 服务器的根目录为输出目录
openPage: '/index.html', // 打开哪个文件
open: false, // 自动打开浏览器
}),
livereload(), // 自动刷新浏览器
],
});
rollup.config.prod.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// import html from '@rollup/plugin-html';
// 压缩
// import terser from '@rollup/plugin-terser';
// 公共配置
import common from "./rollup.config.common.mjs";
// import { htmlProdTemple } from './html-temple.mjs';
// 用于生成 TypeScript 的声明文件
import dts from "rollup-plugin-dts";

// 使用Object.assign扩展公共配置
// 导出一个数组,数组中包含两个配置对象,rollup允许多入口多个产物
export default [
Object.assign({}, common, {
output: [
//打包 AMD CMD UMD
{
dir: "dist",
entryFileNames: "[name].js",
format: "umd",
name: "Tracker",
},
// 压缩的事还是应该交给用户自己去做
// {
// dir: 'dist',
// entryFileNames: '[name].min.js',
// format: 'umd',
// name: "tracker",
// plugins: [terser()],
// },
//打包common js
{
dir: "dist",
entryFileNames: "[name].cjs.js",
format: "cjs",
},
// {
// dir: 'dist',
// entryFileNames: '[name].cjs.min.js',
// format: 'cjs',
// plugins: [terser()],
// },
//打包esModule
{
dir: "dist",
entryFileNames: "[name].esm.js",
format: "es",
},
// {
// dir: 'dist',
// entryFileNames: '[name].esm.min.js',
// format: 'es',
// plugins: [terser()],
// },
],
plugins: [
...common.plugins,
// html(htmlProdTemple)
],
}),
{
// 打包d.ts
input: {
index: "./src/core/index.ts",
},
output: {
dir: "dist",
entryFileNames: "[name].d.ts",
format: "es",
},
plugins: [dts()],
},
];

tsconfig.json 配置:

tsconfig.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
{
"compilerOptions": {
"incremental": false, // TS编译器在第一次编译之后会生成一个存储编译信息的文件,第二次编译会在第一次的基础上进行增量编译,可以提高编译的速度
// "tsBuildInfoFile": "./buildFile", // 增量编译文件的存储位置
// "diagnostics": true, // 打印诊断信息
"target": "esnext", /* 指定 ECMAScript 目标版本:'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */
"module": "esnext", /* 输出的代码使用什么方式进行模块化: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
"lib": [ /* 指定引用的标准库 */
"esnext",
"dom",
], // TS需要引用的库,即声明文件,es5 默认引用dom、es5、scripthost,如需要使用es的高级版本特性,通常都需要配置,如es8的数组新特性需要引入"ES2019.Array",
"allowJs": true, // 允许编译器编译JS,JSX文件
"checkJs": true, // 允许在JS文件中报错,通常与allowJS一起使用
"outDir": "./dist", // 指定输出目录
"rootDir": "./src", // 指定输出文件目录(用于输出),用于控制输出目录结构
// "declaration": true, // 生成声明文件,开启后会自动生成声明文件
// "declarationDir": "./dist/typings", // 指定生成声明文件存放目录
// "emitDeclarationOnly": true, // 只生成声明文件,而不会生成js文件
"sourceMap": false, // 生成目标文件的sourceMap文件
// "inlineSourceMap": true, // 生成目标文件的inline SourceMap,inline SourceMap会包含在生成的js文件中
"declarationMap": false, // 为声明文件生成sourceMap
// "typeRoots": [], // 声明文件目录,默认时node_modules/@types
"types": [], // 加载的声明文件包
"removeComments": true, // 删除注释
// "noEmit": true, // 不输出文件,即编译后不会生成任何js文件
"noEmitOnError": true, // 发送错误时不输出任何文件
"noEmitHelpers": true, // 不生成helper函数,减小体积,需要额外安装,常配合importHelpers一起使用
"importHelpers": true, // 通过tslib引入helper函数,文件必须是模块
"downlevelIteration": true, // 降级遍历器实现,如果目标源是es3/5,那么遍历器会有降级的实现
"strict": true, // 开启所有严格的类型检查
"alwaysStrict": true, // 在代码中注入'use strict'
"noImplicitAny": true, // 不允许隐式的any类型
"strictNullChecks": true, // 不允许把null、undefined赋值给其他类型的变量
"strictFunctionTypes": true, // 不允许函数参数双向协变
"strictPropertyInitialization": true, // 类的实例属性必须初始化
"strictBindCallApply": true, // 严格的bind/call/apply检查
"noImplicitThis": true, // 不允许this有隐式的any类型
"noUnusedLocals": true, // 检查只声明、未使用的局部变量(只提示不报错)
"noUnusedParameters": true, // 检查未使用的函数参数(只提示不报错)
"noFallthroughCasesInSwitch": true, // 防止switch语句贯穿(即如果没有break语句后面不会执行)
"noImplicitReturns": true, //每个分支都会有返回值
"esModuleInterop": true, // 允许export=导出,由import from 导入
"allowUmdGlobalAccess": true, // 允许在模块中全局变量的方式访问umd模块
"moduleResolution": "node", // 模块解析策略,ts默认用node的解析策略,即相对的方式导入
"baseUrl": "./", // 解析非相对模块的基地址,默认是当前目录
"paths": { // 路径映射,相对于baseUrl
// "@/*": [
// "src/*"
// ]
},
"rootDirs": [
"src"
], // 将多个目录放在一个虚拟目录下,用于运行时,即编译后引入文件的位置可能发生变化,这也设置可以虚拟src和out在同一个目录下,不用再去改变路径也不会报错
"listEmittedFiles": true, // 打印输出文件
"listFiles": true, // 打印编译的文件(包括引用的声明文件)
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true
},
// 指定一个匹配列表(属于自动指定该路径下的所有ts相关文件)
"include": [
"src/**/*",
],
// 指定一个排除列表(include的反向操作)
// "exclude": [
// "demo.ts"
// ],
// 指定哪些文件使用该配置(属于手动一个个指定文件)
// "files": [
// "src/index.d.ts"
// ]
}

html-temple.mjs 配合 rollup/plugin-html 生成开发环境测试用的 html 文件,其实感觉也没比新建一个html文件方便。

项目结构

主要目录结构
1
2
3
4
5
6
7
├───📁 dist/
│ └───...
├───📁 server/
│ └───...
├───📁 src/
│ └───...
└───...

根目录:

  1. dist 是Rollup输出目录,存放构建打包的产物
  2. server 一个简单的后端服务,用于测试数据上报。
  3. src 项目源码。
src源码目录结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
├───📁 core/ // 核心源码
│ ├───📁 tracker/ // tracker相关类
│ │ ├───📄 dom.ts // dom埋点
│ │ ├───📄 error.ts // 错误监控
│ │ ├───📄 index.ts // 统一暴露
│ │ ├───📄 location.ts // 路由监控
│ │ ├───📄 navigator.ts // 获取用户信息
│ │ ├───📄 options.ts // tracker配置
│ │ ├───📄 performance.ts // 性能监控,主要是dom和资源加载的性能
│ │ └───📄 trackerCls.ts // 抽象类,控制其它tracker类行为
│ └───📄 index.ts // 入口文件,Tracker类,管理配置和其它监控类实例。
├───📁 types/ // 类型
│ └───📄 index.ts // 定义了一些ts类型
├───📁 utils/ // 工具函数
│ ├───📄 beacon.ts // 封装sendBeacon
│ ├───📄 index.ts // 统一暴露所有工具函数
│ ├───📄 location.ts // 路由相关函数
│ ├───📄 log.ts // 开发环境log
│ ├───📄 navigator.ts // 获取用户信息
│ ├───📄 performance.ts // 性能相关函数
│ ├───📄 string.ts // 计算字符串大小
│ ├───📄 uuid.ts // 获取uuid
│ └───📄 watch.ts // 监视器,暂时没用到
└───📄 index.d.ts // 基础类型声明文件

入口文件

解析项目还是由表及里好,先从入口文件 core/index.ts 讲起。
工具函数和类型文件的内容,在各个监控类用到时再穿插讲解。

入口文件导出了一个名为 Tracker 的类,我称之为主类,继承自 TrackerOptions,主类主要管理着其它监控类实例,并提供了上报数据的方法、与外部环境打交道。

src\core\index.ts
1

TrackerOptions配置类

TrackerOptions 是配置类,管理着所有监控类的配置,如上报地址、功能开启等。所有监控类都要根据配置来执行相应的操作。

src\core\tracker\options.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { DefaultOptions, Options, TrackerConfig } from "../../types"; // 导入类型
import { getCanvasID } from "../../utils"; // 导入通过canvas获取uuid的方法

export default class TrackerOptions {
// 存储配置项,protected权限,实例不能访问,子类和自己可以访问
protected options: Options

// 构造函数,接收配置项
constructor(options: Options) {
// 合并默认和用户传入设置,用户传入设置优先级高
// 通过 Object.assign 合并默认配置和用户配置
this.options = Object.assign(this.initDefault(), options);
}
// 初始化配置项
private initDefault(): DefaultOptions {
return <DefaultOptions>{
requestUrl: "", // 上报地址
uuid: this.generateUserID(), // 用户唯一标识
historyTracker: false, // history模式,开启后会监听路由变化
hashTracker: false, // hash模式,开启后会监听路由变化
errorTracker: false, // 错误监控
domTracker: false, // dom埋点监控
// 需要监控的dom事件
domEventsList: new Set(['click', 'dblclick', 'contextmenu', 'mousedown', 'mouseup', 'mouseout', 'mouseover']),
performanceTracker: false, // 性能监控
navigatorTracker: false, // 用户信息监控
extra: undefined, // 上报时需要携带的额外信息
sdkVersion: TrackerConfig.version, // sdk版本
log: true, // 是否在控制台打印日志
realTime: false, // 是否实时上报
maxSize: 1024 * 50 // 单次上报数据最大值,单位字节
}
}
// 生成uuid
public generateUserID(): string | undefined {
return getCanvasID()
}
}

先看默认配置项,由私有方法 initDefault() 提供,为 DefaultOptions 类型。

DefaultOptions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export interface DefaultOptions {
requestUrl: string, // 上报地址
uuid: string | undefined, // 用户唯一标识
historyTracker: boolean, // history模式,开启后会监听路由变化
hashTracker: boolean, // hash模式,开启后会监听路由变化
errorTracker: boolean, // 错误监控
domTracker: boolean, // dom埋点监控
domEventsList: Set<keyof HTMLElementEventMap>, // 需要监控的dom事件
performanceTracker: boolean, // 性能监控
navigatorTracker: boolean, // 用户信息监控
extra: Record<string, any> | undefined, // 上报时需要携带的额外信息
sdkVersion: string | number, // sdk版本
log: boolean, // 是否在控制台打印日志
realTime: boolean, // 是否实时上报
maxSize: number, // 单次上报数据最大值,单位字节
}
export enum TrackerConfig {
version = '1.0.0', // 版本
}

所有功能默认都是关闭的,用户可以根据需要开启。uuid通过 generateUserID() 方法获取,domEventsList 已经配置了一些常用的dom事件。sdkVersion 则是通过枚举类型 TrackerConfig 获取。

配置类的构造函数传入一个 Options 类型的外部配置项,通过 Object.assign 合并默认配置和用户配置,用户配置优先级高。

Options 类型由 DefaultOptions 类型,通过 Optional 高级类型加工而来,将 requestUrl 设为必选项,而其它都是可选项,也就是必须传入一个上报地址。

Options
1
2
// 用户传入选项
export type Options = Optional<DefaultOptions, 'requestUrl'>

TS并没有提供 Optional 高级类型,用于将某些属性设为可选项。需要自己实现。

  1. 传入两个泛型参数,T为原始类型,K为必选项,是 T 的属性名。
  2. Pick 类型将必选项筛选出来,Omit 去掉必选项,再使用 Partial 类型将其它属性设为可选项。
  3. 最后通过联合类型 & 合并两个类型。

高级类型就像是类型的函数,加工原始类型,返回一个新的类型。

Optional
1
2
3
4
5
6
/**
* 选项生成器
* @param T - 原始类型
* @param K - 必填类型
*/
type Optional<T, K extends keyof T> = Pick<T, K> & Partial<Omit<T, K>>;

最后,将合并后的配置项存储在 options 属性中。为 protected 权限,只能由子类和自己访问。当然,主类也提供了一些方法,允许动态设置一些配置项,如uuid、额外数据等。

trackerCls

主类管理着所有具体监控类的实例,而这些监控类都继承自 trackerCls 这个抽象类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { Options, EventListeners } from "../../types";

// 抽象类,具体的监控类需要继承该类,控制监控类的行为
export default abstract class TrackerCls {
// 存储配置项
protected options: Options
// 存储上报数据的方法
protected reportTracker: Function // 上报数据的方法
// 存储事件监听器
protected eventListeners: EventListeners = {}

constructor(options: Options, reportTracker: Function) {
// 配置项和上报方法由外部传入
this.options = options;
this.reportTracker = reportTracker;
}
// 抽象方法,如何初始化交给具体的监控类去实现。
abstract init(): void
// 封装addEventListener
protected addEventListener(name: string, handler: EventListenerOrEventListenerObject, options: boolean | AddEventListenerOptions = false) {
// 如果没有该事件的监听数组,就初始化一个空的
!this.eventListeners.hasOwnProperty(name) && (this.eventListeners[name] = [])
// 将事件监听器存入数组
this.eventListeners[name].push(handler)
// 添加事件监听
window.addEventListener(name, handler, options)
}
// 额外需要销毁的内容,由具体的监控类去实现
abstract additionalDestroy(): void
// 销毁方法,因为有事件监听器,所以需要销毁事件监听器,避免内存泄漏
public destroy() {
// 遍历eventListeners获取所有事件监听器,然后移除
for (const eventName in this.eventListeners) {
const listeners = this.eventListeners[eventName];
for (const listener of listeners) {
window.removeEventListener(eventName, listener);
}
}
// 清空eventListeners
this.eventListeners = {};
// 调用额外销毁的方法
this.additionalDestroy();
}
}

trackerCls 控制监控类的基本行为:

  1. 构造函数需要接收从外部(主类)传入的配置项和上报方法。并根据配置项初始化自己,在合适的时候上报数据。
  2. 规定了抽象方法 init,具体的监控类需要实现初始化方法。
  3. 提供了 addEventListener 方法,封装了 window.addEventListener,并存储了事件监听器,方便销毁。
  4. 提供了 destroy 方法,用于销毁事件监听器,避免内存泄漏。同时定义了 additionalDestroy 抽象方法,额外需要销毁的内容由具体的监控类去实现。

Tracker主类

再回来看主类 Tracker,继承自 TrackerOptions,所以拥有配置类的属性和方法。

主类的构造函数同样接收一个 Options 类型的配置项,并通过 super 调用父类构造函数,初始化配置项。现在,配置项由主类进行管理了。

主类提供了一些 public 方法,setUserIDsetExtra,允许动态设置配置项。

1
2
3
4
5
6
7
8
9
10
// 允许外部设置uuid
public setUserID<T extends DefaultOptions['uuid']>(uuid: T) {
if (this.isDestroy) return;
this.options.uuid = uuid;
}
// 外部设置额外参数
public setExtra<T extends DefaultOptions['extra']>(extra: T) {
if (this.isDestroy) return;
this.options.extra = extra;
}

继续看构造函数:

  1. 实例化了各种监控类,并存储在了 trackers 私有对象的对应属性上。这是为了方便管理和调用类上的方法(初始化、销毁)。
  2. 所有监控类都需要接收配置项和上报方法,上报方法包装了主类的 reportTracker 方法。

在设计上,监控类只负责自己的监控职责,并在合适的适合上报数据,不负责数据的具体处理和上报逻辑,具体的上报实现由主类负责。

src\core\index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { TrackerOptions, LocationTracker, DomTracker, ErrorTracker, PerformanceTracker, NavigatorTracker } from "./tracker";
// 存储各种Tracker的实例
private trackers: Trackers = {
locationTracker: undefined, // 路由监控实例
domTracker: undefined, // dom埋点监控实例
errorTracker: undefined, // 错误监控实例
performanceTracker: undefined, // 性能监控实例
navigatorTracker: undefined, // 用户信息监控实例
}
constructor(options: Options) {
super(options);
// 创建各种Tracker的实例,将配置和上报方法传入
this.trackers.locationTracker = new LocationTracker(
this.options,
<T>(data: T, key: string) => this.reportTracker(data, key)
);
this.trackers.domTracker = new DomTracker(
this.options,
<T>(data: T, key: string) => this.reportTracker(data, key)
);
this.trackers.errorTracker = new ErrorTracker(
this.options,
<T>(data: T, key: string) => this.reportTracker(data, key)
);
this.trackers.performanceTracker = new PerformanceTracker(
this.options,
<T>(data: T, key: string) => this.reportTracker(data, key)
);
this.trackers.navigatorTracker = new NavigatorTracker(
this.options,
<T>(data: T, key: string) => this.reportTracker(data, key)
);
// 调用初始化方法
this.init();
}

// 类型 src\types\index.ts
import { LocationTracker, DomTracker, ErrorTracker, PerformanceTracker, NavigatorTracker } from "../core/tracker";
export type Trackers = {
locationTracker: LocationTracker | undefined,
domTracker: DomTracker | undefined,
errorTracker: ErrorTracker | undefined,
performanceTracker: PerformanceTracker | undefined,
navigatorTracker: NavigatorTracker | undefined,
}

初始化:
实例化完各种监控类后,调用了 init() 私有方法。

  1. 遍历所有Tracker实例,调用其初始化方法。
  2. 如果不是实时上报模式,初始化 beforeCloseReport,主类控制在页面关闭前上报。

只要监听了事件,其回调都应该保存起来,以便在合适的时候销毁。这里监听了 beforeunload 事件,所以使用 beforeCloseHandler 私有属性保存其回调。

初始化相关属性和方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 存储字符串大小计算方法
private stringSizeCalculation: Function | undefined = undefined
// 存储beforeCloseHandler,页面关闭前执行的回调
private beforeCloseHandler: EventListenerOrEventListenerObject | undefined = undefined
// 初始化
private init() {
try {
// 遍历所有Tracker实例,调用其初始化方法
for (const key in this.trackers) {
this.trackers[key as keyof Trackers]?.init();
}
// 如果不是实时上报模式,初始化beforeCloseReport,主类控制在页面关闭前上报
if (!this.options.realTime) {
// 创建字符串大小计算方法
this.stringSizeCalculation = createStringSizeCalculation();
this.beforeCloseReport();
}
// 如果允许log,则打印初始化成功
this.options.log && console.log('Tracker is OK');
} catch (e) {
// console.log(e);
// 初始化出错,则直接上报错误
sendBeacon(this.options.requestUrl, this.decorateData({
targetKey: "tracker",
event: "error",
message: e,
}));
this.options.log && console.error('Tracker is error');
}
}
// 启动页面关闭前上报
private beforeCloseReport() {
// 设置beforeCloseHandler
this.beforeCloseHandler = () => {
this.sendReport();
}
// 监听页面关闭前beforeunload事件
window.addEventListener("beforeunload", this.beforeCloseHandler);
}

销毁:
主类提供了公共方法 destroy,允许外部销毁监控。主要是调用了各个监控类的销毁方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 销毁
public destroy() {
// 如果已经销毁,直接返回
if (this.isDestroy) return;
// 销毁前把剩余数据传出
this.sendReport();
// 遍历所有监控类
for (const key in this.trackers) {
// 调用其销毁方法
this.trackers[key as keyof Trackers]?.destroy();
// 将其实例置为undefined,以便GC回收
this.trackers[key as keyof Trackers] = undefined;
}
// 移除beforeCloseHandler
this.beforeCloseHandler && window.removeEventListener("beforeunload", this.beforeCloseHandler);
// 设置属性为undefined,以便GC回收
this.stringSizeCalculation = undefined;
this.beforeCloseHandler = undefined;
// 将销毁标志置为true
this.isDestroy = true;
}

数据上报

上报数据的方法由主类提供,并通过构造函数注入给其它各个监控类。

封装sendBeacon

传统的数据上报方式,如 XMLHttpRequest 或 Fetch API,容易受到页面卸载过程中的阻塞,导致数据丢失。

navigator.sendBeacon 可以在页面卸载时安全、可靠地发送数据。

  1. 异步执行,不阻塞页面关闭或跳转。
  2. 不受页面卸载过程的影响,确保数据可靠发送。
  3. 无法获取响应,但在发送简单请求时天然跨域,就像fetch的no-cors模式一样。

缺点:

  1. sendBeacon 只能发送 POST 请求。
  2. 请求类型为 ping,只能传送少量数据(通常是 64KB 以内),无法自定义请求头。
  3. 只能传输 ArrayBuffer、ArrayBufferView、Blob、DOMString、FormData 或 URLSearchParams 类型的数据。
  4. 如果处于危险的网络环境,或者开启了广告屏蔽插件 此请求将无效

sendBeacon(url, data) 接收两个参数,第一个是地址,第二个是数据。

返回值:boolean

  1. true: 数据被异步缓存,但并不保证数据已被成功发送或接收。所以后续考虑只在关闭页面前使用 sendBeacon,其它时候使用 fetch。
  2. false: 数据无法被缓存,通常是因为数据太大或 URL 无效。

数据类型:
如果data是字符串类型,那么content-type会自动匹配为text/plain,如果是FormData类型,则会自动匹配为multipart/form-data类型。

如果想要发送json数据,则需要借助Blob对象。通过Blob的type参数,可以指定MIME类型,间接达到设置Content-Type的目的。

1
2
3
const blob = new Blob([JSON.stringify(params)], {
type: 'application/json'
});

但这会导致跨域,因为不是原始的三个Content-Type。所以不如直接使用text/plain,请求体是JSON字符串。后端使用 JSON.parse 解析。

1
2
3
4
5
6
7
8
9
10
11
12
// 封装sendBeacon,传入url和params,返回上报状态
export function sendBeacon(url: string, params: object): boolean {
// 判断是否有数据,也就是params是否为空对象
if (Object.keys(params).length <= 0) {
return false;
}
// const blob = new Blob([JSON.stringify(params)], {
// type: 'application/json'
// });
const state = navigator.sendBeacon(url, JSON.stringify(params));
return state;
}

一个简单的 express 后端,使用 express.text() 解析请求体的字符串。

1
2
3
4
5
6
7
8
9
10
11
const express = require('express');
const app = express();

app.post('/tracker', express.text(), function (req, res) {
console.log(JSON.parse(req.body));
res.send('ok');
});

app.listen(9000, () => {
console.log('listening on')
})

reportTracker方法

reportTrackerTracker 主类上报数据的私有方法,通过构造函数注入给各个监控类。

src\core\index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private report: Report = {}; // 暂存上报数据
// 上报
private reportTracker<T>(data: T, key: string): boolean {
// 调用decorateData修饰数据
const params = this.decorateData(data);
// 如果是实时上报模式,直接调用sendBeacon上报
if (this.options.realTime) {
return sendBeacon(this.options.requestUrl, params);
} else {
// 将数据存入report属性中对应key的数组中
!this.report.hasOwnProperty(key) && (this.report[key] = []);
this.report[key].push(params);
// 不是实时上报模式,先判断是否超过最大值,超过则上报
const size =
this.stringSizeCalculation &&
this.stringSizeCalculation(JSON.stringify(this.report));
log && log(size, params); // 打印上报数据,方便调试
// 判断是否超过最大值,已经超过了则上报
if (
this.options.maxSize &&
size &&
size > (this.options.maxSize || 10000)
) {
// 调用累积上报方法
this.sendReport();
}
return true;
}
}

首先使用了 decorateData 方法修饰数据,加上了uuid、时间戳、路由等统一信息。

1
2
3
4
5
6
7
8
9
10
// 修饰数据,加上统一信息
private decorateData<T>(data: T): object {
// 将传入的数据和统一信息合并
return Object.assign({}, {
uuid: this.options.uuid, // 加上uuid
time: new Date().getTime(), // 加上时间戳
location: this.trackers.locationTracker?.getLocation(), // 加上当前路由,由路由监控实例提供
extra: this.options.extra, // 加上配置项中的额外数据
}, data);
}

如果是实时上报模式,直接调用 sendBeacon() 上报数据。

如果不是实时上报模式,先将本次数据存入 report 属性对应key的数组中,然后通过 stringSizeCalculation() 方法判断 report 缓存是否超过最大值,超过则调用 sendReport() 累积上报方法。

stringSizeCalculation() 方法通过 TextEncoder 编码器对字符串进行编码,然后返回字节长度,粗略计算字符串大小,单位字节。

src\utils\string.ts
1
2
3
4
5
6
export function createStringSizeCalculation() {
const textEncode = new TextEncoder();
return function (str: string) {
return textEncode.encode(str).length;
}
}

sendReport累积上报

如果不是实时上报模式,则监控数据会缓存在 report 对象中,按监控类型,也就是key分类保存在对应属性值(数组)中。

大多数情况下应该使用非实时模式,监控数据不会立即上报,而是等待数据累积到一定程度再上报,这样可以减少请求次数,提高性能。

1
2
3
4
5
6
7
8
9
10
11
12
// 累积数据上报方法
// 这是一个公共方法,允许外部调用,用户可以在合适的时候上报数据,而不用等数据累积超过最大值
public sendReport(): boolean {
// 如果已经销毁,直接返回
if (this.isDestroy) return false;
// 调用sendBeacon上报数据
const state = sendBeacon(this.options.requestUrl, this.report);
// 上报成功后清空report
state && (this.report = {});
// 返回上报状态
return state;
}

sendReport() 是一个公共方法,允许外部调用,用户可以在合适的时候上报数据,而不用等数据累积超过最大值。

sendTracker用户主动上报

很多情况下,监控需要和业务内容高度耦合,这是通用的监控类或埋点无法做到的,所以主类提供了 sendTracker() 方法,允许用户主动上报数据。

1
2
3
4
5
6
7
8
9
10
11
12
// 主动上报
public sendTracker<T>(targetKey: string = "manual", data?: T) {
if (this.isDestroy) return;
this.reportTracker(
{
event: "manual",
targetKey,
data,
},
"manual"
);
}

sendTracker() 实际上只是调用了 reportTracker() 方法,在 report 缓存中的 key 为 manual

LocationTracker类

监控最重要一点就是知道,当前是在哪个页面,以及用户在当前页面的停留时长。对于SPA或启用了PJAX的站点,还需要监控路由的切换,包括 history 和 hash 的变化。

LocationTracker 类就是用来监控路由信息的。

src\core\tracker\location.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import { Options } from "../../types";
import TrackerCls from "./trackerCls";
import { createHistoryMonitoring, getLocation } from "../../utils";

export default class LocationTracker extends TrackerCls {
private enterTime: number | undefined = undefined; // 记录用户进入当前页面时的时间戳
private location: string | undefined = undefined; // 记录用户当前页面

constructor(options: Options, reportTracker: Function) {
// 调用父类的构造函数
super(options, reportTracker);
// 初始化用户进入当前页面的时间戳和当前页面
this.reLocationRecord();
}
// 初始化
public init() {
// 如果开启了history监控,就监听history变化
if (this.options.historyTracker) {
this.historyChangeReport();
}
// 如果开启了hash监控,就监听hash变化
if (this.options.hashTracker) {
this.hashChangeReport();
}
// 如果开启了任意路由监控,则开启页面关闭前上报关闭信息
if (this.options.historyTracker || this.options.hashTracker) {
this.beforeCloseRouterReport();
}
}
// 销毁时额外需要销毁的内容
additionalDestroy() {
this.enterTime = undefined;
this.location = undefined;
}
// 更新当前路径和进入时间
private reLocationRecord() {
this.enterTime = new Date().getTime();
this.location = getLocation();
}
// 进行location监听
private captureLocationEvent<T>(event: string, targetKey: string, data?: T) {
// 回调
const eventHandler: EventListenerOrEventListenerObject = () => {
// 数据
const d = {
event, // 事件类型
targetKey, // 目标key,按后端需要自定义,默认为 history-pv 或 hash-pv
location: this.location, // 原路由
targetLocation: getLocation(), // 当前路由,也就是目标路由
// 用户访问该路由时长
duration: new Date().getTime() - this.enterTime!,
data, // 额外的数据
};
// 上报数据
this.reportTracker(d, "router");
// 更新当前路径和进入时间
this.reLocationRecord();
};
// 监听事件
this.addEventListener(event, eventHandler);
}
// 监听history变化
private historyChangeReport(eventName: string = "historyChange") {
// 创建History变化的统一事件
createHistoryMonitoring(eventName);
// 监听该事件
this.captureLocationEvent(eventName, "history-pv");
}
// 监听hash变化
private hashChangeReport() {
// 也就是监听hashchange事件
this.captureLocationEvent("hashchange", "hash-pv");
}
// 页面关闭前上报
private beforeCloseRouterReport() {
if (!this.options.realTime) {
return;
}
const eventName = "beforeunload";
const eventHandler: EventListenerOrEventListenerObject = () => {
const d = {
event: eventName,
targetKey: "close",
location: this.location,
duration: new Date().getTime() - this.enterTime!,
};
this.reportTracker(d, "router");
};
this.addEventListener(eventName, eventHandler);
}
// 给外部提供页面信息
public getLocation(): string {
return this.location!;
}
}

监听路由变化

hash变化可以直接监听 hashchange 事件

history变化则比较特殊。

  1. 全局 history 对象上的 back(), forward()go(),浏览器的前进后退按钮,会触发 popstate 事件。
  2. pushState()replaceState() 不会触发 popstate 事件。且没有对应的事件可以监听。

所以需要重写这两个方法,在其被调用时,通知监听者。借助 Event 类可以很方便地创建一个统一的自定义事件。

src\utils\location.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// back()、forward() 和 go() 事件都会触发 popState 事件。
// 但是 pushState() 和 replaceEstate() 不会触发 popState 事件。因此我们需要做些代码处理让它们都能触发某一个事件
export const createHistoryEvent = <T extends keyof History>(
type: T,
eventName: string
): (() => any) => {
const origin = history[type]; // 保存原始方法
const e = new Event(eventName); // 创建自定义事件
const typeEvent = new Event(type); // 创建方法同名事件
return function (this: any) {
// 调用原始方法
const res = origin.apply(this, arguments);
// 触发自定义事件
window.dispatchEvent(typeEvent);
window.dispatchEvent(e);
return res;
};
};
/**
* 创建对history的监听,统一触发指定自定义事件。
* @param {string} [eventName='historyChange'] history变化统一触发的自定义事件名。
* @example
* window.addEventListener('historyChange', () => {
* console.log('history changed!')
* })
*/
export function createHistoryMonitoring(eventName: string = "historyChange") {
// 重写history的pushState方法
window.history["pushState"] = createHistoryEvent("pushState", eventName);
// 重写history的replaceState方法
window.history["replaceState"] = createHistoryEvent(
"replaceState",
eventName
);
// 在触发popstate事件时,触发自定义事件
window.addEventListener("popstate", () => {
window.dispatchEvent(new Event(eventName));
});
}

// 获取当前页面的路径
export function getLocation(): string {
return window.location.pathname + window.location.hash;
}

现在pushStatereplaceState方法,以及popstate事件,都会触发自定义的 historyChange 事件,且还有pushState和replaceState两个和方法同名的事件可供监听,当然目前还没用上,统一事件够用了。

navigatorTracker类

navigatorTracker 类非常简单,就是通过 navigator 对象获取用户的一些信息。

src\core\tracker\navigator.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Options } from "../../types";
import TrackerCls from "./trackerCls";
import { getNavigatorInfo } from "../../utils";

export default class NavigatorTracker extends TrackerCls {
constructor(options: Options, reportTracker: Function) {
super(options, reportTracker);
}
// 初始化
public init() {
if (this.options.navigatorTracker) {
this.navigatorReport()
}
}
additionalDestroy() { }
// 用户信息上报
private navigatorReport() {
this.reportTracker({
targetKey: 'navigator',
event: null,
info: getNavigatorInfo(),
}, 'navigator')
}
}

该类的业务核心是通过 getNavigatorInfo 工具方法获取信息。因为 navigator 对象的属性比较多,需要加工后上报。

src\utils\navigator.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// 获取navigator信息
export function getNavigatorInfo(): object {
// 获取navigator
const navigator = window.navigator;
// 获取ua
const ua = navigator.userAgent;
return {
userAgent: ua,
cookieEnabled: navigator.cookieEnabled,
language: navigator.language,
browser: getBrowser(ua),
os: getOS(ua),
isMobile: isMobile(ua),
screen: {
// 获取屏幕宽高
width: window.screen.width,
height: window.screen.height,
}
}
}

// 获取浏览器信息
export function getBrowser(ua: string) {
ua = ua.toLowerCase();
const browserRegex = {
Edge: /edge\/([\d.]+)/i,
IE: /(rv:|msie\s+)([\d.]+)/i,
Firefox: /firefox\/([\d.]+)/i,
Chrome: /chrome\/([\d.]+)/i,
Opera: /opera\/([\d.]+)/i,
Safari: /version\/([\d.]+).*safari/i
};
for (const browser in browserRegex) {
const match = ua.match(browserRegex[browser as keyof typeof browserRegex]);
if (match) {
return { name: browser, version: match[1] };
}
}
return { name: "", version: "0" };
}

// 获取操作系统信息
export function getOS(ua: string) {
ua = ua.toLowerCase();
const osRegex = [
{ name: "windows", regex: /compatible|windows/i },
{ name: "macOS", regex: /macintosh|macintel/i },
{ name: "iOS", regex: /iphone|ipad/i },
{ name: "android", regex: /android/i },
{ name: "linux", regex: /linux/i }
];
for (const os of osRegex) {
if (ua.match(os.regex)) {
return os.name;
}
}
return "other";
}

// 判断是否为移动端
export function isMobile(ua: string) {
return !!ua.match(
/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i
);
}

对于浏览器、系统、移动端的判断,是通过正则匹配 userAgent 字符串。其实可以只把 UA 传给后端,后端再解析。但前端先处理下获取关键信息,也挺好。

DomTracker类

DomTracker 类通过元素上的埋点,监控用户的指定行为,并上报数据。

src\core\tracker\dom.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import { Options } from "../../types";
import TrackerCls from "./trackerCls";

export default class DomTracker extends TrackerCls {

constructor(options: Options, reportTracker: Function) {
super(options, reportTracker);
}
// 初始化
public init() {
if (this.options.domTracker) {
this.domEventReport()
}
}
additionalDestroy() { }
// 监听dom事件,并上报相关数据
private domEventReport() {
this.options.domEventsList?.forEach(event => {
const eventHandler: EventListenerOrEventListenerObject = (e) => {
const target = e.target as HTMLElement;
// 设置target-events属性,元素层次限制上报的事件
const targetEvents = JSON.stringify(target.getAttribute('target-events'));
if (targetEvents && !targetEvents.includes(e.type)) {
return;
}
// 获取目标key,设置key分辨不同元素
// <button target-key="btn" target-events="['click']">dom事件上报测试</button>
const targetKey = target.getAttribute('target-key');
if (targetKey) {
// console.log(e);
this.reportTracker({
event,
targetKey,
// 元素的基本信息,便于定位
elementInfo: {
name: target.localName ?? target.nodeName,
id: target.id || null,
class: target.className || null,
// innerText: target.innerText,
}
}, 'dom')
}
}
this.addEventListener(event, eventHandler)
})
}
}

核心是 domEventReport() 方法,遍历了配置项中的 domEventsList,并监听对应的事件。

再看看 addEventListener 方法,它是 TrackerCls 类提供的方法,封装了 window.addEventListener

src\core\tracker\trackerCls.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 封装addEventListener
protected addEventListener(
name: string,
handler: EventListenerOrEventListenerObject,
options: boolean | AddEventListenerOptions = false
) {
// 如果没有该事件的监听数组,就初始化一个空的
!this.eventListeners.hasOwnProperty(name) &&
(this.eventListeners[name] = []);
// 将事件监听器存入数组
this.eventListeners[name].push(handler);
// 添加事件监听
window.addEventListener(name, handler, options);
}

所有事件都绑定在 window 上,利用冒泡机制,可以监听到所有元素上的大部分事件。

  1. 先判断元素是否有 target-events 属性,如果有,则判断当前触发的事件是否在 target-events 中,不在则直接返回。
  2. 再判断元素是否有 target-key 属性,如果有,就上报监控数据。

也就是有两个埋点属性

  1. target-key 必须,用于标识元素,也是启用埋点的标志。
  2. target-events 非必须,用于在元素上限制可监控的事件,颗粒度更小,应该是配置项的 domEventsList 的子集,在 domEventsList 之外的事件不会被监控。若没有该属性,则会监控该元素上所有 domEventsList 罗列的事件。

这种策略类似一些日志库,除了在初始化日志类时可以指定要输出的最低日志等级,还可以通过装饰器或者其他方式,控制某个区域输出的最低日志等级。

埋点的例子
1
<button id="btn" target-key="btn" target-events="['click']">dom事件上报测试</button>

ErrorTracker类

ErrorTracker 类用于监控错误信息,包括JS错误、资源加载错误、Promise错误。

注意:Promise错误并不是一个真正的错误,而是指未处理的rejected状态的Promise,它会触发 unhandledrejection 事件。

src\core\tracker\error.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import { Options } from "../../types";
import TrackerCls from "./trackerCls";

export default class ErrorTracker extends TrackerCls {

constructor(options: Options, reportTracker: Function) {
super(options, reportTracker);
}
// 初始化
public init() {
if (this.options.errorTracker) {
this.errorReport()
}
}
additionalDestroy() { }
// 启用错误上报
private errorReport() {
this.errorEvent()
this.promiseReject()
}
// 监听error事件,并上报相关数据
private errorEvent() {
const eventName = 'error';
// 回调
const eventHandler: EventListenerOrEventListenerObject = (e) => {
const [info, targetKey] = this.analyzeError(e)
this.reportTracker({
targetKey: targetKey,
event: 'error',
info: info
}, 'error')
}
// 错误事件不会冒泡,需在捕获阶段监听
this.addEventListener(eventName, eventHandler, true)
}
// 解析错误信息,区分js错误和资源加载错误,返回错误信息和错误分类
private analyzeError(event: Event): [object | string, string] {
const target = event.target || event.srcElement;
// 如果是dom元素,说明是资源加载错误
if (target instanceof HTMLElement) {
return [{
name: target.tagName || target.localName || target.nodeName,
class: target.className || null,
id: target.id || null,
url: (target as any).src || (target as any).href || null,
}, "resourceError"]
}
// 如果event是ErrorEvent类型,说明是js错误
if (event instanceof ErrorEvent) {
return [event.message, "jsError"];
}
// 兜底返回
return [event, "otherError"];
}
// 监控未捕获的Promise Reject,可以认为是Promise错误
private promiseReject() {
const eventName = 'unhandledrejection';
// 回调
const eventHandler: EventListenerOrEventListenerObject = (event) => {
(event as PromiseRejectionEvent).promise.catch(error => {
this.reportTracker({
targetKey: "reject",
event: "promise",
info: error
}, 'error')
})
}
this.addEventListener(eventName, eventHandler)
}
}

区分js和资源加载错误:

  1. js和资源加载错误都会触发 error 事件,但是 event 对象的类型不同。js错误是 ErrorEvent 类型,资源加载错误是 Event 类型。
  2. 脚本运行错误事件是由 window 触发的,而资源加载错误事件是由DOM元素触发的,所以可以通过 event.target 判断。

注意:error事件是不会冒泡的,所以只能在捕获阶段监听。

error事件

除了判断类型,还可以分别监听 error 事件来区分js和资源加载错误。

小知识:

  1. DOM2级事件规定事件流包括三个阶段,事件捕获阶段、处于目标阶段和事件冒泡阶段。
  2. 触发事件的目标对象,不管事件是否支持冒泡,始终可以监听到该事件的触发。

捕获阶段:
window => document => 父级元素 => 目标元素。

js 错误是由 window 触发的,始终可以监听到,无需在捕获阶段监听。

而资源加载错误是由DOM元素触发的,想要事件委托,那就只能在捕获阶段监听。所以可以在 document 上监听 error 事件,捕获资源加载错误。

1
2
3
4
// 监听脚本运行错误
window.addEventListener('error', runtimeErrorHandler, false);
// 监听资源加载错误
document.addEventListener('error', resourceErrorHandler, true);

发生了什么:

  1. js错误在window上触发,目标元素就是window,所以可以在window上通过冒泡阶段监听js错误。
  2. 资源加载错误的目标元素是dom元素,在 document 上通过捕获阶段监听,就可以委托监听资源加载错误。error事件不会冒泡,所以 window 上监听不到该错误。

PerformanceTracker

PerformanceTracker 类用于监控性能,包括dom性能、资源加载性能。

src\core\tracker\performance.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import { Options, Resource } from "../../types";
import TrackerCls from "./trackerCls";
import {
getDomPerformance,
getResourcePerformance,
listenResourceLoad,
} from "../../utils";

export default class PerformanceTracker extends TrackerCls {
// 保存PerformanceObserver性能监控观察者实例。
private performanceObserver: PerformanceObserver | undefined = undefined;

constructor(options: Options, reportTracker: Function) {
super(options, reportTracker);
}
// 初始化
public init() {
if (this.options.performanceTracker) {
this.performanceReport();
}
}
// 开启性能监控上报
private performanceReport(accuracy: number = 2) {
const eventName = "load";
const performance = () => {
// 页面加载完后上报dom性能数据
const domPerformance = getDomPerformance(accuracy);
// 页面加载完后上报已加载完毕的资源性能数据
const resourcePerformance = getResourcePerformance(accuracy);
// 上报的数据
const data = {
targetKey: "performance",
event: "load",
domPerformance,
resourcePerformance,
};
// 上报数据,类型key为performance
this.reportTracker(data, "performance");

// load完后开启资源的持续监控,例如后续请求以及图片的懒加载
this.performanceObserver = listenResourceLoad(
(entry: PerformanceResourceTiming) => {
const resource: Resource = {
name: entry.name, // 资源名称,通常为url
duration: entry.duration.toFixed(accuracy), // 资源加载耗时
type: entry.entryType, // 资源类型
initiatorType: entry.initiatorType, // 发起资源请求的类型(标签名、请求方式等)
size: entry.decodedBodySize || entry.transferSize, // 资源大小
};
const data = {
targetKey: "resourceLoad",
event: "load",
resource,
};
// 上报数据,类型key为performance
this.reportTracker(data, "performance");
}
);
};
const eventHandler: EventListenerOrEventListenerObject = () => {
// 将性能监控函数放到微/宏任务队列中执行,这样就能保证在load事件完成之后执行
if (typeof Promise === 'function'){
Promise.resolve().then(()=>{
setTimeout(performance, 0);
});
} else {
setTimeout(performance, 0);
}
}
// 监听load事件
this.addEventListener(eventName, eventHandler);
}
// 销毁时额外需要销毁的内容
additionalDestroy() {
// 断开资源监控
this.performanceObserver?.disconnect();
}
}

对于window对象的load事件来说,当整个HTML页面的所有依赖资源(JS文件、CSS文件、图片等)加载完成时将会触发。

核心方法 performanceReport 监听 load 事件,在页面加载完后,通过 getDomPerformance 获取dom性能,通过 getResourcePerformance 获取已加载完毕的资源性能数据,并上报。然后通过性能监控器 PerformanceObserver 监控后续资源的加载。

所以性能监控可以分为两部分:

  1. 页面加载完后的性能上报。
  2. 后续资源的持续监控。

performance API

performance API 提供了非常多的属性和方法,用于获取页面性能数据。这个 API 的内容非常多,这里也只能讲用到的。

兼容的获取方式,但现在大多已经不需要这么做了。

1
window.performance || window.mozPerformance || window.msPerformance || window.webkitPerformance || {}

performance.getEntries() 用于获页面中的所有的性能数据,返回一个包含各种性能对象的数组。

  1. PerformanceNavigationTiming 包含有关页面导航和重定向的时间信息,如 unload、redirect、domInteractive 等。
  2. PerformanceResourceTiming 提供了有关页面加载过程中每个资源的时间信息,如加载开始时间、结束时间、传输协议等。
  3. PerformancePaintTiming 提供有关页面绘制过程中的重要时间点的信息,例如首次绘制(first-paint)和首次内容绘制(first-contentful-paint)。

我们通常不会想一次性获取这么一大堆东西,所以需要使用 performance.getEntriesByType(type) 传入 entryType 获取指定的性能对象。返回的都是数组。

  1. navigation 返回包含一个元素的数组,元素类型为 PerformanceNavigationTiming
  2. paint 返回包含两个元素的数组,元素类型都是 PerformanceResourceTiming,其中 [0] 为首次绘制(first-paint),[1] 为首次内容绘制(first-contentful-paint)。
  3. resource 返回当前已加载完的资源的性能信息数组,元素类型为 PerformanceResourceTiming,每个元素都代表一个资源。

参考:
前端性能精进之优化方法论(一)——测量
前端性能监控指标
前端性能指标浅析
前端性能指标
性能监控指标分析
使用 Performance API 获取页面性能
Navigation Timing API 入门
PerformanceObserver前端性能测量方法
首屏事件计算方式
你只会用前端数据埋点 SDK 吗?

导航计时

导航计时(Navigation Timing) API 是 Web 性能 API 的起点。

功能:用于记录并检测用户的设备,网络等环境,以及页面初始资源加载和解析耗时。

在以前通过 performance.navigationperformance.timing 获取,但现在通常使用 performance.getEntriesByType('navigation') 获取。

PerformanceNavigationTiming 有着更全面的导航信息,且精度更高,包括重定向、卸载、重定向、DNS查询、TCP连接、SSL握手、请求、响应等时间。

PerformanceNavigationTiming 属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
(1) navigationStart
navigationStart 表示同一个浏览器上下文中,上一个文档卸载结束的 UNIX 时间戳。如果没有上一个文档,这个值与 fetchStart 相同。

(2) unloadEventStart
unloadEventStart 表示 unload 事件抛出时的 UNIX 时间戳。如果没有上一个文档,或者重定向中的一个与当前文档不同源,该值为 0。

(3) unloadEventEnd
unloadEventEnd 表示 unload 事件处理完成时的 UNIX 时间戳。如果没有上一个文档,或者重定向中的一个与当前文档不同源,该值为 0。

(4) redirectStart
redirectStart 表示第一个 HTTP 重定向开始时的 UNIX 时间戳。如果没有重定向,或者重定向中的一个不同源,该值为 0。

(5) redirectEnd
redirectEnd 表示最后一个 HTTP 重定向完成时(即最后一个 HTTP 响应的最后一个比特被接收到的时间)的 UNIT 时间戳。如果额米有重定向,或者重定向中的一个不同源,该值为 0。

(6) fetchStart
fetchStart 表示浏览器准备好用 HTTP 请求来获取文档的 UNIX 时间戳。这个时间早于检查应用缓存。

(7) domainLookupStart
domainLookupStart 表示域名查询开始的 UNIX 时间戳。如果使用了持续连接,或者这个信息被存储到了缓存或本地资源,那么该值与 fetchStart 相同。

(8) domainLookupEnd
domainLookupEnd 表示域名查询结束的 UNIX 时间戳。如果使用了持续连接,或者这个信息被存储到了缓存或本地资源,那么该值与 fetchStart 相同。

(9) connectStart
connectStart 表示 HTTP 请求开始向服务器发送时的 UNIX 时间戳。如果使用持久连接,则该值与 fetchStart 相同。

(10) connectEnd
connectEnd 表示浏览器与服务器之间的连接建立(即握手与认证等过程全部结束)的 UNIX 时间戳。如果使用持久连接,则该值与 fetchStart 相同。

(11) secureConnectionStart
secureConnectionStart 表示浏览器与服务器开始安全链接的握手时的 UNIX 时间戳。如果当前网页不要求安全链接,该值为 0。

(12) requestStart
requestStart 表示浏览器向服务器发送 HTTP 请求时的 UNIX 时间戳。

(13) responseStart
responseStart 表示浏览器从服务器收到(或从本地缓存读取)第一个字节时的 UNIX 时间戳。如果传输层从开始请求后失败并连接被重开,该值会被重置为新的请求的相应的时间。

(14) responseEnd
responseEnd 表示浏览器从服务器收到(或从本地缓存读取,或从本地资源读取)最后一个字节时(如果在此之前HTTP连接已经关闭,则返回关闭的时间)的 UNIX 时间戳。

(15) domLoading
Performance.domLoading 表示当前网页 DOM 结构开始解析时(即 Document.readyState 属性变为 loading,相应的 readystatechange 事件触发时)的 UNIX 时间戳。

(16) domInteractive
Performance.domInteractive 表示当前网页 DOM 结构解析结束,开始加载内嵌资源时(即 Document.readyState 的属性为 interactive,相应的 readystatechange 事件触发时)的 UNIX 时间戳。

(17) domContentLoadedEventStart
domContentLoadedEventStart 表示解析器触发 DomContentLoaded 事件,即所有需要被执行的脚本已经被解析时的 UNIX 时间戳。

(18) domContentLoadedEventEnd
domContentLoadedEventEnd 表示所有需要被执行的脚本均已被执行完成时的 UNIX 时间戳。

(19) domComplete
domComplete 表示文档解析完成,即 Document.readyState 变为 complete 且相应的 readystatechange 事件被触发时的 UNIX 时间戳。

(20) loadEventStart
loadEventStart 表示该文档下,load 事件被触发的 UNIX 时间戳。如果还未发送,值为 0。

(21) loadEventEnd
loadEventEnd 表示该文档下,load 事件结束,即加载事件完成时的 UNIX 时间戳,如果事件未触发或未完成,值为 0。

性能指标

常见的前端性能指标:

  1. FP(First paint) 首屏绘制,常被用来衡量白屏时间。
  2. FCP(First Contentful Paint) 首屏内容绘制,页面从开始加载到页面内容的任何部分在屏幕上完成渲染的时间。
  3. LCP(Largest Contentful Paint) 最大内容绘制,页面首次开始加载的时间点来报告可视区域内可见的最大图像或者文本块完成渲染的相对时间。
  4. FID(First Input Delay) 首次输入延迟时间,从用户第一次与页面交互,到浏览器对交互作出响应,并实际能够开始处理事件所经过的时间。
  5. TTI(Time to Interactive) 首次可交互时间,页面从开始加载到主要子资源完成渲染,并能够快速、可靠地响应用户输入所需的时间。
  6. CLS(Cumulative Layout Shift) 累计位移偏移,计算页面的视觉稳定性,即页面整个生命周期中所有发生的预料之外的布局偏移的得分的总和。每当一个可视元素位置发生改变,就是发生了布局偏移。
  7. TTFB(Time to First Byte) 首字节时间,从发起请求到服务器响应后收到的第一个字节的时间差,用于衡量服务器处理能力和网络的延迟。

getDomPerformance

getDomPerformance 方法用于获取页面加载完后的dom性能数据。

通过 PerformanceNavigationTimingPerformancePaintTiming 的属性,计算各种性能指标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 获取dom加载性能指标
export function getDomPerformance(accuracy: number = 2): object | null {
// 获取导航计时
const navigationTiming = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming || performance.timing;
// 获取首次渲染计时
const firstPaintTiming = performance.getEntriesByType('paint')[0];
// 获取首次内容渲染计时
const firstContentfulPaintTiming = performance.getEntriesByType('paint')[1];
console.log(firstContentfulPaintTiming, firstPaintTiming)
if (!navigationTiming) return null;
// 浏览器与服务器开始SSL安全链接的握手时间
const sslTime = navigationTiming.secureConnectionStart;
return {
// 页面加载开始时间
startTime: navigationTiming.startTime.toFixed(accuracy),
// 页面加载总耗时
duration: (navigationTiming.duration).toFixed(accuracy),
// DNS查询耗时
DNS: (navigationTiming.domainLookupEnd - navigationTiming.domainLookupStart).toFixed(accuracy),
// TCP连接耗时
TCP: (navigationTiming.connectEnd - navigationTiming.connectStart).toFixed(accuracy),
// SSL连接耗时
SSL: (sslTime > 0 ? navigationTiming.connectEnd - sslTime : 0).toFixed(accuracy),
// 首字节时间,即服务器响应时间
TTFB: (navigationTiming.responseStart - navigationTiming.requestStart).toFixed(accuracy),
// 白屏时间,首屏绘制
FP: (firstPaintTiming ? firstPaintTiming.startTime - navigationTiming.fetchStart : navigationTiming.responseEnd - navigationTiming.fetchStart).toFixed(accuracy),
// 首次内容渲染时间
FCP: (firstContentfulPaintTiming ? firstContentfulPaintTiming.startTime - navigationTiming.fetchStart : 0).toFixed(accuracy),
// 首次可交互时间
TTI: (navigationTiming.domInteractive - navigationTiming.startTime).toFixed(accuracy),
// 页面重定向耗时
redirect: (navigationTiming.redirectEnd - navigationTiming.redirectStart).toFixed(accuracy),
// 重定向次数
redirectCount: navigationTiming.redirectCount,
// 前一个页面卸载耗时
unload: (navigationTiming.unloadEventEnd - navigationTiming.unloadEventStart).toFixed(accuracy),
// HTML 加载完成时间
ready: (navigationTiming.domContentLoadedEventEnd - navigationTiming.startTime).toFixed(accuracy),
// 页面加载总耗时,此时触发完成了onload事件
load: (navigationTiming.loadEventEnd - navigationTiming.startTime).toFixed(accuracy),
// DOM解析耗时,页面请求完成后,到整个DOM解析完所用的时间
dom: (navigationTiming.domContentLoadedEventEnd - navigationTiming.responseEnd).toFixed(accuracy),
// html文档完全解析完毕的时间节点
domComplete: navigationTiming.domComplete.toFixed(accuracy),
// 资源加载耗时
resource: (navigationTiming.domComplete - navigationTiming.domInteractive).toFixed(accuracy),
// HTML加载完时间,指页面所有 HTML 加载完成(不包括页面渲染时间)
htmlLoad: (navigationTiming.responseEnd - navigationTiming.startTime).toFixed(accuracy),
// DOMContentLoaded 事件耗时
DCL: (navigationTiming.domContentLoadedEventEnd - navigationTiming.domContentLoadedEventStart).toFixed(accuracy),
// onload事件耗时
onload: (navigationTiming.loadEventEnd - navigationTiming.loadEventStart).toFixed(accuracy),
}
}

getResourcePerformance

getResourcePerformance 用于获取首屏已经加载完毕的资源的性能信息。

通过 PerformanceResourceTiming 数组,遍历每个资源的性能信息,分类并计算各种性能指标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 获取首屏已经加载完毕的资源的性能信息
export function getResourcePerformance(accuracy: number = 2): InitiatorTypeLiteral | null {
if (!window.performance) return null;
// 获取 PerformanceResourceTiming 数组
const data = window.performance.getEntriesByType('resource') as PerformanceResourceTiming[];
// 保存资源分类
const resources: InitiatorTypeLiteral = {}
data.forEach(i => {
let key = i.initiatorType || 'other';
if (key === 'beacon') return; // 跳过beacon上报请求
if (key === 'other') {
const extension = urlHandle(i.name, 2)
switch (extension) {
case 'css': key = 'css'; break;
case 'js': key = 'js'; break;
case 'json': key = 'json'; break;
case 'png': case 'jpg': case 'jpeg': case 'gif': case 'svg': key = 'img'; break;
default: break;
}
}
!resources.hasOwnProperty(key) && (resources[key] = [])
resources[key].push({
name: i.name, // 资源的名称
duration: i.duration.toFixed(accuracy), // 资源加载耗时
type: i.entryType, // 资源类型
initiatorType: i.initiatorType, // 发起资源请求的类型(标签名)
size: i.decodedBodySize || i.transferSize, // 资源大小
})
})
return resources;
}
// 获取url中需要的数据 type 1: 获取文件名 2:获取后缀 3:获取文件名+后缀 4:获取文件前缀
function urlHandle(url: string, type: number): string | undefined {
let filename = url.substring(url.lastIndexOf('/') + 1)
switch (type) {
case 1: return filename; break;
case 2: return filename.substring(filename.lastIndexOf(".") + 1); break;
case 3: return filename.substring(0, filename.lastIndexOf(".")); break;
case 4: return url.substring(0, url.lastIndexOf('/') + 1); break;
default: return undefined;
}
}

注意:若涉及跨域,并且其响应头没有声明 timing-allow-origin,那么 PerformanceResourceTiming 中的大部分属性可能都是 0。
可以将 timing-allow-origin 设为星号,或指定域名。

1
Timing-Allow-Origin: *

listenResourceLoad

listenResourceLoad 方法用于监听后续资源的加载情况。

通过 PerformanceObserver 监控资源加载,当资源加载完毕后,上报资源性能数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 监听资源加载
export function listenResourceLoad(callback: (arg0: PerformanceResourceTiming) => void): PerformanceObserver {
// 创建一个PerformanceObserver性能观察者实例
const observer = new PerformanceObserver((list, _observer) => {
// 因为只观察了resource,可以将 PerformanceEntryList 作为 PerformanceResourceTiming[] 类型进行遍历
(list.getEntries() as PerformanceResourceTiming[]).forEach((e) => {
// 如果不是beacon请求,就执行回调
if (e.initiatorType !== "beacon") {
callback(e);
}
});
});
// 开始观察entryTypes为resource的性能条目,也就是资源加载性能
observer.observe({
entryTypes: ["resource"],
});
// 返回观察者实例
return observer;
}

其它

项目中一些其它的内容。

canvas指纹

fingerprint 等浏览器指纹识别库太大了,监控项目应该尽量减少第三方依赖,而为了标识用户,canvas 指纹是不错的选择。

不同设备、不同浏览器、不同版本,canvas 生成的图像数据都不同,获取其 Base64 编码的图像数据,然后生成8位hash值,就可以作为浏览器指纹ID。

src\utils\uuid.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 字符串生成8位hash值
function generateHash(str: string): string {
// 使用 atob 函数将 Base64 编码的图像数据解码为二进制字符串。
str = atob(str);
let hash = 0;
// 遍历字符串
for (let i = 0; i < str.length; i++) {
// charCodeAt() 方法可返回指定位置的字符的 Unicode 编码
// << 5 位运算符,将二进制数据后向左移动5位,相当于乘以2的5次方
// 目的是提高 hash 的复杂度
hash = (hash << 5) - hash + str.charCodeAt(i);
// 当JS进行位运算时,它会将操作数视为 32 位整数,忽略其它位。
hash |= 0; // 位运算符,将 hash 强制转换为 32 位整数
}
// 一个32位整数的十六进制位8位,所以就生成了一个8位的hash值
return Math.abs(hash).toString(16).padStart(8, '0');
}

// 通过canvas获取浏览器指纹ID
export function getCanvasID(str: string = '#qx.chuckle,123456789<canvas>'): string | undefined {
const canvas = document.createElement('canvas'); // 创建一个 canvas 元素
const ctx = canvas.getContext("2d"); // 获取 canvas 的 2D 渲染上下文
if (!ctx) {
return undefined;
}
ctx.font = "14px 'Arial'"; // 设置字体
ctx.textBaseline = "bottom"; // 设置基线
ctx.fillStyle = "#f60"; // 设置填充颜色
// 在 canvas 上绘制一个橙色的矩形,矩形的左上角坐标为 (125, 1),宽度为 62 像素,高度为 20 像素。
ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = "#069"; // 设置填充颜色
// 在坐标 (2, 15) 的位置绘制深蓝色的文本,文本内容为 str。
ctx.fillText(str, 2, 15);
ctx.fillStyle = "rgba(102, 204, 0, 0.7)"; // 设置填充颜色
// 在坐标 (4, 17) 的位置绘制半透明绿色的文本,文本内容为 str。
ctx.fillText(str, 4, 17);
// toDataURL将canvas转换为dataUrl也就是base64编码的图像数据,并去掉前缀保留数据部分
const b64 = canvas.toDataURL().replace("data:image/png;base64,", "");
return generateHash(b64);
}