前言
qx-tracker 是一个前端监控和埋点SDK,支持自定义埋点和监控。
埋点就是数据采集-数据处理-数据分析和挖掘,如用户停留时间,用户哪个按钮点的多等。使用ts在编译过程中发现问题,减少生产代码的错误。
本文重新解构一下项目实现,复习相关的API。
构建环境
使用 Rollup
构建项目,TypeScript
编写代码。基本模板为自编写的 rollup-template
package.json
两个npm命令:
npm run build
构建生产环境代码,rollup 使用 rollup.config.prod.mjs
生产配置文件,并将环境变量 ENV
设置为 production
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", "module": "dist/index.esm.js", "browser": "dist/index.js", "types": "dist/index.d.ts",
|
rollup配置
一共有三个配置文件:
rollup.config.common.mjs
公共配置文件,包含基础的公共配置,如 input、基础插件。
rollup.config.dev.mjs
开发环境配置文件,继承公共配置文件,设置 output、增加插件。
rollup.config.prod.mjs
生产环境配置文件,继承公共配置文件,设置 output、增加插件、打包类型声明文件。
rollup.config.common.mjs1 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
| import nodeResolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import clear from 'rollup-plugin-clear';
import typescript from '@rollup/plugin-typescript';
import replace from '@rollup/plugin-replace';
export default { input: { index: './src/core/index.ts', }, plugins: [ replace({ preventAssignment: true, __env__: JSON.stringify(process.env.ENV) }), nodeResolve(), commonjs({ extensions: ['.js', '.ts'] }), clear({ targets: ['dist'], watch: false, }), typescript({}), ], };
|
rollup.config.dev.mjs1 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
| import serve from 'rollup-plugin-serve';
import livereload from "rollup-plugin-livereload";
import html from '@rollup/plugin-html';
import common from './rollup.config.common.mjs';
import { htmlDevTemple } from './html-temple.mjs';
export default Object.assign({}, common, { output: [ { dir: 'dist', entryFileNames: '[name].js', format: 'umd', name: "Tracker" } ], plugins: [ ...common.plugins, html(htmlDevTemple), serve({ port: 3000, contentBase: 'dist', openPage: '/index.html', open: false, }), livereload(), ], });
|
rollup.config.prod.mjs1 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 common from "./rollup.config.common.mjs";
import dts from "rollup-plugin-dts";
export default [ Object.assign({}, common, { output: [ { dir: "dist", entryFileNames: "[name].js", format: "umd", name: "Tracker", }, { dir: "dist", entryFileNames: "[name].cjs.js", format: "cjs", }, { dir: "dist", entryFileNames: "[name].esm.js", format: "es", }, ], plugins: [ ...common.plugins, ], }), { input: { index: "./src/core/index.ts", }, output: { dir: "dist", entryFileNames: "[name].d.ts", format: "es", }, plugins: [dts()], }, ];
|
tsconfig.json 配置:
tsconfig.json1 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, "target": "esnext", "module": "esnext", "lib": [ "esnext", "dom", ], "allowJs": true, "checkJs": true, "outDir": "./dist", "rootDir": "./src", "sourceMap": false, "declarationMap": false, "types": [], "removeComments": true, "noEmitOnError": true, "noEmitHelpers": true, "importHelpers": true, "downlevelIteration": true, "strict": true, "alwaysStrict": true, "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "strictPropertyInitialization": true, "strictBindCallApply": true, "noImplicitThis": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noImplicitReturns": true, "esModuleInterop": true, "allowUmdGlobalAccess": true, "moduleResolution": "node", "baseUrl": "./", "paths": { }, "rootDirs": [ "src" ], "listEmittedFiles": true, "listFiles": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "resolveJsonModule": true }, "include": [ "src/**/*", ], }
|
html-temple.mjs 配合 rollup/plugin-html
生成开发环境测试用的 html 文件,其实感觉也没比新建一个html文件方便。
项目结构
主要目录结构1 2 3 4 5 6 7
| ├───📁 dist/ │ └───... ├───📁 server/ │ └───... ├───📁 src/ │ └───... └───...
|
根目录:
dist
是Rollup输出目录,存放构建打包的产物
server
一个简单的后端服务,用于测试数据上报。
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/ │ │ ├───📄 dom.ts │ │ ├───📄 error.ts │ │ ├───📄 index.ts │ │ ├───📄 location.ts │ │ ├───📄 navigator.ts │ │ ├───📄 options.ts │ │ ├───📄 performance.ts │ │ └───📄 trackerCls.ts │ └───📄 index.ts ├───📁 types/ │ └───📄 index.ts ├───📁 utils/ │ ├───📄 beacon.ts │ ├───📄 index.ts │ ├───📄 location.ts │ ├───📄 log.ts │ ├───📄 navigator.ts │ ├───📄 performance.ts │ ├───📄 string.ts │ ├───📄 uuid.ts │ └───📄 watch.ts └───📄 index.d.ts
|
入口文件
解析项目还是由表及里好,先从入口文件 core/index.ts
讲起。
工具函数和类型文件的内容,在各个监控类用到时再穿插讲解。
入口文件导出了一个名为 Tracker
的类,我称之为主类,继承自 TrackerOptions
,主类主要管理着其它监控类实例,并提供了上报数据的方法、与外部环境打交道。
TrackerOptions配置类
TrackerOptions
是配置类,管理着所有监控类的配置,如上报地址、功能开启等。所有监控类都要根据配置来执行相应的操作。
src\core\tracker\options.ts1 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";
export default class TrackerOptions { protected options: Options constructor(options: Options) { this.options = Object.assign(this.initDefault(), options); } private initDefault(): DefaultOptions { return <DefaultOptions>{ requestUrl: "", uuid: this.generateUserID(), historyTracker: false, hashTracker: false, errorTracker: false, domTracker: false, domEventsList: new Set(['click', 'dblclick', 'contextmenu', 'mousedown', 'mouseup', 'mouseout', 'mouseover']), performanceTracker: false, navigatorTracker: false, extra: undefined, sdkVersion: TrackerConfig.version, log: true, realTime: false, maxSize: 1024 * 50 } } public generateUserID(): string | undefined { return getCanvasID() } }
|
先看默认配置项,由私有方法 initDefault()
提供,为 DefaultOptions
类型。
DefaultOptions1 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, hashTracker: boolean, errorTracker: boolean, domTracker: boolean, domEventsList: Set<keyof HTMLElementEventMap>, performanceTracker: boolean, navigatorTracker: boolean, extra: Record<string, any> | undefined, sdkVersion: string | number, 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 设为必选项,而其它都是可选项,也就是必须传入一个上报地址。
Options1 2
| export type Options = Optional<DefaultOptions, 'requestUrl'>
|
TS并没有提供 Optional
高级类型,用于将某些属性设为可选项。需要自己实现。
- 传入两个泛型参数,
T
为原始类型,K
为必选项,是 T
的属性名。
Pick
类型将必选项筛选出来,Omit
去掉必选项,再使用 Partial
类型将其它属性设为可选项。
- 最后通过联合类型
&
合并两个类型。
高级类型就像是类型的函数,加工原始类型,返回一个新的类型。
Optional1 2 3 4 5 6
|
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 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() { for (const eventName in this.eventListeners) { const listeners = this.eventListeners[eventName]; for (const listener of listeners) { window.removeEventListener(eventName, listener); } } this.eventListeners = {}; this.additionalDestroy(); } }
|
trackerCls
控制监控类的基本行为:
- 构造函数需要接收从外部(主类)传入的配置项和上报方法。并根据配置项初始化自己,在合适的时候上报数据。
- 规定了抽象方法
init
,具体的监控类需要实现初始化方法。
- 提供了
addEventListener
方法,封装了 window.addEventListener
,并存储了事件监听器,方便销毁。
- 提供了
destroy
方法,用于销毁事件监听器,避免内存泄漏。同时定义了 additionalDestroy
抽象方法,额外需要销毁的内容由具体的监控类去实现。
Tracker主类
再回来看主类 Tracker
,继承自 TrackerOptions
,所以拥有配置类的属性和方法。
主类的构造函数同样接收一个 Options
类型的配置项,并通过 super
调用父类构造函数,初始化配置项。现在,配置项由主类进行管理了。
主类提供了一些 public
方法,setUserID
、setExtra
,允许动态设置配置项。
1 2 3 4 5 6 7 8 9 10
| 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; }
|
继续看构造函数:
- 实例化了各种监控类,并存储在了
trackers
私有对象的对应属性上。这是为了方便管理和调用类上的方法(初始化、销毁)。
- 所有监控类都需要接收配置项和上报方法,上报方法包装了主类的
reportTracker
方法。
在设计上,监控类只负责自己的监控职责,并在合适的适合上报数据,不负责数据的具体处理和上报逻辑,具体的上报实现由主类负责。
src\core\index.ts1 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";
private trackers: Trackers = { locationTracker: undefined, domTracker: undefined, errorTracker: undefined, performanceTracker: undefined, navigatorTracker: undefined, } constructor(options: Options) { super(options); 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(); }
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()
私有方法。
- 遍历所有Tracker实例,调用其初始化方法。
- 如果不是实时上报模式,初始化
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
private beforeCloseHandler: EventListenerOrEventListenerObject | undefined = undefined
private init() { try { for (const key in this.trackers) { this.trackers[key as keyof Trackers]?.init(); } if (!this.options.realTime) { this.stringSizeCalculation = createStringSizeCalculation(); this.beforeCloseReport(); } this.options.log && console.log('Tracker is OK'); } catch (e) { sendBeacon(this.options.requestUrl, this.decorateData({ targetKey: "tracker", event: "error", message: e, })); this.options.log && console.error('Tracker is error'); } }
private beforeCloseReport() { this.beforeCloseHandler = () => { this.sendReport(); } 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(); this.trackers[key as keyof Trackers] = undefined; } this.beforeCloseHandler && window.removeEventListener("beforeunload", this.beforeCloseHandler); this.stringSizeCalculation = undefined; this.beforeCloseHandler = undefined; this.isDestroy = true; }
|
数据上报
上报数据的方法由主类提供,并通过构造函数注入给其它各个监控类。
封装sendBeacon
传统的数据上报方式,如 XMLHttpRequest 或 Fetch API,容易受到页面卸载过程中的阻塞,导致数据丢失。
而 navigator.sendBeacon
可以在页面卸载时安全、可靠地发送数据。
- 异步执行,不阻塞页面关闭或跳转。
- 不受页面卸载过程的影响,确保数据可靠发送。
- 无法获取响应,但在发送简单请求时天然跨域,就像fetch的no-cors模式一样。
缺点:
- sendBeacon 只能发送 POST 请求。
- 请求类型为 ping,只能传送少量数据(通常是 64KB 以内),无法自定义请求头。
- 只能传输 ArrayBuffer、ArrayBufferView、Blob、DOMString、FormData 或 URLSearchParams 类型的数据。
- 如果处于危险的网络环境,或者开启了广告屏蔽插件 此请求将无效
sendBeacon(url, data)
接收两个参数,第一个是地址,第二个是数据。
返回值:boolean
- true: 数据被异步缓存,但并不保证数据已被成功发送或接收。所以后续考虑只在关闭页面前使用 sendBeacon,其它时候使用 fetch。
- 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
| export function sendBeacon(url: string, params: object): boolean { if (Object.keys(params).length <= 0) { return false; } 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方法
reportTracker
是 Tracker
主类上报数据的私有方法,通过构造函数注入给各个监控类。
src\core\index.ts1 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 { const params = this.decorateData(data); if (this.options.realTime) { return sendBeacon(this.options.requestUrl, params); } else { !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, 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.ts1 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; const state = sendBeacon(this.options.requestUrl, this.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.ts1 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() { if (this.options.historyTracker) { this.historyChangeReport(); } 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(); } private captureLocationEvent<T>(event: string, targetKey: string, data?: T) { const eventHandler: EventListenerOrEventListenerObject = () => { const d = { event, targetKey, location: this.location, targetLocation: getLocation(), duration: new Date().getTime() - this.enterTime!, data, }; this.reportTracker(d, "router"); this.reLocationRecord(); }; this.addEventListener(event, eventHandler); } private historyChangeReport(eventName: string = "historyChange") { createHistoryMonitoring(eventName); this.captureLocationEvent(eventName, "history-pv"); } private hashChangeReport() { 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变化则比较特殊。
- 全局
history
对象上的 back()
, forward()
和 go()
,浏览器的前进后退按钮,会触发 popstate
事件。
- 但
pushState()
和 replaceState()
不会触发 popstate
事件。且没有对应的事件可以监听。
所以需要重写这两个方法,在其被调用时,通知监听者。借助 Event
类可以很方便地创建一个统一的自定义事件。
src\utils\location.ts1 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
|
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; }; };
export function createHistoryMonitoring(eventName: string = "historyChange") { window.history["pushState"] = createHistoryEvent("pushState", eventName); window.history["replaceState"] = createHistoryEvent( "replaceState", eventName ); window.addEventListener("popstate", () => { window.dispatchEvent(new Event(eventName)); }); }
export function getLocation(): string { return window.location.pathname + window.location.hash; }
|
现在pushState
和replaceState
方法,以及popstate事件,都会触发自定义的 historyChange
事件,且还有pushState和replaceState两个和方法同名的事件可供监听,当然目前还没用上,统一事件够用了。
navigatorTracker类
navigatorTracker
类非常简单,就是通过 navigator
对象获取用户的一些信息。
src\core\tracker\navigator.ts1 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.ts1 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
| export function getNavigatorInfo(): object { const navigator = window.navigator; 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.ts1 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() { } private domEventReport() { this.options.domEventsList?.forEach(event => { const eventHandler: EventListenerOrEventListenerObject = (e) => { const target = e.target as HTMLElement; const targetEvents = JSON.stringify(target.getAttribute('target-events')); if (targetEvents && !targetEvents.includes(e.type)) { return; } const targetKey = target.getAttribute('target-key'); if (targetKey) { this.reportTracker({ event, targetKey, elementInfo: { name: target.localName ?? target.nodeName, id: target.id || null, class: target.className || null, } }, 'dom') } } this.addEventListener(event, eventHandler) }) } }
|
核心是 domEventReport()
方法,遍历了配置项中的 domEventsList
,并监听对应的事件。
再看看 addEventListener
方法,它是 TrackerCls
类提供的方法,封装了 window.addEventListener
。
src\core\tracker\trackerCls.ts1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 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
上,利用冒泡机制,可以监听到所有元素上的大部分事件。
- 先判断元素是否有
target-events
属性,如果有,则判断当前触发的事件是否在 target-events
中,不在则直接返回。
- 再判断元素是否有
target-key
属性,如果有,就上报监控数据。
也就是有两个埋点属性:
target-key
必须,用于标识元素,也是启用埋点的标志。
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.ts1 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() } 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) } private analyzeError(event: Event): [object | string, string] { const target = event.target || event.srcElement; 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"] } if (event instanceof ErrorEvent) { return [event.message, "jsError"]; } return [event, "otherError"]; } 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和资源加载错误:
- js和资源加载错误都会触发
error
事件,但是 event
对象的类型不同。js错误是 ErrorEvent
类型,资源加载错误是 Event
类型。
- 脚本运行错误事件是由
window
触发的,而资源加载错误事件是由DOM元素触发的,所以可以通过 event.target
判断。
注意:error事件是不会冒泡的,所以只能在捕获阶段监听。
error事件
除了判断类型,还可以分别监听 error
事件来区分js和资源加载错误。
小知识:
- DOM2级事件规定事件流包括三个阶段,事件捕获阶段、处于目标阶段和事件冒泡阶段。
- 触发事件的目标对象,不管事件是否支持冒泡,始终可以监听到该事件的触发。
捕获阶段:
window => document => 父级元素 => 目标元素。
js 错误是由 window
触发的,始终可以监听到,无需在捕获阶段监听。
而资源加载错误是由DOM元素触发的,想要事件委托,那就只能在捕获阶段监听。所以可以在 document
上监听 error
事件,捕获资源加载错误。
1 2 3 4
| window.addEventListener('error', runtimeErrorHandler, false);
document.addEventListener('error', resourceErrorHandler, true);
|
发生了什么:
- js错误在
window
上触发,目标元素就是window
,所以可以在window
上通过冒泡阶段监听js错误。
- 资源加载错误的目标元素是dom元素,在
document
上通过捕获阶段监听,就可以委托监听资源加载错误。error事件不会冒泡,所以 window
上监听不到该错误。
PerformanceTracker
类用于监控性能,包括dom性能、资源加载性能。
src\core\tracker\performance.ts1 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 { 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 = () => { const domPerformance = getDomPerformance(accuracy); const resourcePerformance = getResourcePerformance(accuracy); const data = { targetKey: "performance", event: "load", domPerformance, resourcePerformance, }; this.reportTracker(data, "performance");
this.performanceObserver = listenResourceLoad( (entry: PerformanceResourceTiming) => { const resource: Resource = { name: entry.name, duration: entry.duration.toFixed(accuracy), type: entry.entryType, initiatorType: entry.initiatorType, size: entry.decodedBodySize || entry.transferSize, }; const data = { targetKey: "resourceLoad", event: "load", resource, }; this.reportTracker(data, "performance"); } ); }; const eventHandler: EventListenerOrEventListenerObject = () => { if (typeof Promise === 'function'){ Promise.resolve().then(()=>{ setTimeout(performance, 0); }); } else { setTimeout(performance, 0); } } this.addEventListener(eventName, eventHandler); } additionalDestroy() { this.performanceObserver?.disconnect(); } }
|
对于window对象的load事件来说,当整个HTML页面的所有依赖资源(JS文件、CSS文件、图片等)加载完成时将会触发。
核心方法 performanceReport
监听 load
事件,在页面加载完后,通过 getDomPerformance
获取dom性能,通过 getResourcePerformance
获取已加载完毕的资源性能数据,并上报。然后通过性能监控器 PerformanceObserver
监控后续资源的加载。
所以性能监控可以分为两部分:
- 页面加载完后的性能上报。
- 后续资源的持续监控。
performance
API 提供了非常多的属性和方法,用于获取页面性能数据。这个 API 的内容非常多,这里也只能讲用到的。
兼容的获取方式,但现在大多已经不需要这么做了。
1
| window.performance || window.mozPerformance || window.msPerformance || window.webkitPerformance || {}
|
performance.getEntries()
用于获页面中的所有的性能数据,返回一个包含各种性能对象的数组。
PerformanceNavigationTiming
包含有关页面导航和重定向的时间信息,如 unload、redirect、domInteractive 等。
PerformanceResourceTiming
提供了有关页面加载过程中每个资源的时间信息,如加载开始时间、结束时间、传输协议等。
PerformancePaintTiming
提供有关页面绘制过程中的重要时间点的信息,例如首次绘制(first-paint)和首次内容绘制(first-contentful-paint)。
我们通常不会想一次性获取这么一大堆东西,所以需要使用 performance.getEntriesByType(type)
传入 entryType
获取指定的性能对象。返回的都是数组。
navigation
返回包含一个元素的数组,元素类型为 PerformanceNavigationTiming
paint
返回包含两个元素的数组,元素类型都是 PerformanceResourceTiming
,其中 [0]
为首次绘制(first-paint),[1]
为首次内容绘制(first-contentful-paint)。
resource
返回当前已加载完的资源的性能信息数组,元素类型为 PerformanceResourceTiming
,每个元素都代表一个资源。
参考:
前端性能精进之优化方法论(一)——测量
前端性能监控指标
前端性能指标浅析
前端性能指标
性能监控指标分析
使用 Performance API 获取页面性能
Navigation Timing API 入门
PerformanceObserver前端性能测量方法
首屏事件计算方式
你只会用前端数据埋点 SDK 吗?
导航计时
导航计时(Navigation Timing) API 是 Web 性能 API 的起点。
功能:用于记录并检测用户的设备,网络等环境,以及页面初始资源加载和解析耗时。
在以前通过 performance.navigation
和 performance.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。
|
性能指标
常见的前端性能指标:
- FP(First paint) 首屏绘制,常被用来衡量白屏时间。
- FCP(First Contentful Paint) 首屏内容绘制,页面从开始加载到页面内容的任何部分在屏幕上完成渲染的时间。
- LCP(Largest Contentful Paint) 最大内容绘制,页面首次开始加载的时间点来报告可视区域内可见的最大图像或者文本块完成渲染的相对时间。
- FID(First Input Delay) 首次输入延迟时间,从用户第一次与页面交互,到浏览器对交互作出响应,并实际能够开始处理事件所经过的时间。
- TTI(Time to Interactive) 首次可交互时间,页面从开始加载到主要子资源完成渲染,并能够快速、可靠地响应用户输入所需的时间。
- CLS(Cumulative Layout Shift) 累计位移偏移,计算页面的视觉稳定性,即页面整个生命周期中所有发生的预料之外的布局偏移的得分的总和。每当一个可视元素位置发生改变,就是发生了布局偏移。
- TTFB(Time to First Byte) 首字节时间,从发起请求到服务器响应后收到的第一个字节的时间差,用于衡量服务器处理能力和网络的延迟。
getDomPerformance
方法用于获取页面加载完后的dom性能数据。
通过 PerformanceNavigationTiming
和 PerformancePaintTiming
的属性,计算各种性能指标。
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
| 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; const sslTime = navigationTiming.secureConnectionStart; return { startTime: navigationTiming.startTime.toFixed(accuracy), duration: (navigationTiming.duration).toFixed(accuracy), DNS: (navigationTiming.domainLookupEnd - navigationTiming.domainLookupStart).toFixed(accuracy), TCP: (navigationTiming.connectEnd - navigationTiming.connectStart).toFixed(accuracy), 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), ready: (navigationTiming.domContentLoadedEventEnd - navigationTiming.startTime).toFixed(accuracy), load: (navigationTiming.loadEventEnd - navigationTiming.startTime).toFixed(accuracy), dom: (navigationTiming.domContentLoadedEventEnd - navigationTiming.responseEnd).toFixed(accuracy), domComplete: navigationTiming.domComplete.toFixed(accuracy), resource: (navigationTiming.domComplete - navigationTiming.domInteractive).toFixed(accuracy), htmlLoad: (navigationTiming.responseEnd - navigationTiming.startTime).toFixed(accuracy), DCL: (navigationTiming.domContentLoadedEventEnd - navigationTiming.domContentLoadedEventStart).toFixed(accuracy), onload: (navigationTiming.loadEventEnd - navigationTiming.loadEventStart).toFixed(accuracy), } }
|
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; const data = window.performance.getEntriesByType('resource') as PerformanceResourceTiming[]; const resources: InitiatorTypeLiteral = {} data.forEach(i => { let key = i.initiatorType || 'other'; if (key === 'beacon') return; 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; }
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 设为星号,或指定域名。
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 { const observer = new PerformanceObserver((list, _observer) => { (list.getEntries() as PerformanceResourceTiming[]).forEach((e) => { if (e.initiatorType !== "beacon") { callback(e); } }); }); observer.observe({ entryTypes: ["resource"], }); return observer; }
|
其它
项目中一些其它的内容。
canvas指纹
fingerprint 等浏览器指纹识别库太大了,监控项目应该尽量减少第三方依赖,而为了标识用户,canvas 指纹是不错的选择。
不同设备、不同浏览器、不同版本,canvas 生成的图像数据都不同,获取其 Base64 编码的图像数据,然后生成8位hash值,就可以作为浏览器指纹ID。
src\utils\uuid.ts1 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
| function generateHash(str: string): string { str = atob(str); let hash = 0; for (let i = 0; i < str.length; i++) { hash = (hash << 5) - hash + str.charCodeAt(i); hash |= 0; } return Math.abs(hash).toString(16).padStart(8, '0'); }
export function getCanvasID(str: string = '#qx.chuckle,123456789<canvas>'): string | undefined { const canvas = document.createElement('canvas'); const ctx = canvas.getContext("2d"); if (!ctx) { return undefined; } ctx.font = "14px 'Arial'"; ctx.textBaseline = "bottom"; ctx.fillStyle = "#f60"; ctx.fillRect(125, 1, 62, 20); ctx.fillStyle = "#069"; ctx.fillText(str, 2, 15); ctx.fillStyle = "rgba(102, 204, 0, 0.7)"; ctx.fillText(str, 4, 17); const b64 = canvas.toDataURL().replace("data:image/png;base64,", ""); return generateHash(b64); }
|