Skip to content

前端监控 SDK

核心目标

  1. 保障应用稳定: 主动防御线上风险,快速定位并修复崩溃与错误,确保服务高可用
  2. 提升用户体验: 精准量化性能瓶颈,持续优化加载速度、交互响应与视觉稳定性,打造流畅愉悦的用户旅程。
  3. 驱动业务增长: 深度洞察用户行为与业务转化,数据驱动产品迭代与决策,最大化用户价值与商业成果。

功能模块

1. 性能监控

目标:量化用户体验瓶颈,驱动性能优化
监控场景

  • 页面加载性能
    • 核心 Web 指标:LCP(最大内容绘制)、FID(首次输入延迟 → 升级为 INP)、CLS(累积布局偏移)
    • 关键节点:FP/FCPTTI(可交互时间)、DCL(DOMContentLoaded)、L(onLoad)
  • 资源加载性能
    • 静态资源(JS/CSS/图片/字体):加载耗时、成功率、CDN 有效性
  • 接口性能
    • 异步请求:API 响应时间、传输体积(Request/Response)、TTFB(首字节时间)

2. 错误监控

目标:主动防御线上故障,降低用户流失
监控场景

错误类型捕获能力关键上下文
JS 运行时错误window.onerror + 全局监听调用栈、源码行列号
资源加载失败performance.getEntries() 过滤资源 URL、失败类型(404/Timeout)
Promise 异常unhandledrejection 事件异常对象、关联异步操作
白屏检测DOM 快照 + 关键元素存活检查屏幕截图、页面结构快照
接口错误拦截 XHR/Fetch 的 status>=400请求参数、响应体、耗时

3. 用户行为监控

目标:构建用户旅程地图,驱动产品决策
监控场景

行为类型采集方式业务价值
基础流量PV/UV、来源渠道、会话时长流量质量分析
点击/曝光事件事件代理 + 自定义埋点 + 曝光功能使用率、CTR 转化
关键行为轨迹录制用户操作序列(点击/滚动/输入)复现 Bug、分析流失场景

SDK模块

上报策略分析

上报策略

  • 上报策略分为两个方向: 实时上报 + 延时上报

实时上报

  • 所有异常类数据(JS 错误/资源加载错误/Promise 异常/接口异常等)
  • 因为需要实时监控,实时报警,实时处理,防止影响范围扩大

延时上报

对于非实时上报的数据,采用以下处理流程:

  1. 抽样决策:根据预设抽样率(可配置)决定是否处理该数据
  2. 重复过滤:为数据生成唯一标识 hashCode(基于数据内容特征),检测并丢弃短时间内(如 30 分钟)的重复数据
  3. 持久化存储:通过抽样且非重复的数据,存储至 IndexedDB 数据库
  4. 定时批量上报
    • 启动异步上报队列,通过定时器每 30 秒(可配置)触发上报任务
    • 每次从 IndexedDB 按时间顺序取出最多 20 条(可配置)数据进行上报
    • 上报成功后删除已发送数据

SDK 架构图

  • SDK 采用单例模式设计,通过统一入口简化集成流程,开发者可通过配置参数灵活控制监控行为,并支持自定义数据上报处理能力。

SDK架构图

异常监控

JS 运行时异常

  • 这类异常通常由代码执行过程中的错误引起,比如类型错误引用错误范围错误等。监控这些异常主要依靠全局错误事件监听(window.onerrorwindow.addEventListener('error'))。需要注意的是,语法错误(SyntaxError)无法被捕获,因为它们在解析阶段就会发生,导致代码块不会执行。

案例 1:未定义变量(ReferenceError)

js
function getUserData() {
  // 未定义变量
  console.log(user); // ReferenceError: user is not defined

  // 未初始化变量
  const data = user.data; // 嵌套错误
}

案例 2:类型错误(TypeError)

js
// 场景1:访问null/undefined属性
const user = null;
console.log(user.name); // TypeError: Cannot read properties of null

// 场景2:非函数调用
const config = { debug: true };
config.debug(); // TypeError: config.debug is not a function

// 场景3:非法操作
const obj = Object.freeze({ prop: 42 });
obj.prop = 100; // TypeError: Cannot assign to read only property

案例 3:范围错误(RangeError)

js
// 递归栈溢出
function stackOverflow() {
  stackOverflow();
}

// 非法数组长度
const arr = new Array(-1); // RangeError: Invalid array length

// 数字转换异常
const bigNum = 10n;
Number(bigNum); // RangeError: The number 10 cannot be converted to a BigInt

异步异常(setTimeout、setInterval)

  • 异步异常无法被 try catch 捕获,需要通过 window.onerror 捕获
js
try {
  setTimeout(() => {
    a.x;
  });
} catch (error) {
  console.log("error");
}

关键代码实现

js
const _originOnerror = window.onerror;
window.onerror = (...arg: any) => {
  const [message, sourceURL, line, column, errorObj] = arg;
  // formatError主要是抹平不同浏览器错误对象的差异
  // 从错误栈中提取有用的信息
  const e = Utils.formatError(errorObj);
  this.callback({
    type: ReportType.JS_ERROR,
    message: e.message || message,
    sourceURL: encodeURIComponent(e.sourceURL || sourceURL),
    line: e.line || line,
    column: e.column || column,
    selector: Utils.getSelectors(),
    stack: encodeURIComponent(errorObj?.stack || ""),
  });
  _originOnerror && _originOnerror.apply(window, arg);
};

Promise 异常

  • Promise 异常是异步编程中的常见问题,指未被捕获的 Promise 拒绝(rejection)。

案例 1:未处理的 Promise 拒绝

js
// 直接拒绝但未捕获
function fetchData() {
  return new Promise((resolve, reject) => {
    reject(new Error("Network timeout")); // 未处理
  });
}

fetchData(); // 触发 unhandledrejection

案例 2:async/await 未捕获异常

js
async function processOrder() {
  throw new Error("Invalid order data");
}

// 未使用 try/catch 包裹
processOrder(); // 触发 unhandledrejection

案例 3:链式调用缺少 catch

js
fetch("/api/data")
  .then((res) => {
    if (!res.ok) throw new Error("API response error");
    return res.json();
  })
  .then((data) => console.log(data));
// 缺少 catch 处理,API 错误时触发 rejection

案例 4:Promise.all 中的异常

js
Promise.all([fetch("/api/user"), fetch("/api/products")])
  .then((results) => {
    // 如果任一请求失败,整个 Promise 被拒绝
  })
  .catch((e) => console.log(e)); // 有 catch 不会触发 unhandledrejection

// 没有 catch 的情况:
Promise.all([failedPromise1, failedPromise2]); // 触发 unhandledrejection

关键代码实现

js
// Promise错误捕捉
const _originOnunhandledrejection = window.onunhandledrejection;
window.onunhandledrejection = (event) => {
  // reject 非Error错误不上报
  // 断定为用户主动抛出的错误,不进行上报

  // Promise.reject("简单字符串错误"); // ❌ 无stack属性
  // Promise.reject({ code: 500 }); // ❌ 无stack属性
  // Promise.reject(new Error("todo"));

  if (!event?.reason?.stack) return;
  const e = Utils.formatError(event.reason || {});
  const matchResult = event?.reason?.stack?.match(/at\s+(.+):(\d+):(\d+)/);
  this.callback({
    type: ReportType.PROMISE_ERROR,
    message: e.message,
    sourceURL: encodeURIComponent(matchResult[1] || ""),
    line: e.line,
    column: e.column,
    selector: Utils.getSelectors(),
    stack: encodeURIComponent(event?.reason?.stack) || "",
  });
  _originOnunhandledrejection &&
    _originOnunhandledrejection.call(window, event);
};

资源加载异常

阶段

  1. 捕获阶段(第三个参数为 true
    能同时捕获 JavaScript 执行错误带有 src 属性的标签元素(如图片、脚本)的资源加载错误。

  2. 冒泡阶段(第三个参数为 false
    能捕获 JavaScript 执行错误,但无法捕获带有 src 属性的标签元素的资源加载错误。

监控类型

  • 静态资源加载异常,会被 **window.addEventListener('error',handler,true)**捕获,同时它也可以 js 运用时异常,需要过滤掉 js 运行时异常。

  • 动态资源加载异常,比如图片、脚本、样式表等,可以通过PerformanceObserver来监听。

关键代码实现

js
/**
 * 资源加载监控
 */

import { ReportType } from './constant';
import { ReportParams } from './types';
import Utils from './utils';

export default class ResourcesError {
	reportedErrors = new Set<string>();
	observer: PerformanceObserver | null = null;

	constructor(private callback: (params: ReportParams) => void) {
		Utils.log('资源加载监控初始化成功');
		this.init();
	}
	init() {
		this.setupErrorEventListener();
		this.setupPerformanceObserver();
	}

	// 静态资源错误监听
	setupErrorEventListener() {
		window.addEventListener(
			'error',
			(event: any) => {
				// 只处理资源错误
				const sourceURL =
					event.target &&
					(event.target.src || event.target.href || event.target.currentSrc);
				if (sourceURL) {
					this.handleReport(sourceURL, 'error-event-listener');
				}
			},
			true
		);
	}

	/**
	 * 动态资源错误监控
	 */
	setupPerformanceObserver() {
		if (!window.PerformanceObserver) return;
		try {
			const errorObserver = new PerformanceObserver(list => {
				list.getEntries().forEach(entry => {
					if (entry.entryType === 'resource' && entry.duration === 0) {
						this.handleReport(entry.name, 'performance-observer');
					}
				});
			});

			errorObserver.observe({ type: 'resource' });
		} catch (e) {
			Utils.log('资源错误观察器初始化失败');
		}
	}

	handleReport(sourceURL: string, subType: string) {
		if (this.reportedErrors.has(sourceURL)) return;
		this.reportedErrors.add(sourceURL);
		this.callback({
			type: ReportType.RESOURCES_ERROR,
			subType,
			message: '资源加载异常',
			selector: Utils.getSelectors(),
			sourceURL,
		});
	}
}

白屏监控

  • document.elementsFromPoint(x,y) 是一个强大的 DOM API,它返回一个数组,包含指定坐标点处所有层叠的元素(从最顶层的元素到底层元素),按从最顶层到最底层的顺序排列。
js
// 获取视口坐标 (100, 200) 处的所有元素
const elements = document.elementsFromPoint(100, 200);

// 结果示例:
// [div.popup, button.submit, form.container, div.content, body, html]

实现思路

  1. 创建检测点:在页面的主要轴线上(水平线和垂直线)以及四个角落创建检测点。这些点将用于检测页面是否白屏。
  2. 判断元素是否为白屏元素:如果检测到的元素在白屏元素列表中,则认为该点为空白点。
  3. 判断是否为白屏:如果 80% 的点为空白点,则认为页面为白屏。

关键代码实现

js
/**
 * 页面白屏监控
 */
import { ReportType } from './constant';
import { ReportParams } from './types';
import Utils from './utils';

export default class WhiteScreen {
	constructor(
		private callback: (params: ReportParams) => void,
		private filterElements?: Array<string>
	) {
		Utils.log('页面白屏监控初始化成功');
		this.init();
	}

	getSelector(element: HTMLElement): string {
		if (element.id) {
			return '#' + element.id;
		} else if (element.className) {
			return (
				'.' +
				element.className
					.split(' ')
					.filter(item => !!item)
					.join('.')
			);
		} else {
			return element.nodeName.toLowerCase();
		}
	}

	checkWhiteScreen() {
		const elements = this.filterElements || ['html', 'body'];
		const w = window.innerWidth;
		const h = window.innerHeight;
		let emptyPoints = 0;

		// 判断元素是否为白屏元素
		const isWrapper = (element: HTMLElement) => {
			const selector = this.getSelector(element);
			if (elements.includes(selector)) {
				// 如果检查到的元素在白屏元素列表中,则认为该点为空白点
				emptyPoints++;
			}
		};

		// 创建检测点数组
		const points = [];

		// 主要轴线上的点
		for (let i = 1; i <= 9; i++) {
			points.push({ x: (w * i) / 10, y: h / 2 }); // 水平线
			points.push({ x: w / 2, y: (h * i) / 10 }); // 垂直线
		}

		// 四个角落的点
		points.push({ x: w * 0.1, y: h * 0.1 });
		points.push({ x: w * 0.9, y: h * 0.1 });
		points.push({ x: w * 0.1, y: h * 0.9 });
		points.push({ x: w * 0.9, y: h * 0.9 });

		points.forEach(point => {
			const elementsAtPoint = document.elementsFromPoint(point.x, point.y);
			const topElement = elementsAtPoint[0];

			if (topElement) {
				isWrapper(topElement as HTMLElement);
			}
		});

		// 检测中心点
		const centerElements = document.elementsFromPoint(w / 2, h / 2);
		const centerElement = centerElements[0];
		const centerSelector = this.getSelector(centerElement as HTMLElement);

		isWrapper(centerElement as HTMLElement);
		// 判断是否为白屏(80%的点为空白)
		const isWhiteScreen = emptyPoints >= points.length * 0.8;
		if (isWhiteScreen) {
			Utils.log('检测到白屏!');
			this.callback({
				type: ReportType.BLANKSCREEN,
				message: '检测到白屏',
				emptyPoints,
				totalPoints: points.length,
				screen: `${window.screen.width}X${window.screen.height}`, // 屏幕
				viewPoint: `${w}X${h}`, // 浏览器
				centerElement: centerSelector,
			});
		}
	}
	init() {
		Utils.onload(() => {
			this.checkWhiteScreen();
		});
	}
}

性能监控

Resource Timing(资源计时) 流程图,属于 Web 性能 API 的一部分,用于精确度量网页资源(如脚本、样式、图片等 )加载各阶段的时间,帮助开发者分析和优化网页加载性能 。

加载节点图

性能关键指标优化说明

指标计算方式说明
TTFBresponseStart - redirectStart首字节时间:从页面重定向开始到接收服务器返回的第一个字节的时间差
FPresponseEnd - fetchStart首次绘制时间&白屏时间:浏览器将第一个像素(通常是背景色)绘制到屏幕的时间,反映白屏阶段时长
FCP通过 web-vitals 库测量首次内容绘制:浏览器首次渲染出 DOM 内容的时间(如文本、图像等)
FMP通过 web-vitals 库测量首次有意义绘制:主要内容完成渲染的时间点(主观指标,通常对应核心内容可见或可交互时刻)
LCP通过 web-vitals 库测量最大内容渲染:视窗内最大内容元素(如图片/标题块)完成渲染的时间(动态指标,随加载过程更新)
DCLdomContentLoadedEventEnd - domContentLoadedEventStartDOM 解析耗时:DOMContentLoaded 事件从开始到结束的持续时间(反映 DOM 树构建速度)
LloadEventStart - fetchStart页面完全加载时间:从开始加载到 load 事件触发的时间(包含所有资源加载)
TTI通过 web-vitals 库测量首次可交互时间:页面达到完全可交互状态的时间(需满足 FP 完成+主线程空闲+元素可操作)
FID(被废弃 INP 替代)通过 web-vitals 库测量首次输入延迟:用户首次交互操作(点击/输入)到浏览器实际响应的延迟时间

TTI 触发条件详解

  1. 首次绘制完成:页面已完成至少一个像素的渲染(FP 阶段结束)
  2. 主线程空闲:浏览器主线程连续空闲时间 ≥ 50ms(无长任务阻塞)
  3. 元素可操作:关键交互元素(按钮/输入框等)可即时响应用户操作
    → 当三项条件同时满足时,TTI 被记录

FID 测量原理

  1. 用户首次交互:监测页面生命周期中的第一次用户操作(点击/触摸/键盘输入)
  2. 主线程阻塞检测:若交互发生时主线程正执行长任务(JS 执行/渲染等),记录任务结束到响应开始的延迟
  3. 阈值判定:实际延迟 = 浏览器开始处理输入事件的时间戳 - 用户交互时间戳

页面性能

  • 主要是通过performancePerformanceObserverweb-vitals 库,来获取性能指标

关键代码实现

js
/**
 * 页面性能指标
 */
import { onFCP, onLCP, onFID } from 'web-vitals';
import { ReportType } from './constant';
import { ReportParams } from './types';
import Utils from './utils';

export default class Perf {
	cycleFreq = 100; // 循环轮询的时间
	timer: any = 0;
	constructor(private callback: (params: ReportParams) => void) {
		Utils.log('页面渲染性能监控初始化成功');
		this.init();
	}
	runCheck() {
		let FCP = 0;
		let LCP = 0;
		let FID = 0;
		let FMP = 0;

		const p = performance.getEntriesByType(
			'navigation'
		)[0] as PerformanceNavigationTiming;
		new PerformanceObserver((entryList, observer) => {
			const perfEntries = entryList.getEntries();
			FMP = perfEntries[0].startTime;
			observer.disconnect(); //不再观察了
		}).observe({ entryTypes: ['element'] }); //观察页面中的意义的元素
		// 首次内容绘制
		onFCP(data => {
			FCP = data.value;
			callback();
		});
		// 最大内容绘制
		onLCP(data => {
			LCP = data.value;
			callback();
		});
		// 首次输入延迟
		onFID(data => {
			FID = data.value;
			callback();
		});
		const callback = () => {
			this.callback({
				type: ReportType.PERFORMANCE,
				message: '页面渲染性能',
				unload: (p.unloadEventEnd - p.unloadEventStart).toFixed(2), // 前一个页面卸载耗时
				redirect: (p.redirectEnd - p.redirectStart).toFixed(2), // 重定向的时间
				appCache: (p.domainLookupStart - p.fetchStart).toFixed(2), // 读取缓存的时间
				dns: (p.domainLookupEnd - p.domainLookupStart).toFixed(2), // DNS查询耗时
				tcp: (p.connectEnd - p.connectStart).toFixed(2), // TCP连接耗时
				ssl: (p.connectEnd - p.secureConnectionStart).toFixed(2), // SSL 安全连接耗时
				rst: (p.responseStart - p.requestStart).toFixed(2), // 请求响应耗时
				trans: (p.responseEnd - p.responseStart).toFixed(2), // 响应数据传输耗时
				ready: (p.domComplete - p.domInteractive).toFixed(2), // DOM解析耗时(不包含dom内嵌资源加载时间)
				resources: (p.loadEventStart - p.domContentLoadedEventEnd).toFixed(2), //资源加载耗时
				onLoad: (p.loadEventEnd - p.loadEventStart).toFixed(2), // onLoad事件耗时(所有资源已加载)
				// 指标
				TTFB: (p.responseStart - p.redirectStart).toFixed(2), // 首字节(重定向到->服务器接收页面的第一个字节)
				FP: (p.responseEnd - p.fetchStart).toFixed(2), // 白屏时间 首次绘制包括了任何用户自定义的背景颜色绘制,它是将第一个像素点绘制到屏幕的时刻
				FCP: FCP.toFixed(2), //	首次内容绘制 是浏览器将第一个DOM渲染到屏幕的时间,可以是任何文本、图像、SVG等的时间
				FMP: FMP.toFixed(2), //	首次有意义绘制
				LCP: LCP.toFixed(2), //	最大内容渲染(事件响应时触发)
				DCL: (
					p.domContentLoadedEventEnd - p.domContentLoadedEventStart
				).toFixed(2), // DOMContentLoaded事件耗时
				L: (p.loadEventStart - p.fetchStart).toFixed(2), // 页面完全加载总时间(load)
				TTI: (p.domInteractive - p.fetchStart).toFixed(2), // 首次可交互时间
				FID: FID.toFixed(2), //	首次输入延迟时间(点击到响应的时间差)
			});
		};
		// 保证load事件结束
		if (p.loadEventEnd) {
			callback();
		} else {
			clearInterval(this.timer);
			this.timer = setTimeout(this.runCheck.bind(this), this.cycleFreq);
		}
	}

	init() {
		window.addEventListener('load', () => {
			this.runCheck();
		});
	}
}

资源性能

  • 主要是通过PerformanceObserverperformance来获取性能指标,优先使用 PerformanceObserver,可以实时获取资源加载性能数据,而无需手动轮询

  • 跨域资源 redirectStartredirectEnddomainLookupStartdomainLookupEndconnectStartconnectEndsecureConnectionStartrequestStartresponseStarttransferSize的值都为 0,需要设置跨域资源需要设置响应头 Timing-Allow-Origin:*

关键代码实现

js
/**
 * 资源加载性能指标
 */
import { ReportType } from './constant';
import { ReportParams } from './types';
import Utils from './utils';

export default class ResourcesPerf {
	reportedUrls = new Set<string>();

	constructor(
		private callback: (params: ReportParams) => void,
		private reportUrl?: string
	) {
		Utils.log('资源加载性能指标初始化成功');
		this.init();
	}
	resolveEntries(entries: PerformanceResourceTiming[]): Array<ReportParams> {
		const timings: Array<ReportParams> = [];
		entries.forEach((timing: PerformanceResourceTiming) => {
			if (this.shouldReportResource(timing)) {
				timings.push({
					type: ReportType.RES_PERFORMANCE,
					message: '资源加载性能',
					resourcesUrl: timing.name,
					resourcesType: timing.initiatorType,
					redirect: (timing.redirectEnd - timing.redirectStart).toFixed(2), // 重定向
					appCache: (timing.domainLookupStart - timing.fetchStart).toFixed(2), // 缓存
					dns: (timing.domainLookupEnd - timing.domainLookupStart).toFixed(2),
					tcp: (timing.connectEnd - timing.connectStart).toFixed(2),
					ssl: (timing.connectEnd - timing.secureConnectionStart).toFixed(2), // https下有效
					rst: (timing.responseStart - timing.requestStart).toFixed(2), // 请求响应耗时
					trans: (timing.responseEnd - timing.responseStart).toFixed(2), // 内容传输耗时
					duration: timing.duration.toFixed(2), // 加载时长
					decodedBodySize: timing.decodedBodySize,
					encodedBodySize: timing.encodedBodySize,
					transferSize: timing.transferSize,
				});
			}
		});
		return timings;
	}
	/**
	 * 检查是否需要上报该资源
	 */
	shouldReportResource({
		initiatorType,
		name,
	}: PerformanceResourceTiming): boolean {
		if (this.reportUrl && name.startsWith(this.reportUrl)) {
			// 排除上报URL自身(防止死循环)
			return false;
		}
		// 避免重复上报相同资源
		if (this.reportedUrls.has(name)) {
			return false;
		}

		// 只上报指定类型的资源
		const reportableTypes = [
			'link', // CSS
			'script', // JavaScript
			'img', // 图片
			'audio', // 音频
			'video', // 视频
			'css', // 字体等CSS资源
		];
		return reportableTypes.includes(initiatorType);
	}
	init() {
		if (window.PerformanceObserver) {
			const observer = new window.PerformanceObserver(performance => {
				const entries = performance.getEntries() as PerformanceResourceTiming[];
				const timings = this.resolveEntries(entries);
				timings.forEach(timing => {
					this.callback(timing);
					this.reportedUrls.add(timing.resourcesUrl); // 记录已上报的资源
				});
			});
			observer.observe({
				entryTypes: ['resource'],
			});
		} else {
			window.addEventListener('load', () => {
				const entries = performance.getEntriesByType(
					'resource'
				) as PerformanceResourceTiming[];
				const timings = this.resolveEntries(entries);
				timings.forEach(timing => {
					this.callback(timing);
					this.reportedUrls.add(timing.resourcesUrl);
				});
			});
		}
	}
}

接口性能

  • 接口性能主要对XMLHttpRequest,opensend 方法进行重写,获取接口的请求参数、响应参数、耗时、状态码等信息

关键代码实现

js
/**
 * 接口监控
 */
import { ReportType } from './constant';
import { ReportParams } from './types';
import Utils from './utils';

// 使用 WeakMap 存储每个 XMLHttpRequest 实例的监控信息
const xhrInfoMap = new WeakMap<XMLHttpRequest, Partial<ReportParams>>();

export default class Xhr {
	constructor(private callback: (params: ReportParams) => void) {
		Utils.log('接口监控初始化成功');
		this.xhrHook();
	}

	xhrHook() {
		const xhrSelf = this;
		const xhrProto = XMLHttpRequest.prototype;
		const originalOpen = xhrProto.open;
		const originalSend = xhrProto.send;

		// 重写 open 方法
		xhrProto.open = function (method: string, url: string) {
			// 将初始信息存入 WeakMap
			xhrInfoMap.set(this, {
				url: url,
				method: method.toUpperCase(),
			});

			return originalOpen.apply(this, arguments as any);
		};

		// 重写 send 方法
		xhrProto.send = function (body?: Document | XMLHttpRequestBodyInit | null) {
			const xhr = this;
			const startTime = Date.now();

			// 从 WeakMap 获取初始信息
			const initialInfo = xhrInfoMap.get(xhr) || {};

			// 创建事件处理函数
			const handleEvent = (eventType: string) => () => {
				const info = xhrInfoMap.get(xhr) || initialInfo;

				// 更新监控信息
				const updatedInfo: Partial<ReportParams> = {
					...info,
					subType: eventType,
					status: xhr.status,
					success:
						(xhr.status >= 200 && xhr.status <= 206) || xhr.status === 304,
					duration: Date.now() - startTime,
					requestData: Utils.isFormData(body) ? 'FormData' : body,
				};

				// 处理响应数据
				if (xhr.response) {
					let responseSize = null;
					let responseData = null;

					switch (xhr.responseType) {
						case 'json':
							try {
								responseSize = JSON.stringify(xhr.response).length;
								responseData = JSON.stringify(xhr.response);
							} catch (error) {
								console.error('JSON stringify error:', error);
							}
							break;
						case 'blob':
							responseSize = (xhr.response as Blob).size;
							break;
						case 'arraybuffer':
							responseSize = (xhr.response as ArrayBuffer).byteLength;
							break;
						case 'document':
							if (xhr.response.documentElement) {
								responseSize =
									xhr.response.documentElement &&
									xhr.response.documentElement.innerHTML &&
									xhr.response.documentElement.innerHTML.length + 28;
							}

							break;
						default:
							try {
								if (typeof xhr.response === 'string') {
									responseSize = xhr.response.length;
									responseData = xhr.response;
								} else {
									responseSize = JSON.stringify(xhr.response).length;
									responseData = JSON.stringify(xhr.response);
								}
							} catch (error) {
								console.error('Response processing error:', error);
							}
					}

					updatedInfo.responseSize = responseSize;
					updatedInfo.responseData = responseData;
				}

				// 更新 WeakMap 中的信息
				xhrInfoMap.set(xhr, updatedInfo);

				// 触发回调
				xhrSelf.callback({
					...updatedInfo,
					type: ReportType.XHR,
					message: '接口监控',
				} as ReportParams);
			};

			// 添加事件监听
			const addEvent = (type: string) => {
				xhr.addEventListener(type, handleEvent(type));
			};

			if (xhr.addEventListener!) {
				addEvent('load');
				addEvent('error');
				addEvent('abort');
			} else {
				const originalStateChange = xhr.onreadystatechange;
				xhr.onreadystatechange = function () {
					originalStateChange?.apply(this, arguments as any);
					if (xhr.readyState === 4) {
						handleEvent('load')();
					}
				};
			}

			return originalSend.apply(xhr, arguments as any);
		};
	}
}

无痕埋点(自动埋点)

实现思路

  • 在 document 上添加点击事件(事件代理)

  • 当事件发生时,获取事件源 target

  • 检查 target 上是否有data-report属性,如果有则上报数据

  • 如果没有,则向上遍历父节点最多 5 层,直到找到带有data-report属性的节点,如果有则上报数据

    TIP

    注意:在虚拟 DOM 环境下(如 React、Vue),事件源 target 可能是实际 DOM,但自定义属性(如 data-report)可能绑定在虚拟 DOM 对应的真实 DOM 的父节点或祖先节点上,因此需要向上查找。

  • 虚拟的 DOM 经过转换过后

point1

  • 事件源 target

point2

关键代码实现

js
/**
 * 自动埋点
 */
import { ReportType } from './constant';
import { ReportParams } from './types';
import Utils from './utils';

export default class AutomaticBurialPoint {
	private readonly config = {
		attributeName: 'appear', // 定义的的data-appear属性名
		maxDepth: 5, // DOM树最大遍历深度
		capture: true, // 使用捕获阶段
		passive: true, // 不阻止默认事件
	};
	constructor(private callback: (params: ReportParams) => void) {
		Utils.log('自动埋点初始化成功');
		this.init();
	}

	eventHandler(event: Event) {
		const target = event.target as HTMLElement;
		if (!target) return;
		let depth = 0;
		const selectors = [];
		selectors.push(target);
		let reportData = target.dataset[this.config.attributeName];
		let parentNode = target.parentNode as HTMLElement;

		while (!reportData && parentNode && depth < this.config.maxDepth) {
			// 检查是否为文档节点
			if (parentNode.nodeType === Node.DOCUMENT_NODE) {
				return;
			}
			// 检查是否为元素节点 (Element.ELEMENT_NODE === 1)
			if (parentNode.nodeType !== Node.ELEMENT_NODE) {
				parentNode = parentNode.parentNode as HTMLElement;
				continue;
			}
			if (parentNode.tagName.toLocaleUpperCase() === 'BODY') {
				return;
			}
			selectors.push(parentNode);
			reportData = parentNode.dataset[this.config.attributeName];
			// 向上遍历
			parentNode = parentNode.parentNode as HTMLElement;
			depth++;
		}
		if (!reportData) return;
		this.callback({
			type: ReportType.AUTOMATIC_BURIAL_POINT,
			message: '自动埋点',
			data: reportData,
			selector: Utils.getSelectors(selectors),
		});
	}

	init() {
		document.addEventListener('click', this.eventHandler.bind(this), {
			capture: this.config.capture,
			passive: this.config.passive,
		});
	}
}

曝光埋点

  • 曝光埋点指在用户浏览网页时,当特定 DOM 元素进入可视区域时自动触发数据上报,用于分析元素可见性与用户行为转化关系。

实现思路

曝光节点分为两类:静态节点,和动态节点

  • 1、对于静态节点,在页面加载完成后,获取所有带有data-appear属性的 DOM 节点,通过 IntersectionObserver 监听,当节点在可视区域内,上报数据
  • 2、对于动态节点,在页面加载完成后,通过 MutationObserver 监听 DOM 变化,当有新的 DOM 节点插入时,判断是否带有data-appear属性,如果有则通过 IntersectionObserver 监听,当节点在可视区域内,上报数据
  • 3、由于节点可能在虚拟 DOM 中,不一定立马会拿到真实的 DOM,这里需要懒递归十次每次 100 毫秒
  • 4、在当前页面,已经曝光过的,防止重复上报,通过 WeakSet 来记录已经曝光过的节点,同时,在曝光后,停止监听该节点

代码实现

js
import { ReportType } from './constant';
import { ReportParams } from './types';
import Utils from './utils';
/**
 * 爆光上报(是否可见浏览过)
 */

export default class Exposure {
	observer!: IntersectionObserver;
	mutationObserver!: MutationObserver;
	reportedElements: WeakSet<Element> = new WeakSet();
	newElementsFound = false;
	maxRetries = 10; // 最大重试次数
	retryCount = 0; // 当前重试次数
	isInit = false;
	constructor(private callback: (params: ReportParams) => void) {
		Utils.log('曝光监控初始化成功');
		this.setupObservers();
		this.init();
	}

	// 初始化曝光监控
	setupObservers() {
		// 创建 IntersectionObserver 监控元素曝光
		this.observer = new IntersectionObserver(entries => {
			entries.forEach(entry => {
				const element = entry.target;

				// 检查元素是否已经上报过
				if (this.reportedElements.has(element)) {
					// 已上报的元素停止观察
					this.observer.unobserve(element);
					return;
				}

				const appearData = element.getAttribute('data-appear');

				// 当元素进入视口且达到可见比例阈值
				if (entry.intersectionRatio > 0 && appearData) {
					// 标记元素已上报
					this.reportedElements.add(element);

					// 上报曝光数据
					this.callback({
						type: ReportType.EXPOSURE,
						message: '曝光埋点',
						exposure: appearData,
					});

					// 停止观察该元素
					this.observer.unobserve(element);
				}
			});
		});

		// 创建 MutationObserver 监控动态元素
		this.mutationObserver = new MutationObserver(mutations => {
			mutations.forEach(mutation => {
				// 处理新增节点
				mutation.addedNodes.forEach(node => {
					if (node instanceof HTMLElement) {
						this.processNewElement(node);
					}
				});
			});
		});
	}

	// 处理新元素及其子元素
	processNewElement(element: HTMLElement) {
		// 检查元素本身是否有曝光属性
		if (
			element.hasAttribute('data-appear') &&
			!this.reportedElements.has(element)
		) {
			this.observer.observe(element);
		}

		// 检查子元素是否有曝光属性
		const exposureElements = element.querySelectorAll('[data-appear]');
		exposureElements.forEach(el => {
			if (!this.reportedElements.has(el)) {
				this.observer.observe(el);
			}
		});
	}

	// 扫描并观察页面元素
	private observePageElements() {
		const appears = document.querySelectorAll('[data-appear]');
		appears.forEach(element => {
			if (!this.reportedElements.has(element)) {
				this.observer.observe(element);
			}
			this.newElementsFound = true;
		});
		return this.newElementsFound;
	}

	// 安全重试机制
	safeRetry() {
		if (this.retryCount >= this.maxRetries) {
			Utils.log('达到最大重试次数(10次),停止尝试', 'warn');
			return;
		}

		this.retryCount++;
		setTimeout(() => {
			if (!this.observePageElements()) {
				this.safeRetry();
			}
		}, 100);
	}

	// 初始化和扫描页面元素
	scanAndObserve() {
		if (!this.observePageElements()) {
			this.safeRetry();
		}
	}

	// 强制重新扫描和观察页面元素
	forceObservers() {
		this.resetExposureState();
		this.scanAndObserve();
	}

	// 重置曝光状态
	resetExposureState() {
		// this.observer.disconnect();
		this.reportedElements = new WeakSet();
		this.retryCount = 0;
	}

	// 初始化监控
	init() {
		// 监听页面加载
		window.addEventListener('load', () => {
			if (this.isInit) return;
			this.isInit = true;
			this.resetExposureState();
			this.scanAndObserve();
		});

		// 监听路由变化
		Utils.routerChangeListener(() => {
			if (this.isInit) return;
			this.isInit = true;
			this.resetExposureState();
			this.scanAndObserve();
		});

		// 开始监控DOM变化
		this.mutationObserver.observe(document.body, {
			childList: true,
			subtree: true,
		});
	}
}

PV

  • 浏览器每访问一次就是一个 PV,逐渐累计成为 PV 总数

  • UA 会根据 PV 做去重计算出来,一个自然日内一个 IP 只记录为一个 UA

  • 实现思路:

    • 在初始化时上报一个 PV,路由改变时也上报一个 PV
    • VueRouter 在打开页面的时候,会触发 historyreplaceState,导致路由改变被监听
    • 如果这里不做处理会触发 2 次上报造成很多脏数据,经过多次测试,replaceState 事件触发机制,在 setTimeout 之后,所以这里在初始化时,将路由监听放在 setTimeout 之后

代码实现

js
/**
 * PV,UA根据PV计算
 */

import { ReportType } from './constant';
import { ReportParams } from './types';
import Utils from './utils';

export default class Pv {
	constructor(private callback: (params: ReportParams) => void) {
		Utils.log('PV监控初始化成功');
		this.init();
	}
	init() {
		setTimeout(() => {
			this.callback({
				time: Date.now(),
				type: ReportType.PV,
				message: 'PV',
			});
			Utils.routerChangeListener(() => {
				this.callback({
					time: Date.now(),
					type: ReportType.PV,
					message: 'PV',
				});
			});
		}, 100);
	}
}

用户停留时间

实现思路

  • 通过监听用户活动,记录用户最后活动时间,如果超过 15 分钟,则认为用户离线,不上报停留时间
  • 如果用户有活动,则更新最后活动时间
  • 页面关闭&页面不可见&路由变化 上报停留时间
  • 防止误判离线,如果用户有活动,则更新最后活动时间

代码实现

js
/**
 * 用户页面停留时长
 */
import { ReportType } from './constant';
import { ReportParams } from './types';
import Utils from './utils';

export default class PageDwellTime {
	// 配置常量
	OFFLINE_MILL = 15 * 60 * 1000; // 15分钟不操作认为不在线
	MIN_DURATION = 1000; // 最小有效停留时间(1秒)

	// 状态变量
	lastTime: number;
	lastActiveTime: number; // 最后活动时间
	prePageUrl: string;

	constructor(private callback: (params: ReportParams) => void) {
		Utils.log('用户页面停留时长初始化成功');

		this.prePageUrl = encodeURIComponent(location.href);
		const time = performance.now();
		this.lastTime = time;
		this.lastActiveTime = time;
		this.init();
	}

	init() {
		setTimeout(() => {
			// 路由变化监听
			Utils.routerChangeListener(() => {
				this.reportDwellTime();
				this.prePageUrl = encodeURIComponent(location.href);
			});
		}, 100);

		// 用户活动监听(防止误判离线)
		window.addEventListener('mousemove', this.updateLastActiveTime.bind(this));
		window.addEventListener('keydown', this.updateLastActiveTime.bind(this));

		// 页面关闭监听
		window.addEventListener('beforeunload', this.handlePageUnload.bind(this));
		window.addEventListener('pagehide', this.handlePageUnload.bind(this));

		// 重置
		window.addEventListener('pageshow', () => {
			this.lastTime = performance.now();
			this.lastActiveTime = performance.now();
		});
	}

	/**
	 * 更新最后活动时间
	 */
	private updateLastActiveTime(): void {
		this.lastActiveTime = performance.now();
	}

	/**
	 * 处理页面卸载
	 */
	private handlePageUnload(): void {
		// 上报停留时间
		this.reportDwellTime();
	}

	/**
	 * 上报停留时间
	 */
	private reportDwellTime(): void {
		const now = performance.now();
		const duration = now - this.lastTime;
		const activeTime = now - this.lastActiveTime;
		// 忽略过短的停留时间
		if (duration < this.MIN_DURATION) {
			Utils.log(`忽略过短的停留时间: ${Math.round(duration)}ms`);
			return;
		}

		// 超过十五分钟,判定为掉线
		if (activeTime > this.OFFLINE_MILL) {
			Utils.log(`检测到离线状态: ${Math.round(duration / 1000)}秒`);
			this.lastTime = now;
			this.lastActiveTime = now;
			return;
		}

		// 准备上报数据
		const reportData: ReportParams = {
			type: ReportType.PAGE_DWELL_TIME,
			message: '页面停留时长',
			durationMs: Math.round(duration),
			pageUrl: this.prePageUrl,
			visibilityState: document.visibilityState,
		};
		this.callback(reportData);
		Utils.log(`页面停留上报: ${Math.round(duration)}ms`);
		// 更新时间戳
		this.lastTime = now;
		this.lastActiveTime = now;
	}
}

用户行为录制

  • 用户行为录制,记录用户在页面上的操作,如点击、滚动、输入等,还原用户行为
  • 主要的利用rrweb,来进行 DOM 快照,然后通过rrwebrecord方法进行录制,将数据发送到后端,通过rrwebreplay方法进行回放
  • 由于数据量会比较大,所以需要开发人员手动的开始录制,手动结束录制,然后发送到后端

实现思路

js
import { ReportType } from './constant';
import { ReportParams } from './types';
import Utils from './utils';
import { record, pack } from 'rrweb';

/**
 * 用户行为录制
 */
export default class BehaviorRecorder {
	private instanceRecord: any;
	private events: any = [];
	private isRecording = false;
	constructor(private callback: (params: ReportParams) => void) {
		Utils.log('用户行为录制初始化成功');
	}
	public start(): void {
		if (this.isRecording) return;
		this.isRecording = true;
		this.events = [];
		this.instanceRecord = record({
			emit: event => {
				this.events.push(event);
			},

			/**
			 * 性能优化:每N个事件后生成一个全量快照
			 * 防止增量事件过多导致内存问题
			 */
			checkoutEveryNth: 100,

			/**
			 * 性能优化:每N毫秒强制生成全量快照
			 * 防止长时间未生成快照导致重放问题
			 */
			checkoutEveryNms: 30 * 1000, // 30秒

			/**
			 * 降低鼠标移动事件采样率
			 * 平衡精度和性能(值越高采样率越低)
			 */
			mousemoveWait: 150,

			/**
			 * 安全:添加此类名的元素将被完全阻止录制
			 * 用法:<div class="rr-block">敏感内容</div>
			 */
			blockClass: 'rr-block',

			/**
			 * 安全:添加此类名的元素内容将被忽略
			 * 用法:<div class="rr-ignore">忽略内容但保留元素</div>
			 */
			ignoreClass: 'rr-ignore',

			/**
			 * 安全:添加此类名的文本内容将被掩码
			 * 用法:<span class="rr-mask">信用卡号</span>
			 */
			maskTextClass: 'rr-mask',

			/**
			 * 安全:自动掩码所有输入框内容
			 * 防止敏感数据泄露
			 */
			maskAllInputs: true,

			/**
			 * 安全:细化输入框掩码配置
			 */
			maskInputOptions: {
				password: true, // 密码框
				text: true, // 文本输入
				email: true, // 邮箱输入
				tel: true, // 电话输入
				number: true, // 数字输入
				search: true, // 搜索框
				url: true, // URL输入
				textarea: true, // 文本域
				select: true, // 下拉选择
			},

			/**
			 * 数据压缩:使用rrweb自带的LZ字符串压缩
			 * 可减少30-70%的数据体积
			 */
			packFn: pack,

			/**
			 * DOM优化:精简DOM结构
			 * 移除不必要的内容以减小数据大小
			 */
			slimDOMOptions: {
				script: true, // 移除脚本
				comment: true, // 移除注释
				headFavicon: true, // 移除favicon
				headWhitespace: true, // 移除头部空白
				headMetaSocial: true, // 移除社交媒体meta
			},

			/**
			 * 高级功能:录制Canvas内容
			 * 适用于图形验证码、图表等场景
			 */
			recordCanvas: false,

			/**
			 * 性能优化:内联样式表
			 * 提高重放准确性,但略微增加数据大小
			 */
			inlineStylesheet: true,

			/**
			 * 字体处理:收集页面字体
			 * 确保重放时字体一致性
			 */
			collectFonts: true,

			/**
			 * 跨域支持:录制跨域iframe内容
			 * 需要目标iframe配合设置allow-same-origin
			 */
			recordCrossOriginIframes: true,

			/**
			 * 采样策略:优化高频事件
			 */
			sampling: {
				scroll: 150, // 滚动事件采样间隔(ms)
				input: 'last' as const, // 只记录最终输入值

				// 鼠标交互事件采样
				mouseInteraction: {
					MouseUp: true,
					MouseDown: true,
					Click: true,
					ContextMenu: true,
					DblClick: true,
					Focus: true,
					Blur: true,
					TouchStart: true,
					TouchEnd: true,
				},
			},
		});
	}

	public stop(): void {
		this.isRecording = false;
		this.callback({
			type: ReportType.BEHAVIOR_RECORDER,
			data: this.events,
		});
		this.instanceRecord && this.instanceRecord();
	}
}

路由监听机制对比

Hash 路由

  1. 监听事件:使用 hashchange 事件监听路由变化。
  2. 触发条件
    • Hash 变化时会触发 hashchange 事件。
    • 同时会触发 popstate 事件,且 popstate 先于 hashchange 触发。
  3. 局限性:无法监听 History API 触发的路由变化。

History 路由

  1. 核心 API
    • 导航类:back()go()forward()
    • 修改类:pushState()replaceState()
  2. 监听事件:通过 popstate 监听路由变化。
    • 触发条件:仅响应导航类 API(back/go/forward)或浏览器前进/后退按钮操作。
    • 不触发条件pushStatereplaceState 不会触发 popstate
  3. 补充方案:需手动拦截 pushState/replaceState 调用或通过自定义事件模拟监听。

关键差异总结

特性Hash 路由History 路由
监听事件hashchangepopstate
响应操作Hash 变化back/go/forward 和浏览器导航
API 触发顺序popstatehashchange不适用
修改类 API 监听不可监听 History API需手动扩展(如重写 pushState

兼容 History API 和 Hash 的路由监听

核心实现思路

  1. 统一事件监听:使用 popstate 作为基础事件监听器
  2. AOP 增强 History API:通过重写 pushState/replaceState 方法补发自定义事件

关键代码实现

js
	patchHistoryMethod(type: keyof History) {
		const orig = history[type];
		const _this = this;
		return function (this: unknown) {
			_this.queueFlush(() => {
				if (_this.isBeforeunload) return;
				const e = new Event(type);
				window.dispatchEvent(e);
			});
			return orig.apply(this, arguments);
		};
	}

	// 路由方法重写
	rewriteHistoryMethods() {
		history.pushState = this.patchHistoryMethod('pushState');
		history.replaceState = this.patchHistoryMethod('replaceState');
	}

	// 路由变化监听
	routerChangeListener(handler: (e: Event) => void) {
		// 监听Hash、History路由变化
		window.addEventListener('popstate', handler, true);
		// 监听手动调用 pushState/replaceState
		window.addEventListener('replaceState', handler, true);
		window.addEventListener('pushState', handler, true);
	}


	// 通过微任务解决,连续触发问题
	queueFlush(flushJobs: () => void) {
		if (!this.isFlushPending) {
			this.isFlushPending = true;
			Promise.resolve()
				.then(() => {
					this.isFlushPending = false;
					flushJobs();
				})
				.catch(error => {
					this.isFlushPending = false;
					this.log(`Error in queue flush: ${error.message}`, 'warn');
				});
		}
	}

VueRouter 特殊行为

问题本质分析

VueRouter 在路由变更时会连续触发多个历史记录操作,主要表现:

  1. 初始化阶段replaceState(设置初始路由状态)
  2. 路由跳转时replaceState(准备阶段)→ pushState(实际跳转)
  3. 页面刷新时:通过 beforeunload 触发 replaceState(保存当前状态)