Appearance
前端监控 SDK
核心目标
- 保障应用稳定: 主动防御线上风险,快速定位并修复崩溃与错误,确保服务高可用。
- 提升用户体验: 精准量化性能瓶颈,持续优化加载速度、交互响应与视觉稳定性,打造流畅愉悦的用户旅程。
- 驱动业务增长: 深度洞察用户行为与业务转化,数据驱动产品迭代与决策,最大化用户价值与商业成果。
功能模块
1. 性能监控
目标:量化用户体验瓶颈,驱动性能优化
监控场景:
- 页面加载性能
- 核心 Web 指标:
LCP
(最大内容绘制)、FID
(首次输入延迟 → 升级为INP
)、CLS
(累积布局偏移) - 关键节点:
FP/FCP
、TTI
(可交互时间)、DCL
(DOMContentLoaded)、L
(onLoad)
- 核心 Web 指标:
- 资源加载性能
- 静态资源(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、分析流失场景 |
上报策略分析
- 上报策略分为两个方向: 实时上报 + 延时上报
实时上报
- 所有异常类数据(JS 错误/资源加载错误/Promise 异常/接口异常等)
- 因为需要实时监控,实时报警,实时处理,防止影响范围扩大
延时上报
对于非实时上报的数据,采用以下处理流程:
- 抽样决策:根据预设抽样率(可配置)决定是否处理该数据
- 重复过滤:为数据生成唯一标识
hashCode
(基于数据内容特征),检测并丢弃短时间内(如 30 分钟)的重复数据- 持久化存储:通过抽样且非重复的数据,存储至 IndexedDB 数据库
- 定时批量上报:
- 启动异步上报队列,通过定时器每 30 秒(可配置)触发上报任务
- 每次从 IndexedDB 按时间顺序取出最多 20 条(可配置)数据进行上报
- 上报成功后删除已发送数据
SDK 架构图
- SDK 采用单例模式设计,通过统一入口简化集成流程,开发者可通过配置参数灵活控制监控行为,并支持自定义数据上报处理能力。
异常监控
JS 运行时异常
- 这类异常通常由代码执行过程中的错误引起,比如类型错误、引用错误、范围错误等。监控这些异常主要依靠全局错误事件监听(window.onerror 或 window.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);
};
资源加载异常
阶段
捕获阶段(第三个参数为
true
):
能同时捕获 JavaScript 执行错误和带有src
属性的标签元素(如图片、脚本)的资源加载错误。冒泡阶段(第三个参数为
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]
实现思路
- 创建检测点:在页面的主要轴线上(水平线和垂直线)以及四个角落创建检测点。这些点将用于检测页面是否白屏。
- 判断元素是否为白屏元素:如果检测到的元素在白屏元素列表中,则认为该点为空白点。
- 判断是否为白屏:如果 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 的一部分,用于精确度量网页资源(如脚本、样式、图片等 )加载各阶段的时间,帮助开发者分析和优化网页加载性能 。
性能关键指标优化说明
指标 | 计算方式 | 说明 |
---|---|---|
TTFB | responseStart - redirectStart | 首字节时间:从页面重定向开始到接收服务器返回的第一个字节的时间差 |
FP | responseEnd - fetchStart | 首次绘制时间&白屏时间:浏览器将第一个像素(通常是背景色)绘制到屏幕的时间,反映白屏阶段时长 |
FCP | 通过 web-vitals 库测量 | 首次内容绘制:浏览器首次渲染出 DOM 内容的时间(如文本、图像等) |
FMP | 通过 web-vitals 库测量 | 首次有意义绘制:主要内容完成渲染的时间点(主观指标,通常对应核心内容可见或可交互时刻) |
LCP | 通过 web-vitals 库测量 | 最大内容渲染:视窗内最大内容元素(如图片/标题块)完成渲染的时间(动态指标,随加载过程更新) |
DCL | domContentLoadedEventEnd - domContentLoadedEventStart | DOM 解析耗时:DOMContentLoaded 事件从开始到结束的持续时间(反映 DOM 树构建速度) |
L | loadEventStart - fetchStart | 页面完全加载时间:从开始加载到 load 事件触发的时间(包含所有资源加载) |
TTI | 通过 web-vitals 库测量 | 首次可交互时间:页面达到完全可交互状态的时间(需满足 FP 完成+主线程空闲+元素可操作) |
FID(被废弃 INP 替代) | 通过 web-vitals 库测量 | 首次输入延迟:用户首次交互操作(点击/输入)到浏览器实际响应的延迟时间 |
TTI 触发条件详解
- 首次绘制完成:页面已完成至少一个像素的渲染(FP 阶段结束)
- 主线程空闲:浏览器主线程连续空闲时间 ≥ 50ms(无长任务阻塞)
- 元素可操作:关键交互元素(按钮/输入框等)可即时响应用户操作
→ 当三项条件同时满足时,TTI 被记录
FID 测量原理
- 用户首次交互:监测页面生命周期中的第一次用户操作(点击/触摸/键盘输入)
- 主线程阻塞检测:若交互发生时主线程正执行长任务(JS 执行/渲染等),记录任务结束到响应开始的延迟
- 阈值判定:实际延迟 = 浏览器开始处理输入事件的时间戳 - 用户交互时间戳
页面性能
- 主要是通过performance、PerformanceObserver、web-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();
});
}
}
资源性能
主要是通过PerformanceObserver、performance来获取性能指标,优先使用 PerformanceObserver,可以实时获取资源加载性能数据,而无需手动轮询
跨域资源
redirectStart
、redirectEnd
、domainLookupStart
、domainLookupEnd
、connectStart
、connectEnd
、secureConnectionStart
、requestStart
、responseStart
、transferSize
的值都为 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,open、send 方法进行重写,获取接口的请求参数、响应参数、耗时、状态码等信息
关键代码实现
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 经过转换过后
- 事件源 target
关键代码实现
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 在打开页面的时候,会触发 history
replaceState
,导致路由改变被监听 - 如果这里不做处理会触发 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 快照,然后通过
rrweb
的record
方法进行录制,将数据发送到后端,通过rrweb
的replay
方法进行回放 - 由于数据量会比较大,所以需要开发人员手动的开始录制,手动结束录制,然后发送到后端
实现思路
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 路由
- 监听事件:使用
hashchange
事件监听路由变化。 - 触发条件:
- Hash 变化时会触发
hashchange
事件。 - 同时会触发
popstate
事件,且popstate
先于hashchange
触发。
- Hash 变化时会触发
- 局限性:无法监听 History API 触发的路由变化。
History 路由
- 核心 API:
- 导航类:
back()
、go()
、forward()
- 修改类:
pushState()
、replaceState()
- 导航类:
- 监听事件:通过
popstate
监听路由变化。- 触发条件:仅响应导航类 API(
back/go/forward
)或浏览器前进/后退按钮操作。 - 不触发条件:
pushState
和replaceState
不会触发popstate
。
- 触发条件:仅响应导航类 API(
- 补充方案:需手动拦截
pushState/replaceState
调用或通过自定义事件模拟监听。
关键差异总结
特性 | Hash 路由 | History 路由 |
---|---|---|
监听事件 | hashchange | popstate |
响应操作 | Hash 变化 | 仅 back/go/forward 和浏览器导航 |
API 触发顺序 | popstate → hashchange | 不适用 |
修改类 API 监听 | 不可监听 History API | 需手动扩展(如重写 pushState ) |
兼容 History API 和 Hash 的路由监听
核心实现思路
- 统一事件监听:使用
popstate
作为基础事件监听器 - 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 在路由变更时会连续触发多个历史记录操作,主要表现:
- 初始化阶段:
replaceState
(设置初始路由状态) - 路由跳转时:
replaceState
(准备阶段)→pushState
(实际跳转) - 页面刷新时:通过
beforeunload
触发replaceState
(保存当前状态)