构建一个前端性能及错误管理平台的简易实现。这个平台将专注于监测常见的性能指标(如 Web Vitals)和捕获前端错误(同步、异步、资源加载错误),并将这些数据上报到一个指定的(示例)地址。
核心思路
- 数据采集 : 利用浏览器提供的标准 API(如
PerformanceObserver
,window.onerror
,window.addEventListener
)来捕获性能指标和错误信息。 - 数据格式化: 将采集到的原始数据处理成统一的结构,方便后续分析。
- 数据上报 : 将格式化后的数据通过 HTTP 请求(优先使用
navigator.sendBeacon
)发送到后端接收接口。 - SDK 设计: 将监控逻辑封装成一个 SDK,方便在项目中引入和初始化。
监测指标
-
性能指标 (Performance Metrics) :
- FP (First Paint) : 首次绘制,标记浏览器首次在屏幕上渲染像素的时间点。
- FCP (First Contentful Paint) : 首次内容绘制,标记浏览器首次渲染来自 DOM 内容的任何部分(文本、图像等)的时间点。
- LCP (Largest Contentful Paint) : 最大内容绘制,标记视口内可见的最大图像或文本块的渲染时间。是核心 Web 指标之一。
- FID (First Input Delay) : 首次输入延迟,测量从用户首次与页面交互(点击、轻触等)到浏览器实际能够开始处理事件处理程序的时间。是核心 Web 指标之一。
- CLS (Cumulative Layout Shift) : 累积布局偏移,测量页面在加载期间发生的意外布局变化的总和。是核心 Web 指标之一。
- TTFB (Time to First Byte) : 首字节时间,衡量浏览器从服务器接收到响应的第一个字节所需的时间。
- Navigation Timing: 页面加载各阶段的详细时间,如 DNS 查询、TCP 连接、请求响应等。
- Resource Timing: 页面加载的各项资源(JS, CSS, 图片等)的加载时间详情。
-
错误类型 (Error Types) :
- JS 运行时错误 (Runtime Errors) : 同步代码执行错误 (
window.onerror
)。 - 未处理的 Promise 拒绝 (Unhandled Promise Rejections) : 异步操作中未被
catch
的错误 (unhandledrejection
事件)。 - 资源加载错误 (Resource Errors) : 图片、脚本、样式表等资源加载失败 (
error
事件捕获)。 - 自定义错误 (Custom Errors) : 通过
try...catch
或主动上报的业务逻辑错误。
- JS 运行时错误 (Runtime Errors) : 同步代码执行错误 (
代码实现 (monitor.js
)
JavaScript
/**
* 前端监控 SDK
* @class MonitorSDK
*/
class MonitorSDK {
constructor() {
this.config = {
reportUrl: null, // 上报地址
appName: 'defaultApp', // 应用名称
appVersion: '1.0.0', // 应用版本
environment: 'production', // 环境:development, test, production
userId: null, // 用户标识
sessionId: this.generateSessionId(), // 会话标识
enablePerformance: true, // 是否开启性能监控
enableError: true, // 是否开启错误监控
enableResourceTiming: false, // 是否收集资源加载详情 (可能产生大量数据)
sampleRate: 1, // 数据采样率 (0 到 1)
maxBatchSize: 10, // 最大批量上报数量
maxWaitTime: 5000, // 最大等待上报时间 (ms)
};
this.queue = []; // 数据上报队列
this.timeoutId = null; // 批量上报定时器
this.initialized = false; // 是否已初始化
}
/**
* 初始化 SDK
* @param {object} options - 配置项
*/
init(options) {
if (this.initialized) {
console.warn('MonitorSDK already initialized.');
return;
}
// 合并配置
this.config = { ...this.config, ...options };
if (!this.config.reportUrl) {
console.error('MonitorSDK Error: reportUrl is required.');
return;
}
console.log('MonitorSDK initializing with config:', this.config);
// 随机采样
if (Math.random() > this.config.sampleRate) {
console.log('MonitorSDK: Data collection skipped due to sample rate.');
return; // 不进行初始化
}
if (this.config.enablePerformance) {
this.initPerformanceMonitoring();
}
if (this.config.enableError) {
this.initErrorMonitoring();
}
// 页面卸载时尝试发送剩余数据
window.addEventListener('unload', () => {
this.flushQueue();
});
this.initialized = true;
console.log('MonitorSDK initialized successfully.');
}
/**
* 生成简单的会话 ID
* @returns {string}
*/
generateSessionId() {
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
}
// =========================================================================
// 性能监控 (Performance Monitoring)
// =========================================================================
initPerformanceMonitoring() {
console.log('Initializing performance monitoring...');
this.monitorWebVitals();
this.monitorNavigationTiming();
if (this.config.enableResourceTiming) {
this.monitorResourceTiming();
}
}
/**
* 监测核心 Web 指标 (Web Vitals) 及其他性能指标
*/
monitorWebVitals() {
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const entryType = entry.entryType;
let metricData = null;
if (entryType === 'paint') {
if (entry.name === 'first-paint') {
metricData = this.createMetric('performance', 'FP', entry.startTime);
} else if (entry.name === 'first-contentful-paint') {
metricData = this.createMetric('performance', 'FCP', entry.startTime);
}
} else if (entryType === 'largest-contentful-paint') {
metricData = this.createMetric('performance', 'LCP', entry.startTime, {
element: entry.element ? entry.element.tagName : 'unknown',
url: entry.url,
size: entry.size,
loadTime: entry.loadTime,
renderTime: entry.renderTime,
});
} else if (entryType === 'first-input') {
metricData = this.createMetric('performance', 'FID', entry.processingStart - entry.startTime, {
duration: entry.duration,
target: entry.target ? entry.target.tagName : 'unknown',
});
} else if (entryType === 'layout-shift') {
// CLS 通常需要累加,这里只上报单次偏移,更准确的 CLS 计算建议使用 web-vitals 库
// 这里仅作示例,上报单次大于 0.1 的偏移
if (!entry.hadRecentInput && entry.value > 0.1) {
metricData = this.createMetric('performance', 'LayoutShift', entry.value, {
startTime: entry.startTime,
sources: entry.sources?.map(s => ({
node: s.node?.tagName,
previousRect: s.previousRect,
currentRect: s.currentRect,
})),
});
}
}
if (metricData) {
this.addToQueue(metricData);
}
}
});
// 注册需要观察的性能条目类型
observer.observe({ entryTypes: ['paint', 'largest-contentful-paint', 'first-input', 'layout-shift'] });
console.log('PerformanceObserver for Web Vitals initialized.');
} catch (error) {
console.error('Failed to initialize PerformanceObserver for Web Vitals:', error);
this.reportCaughtError(error, 'sdk-error', { context: 'monitorWebVitals' });
}
}
/**
* 监测页面导航计时 (Navigation Timing)
*/
monitorNavigationTiming() {
try {
// 使用 PerformanceNavigationTiming (更现代)
const handleEntries = (entries) => {
entries.forEach(entry => {
if (entry.entryType === 'navigation') {
const timing = entry;
const navigationData = {
fetchStart: timing.fetchStart,
domainLookupStart: timing.domainLookupStart,
domainLookupEnd: timing.domainLookupEnd,
connectStart: timing.connectStart,
connectEnd: timing.connectEnd,
requestStart: timing.requestStart,
responseStart: timing.responseStart, // TTFB 近似值
responseEnd: timing.responseEnd,
domInteractive: timing.domInteractive,
domContentLoadedEventStart: timing.domContentLoadedEventStart,
domContentLoadedEventEnd: timing.domContentLoadedEventEnd,
domComplete: timing.domComplete,
loadEventStart: timing.loadEventStart,
loadEventEnd: timing.loadEventEnd,
// 计算的指标
dnsLookupTime: timing.domainLookupEnd - timing.domainLookupStart,
tcpConnectTime: timing.connectEnd - timing.connectStart,
ttfb: timing.responseStart - timing.requestStart, // 更精确的 TTFB
requestTime: timing.responseEnd - timing.requestStart,
domContentLoadedTime: timing.domContentLoadedEventEnd - timing.domContentLoadedEventStart,
loadTime: timing.loadEventEnd - timing.fetchStart, // 页面总加载时间
domReadyTime: timing.domInteractive - timing.fetchStart,
type: timing.type, // 'navigate', 'reload', 'back_forward'
redirectCount: timing.redirectCount,
};
const metricData = this.createMetric('performance', 'NavigationTiming', navigationData.loadTime, navigationData);
this.addToQueue(metricData);
console.log('Navigation Timing captured:', navigationData);
}
});
};
if (typeof PerformanceObserver !== 'undefined' && PerformanceObserver.supportedEntryTypes?.includes('navigation')) {
const observer = new PerformanceObserver((list) => {
handleEntries(list.getEntries());
});
// 延迟执行,确保 navigation entry 已经产生
setTimeout(() => {
// 有些浏览器可能在 load 事件后才完全准备好 navigation entry
const entries = performance.getEntriesByType('navigation');
if (entries.length > 0) {
handleEntries(entries);
} else {
// 如果还未获取到,尝试观察
observer.observe({ type: 'navigation', buffered: true });
console.log('PerformanceObserver for Navigation Timing initialized.');
}
}, 500); // 延迟一点确保 navigation entry 可用
} else if (performance.timing) {
// 兼容旧版浏览器 (Navigation Timing Level 1)
window.addEventListener('load', () => {
setTimeout(() => { // 确保 loadEventEnd 有值
const timing = performance.timing;
const navigationData = {
fetchStart: timing.fetchStart,
domainLookupStart: timing.domainLookupStart,
domainLookupEnd: timing.domainLookupEnd,
connectStart: timing.connectStart,
connectEnd: timing.connectEnd,
requestStart: timing.requestStart,
responseStart: timing.responseStart,
responseEnd: timing.responseEnd,
domInteractive: timing.domInteractive,
domContentLoadedEventStart: timing.domContentLoadedEventStart,
domContentLoadedEventEnd: timing.domContentLoadedEventEnd,
domComplete: timing.domComplete,
loadEventStart: timing.loadEventStart,
loadEventEnd: timing.loadEventEnd,
// 计算的指标
dnsLookupTime: timing.domainLookupEnd - timing.domainLookupStart,
tcpConnectTime: timing.connectEnd - timing.connectStart,
ttfb: timing.responseStart - timing.requestStart,
requestTime: timing.responseEnd - timing.requestStart,
domContentLoadedTime: timing.domContentLoadedEventEnd - timing.domContentLoadedEventStart,
loadTime: timing.loadEventEnd - timing.fetchStart,
domReadyTime: timing.domInteractive - timing.fetchStart,
};
// Navigation Type 在 Level 1 中不易获取
if (navigationData.loadTime >= 0) { // 确保数据有效
const metricData = this.createMetric('performance', 'NavigationTimingCompat', navigationData.loadTime, navigationData);
this.addToQueue(metricData);
console.log('Navigation Timing (Compat) captured:', navigationData);
} else {
console.warn('Performance.timing data seems invalid.');
}
}, 0);
});
} else {
console.warn('Navigation Timing API not supported.');
}
} catch (error) {
console.error('Failed to initialize Navigation Timing monitoring:', error);
this.reportCaughtError(error, 'sdk-error', { context: 'monitorNavigationTiming' });
}
}
/**
* 监测资源加载计时 (Resource Timing)
*/
monitorResourceTiming() {
try {
const handleEntries = (entries) => {
entries.forEach(entry => {
// 过滤掉自身上报请求和非必要资源类型(可选)
if (entry.name.startsWith(this.config.reportUrl) || ['fetch', 'xmlhttprequest'].includes(entry.initiatorType)) {
return;
}
const resourceData = {
name: entry.name, // 资源 URL
entryType: entry.entryType, // 'resource'
initiatorType: entry.initiatorType, // 'script', 'link', 'img', etc.
startTime: entry.startTime,
duration: entry.duration,
fetchStart: entry.fetchStart,
domainLookupStart: entry.domainLookupStart,
domainLookupEnd: entry.domainLookupEnd,
connectStart: entry.connectStart,
connectEnd: entry.connectEnd,
requestStart: entry.requestStart,
responseStart: entry.responseStart,
responseEnd: entry.responseEnd,
transferSize: entry.transferSize, // 传输大小
encodedBodySize: entry.encodedBodySize, // 编码后的 body 大小
decodedBodySize: entry.decodedBodySize, // 解码后的 body 大小
// 计算指标
dnsLookupTime: entry.domainLookupEnd - entry.domainLookupStart,
tcpConnectTime: entry.connectEnd - entry.connectStart,
ttfb: entry.responseStart - entry.requestStart,
contentDownloadTime: entry.responseEnd - entry.responseStart,
};
const metricData = this.createMetric('performance', 'ResourceTiming', resourceData.duration, resourceData);
this.addToQueue(metricData);
});
};
if (typeof PerformanceObserver !== 'undefined' && PerformanceObserver.supportedEntryTypes?.includes('resource')) {
const observer = new PerformanceObserver((list) => {
handleEntries(list.getEntries());
});
// buffered: true 获取页面加载时已经产生的资源条目
observer.observe({ type: 'resource', buffered: true });
console.log('PerformanceObserver for Resource Timing initialized.');
} else {
// 对于不支持 PO 的情况,可以在 load 事件后获取所有资源
window.addEventListener('load', () => {
const entries = performance.getEntriesByType('resource');
handleEntries(entries);
console.log('Resource Timing (Compat) captured after load.');
});
console.warn('PerformanceObserver not fully supported for Resource Timing, using fallback.');
}
} catch (error) {
console.error('Failed to initialize Resource Timing monitoring:', error);
this.reportCaughtError(error, 'sdk-error', { context: 'monitorResourceTiming' });
}
}
// =========================================================================
// 错误监控 (Error Monitoring)
// =========================================================================
initErrorMonitoring() {
console.log('Initializing error monitoring...');
this.monitorJsErrors();
this.monitorPromiseRejections();
this.monitorResourceErrors();
// 可以考虑增加对 console.error 的劫持等
}
/**
* 监测 JavaScript 运行时错误
*/
monitorJsErrors() {
const originalOnError = window.onerror;
window.onerror = (message, source, lineno, colno, error) => {
console.log('onerror captured:', message, source, lineno, colno, error);
try {
const errorData = this.createError(
'js-error',
message,
{
filename: source,
lineno: lineno,
colno: colno,
stack: error ? error.stack : null, // 尝试获取错误堆栈
}
);
this.addToQueue(errorData);
} catch (e) {
console.error('Error in window.onerror handler:', e);
}
// 如果原始的 onerror 存在,则调用它
if (typeof originalOnError === 'function') {
return originalOnError.call(window, message, source, lineno, colno, error);
}
// 返回 false 以允许默认的浏览器错误处理继续执行
// 返回 true 会阻止默认处理(例如,控制台不再显示错误)
return false;
};
console.log('window.onerror handler attached.');
}
/**
* 监测未处理的 Promise 拒绝
*/
monitorPromiseRejections() {
window.addEventListener('unhandledrejection', (event) => {
console.log('unhandledrejection captured:', event);
try {
let message = 'Unhandled Promise Rejection';
let stack = null;
let details = {};
if (event.reason) {
if (event.reason instanceof Error) {
message = event.reason.message;
stack = event.reason.stack;
} else if (typeof event.reason === 'string') {
message = event.reason;
} else {
try {
message = JSON.stringify(event.reason);
} catch (e) {
message = 'Unhandled Promise Rejection with non-serializable reason';
}
}
details.reason = String(event.reason); // 记录原始 reason
}
const errorData = this.createError(
'promise-rejection',
message,
{
stack: stack,
details: details,
}
);
this.addToQueue(errorData);
} catch (e) {
console.error('Error in unhandledrejection handler:', e);
}
// event.preventDefault(); // 可以阻止错误在控制台显示
});
console.log('unhandledrejection listener attached.');
}
/**
* 监测资源加载错误 (img, script, css, etc.)
* 注意:这个方法捕获的是网络层面的资源加载失败,而不是 JS 错误
*/
monitorResourceErrors() {
// 使用捕获阶段,因为很多资源错误在冒泡阶段可能不会到达 window
window.addEventListener('error', (event) => {
// 过滤掉 JS 运行时错误 (onerror 会处理)
if (event instanceof ErrorEvent) {
// console.log('Skipping ErrorEvent in resource error listener.');
return;
}
const target = event.target || event.srcElement;
// 确保是元素节点并且是资源类型 (IMG, SCRIPT, LINK)
if (target instanceof HTMLElement && ['IMG', 'SCRIPT', 'LINK'].includes(target.tagName)) {
console.log('Resource error captured:', event);
try {
const errorData = this.createError(
'resource-error',
`Failed to load resource: ${target.src || target.href}`,
{
tagName: target.tagName,
source: target.src || target.href, // 获取资源 URL
outerHTML: target.outerHTML.substring(0, 200), // 记录元素的部分 HTML
}
);
this.addToQueue(errorData);
} catch (e) {
console.error('Error in resource error handler:', e);
}
} else {
// 可能还有其他类型的 error 事件,比如来自 Web Worker 或其他地方
// console.log('Non-resource error event captured:', event);
}
}, true); // 使用捕获阶段!
console.log('Resource error listener attached (capturing phase).');
}
/**
* 手动上报捕获到的错误 (例如在 try...catch 中)
* @param {Error} error - 错误对象
* @param {string} type - 自定义错误类型 (e.g., 'api-error', 'business-logic-error')
* @param {object} [extraData={}] - 附加信息
*/
reportCaughtError(error, type = 'caught-error', extraData = {}) {
if (!this.initialized) return; // 未初始化则不上报
console.log(`Manually reporting caught error (${type}):`, error, extraData);
try {
const errorData = this.createError(
type,
error.message,
{
stack: error.stack,
...extraData, // 合并附加信息
}
);
this.addToQueue(errorData);
} catch (e) {
console.error('Error reporting caught error:', e);
}
}
// =========================================================================
// 数据格式化与上报 (Data Formatting & Reporting)
// =========================================================================
/**
* 创建标准化的指标数据结构
* @param {string} category - 'performance' or 'error' or 'custom'
* @param {string} type - 具体指标/错误类型 (e.g., 'FCP', 'js-error')
* @param {*} value - 指标值或错误消息
* @param {object} [payload={}] - 附加数据
* @returns {object}
*/
createMetric(category, type, value, payload = {}) {
return {
timestamp: Date.now(),
category: category,
type: type,
value: value,
payload: payload,
// 通用上下文信息
context: this.getCommonContext(),
};
}
/**
* 创建标准化的错误数据结构
* @param {string} type - 错误类型 (e.g., 'js-error', 'resource-error')
* @param {string} message - 错误消息
* @param {object} [payload={}] - 附加数据 (stack, filename, lineno, etc.)
* @returns {object}
*/
createError(type, message, payload = {}) {
return {
timestamp: Date.now(),
category: 'error',
type: type,
message: message,
payload: payload,
// 通用上下文信息
context: this.getCommonContext(),
};
}
/**
* 获取通用的上下文信息
* @returns {object}
*/
getCommonContext() {
return {
appName: this.config.appName,
appVersion: this.config.appVersion,
environment: this.config.environment,
userId: this.config.userId, // 可能需要动态获取
sessionId: this.config.sessionId,
url: window.location.href,
userAgent: navigator.userAgent,
language: navigator.language,
// 可以添加屏幕分辨率、网络状态等
screenResolution: `${window.screen.width}x${window.screen.height}`,
connection: navigator.connection ? {
effectiveType: navigator.connection.effectiveType, // '4g', '3g', etc.
rtt: navigator.connection.rtt, // 往返时间
downlink: navigator.connection.downlink, // 下行速度 Mbps
} : null,
};
}
/**
* 将数据添加到上报队列,并触发延迟上报
* @param {object} data - 格式化后的监控数据
*/
addToQueue(data) {
if (!this.initialized) return; // 未初始化则不添加
this.queue.push(data);
console.log('Data added to queue:', data);
console.log('Current queue size:', this.queue.length);
// 如果队列达到最大批次大小,立即发送
if (this.queue.length >= this.config.maxBatchSize) {
console.log('Queue reached max size, flushing immediately.');
this.flushQueue();
} else if (!this.timeoutId) {
// 如果定时器未启动,则启动一个延迟发送的定时器
console.log(`Starting batch timer (${this.config.maxWaitTime}ms)...`);
this.timeoutId = setTimeout(() => {
console.log('Batch timer expired, flushing queue.');
this.flushQueue();
}, this.config.maxWaitTime);
}
}
/**
* 发送队列中的所有数据
*/
flushQueue() {
// 清除可能存在的定时器
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
console.log('Batch timer cleared.');
}
if (this.queue.length === 0) {
// console.log('Queue is empty, nothing to flush.');
return;
}
const dataToSend = [...this.queue];
this.queue = []; // 清空队列
console.log(`Flushing ${dataToSend.length} items to ${this.config.reportUrl}`);
// 优先使用 sendBeacon,它在页面卸载时更可靠,且是异步非阻塞的
if (navigator.sendBeacon) {
try {
const blob = new Blob([JSON.stringify(dataToSend)], { type: 'application/json; charset=UTF-8' });
const success = navigator.sendBeacon(this.config.reportUrl, blob);
if (success) {
console.log('Data successfully sent via sendBeacon.');
} else {
console.error('sendBeacon returned false. Data might not have been sent.');
// 可以考虑回退到 fetch 或 XMLHttpRequest (但在 unload 中可能不可靠)
// this.fallbackReport(dataToSend);
}
} catch (error) {
console.error('Error sending data via sendBeacon:', error);
// this.fallbackReport(dataToSend);
this.reportCaughtError(error, 'sdk-error', { context: 'sendBeacon' });
}
} else {
// 回退到 fetch 或 XMLHttpRequest
this.fallbackReport(dataToSend);
}
}
/**
* 使用 fetch 或 XMLHttpRequest 作为备用上报方式
* @param {Array} data - 要发送的数据数组
*/
fallbackReport(data) {
console.log('Using fallback reporting method (fetch)...');
try {
fetch(this.config.reportUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=UTF-8',
},
body: JSON.stringify(data),
keepalive: true, // 尝试在页面卸载后保持请求 (并非所有浏览器都完美支持)
})
.then(response => {
if (!response.ok) {
console.error(`Fallback report failed with status: ${response.status}`);
} else {
console.log('Data successfully sent via fetch (fallback).');
}
})
.catch(error => {
console.error('Error sending data via fetch (fallback):', error);
this.reportCaughtError(error, 'sdk-error', { context: 'fetchFallback' });
});
} catch (error) {
console.error('Error initiating fetch (fallback):', error);
this.reportCaughtError(error, 'sdk-error', { context: 'fetchFallbackInitiation' });
}
}
}
// 导出单例或类,取决于使用方式
// export default new MonitorSDK(); // 单例模式
// export { MonitorSDK }; // 导出类,允许创建多个实例 (不太常见)
// 为了方便在 HTML 中直接使用,挂载到 window (非模块化环境)
window.MonitorSDKInstance = new MonitorSDK();
代码讲解
-
MonitorSDK
类:-
constructor
: 初始化config
(默认配置)、queue
(上报队列)、timeoutId
(批量上报定时器) 和initialized
状态。sessionId
在实例化时生成。 -
init(options)
:- 防止重复初始化。
- 合并用户传入的
options
和默认config
。 - 检查
reportUrl
是否提供。 - 根据
sampleRate
(采样率) 决定是否继续初始化,用于控制数据量。 - 根据配置调用
initPerformanceMonitoring
和initErrorMonitoring
。 - 监听
unload
事件,在页面关闭前尝试发送队列中剩余的数据。 - 标记
initialized
为true
。
-
generateSessionId()
: 生成一个简单的唯一会话标识。
-
-
性能监控 (
initPerformanceMonitoring
,monitorWebVitals
,monitorNavigationTiming
,monitorResourceTiming
) :-
initPerformanceMonitoring
: 根据配置启动各项性能监控。 -
monitorWebVitals
:- 使用
PerformanceObserver
API,这是现代浏览器推荐的方式,可以异步、高效地获取性能条目。 - 观察
paint
(FP, FCP),largest-contentful-paint
(LCP),first-input
(FID),layout-shift
(CLS 的组成部分)。 - 对获取到的
entry
进行判断和处理,提取关键信息(如 LCP 的元素、大小,FID 的延迟时间、目标元素等)。 - 调用
createMetric
格式化数据,然后addToQueue
。 - 包含
try...catch
以处理 API 不可用或执行出错的情况。 - 注意 : CLS 的准确计算通常需要累加整个页面的生命周期内的布局偏移,这里仅作单次偏移的上报示例。推荐使用 Google 的
web-vitals
库来获取更准确的 CLS 和其他核心 Web 指标。
- 使用
-
monitorNavigationTiming
:- 优先使用
PerformanceObserver
监听navigation
类型的条目 (Navigation Timing Level 2 API),它提供了更详细和准确的数据。 - 如果
PerformanceObserver
或navigation
类型不支持,则回退到监听window.onload
事件,并在事件触发后访问performance.timing
(Navigation Timing Level 1 API)。Level 1 的数据不如 Level 2 丰富(例如缺少type
和redirectCount
)。 - 从
timing
对象中提取各个阶段的时间戳,并计算出有意义的指标(DNS 查询耗时、TCP 连接耗时、TTFB、页面总加载时间等)。 - 格式化数据并加入队列。
- 使用
setTimeout
确保在load
事件后performance.timing
的loadEventEnd
等属性有值。
- 优先使用
-
monitorResourceTiming
:- 同样优先使用
PerformanceObserver
监听resource
类型的条目。设置buffered: true
可以获取在观察者创建之前就已经加载完成的资源条目。 - 如果
PerformanceObserver
不支持,则回退到在window.onload
后使用performance.getEntriesByType('resource')
获取所有已加载资源的信息。 - 对每个资源条目 (
entry
),提取 URL (name
)、发起者类型 (initiatorType
)、耗时 (duration
)、大小 (transferSize
等) 以及各个阶段的时间。 - 可以根据需要过滤掉某些资源(如监控 SDK 自身的上报请求)。
- 格式化数据并加入队列。注意:开启此项可能会产生大量数据。
- 同样优先使用
-
-
错误监控 (
initErrorMonitoring
,monitorJsErrors
,monitorPromiseRejections
,monitorResourceErrors
,reportCaughtError
) :-
initErrorMonitoring
: 启动各项错误监控。 -
monitorJsErrors
:- 覆盖
window.onerror
处理函数。这是捕获全局未被try...catch
的同步 JavaScript 运行时错误的主要方式。 - 回调函数接收
message
,source
(文件名),lineno
(行号),colno
(列号),error
(错误对象)。 - 尝试从
error
对象获取stack
(错误堆栈信息),这对于调试非常重要。 - 调用
createError
格式化错误数据,然后addToQueue
。 - 保留了调用原始
onerror
的逻辑(如果存在)。返回false
让浏览器继续默认的错误处理(在控制台打印错误)。
- 覆盖
-
monitorPromiseRejections
:- 监听
unhandledrejection
事件。这用于捕获 Promise 中发生但没有被.catch()
处理的拒绝(异步错误)。 - 事件对象
event
的reason
属性包含拒绝的原因,可能是 Error 对象、字符串或其他类型。 - 提取
message
和stack
(如果reason
是 Error 对象)。 - 格式化错误数据并加入队列。
- 监听
-
monitorResourceErrors
:- 监听
error
事件,关键在于第三个参数设置为true
,表示在捕获阶段 监听。资源加载错误(如<img>
,<script>
,<link>
加载失败)通常不会冒泡到window
,必须在捕获阶段捕获。 - 通过检查
event.target
(或event.srcElement
) 来判断是否是元素以及是否是目标资源标签(IMG, SCRIPT, LINK)。 - 过滤掉
ErrorEvent
实例,因为 JS 运行时错误会由onerror
处理,避免重复上报。 - 提取资源
src
或href
作为错误信息的一部分。 - 格式化错误数据并加入队列。
- 监听
-
reportCaughtError
:- 提供一个公共方法,允许业务代码在
try...catch
块中捕获到错误后,手动调用此方法进行上报。 - 接收
error
对象、自定义type
和extraData
(附加信息)。
- 提供一个公共方法,允许业务代码在
-
-
数据格式化与上报 (
createMetric
,createError
,getCommonContext
,addToQueue
,flushQueue
,fallbackReport
) :-
createMetric
,createError
: 定义了两种主要的数据结构(指标和错误),包含时间戳、类别、类型、值/消息、附加数据 (payload
) 以及通用上下文 (context
)。 -
getCommonContext
: 提取通用的上下文信息,如应用名称/版本、环境、用户 ID、会话 ID、当前页面 URL、User Agent、语言、屏幕分辨率、网络状态等。这些信息对于后续分析非常有用。 -
addToQueue
:-
将格式化后的数据推入
queue
数组。 -
实现批量上报逻辑:
- 如果队列大小达到
maxBatchSize
,立即调用flushQueue
发送。 - 否则,如果当前没有等待发送的定时器 (
timeoutId
为 null),则启动一个setTimeout
,在maxWaitTime
后调用flushQueue
。这可以减少短时间内频繁的网络请求。
- 如果队列大小达到
-
-
flushQueue
:-
实际执行数据发送的函数。
-
先清除可能存在的
setTimeout
定时器。 -
如果队列为空,则不执行任何操作。
-
取出队列中的所有数据 (
dataToSend
),并清空queue
。 -
优先使用
navigator.sendBeacon(url, data)
:sendBeacon
是专门设计用于发送少量统计数据的 API。- 它是异步的,不会阻塞主线程。
- 它能在页面卸载(unload/beforeunload)过程中更可靠地完成发送,即使页面即将关闭。
- 数据需要是
Blob
,FormData
或ArrayBufferView
等类型,这里将 JSON 数组字符串化后创建Blob
。 - 检查返回值,
true
表示浏览器已成功将请求加入发送队列,false
表示失败(可能是数据过大或浏览器限制)。
-
回退机制 (
fallbackReport
) :- 如果
sendBeacon
不可用或返回false
,则使用fetch
(或XMLHttpRequest
) 作为备选。 - 使用
POST
方法,设置Content-Type
为application/json
。 keepalive: true
: 这是一个重要的fetch
选项,它指示浏览器在页面卸载后尝试保持该请求的连接,提高了在页面关闭时发送成功的概率,但并非所有浏览器都完美支持,且可靠性不如sendBeacon
。- 包含错误处理逻辑。
- 如果
-
-
如何在项目中使用
-
引入 SDK:
- 如果使用模块化(ESM/CommonJS),可以
import MonitorSDK from './monitor.js';
或const { MonitorSDK } = require('./monitor.js');
。 - 如果直接在 HTML 中使用
<script>
标签引入monitor.js
,它会将实例挂载到window.MonitorSDKInstance
。
- 如果使用模块化(ESM/CommonJS),可以
-
初始化 : 在你的应用入口文件(如
main.js
,app.js
或 HTML 的<script>
块)的早期阶段进行初始化。JavaScript// 示例:在 Vue 或 React 应用的入口文件 // import MonitorSDKInstance from './monitor'; // 或者 const MonitorSDKInstance = window.MonitorSDKInstance; MonitorSDKInstance.init({ reportUrl: 'https://your-backend-endpoint.com/report', // 替换为你的后端接收地址 appName: 'myWebApp', appVersion: '1.2.3', // 可以从 package.json 或构建变量获取 environment: process.env.NODE_ENV || 'production', // 根据环境设置 userId: getUserIdentifier(), // 获取用户 ID 的函数(如果需要) enablePerformance: true, enableError: true, enableResourceTiming: false, // 按需开启 sampleRate: 1, // 1 表示 100% 采样 // 其他配置... }); // 获取用户 ID 的示例函数 (需要根据你的认证系统实现) function getUserIdentifier() { // 尝试从 localStorage, cookie 或全局状态获取 return localStorage.getItem('userId') || 'anonymous'; } // 如果需要在 try...catch 中手动上报错误 try { // some potentially problematic code // ... if (someConditionFails) { throw new Error("Business logic validation failed for condition X."); } } catch (error) { console.error("Caught an error:", error); // 手动上报,可以附加更多业务上下文信息 MonitorSDKInstance.reportCaughtError(error, 'business-logic-error', { context: 'processing user data', userId: getUserIdentifier(), // 再次确认用户 ID relevantData: { /* 一些相关数据 */ } }); // 可能还需要进行错误恢复处理 }
后续增强方向
-
Source Map 支持: 对于压缩混淆后的代码,错误堆栈信息可读性很差。需要后端配合,根据上报的行/列号和 Source Map 文件,将堆栈信息还原成源码位置。这通常需要在上报错误时携带应用版本号,后端根据版本号查找对应的 Source Map 文件。
-
更精确的 Web Vitals : 使用 Google 的
web-vitals
库 (npm install web-vitals
) 可以更简单、准确地获取 LCP, FID, CLS 等核心指标,它处理了许多边界情况。kotlinimport { getLCP, getFID, getCLS } from 'web-vitals'; // 在 monitorWebVitals 中替换或补充 getCLS(metric => this.addToQueue(this.createMetric('performance', 'CLS', metric.value, { delta: metric.delta, entries: metric.entries }))); getFID(metric => this.addToQueue(this.createMetric('performance', 'FID', metric.value, { delta: metric.delta, entries: metric.entries }))); getLCP(metric => this.addToQueue(this.createMetric('performance', 'LCP', metric.value, { element: metric.entries[0]?.element?.tagName, size: metric.size, loadTime: metric.loadTime, renderTime: metric.renderTime })));
-
用户行为追踪: 监听点击事件、路由变化(对于 SPA)、页面可见性变化等,将这些行为信息与错误或性能问题关联起来,有助于复现问题。
-
API 请求监控 : 劫持
fetch
和XMLHttpRequest
来监控 API 请求的成功/失败、耗时、请求/响应大小等。 -
会话追踪 (Session Replay) : 记录用户的操作序列(点击、滚动、输入等)和页面变化,用于回放用户会话,可视化地复现 Bug(实现复杂,通常使用成熟的第三方库)。
-
更完善的采样策略: 除了初始化时的随机采样,还可以针对特定事件类型(如严重错误全量上报,性能指标采样上报)或用户群体进行采样。
-
SDK 异常捕获: SDK 自身也可能出错,需要有机制捕获 SDK 内部的异常,避免影响业务代码,并考虑是否上报这些元错误。
-
配置热更新: 允许通过后端下发配置,动态调整采样率、开关某些监控项等。
-
离线存储 : 当网络不可用时,将数据暂存到
localStorage
或IndexedDB
,待网络恢复后再次尝试上报。
这个实现提供了一个相对完整的前端监控基础框架。你可以根据项目的具体需求,选择性地实现或增强其中的功能。记住,监控本身也可能带来微小的性能开销,需要权衡监控的全面性和对用户体验的影响。