各位前端领域的同仁们,大家好!
今天,我们将共同探讨一个在日常开发与维护中常常令人头疼,但又至关重要的议题:前端日志的追踪与管理。你是否曾经历过用户反馈了一个难以复现的Bug,却苦于没有足够的现场信息而无从下手?你是否曾面对线上应用突发的性能问题,却不知道是哪段代码或哪个用户操作导致了瓶颈?当后端日志无法触及用户浏览器这一“最后一公里”的真实情况时,我们该如何破局?
答案便是:构建一个强大的前端日志收集系统。
在本场讲座中,我将作为一名编程专家,带领大家深入理解前端日志收集的必要性、核心概念,并通过JavaScript亲手实现一个功能完善、健壮可靠的前端日志收集系统。我们将从零开始,逐步构建日志的核心模块、错误捕获机制、用户行为追踪、性能数据采集,并探讨数据传输、存储、隐私与安全等高级话题。
准备好了吗?让我们一起开启这段技术探索之旅。
一、为什么前端日志如此重要?前端监控的“最后一公里”
在现代Web应用,特别是单页应用(SPA)和复杂交互式界面的时代,前端不再仅仅是展示数据的“瘦客户端”,它承载了大量的业务逻辑、用户交互和状态管理。这意味着,许多问题——从细微的UI偏差到导致应用崩溃的致命错误——都可能发生在用户的浏览器环境中。
传统的后端日志系统固然重要,它们能记录服务器端的请求、处理逻辑、数据库操作等。但它们无法回答以下这些关键问题:
- 用户到底做了什么? 是点击了某个按钮,填写了某个表单,还是进行了特定的导航路径,才导致了问题?
- 前端代码执行时发生了什么错误? 是JavaScript运行时错误、Promise拒绝、资源加载失败,还是某个组件渲染异常?
- 用户的设备和网络环境如何? 是在低速网络下加载缓慢,还是在特定浏览器版本下出现了兼容性问题?屏幕尺寸、操作系统版本等信息对调试至关重要。
- 前端性能表现如何? 页面首次内容绘制时间(FCP)、最大内容绘制时间(LCP)、首次输入延迟(FID)等核心指标是否达标?
缺乏前端日志,我们就像在黑暗中摸索,只能根据用户模糊的描述和后端有限的信息进行猜测,效率低下且往往无法彻底解决问题。一个完善的前端日志收集系统,就是我们洞察用户真实体验、定位问题根源、优化应用性能的“眼睛”和“耳朵”。
它将帮助我们将被动响应转变为主动监控,从“用户反馈了,我再看”变为“问题发生了,我已知道并正在解决”。
二、前端日志系统的核心概念与设计原则
在着手实现之前,我们首先需要明确前端日志系统的核心构成要素和设计原则。
2.1 日志的类型与内容
一个全面的前端日志系统应该能够收集以下几类信息:
-
错误日志 (Error Logs):
- JavaScript运行时错误(
Uncaught TypeError,ReferenceError等)。 - 未处理的Promise拒绝(
Unhandled Promise Rejection)。 - 资源加载失败(图片、CSS、JavaScript文件加载失败)。
- API请求失败(HTTP状态码非2xx)。
- 自定义业务错误(例如,表单校验失败、业务逻辑异常)。
- 错误堆栈、错误信息、发生错误的URL、行号列号等是必备信息。
- JavaScript运行时错误(
-
行为日志 (Behavior Logs):
- 页面访问与路由切换(用户访问了哪些页面,切换路径)。
- 用户点击事件(点击了哪个按钮、链接,触发了什么操作)。
- 表单输入与提交(对敏感信息需脱敏处理)。
- 关键业务操作(例如,商品加入购物车、支付成功、文件上传等)。
- 记录事件类型、目标元素、相关数据等。
-
性能日志 (Performance Logs):
- 页面加载性能(FCP, LCP, FID, CLS等核心Web Vitals指标)。
- 自定义性能指标(例如,某个组件的渲染时间、API请求耗时)。
- 资源加载瀑布图(虽然全量收集不现实,但可记录关键资源耗时)。
- 记录指标名称、值、发生时间等。
-
调试与信息日志 (Debug & Info Logs):
- 应用初始化时的关键信息(版本号、环境信息)。
- 重要模块的生命周期事件。
- 复杂业务逻辑的执行路径。
- 通常在开发或测试环境使用,生产环境可关闭或降低级别。
-
环境信息 (Environment Context):
- 用户标识: 用户ID、会话ID、匿名ID。
- 设备信息: 浏览器类型、版本、操作系统、屏幕分辨率。
- 网络信息: 连接类型(WiFi, 4G)、网络状态。
- 应用信息: 应用版本、当前页面URL。
- 这些上下文信息对于理解问题发生的背景至关重要。
2.2 日志级别 (Log Levels)
为了区分日志的重要性,我们通常会定义不同的日志级别。常见的级别包括:
| 级别 | 描述 | 用途 |
|---|---|---|
DEBUG |
最详细的日志信息,通常只在开发环境使用。 | 调试复杂逻辑,追踪变量状态。 |
INFO |
提供应用运行时的重要事件信息。 | 记录用户操作、应用启动、重要业务流程。 |
WARN |
潜在的问题,不影响应用正常运行,但值得关注。 | 资源加载失败(非关键)、弃用警告、非预期但可恢复的情况。 |
ERROR |
运行时错误,可能导致功能异常,但应用未崩溃。 | JavaScript运行时错误、API请求失败、自定义业务错误。 |
CRITICAL |
致命错误,导致应用无法继续运行或严重数据损坏。 | 极少使用,通常由更高级别的监控系统触发,前端层面多由ERROR覆盖。 |
通过配置不同的日志级别,我们可以在生产环境中只收集WARN、ERROR级别以上的日志,而在开发环境中打开DEBUG、INFO级别,以减少日志量并提高性能。
2.3 日志数据结构
每条日志都应该是一个结构化的数据对象,包含以下基本字段:
{
"timestamp": "2023-10-27T10:30:00.123Z", // UTC时间戳
"level": "ERROR", // 日志级别
"message": "Uncaught TypeError: Cannot read property 'foo' of undefined", // 日志信息
"userId": "user-123", // 用户ID
"sessionId": "sess-abc", // 会话ID
"appVersion": "1.0.0", // 应用版本
"browser": { // 浏览器信息
"name": "Chrome",
"version": "118.0.0.0",
"userAgent": "Mozilla/5.0..."
},
"os": { // 操作系统信息
"name": "Windows",
"version": "10"
},
"url": "https://example.com/some/path", // 当前页面URL
"referrer": "https://example.com/prev", // 来源URL
"stack": "TypeError: ...n at ...", // 错误堆栈
"context": { // 额外上下文信息
"componentName": "UserProfile",
"dataId": "456",
"apiEndpoint": "/api/user/profile"
}
}
结构化日志的优点在于方便后端存储、查询和分析。
2.4 日志的传输与存储策略
前端日志的传输与存储需要考虑以下几点:
- 异步传输: 日志收集是一个IO密集型操作,应尽量避免阻塞主线程。
- 批量发送: 单条发送日志会产生大量的网络请求,增加服务器和客户端的负担。应将多条日志聚合成批次发送。
- 持久化: 在用户刷新页面、网络中断等情况下,未发送的日志不应丢失。
- 传输协议: 通常使用HTTP POST请求发送JSON数据。在页面卸载时,
navigator.sendBeacon是一个更可靠的选择。 - 后端接收: 后端需要提供一个API接口来接收前端发送的日志,并将其存储到日志管理系统(如ELK Stack、Splunk、Datadog等)或数据库中。
2.5 隐私与安全
在收集用户数据时,隐私和安全是不可忽视的红线:
- 数据脱敏/匿名化: 严格禁止收集用户的敏感个人信息(PII),如身份证号、银行卡号、密码、真实姓名、手机号等。如果必须收集,则需进行严格的脱敏处理。
- 最小化原则: 只收集解决问题所必需的信息,避免过度收集。
- HTTPS: 确保日志传输全程使用HTTPS,防止数据在传输过程中被窃取或篡改。
- 用户同意: 在某些地区(如欧盟的GDPR),可能需要明确征得用户的同意才能收集日志。
三、构建前端日志收集系统:核心模块实现
现在,我们开始进入激动人心的代码实现环节。我们将分步构建系统的各个核心模块。
3.1 基础日志器 (Base Logger)
我们首先创建一个Logger类,它将提供不同日志级别的方法,并负责将日志条目标准化、存储到缓冲区。
// logger.js
/**
* @class LoggerConfig
* @property {string} level - 默认日志级别,低于此级别的日志将不被处理。
* @property {string} appVersion - 应用版本号。
* @property {boolean} enableConsole - 是否在控制台打印日志。
*/
class Logger {
/**
* @param {object} config - 配置对象。
* @param {LogSender} logSender - 日志发送器实例。
*/
constructor(config = {}, logSender) {
this.config = {
level: 'INFO', // 默认日志级别
appVersion: 'unknown',
enableConsole: true, // 默认在控制台打印
// 其他配置...
...config
};
if (!logSender) {
console.warn('Logger initialized without a LogSender. Logs will only be consoled.');
}
this.logSender = logSender; // 注入日志发送器
this.levels = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
CRITICAL: 4
};
// 绑定所有日志方法到当前实例,确保this指向正确
for (const levelName in this.levels) {
this[levelName.toLowerCase()] = this._log.bind(this, levelName);
}
}
/**
* 核心日志记录方法
* @param {string} level - 日志级别 (DEBUG, INFO, WARN, ERROR, CRITICAL)
* @param {string} message - 日志信息
* @param {object} [context={}] - 额外上下文数据
*/
_log(level, message, context = {}) {
// 判断当前日志级别是否低于配置的最低级别,如果是则不处理
if (this.levels[level] < this.levels[this.config.level]) {
return;
}
// 构建标准化日志条目
const logEntry = {
timestamp: new Date().toISOString(),
level: level,
message: message,
appVersion: this.config.appVersion,
context: context,
// 稍后会由 LogSender 补充更多环境信息
};
// 如果启用控制台打印,则打印到控制台
if (this.config.enableConsole) {
this._consoleLog(level, message, context);
}
// 将日志添加到发送器的缓冲区
if (this.logSender) {
this.logSender.add(logEntry);
}
}
/**
* 在控制台打印日志
* @param {string} level - 日志级别
* @param {string} message - 日志信息
* @param {object} context - 额外上下文数据
*/
_consoleLog(level, message, context) {
const consoleMethod = level.toLowerCase();
if (typeof console[consoleMethod] === 'function') {
console[consoleMethod](`[${level}] ${message}`, context);
} else {
// 对于CRITICAL等没有直接对应console方法的,使用error或log
console.log(`[${level}] ${message}`, context);
}
}
}
// 导出 Logger 类
export { Logger };
代码解析:
Logger类构造函数接收一个配置对象和一个logSender实例。logSender将负责日志的实际传输,这里我们通过依赖注入的方式传入。levels对象定义了日志级别的优先级,用于过滤低于设定阈值的日志。_log是核心方法,它接收日志级别、消息和上下文信息,构建一个标准化的日志对象。_consoleLog负责将日志打印到浏览器控制台,方便开发调试。- 我们通过循环动态创建了
debug,info,warn,error,critical等方法,并使用bind确保this指向Logger实例。
3.2 用户与环境上下文 (User and Environment Context)
为了让每条日志都携带足够的信息,我们需要一个模块来收集当前用户和运行环境的数据。
// userContext.js
/**
* @class UserContext
* 负责收集用户的唯一标识、会话信息以及当前运行环境(浏览器、操作系统、URL等)。
*/
class UserContext {
constructor() {
this._userId = this._getUserId();
this._sessionId = this._getSessionId();
this._userAgent = navigator.userAgent;
this._browserInfo = this._parseUserAgent(this._userAgent);
this._osInfo = this._parseOS(this._userAgent);
this._viewport = `${window.innerWidth}x${window.innerHeight}`;
this._currentUrl = window.location.href;
this._referrer = document.referrer;
this._networkType = this._getNetworkType();
}
/**
* 从 localStorage 或 cookie 获取用户ID,如果不存在则生成一个匿名ID。
* @returns {string} 用户ID。
* @private
*/
_getUserId() {
let userId = localStorage.getItem('app_user_id');
if (!userId) {
userId = `anonymous_${Math.random().toString(36).substring(2, 12)}`;
localStorage.setItem('app_user_id', userId);
}
return userId;
}
/**
* 从 sessionStorage 获取会话ID,如果不存在则生成一个新的会话ID。
* @returns {string} 会话ID。
* @private
*/
_getSessionId() {
let sessionId = sessionStorage.getItem('app_session_id');
if (!sessionId) {
sessionId = `${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
sessionStorage.setItem('app_session_id', sessionId);
}
return sessionId;
}
/**
* 解析 User-Agent 字符串以获取浏览器信息。
* 这是一个简化的实现,实际应用中可能需要更健壮的库(如 ua-parser-js)。
* @param {string} userAgent - navigator.userAgent 字符串。
* @returns {object} 包含 name 和 version 的浏览器信息。
* @private
*/
_parseUserAgent(userAgent) {
const browser = {};
let match;
if ((match = userAgent.match(/(firefox)/([d.]+)/i))) {
browser.name = match[1];
browser.version = match[2];
} else if ((match = userAgent.match(/(chrome|crios)/([d.]+)/i))) {
browser.name = 'Chrome';
browser.version = match[2];
} else if ((match = userAgent.match(/(safari)/([d.]+)/i))) {
browser.name = 'Safari';
browser.version = match[2];
} else if ((match = userAgent.match(/(edge)/([d.]+)/i))) {
browser.name = 'Edge';
browser.version = match[2];
} else if ((match = userAgent.match(/(msie |rv:)(d+.d+)/i))) {
browser.name = 'IE';
browser.version = match[2];
} else {
browser.name = 'Unknown';
browser.version = 'Unknown';
}
return browser;
}
/**
* 解析 User-Agent 字符串以获取操作系统信息。
* 这是一个简化的实现。
* @param {string} userAgent - navigator.userAgent 字符串。
* @returns {object} 包含 name 和 version 的操作系统信息。
* @private
*/
_parseOS(userAgent) {
const os = {};
if (/windows nt (d+.d+)/i.test(userAgent)) {
os.name = 'Windows';
const version = parseFloat(RegExp.$1);
if (version >= 10.0) os.version = '10';
else if (version >= 6.2) os.version = '8';
else if (version >= 6.1) os.version = '7';
else os.version = 'XP/Vista';
} else if (/mac os x (d+)_(d+)_?(d+)?/i.test(userAgent)) {
os.name = 'macOS';
os.version = `${RegExp.$1}.${RegExp.$2}.${RegExp.$3 || '0'}`;
} else if (/android (d+.d+)/i.test(userAgent)) {
os.name = 'Android';
os.version = RegExp.$1;
} else if (/iphone|ipad|ipod/i.test(userAgent)) {
os.name = 'iOS';
os.version = /OS (d+)_(d+)_?(d+)?/i.test(userAgent) ? `${RegExp.$1}.${RegExp.$2}.${RegExp.$3 || '0'}` : 'Unknown';
} else if (/linux/i.test(userAgent)) {
os.name = 'Linux';
os.version = 'Unknown';
} else {
os.name = 'Unknown';
os.version = 'Unknown';
}
return os;
}
/**
* 获取网络连接类型。
* @returns {string} 网络类型。
* @private
*/
_getNetworkType() {
if (navigator.connection && navigator.connection.effectiveType) {
return navigator.connection.effectiveType;
}
return 'unknown';
}
/**
* 更新当前页面的URL(通常在路由切换时调用)。
* @param {string} url - 新的URL。
*/
updateCurrentUrl(url) {
this._currentUrl = url;
}
/**
* 获取所有上下文信息。
* @returns {object} 包含所有用户和环境信息的对象。
*/
getContext() {
return {
userId: this._userId,
sessionId: this._sessionId,
userAgent: this._userAgent,
browser: this._browserInfo,
os: this._osInfo,
viewport: this._viewport,
currentUrl: this._currentUrl,
referrer: this._referrer,
networkType: this._networkType,
// 更多信息可以根据需要添加,例如屏幕像素比、语言等
// devicePixelRatio: window.devicePixelRatio,
// language: navigator.language,
};
}
}
export { UserContext };
代码解析:
UserContext类在构造函数中收集了大量的环境信息。_getUserId和_getSessionId方法分别从localStorage和sessionStorage中获取或生成唯一的ID,以确保用户和会话的连续性。_parseUserAgent和_parseOS是简化的用户代理字符串解析器,用于识别浏览器和操作系统。在实际项目中,可以考虑使用更成熟的第三方库(如ua-parser-js)来获取更精确的信息。_getNetworkType利用navigator.connectionAPI获取网络类型。updateCurrentUrl方法允许外部更新当前页面的URL,这对于SPA的路由追踪非常重要。getContext方法返回一个包含所有收集到信息的对象,供日志发送器在发送日志时附加到每个日志条目上。
3.3 日志发送器 (Log Sender)
日志发送器负责缓冲日志、批量发送,并处理网络请求。
// logSender.js
/**
* @class LogSenderConfig
* @property {string} sendUrl - 日志发送的后端API地址。
* @property {number} batchSize - 每次发送的日志条目数量。
* @property {number} sendInterval - 定时发送日志的间隔时间(毫秒)。如果为0,则只在缓冲区满或页面卸载时发送。
* @property {number} maxBufferSize - 本地缓冲区最大日志条目数,防止内存溢出。
* @property {string} storageKey - 用于 localStorage 存储日志缓冲区的键名。
*/
class LogSender {
/**
* @param {object} config - 配置对象。
* @param {UserContext} userContext - 用户上下文实例。
*/
constructor(config = {}, userContext) {
this.config = {
sendUrl: '/api/logs',
batchSize: 10,
sendInterval: 5000, // 5秒发送一次
maxBufferSize: 1000,
storageKey: 'frontend_log_buffer',
...config
};
this.buffer = []; // 内存缓冲区
this.userContext = userContext; // 注入用户上下文
this.timer = null; // 定时器句柄
this._loadBufferFromStorage(); // 初始化时从 localStorage 加载未发送的日志
this._initTimer(); // 启动定时发送机制
this._setupUnloadHandler(); // 注册页面卸载事件处理器
}
/**
* 从 localStorage 加载之前未发送的日志。
* @private
*/
_loadBufferFromStorage() {
try {
const storedBuffer = localStorage.getItem(this.config.storageKey);
if (storedBuffer) {
this.buffer = JSON.parse(storedBuffer);
// 限制加载的缓冲区大小,防止旧的、过大的数据影响应用
if (this.buffer.length > this.config.maxBufferSize) {
this.buffer = this.buffer.slice(this.buffer.length - this.config.maxBufferSize);
}
console.log(`Loaded ${this.buffer.length} logs from localStorage.`);
}
} catch (e) {
console.error('Failed to load log buffer from localStorage:', e);
this.buffer = []; // 加载失败,清空缓冲区
}
}
/**
* 将当前内存缓冲区中的日志保存到 localStorage。
* @private
*/
_saveBufferToStorage() {
try {
// 只保存最新的 maxBufferSize 条日志
const logsToSave = this.buffer.slice(-this.config.maxBufferSize);
localStorage.setItem(this.config.storageKey, JSON.stringify(logsToSave));
} catch (e) {
console.error('Failed to save log buffer to localStorage:', e);
// 如果存储失败,尝试清空,避免持续错误
if (e.name === 'QuotaExceededError') {
console.warn('LocalStorage quota exceeded, clearing log buffer in storage.');
localStorage.removeItem(this.config.storageKey);
}
}
}
/**
* 初始化定时发送器。
* @private
*/
_initTimer() {
if (this.config.sendInterval > 0) {
this.timer = setInterval(() => this.sendLogs(), this.config.sendInterval);
}
}
/**
* 注册页面卸载事件处理器,确保在页面关闭前发送所有待发送日志。
* @private
*/
_setupUnloadHandler() {
// 使用 beforeunload 和 unload 事件确保在页面关闭时尽可能发送日志
window.addEventListener('beforeunload', () => {
this.sendLogs(true); // 标记为卸载时发送
});
// 某些浏览器可能不支持 beforeunload 的异步操作,unolad 也能提供一些保障
window.addEventListener('unload', () => {
this.sendLogs(true);
});
}
/**
* 向缓冲区添加一条日志。
* @param {object} logEntry - 待添加的日志条目。
*/
add(logEntry) {
// 附加用户和环境上下文信息
const enrichedEntry = {
...logEntry,
...this.userContext.getContext(), // 合并所有上下文信息
};
this.buffer.push(enrichedEntry);
// 如果缓冲区超过最大限制,移除最旧的日志
if (this.buffer.length > this.config.maxBufferSize) {
this.buffer.shift();
}
this._saveBufferToStorage(); // 每次添加都持久化,增强鲁棒性
// 如果缓冲区达到批量发送阈值,立即发送
if (this.buffer.length >= this.config.batchSize) {
this.sendLogs();
}
}
/**
* 发送缓冲区中的日志。
* @param {boolean} [isUnload=false] - 是否在页面卸载时发送。
*/
async sendLogs(isUnload = false) {
if (this.buffer.length === 0) {
return;
}
// 取出当前缓冲区的所有日志进行发送
const logsToSend = this.buffer.splice(0, this.buffer.length);
this._saveBufferToStorage(); // 清空 localStorage 中的缓冲区
try {
const payload = JSON.stringify(logsToSend);
if (isUnload && navigator.sendBeacon) {
// 在页面卸载时,优先使用 sendBeacon API,它保证请求在页面卸载后继续发送,不阻塞主线程。
// sendBeacon 只能发送 ArrayBufferView, Blob, DOMString, FormData 类型的数据
const success = navigator.sendBeacon(this.config.sendUrl, new Blob([payload], { type: 'application/json' }));
if (success) {
console.log(`Successfully sent ${logsToSend.length} logs via sendBeacon.`);
} else {
console.warn('sendBeacon failed, logs might not be sent. Fallback to fetch.');
// 如果sendBeacon失败,可以尝试fallback到fetch
await this._sendLogsWithFetch(logsToSend, payload, true);
}
} else {
await this._sendLogsWithFetch(logsToSend, payload, isUnload);
}
} catch (error) {
console.error('Error sending logs:', error);
// 发送失败的日志重新放回缓冲区,等待下次重试 (需要考虑重复发送和最大重试次数)
this.buffer.push(...logsToSend);
this._saveBufferToStorage();
}
}
/**
* 使用 fetch API 发送日志。
* @param {Array<object>} logsToSend - 待发送的日志数组。
* @param {string} payload - JSON 字符串格式的日志数据。
* @param {boolean} isUnload - 是否在页面卸载时发送。
* @private
*/
async _sendLogsWithFetch(logsToSend, payload, isUnload) {
try {
const response = await fetch(this.config.sendUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: payload,
// keepalive 在页面卸载时非常重要,它允许请求在页面关闭后继续进行。
keepalive: isUnload
});
if (!response.ok) {
console.error('Failed to send logs:', response.status, response.statusText);
// 失败的日志重新放回缓冲区
this.buffer.push(...logsToSend);
this._saveBufferToStorage();
} else {
console.log(`Successfully sent ${logsToSend.length} logs.`);
}
} catch (error) {
console.error('Error during fetch operation:', error);
this.buffer.push(...logsToSend); // 重新放回缓冲区
this._saveBufferToStorage();
}
}
}
export { LogSender };
代码解析:
LogSender类负责管理一个内存缓冲区this.buffer。_loadBufferFromStorage和_saveBufferToStorage方法利用localStorage实现日志的持久化,即使页面刷新或关闭,未发送的日志也能在下次加载时恢复。我们还加入了大小限制,防止localStorage被撑爆。_initTimer设置一个定时器,周期性地触发日志发送。_setupUnloadHandler监听beforeunload和unload事件,在页面卸载前尽力发送所有剩余日志。add方法接收日志条目,为其附加UserContext提供的一切环境信息,然后加入缓冲区。当缓冲区达到batchSize时,会立即触发发送。sendLogs方法是发送核心逻辑。- 它首先清空当前缓冲区,并将日志保存到
logsToSend。 - 关键点: 在页面卸载时(
isUnload为true),它会优先使用navigator.sendBeacon。sendBeacon是一个专门为这种情况设计的API,它能保证在页面卸载后,请求仍能可靠地发送到服务器,而不会阻塞页面关闭或被浏览器取消。 - 如果
sendBeacon不可用或失败,或者不是在卸载时发送,则退而使用fetchAPI。 - 使用
fetch时,如果isUnload为true,务必将keepalive选项设置为true,这与sendBeacon类似,允许请求在页面关闭后继续进行。 - 无论
sendBeacon还是fetch,如果发送失败,会将日志重新放回缓冲区,等待下次重试,提高了系统的鲁棒性。
- 它首先清空当前缓冲区,并将日志保存到
3.4 错误捕获机制 (Error Capturing)
错误是日志中最关键的部分之一。我们需要捕获各种类型的运行时错误。
// errorCapture.js
/**
* 设置全局错误捕获机制。
* @param {Logger} logger - 日志器实例。
*/
function setupErrorCapturing(logger) {
/**
* 捕获未捕获的 JavaScript 运行时错误。
* @param {string} message - 错误消息。
* @param {string} source - 发生错误的脚本URL。
* @param {number} lineno - 发生错误的行号。
* @param {number} colno - 发生错误的列号。
* @param {Error} error - 错误对象。
*/
window.onerror = (message, source, lineno, colno, error) => {
// 阻止默认的浏览器错误处理,避免控制台重复打印
// 但通常我们不阻止,让浏览器也打印出来方便开发
// return true;
const errorContext = {
type: 'JavaScript Error',
source: source,
lineno: lineno,
colno: colno,
stack: error ? error.stack : 'N/A', // 尽可能获取堆栈信息
errorName: error ? error.name : 'UnknownError'
};
logger.error(`Uncaught Error: ${message}`, errorContext);
};
/**
* 捕获未处理的 Promise 拒绝。
* @param {PromiseRejectionEvent} event - Promise 拒绝事件对象。
*/
window.onunhandledrejection = (event) => {
// 阻止默认的浏览器 Promise 拒绝处理
event.preventDefault();
const reason = event.reason;
const errorContext = {
type: 'Promise Rejection',
message: reason && typeof reason === 'object' && reason.message ? reason.message : String(reason),
stack: reason && typeof reason === 'object' && reason.stack ? reason.stack : 'N/A',
errorName: reason && typeof reason === 'object' && reason.name ? reason.name : 'UnhandledPromiseRejection'
};
logger.error(`Unhandled Promise Rejection: ${errorContext.message}`, errorContext);
};
/**
* 捕获资源加载错误 (例如图片、脚本、CSS)。
* 注意:此方法无法捕获所有资源错误,特别是跨域脚本错误信息会非常有限。
* 可以考虑使用 PerformanceObserver 或自定义事件来更精确地捕获。
* @param {Event} event - 错误事件。
*/
document.addEventListener('error', (event) => {
const target = event.target;
// 检查是否是资源加载错误
if (target instanceof HTMLImageElement ||
target instanceof HTMLLinkElement || // CSS 资源
target instanceof HTMLScriptElement || // JS 资源
target instanceof HTMLVideoElement ||
target instanceof HTMLAudioElement) {
const resourceContext = {
type: 'Resource Error',
tagName: target.tagName,
srcOrHref: target.src || target.href,
id: target.id,
className: target.className,
outerHTML: target.outerHTML.substring(0, 200) // 截取部分HTML
};
logger.warn(`Resource loading failed: ${resourceContext.srcOrHref}`, resourceContext);
}
}, true); // 使用捕获阶段捕获事件
}
export { setupErrorCapturing };
代码解析:
window.onerror:这是捕获全局 JavaScript 运行时错误的利器。当发生未被try...catch捕获的错误时,此事件会被触发。我们从中提取错误消息、源文件、行号、列号和错误对象,并记录堆栈信息。window.onunhandledrejection:专门用于捕获未处理的 Promise 拒绝。event.preventDefault()可以阻止浏览器默认的控制台警告。我们同样提取拒绝原因和堆栈。document.addEventListener('error', ..., true):通过在捕获阶段监听error事件,我们可以捕获到资源(如图片、脚本、样式表)加载失败时触发的错误。通过检查event.target的类型,我们可以判断是哪种资源以及其src或href。需要注意的是,对于跨域脚本,onerror事件通常只会提供"Script error."而无详细信息,这是浏览器出于安全考虑的限制。
3.5 用户行为追踪 (User Interaction Tracking)
追踪用户行为对于理解问题发生路径、分析用户习惯至关重要。
// interactionCapture.js
/**
* 设置用户交互行为捕获机制。
* @param {Logger} logger - 日志器实例。
* @param {UserContext} userContext - 用户上下文实例。
*/
function setupInteractionCapturing(logger, userContext) {
/**
* 捕获页面导航(路由切换)事件。
* 对于单页应用,需要重写 pushState 和 replaceState 方法来监听路由变化。
*/
const originalPushState = history.pushState;
history.pushState = function() {
const result = originalPushState.apply(this, arguments);
userContext.updateCurrentUrl(window.location.href); // 更新用户上下文的当前URL
logger.info(`Navigation: Pushed state to ${window.location.href}`, { type: 'navigation', method: 'pushState', url: window.location.href });
return result;
};
const originalReplaceState = history.replaceState;
history.replaceState = function() {
const result = originalReplaceState.apply(this, arguments);
userContext.updateCurrentUrl(window.location.href);
logger.info(`Navigation: Replaced state to ${window.location.href}`, { type: 'navigation', method: 'replaceState', url: window.location.href });
return result;
};
// 监听浏览器前进/后退事件
window.addEventListener('popstate', () => {
userContext.updateCurrentUrl(window.location.href);
logger.info(`Navigation: Popstate to ${window.location.href}`, { type: 'navigation', method: 'popstate', url: window.location.href });
});
// 页面首次加载记录
window.addEventListener('load', () => {
logger.info(`Page loaded: ${window.location.href}`, { type: 'pageLoad', url: window.location.href });
});
/**
* 捕获用户点击事件。
* 使用事件委托,监听 body 上的点击事件,减少事件监听器数量。
*/
document.body.addEventListener('click', (event) => {
const target = event.target;
// 过滤掉非交互元素或不重要的点击
if (target.tagName === 'BUTTON' ||
target.tagName === 'A' ||
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.hasAttribute('data-log-click') || // 自定义属性标记需要追踪的点击
target.closest('[data-log-click]')) { // 查找父级是否有标记
const clickContext = {
type: 'User Click',
element: target.tagName,
id: target.id,
class: target.className,
text: target.innerText ? target.innerText.trim().substring(0, 100) : '', // 截取文本内容
path: _getElementPath(target), // 获取元素在DOM树中的路径
screenX: event.screenX,
screenY: event.screenY,
clientX: event.clientX,
clientY: event.clientY,
};
logger.info(`User Clicked: ${clickContext.element} ${clickContext.id || clickContext.class || clickContext.text}`, clickContext);
}
}, true); // 使用捕获阶段
/**
* 获取元素的DOM路径,用于定位。
* @param {HTMLElement} element - 目标元素。
* @returns {string} 元素的DOM路径。
* @private
*/
function _getElementPath(element) {
if (!element || element === document.body) return 'body';
const path = [];
while (element && element !== document.body) {
let selector = element.tagName.toLowerCase();
if (element.id) {
selector += `#${element.id}`;
} else if (element.className) {
selector += `.${element.className.split(' ').filter(Boolean).join('.')}`;
}
path.unshift(selector);
element = element.parentElement;
}
return path.join(' > ');
}
// 更多行为追踪,例如表单提交、输入框失焦等
// document.body.addEventListener('submit', (event) => {
// const form = event.target;
// logger.info('Form submitted', {
// formId: form.id,
// action: form.action,
// method: form.method
// });
// // 注意:避免直接记录表单中的敏感数据
// }, true);
}
export { setupInteractionCapturing };
代码解析:
- 路由追踪 (SPA): 对于单页应用,浏览器原生的
hashchange或popstate事件可能不足以捕获所有路由变化。我们通过重写history.pushState和history.replaceState方法来拦截路由导航。每次路由变化时,更新UserContext的当前URL,并记录一条INFO级别的日志。 - 点击事件: 我们在
document.body上设置一个事件委托,监听所有点击事件。通过检查event.target的tagName、id、className或自定义的data-log-click属性,判断是否是需要追踪的交互元素。_getElementPath函数帮助我们构建一个可读性强的DOM路径,便于定位元素。 - 页面加载: 监听
window.load事件,记录页面首次加载。 - 隐私注意: 在记录表单提交等行为时,务必注意数据脱敏,避免收集敏感信息。
3.6 性能指标收集 (Performance Monitoring)
性能是用户体验的基石。我们可以利用浏览器提供的Performance API来收集关键性能指标。
// performanceMonitoring.js
/**
* 设置性能指标收集机制。
* @param {Logger} logger - 日志器实例。
*/
function setupPerformanceMonitoring(logger) {
if (!('PerformanceObserver' in window)) {
console.warn('PerformanceObserver API not supported in this browser.');
return;
}
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
const metricContext = {
type: 'Performance Metric',
metricName: entry.name,
entryType: entry.entryType,
duration: entry.duration,
startTime: entry.startTime,
// 更多属性根据 entry.entryType 不同而异
};
switch (entry.entryType) {
case 'paint':
// FCP (First Contentful Paint)
if (entry.name === 'first-contentful-paint') {
logger.info(`Performance: FCP = ${entry.startTime.toFixed(2)}ms`, { ...metricContext, value: entry.startTime });
}
// FP (First Paint)
if (entry.name === 'first-paint') {
logger.debug(`Performance: FP = ${entry.startTime.toFixed(2)}ms`, { ...metricContext, value: entry.startTime });
}
break;
case 'largest-contentful-paint':
// LCP (Largest Contentful Paint)
logger.info(`Performance: LCP = ${entry.startTime.toFixed(2)}ms`, { ...metricContext, value: entry.startTime, element: entry.element ? _getElementPath(entry.element) : 'N/A' });
break;
case 'layout-shift':
// CLS (Cumulative Layout Shift)
// CLS 需要累积计算,这里只记录单次 shift,实际应用中需要额外逻辑计算总 CLS。
// logger.debug(`Performance: Layout Shift = ${entry.value.toFixed(4)}`, { ...metricContext, value: entry.value, hadRecentInput: entry.hadRecentInput });
break;
case 'first-input':
// FID (First Input Delay)
logger.info(`Performance: FID = ${entry.duration.toFixed(2)}ms`, { ...metricContext, value: entry.duration, processingStart: entry.processingStart, inputType: entry.name });
break;
case 'resource':
// 资源加载性能,只记录慢速资源或关键资源
if (entry.duration > 200) { // 超过200ms的资源
logger.debug(`Performance: Slow Resource: ${entry.name} = ${entry.duration.toFixed(2)}ms`, { ...metricContext, value: entry.duration, initiatorType: entry.initiatorType });
}
break;
case 'navigation':
// 页面导航计时,如 DNS Lookup, TCP Handshake, Response Time等
// 通常在页面加载完成后一次性报告
logger.debug(`Performance: Navigation Timing: ${entry.name} = ${entry.duration.toFixed(2)}ms`, { ...metricContext, value: entry.duration });
break;
case 'longtask':
// 长任务,阻塞主线程超过50ms的任务
if (entry.duration > 50) {
logger.warn(`Performance: Long Task = ${entry.duration.toFixed(2)}ms`, { ...metricContext, value: entry.duration, attribution: entry.attribution });
}
break;
// Add more entry types as needed
}
});
});
// 观察多种性能事件类型
observer.observe({
entryTypes: ['paint', 'largest-contentful-paint', 'layout-shift', 'first-input', 'resource', 'navigation', 'longtask']
});
/**
* 获取元素的DOM路径,用于定位。
* @param {HTMLElement} element - 目标元素。
* @returns {string} 元素的DOM路径。
* @private
*/
function _getElementPath(element) {
if (!element || element === document.body) return 'body';
const path = [];
while (element && element !== document.body) {
let selector = element.tagName.toLowerCase();
if (element.id) {
selector += `#${element.id}`;
} else if (element.className) {
selector += `.${element.className.split(' ').filter(Boolean).join('.')}`;
}
path.unshift(selector);
element = element.parentElement;
}
return path.join(' > ');
}
}
export { setupPerformanceMonitoring };
代码解析:
PerformanceObserver:这是现代浏览器提供的一个强大API,用于异步、高效地收集各种性能指标。- 我们创建了一个
PerformanceObserver实例,并传入一个回调函数。当观察到的性能事件发生时,回调函数会被触发,并提供一个PerformanceEntryList。 observer.observe()方法告诉浏览器我们对哪些entryTypes感兴趣,例如paint(FCP、FP)、largest-contentful-paint(LCP)、layout-shift(CLS)、first-input(FID)等。- 在回调函数中,我们遍历
entries,根据entry.entryType和entry.name来识别具体的性能指标,并将其记录为日志。例如:- FCP (First Contentful Paint): 首次内容绘制时间,表示页面内容首次呈现在屏幕上的时间。
- LCP (Largest Contentful Paint): 最大内容绘制时间,表示页面最大视觉元素完成渲染的时间,是感知加载速度的关键指标。
- FID (First Input Delay): 首次输入延迟,表示用户首次与页面交互(如点击按钮)到浏览器实际响应之间的时间。
- CLS (Cumulative Layout Shift): 累积布局偏移,衡量页面在加载过程中布局变化的幅度,影响用户体验。这里仅记录单次偏移,实际系统需要累加。
- Resource: 可以用来监控单个资源的加载耗时,发现慢速资源。
- Longtask: 记录阻塞主线程超过50ms的任务,有助于发现性能瓶颈。
- 同样提供了
_getElementPath函数来定位LCP元素等。
3.7 系统集成与初始化
现在,我们将所有模块整合到一个入口文件中,实现系统的初始化。
// main.js
import { Logger } from './logger';
import { UserContext } from './userContext';
import { LogSender } from './logSender';
import { setupErrorCapturing } from './errorCapture';
import { setupInteractionCapturing } from './interactionCapture';
import { setupPerformanceMonitoring } from './performanceMonitoring';
/**
* 初始化前端日志收集系统。
* @param {object} config - 全局配置对象。
* @returns {Logger} - 返回初始化后的Logger实例,供应用其他部分使用。
*/
function initializeFrontendLogger(config = {}) {
// 默认配置
const defaultConfig = {
appVersion: '1.0.0',
logLevel: 'INFO', // 生产环境默认为INFO,开发环境可设为DEBUG
sendUrl: '/api/frontend-logs', // 后端接收日志的API地址
batchSize: 5, // 每5条日志发送一次
sendInterval: 3000, // 每3秒发送一次
maxBufferSize: 500, // 内存和localStorage最大缓存日志条数
storageKey: 'app_frontend_log_buffer',
enableConsole: process.env.NODE_ENV !== 'production', // 生产环境默认不打console
};
const finalConfig = { ...defaultConfig, ...config };
// 1. 初始化用户上下文
const userContext = new UserContext();
// 2. 初始化日志发送器
const logSender = new LogSender(finalConfig, userContext);
// 3. 初始化日志器
const logger = new Logger(finalConfig, logSender);
// 4. 设置错误捕获
setupErrorCapturing(logger);
// 5. 设置用户交互捕获
setupInteractionCapturing(logger, userContext);
// 6. 设置性能指标捕获
setupPerformanceMonitoring(logger);
// 暴露全局变量,方便在应用任何地方调用(可选,更推荐依赖注入)
window.appLogger = logger;
logger.info('Frontend logging system initialized.', { config: finalConfig });
return logger;
}
// 在应用入口点调用初始化函数
// 例如:
// document.addEventListener('DOMContentLoaded', () => {
// initializeFrontendLogger({
// appVersion: '1.2.3',
// logLevel: 'DEBUG', // 或者根据环境判断
// sendUrl: 'https://your-backend.com/api/logs'
// });
//
// // 示例:在应用中记录日志
// window.appLogger.info('Application started successfully.');
// try {
// // 模拟一个错误
// // throw new Error('Something went wrong in the main logic!');
// } catch (e) {
// window.appLogger.error('Caught error in main app logic', { error: e.message, stack: e.stack });
// }
//
// // 模拟一个API调用
// fetch('/some/data')
// .then(res => res.json())
// .then(data => {
// window.appLogger.debug('Data fetched successfully', { dataLength: data.length });
// })
// .catch(err => {
// window.appLogger.error('Failed to fetch data', { api: '/some/data', error: err.message });
// });
// });
// 或者,如果你使用模块打包工具 (如 Webpack, Vite),可以直接导出并导入到你的 main.js 或 App.vue/App.jsx
export { initializeFrontendLogger };
代码解析:
initializeFrontendLogger函数作为系统的入口,负责创建并协调所有模块。- 它接收一个配置对象,并合并默认配置。
- 按照依赖关系依次初始化
UserContext、LogSender和Logger。 - 然后,调用各个
setup...Capturing函数,将logger和userContext实例传递进去,注册各种事件监听器。 - 最后,打印一条初始化成功的日志,并将
logger实例暴露到window.appLogger(可选,更推荐在框架中使用依赖注入或上下文API传递)。 - 在你的应用入口文件(例如
main.js),调用initializeFrontendLogger即可启动整个日志系统。
四、高级话题与最佳实践
一个可用的日志系统已经搭建起来了,但要使其在生产环境中稳定、高效、安全地运行,我们还需要考虑更多高级话题和最佳实践。
4.1 数据隐私与安全:红线不可逾越
这是前端日志收集的重中之重,任何疏忽都可能导致严重的法律和信任问题。
- 数据脱敏 (Data Masking/Anonymization):
- 输入框内容: 避免直接记录用户输入到表单中的内容,尤其是密码、信用卡号、身份证号、手机号等敏感信息。如果业务上需要,只能记录脱敏后的值(如
****1234)或哈希值。 - URL参数: 检查URL中是否包含敏感信息(如用户ID、Token),在记录时进行清理。
- 自定义上下文: 在
logger.info('...', { customData: sensitiveInfo })时,开发者必须自觉过滤敏感数据。 - 实现方式: 可以在
LogSender.add()方法中增加一个前置处理器,对logEntry中的特定字段进行正则匹配或替换。
- 输入框内容: 避免直接记录用户输入到表单中的内容,尤其是密码、信用卡号、身份证号、手机号等敏感信息。如果业务上需要,只能记录脱敏后的值(如
- 最小化原则 (Data Minimization): 只收集解决问题所必需的信息。例如,如果只需要知道用户是否点击了某个按钮,就不需要记录按钮的所有CSS样式或DOM结构。
- HTTPS传输: 日志数据包含用户行为和系统状态,必须通过HTTPS加密传输,防止中间人攻击窃取数据。
- 用户同意 (Consent): 根据GDPR、CCPA等法规,在某些地区收集用户数据可能需要明确的用户同意。这可能意味着在日志系统初始化前,需要展示一个隐私政策弹窗,并等待用户授权。如果用户拒绝,则不应启用日志收集。
- 本地存储安全:
localStorage和sessionStorage不是安全的存储介质,不应存放敏感信息。我们的日志缓冲区只存放待发送的、已脱敏的日志。
示例:日志脱敏的简单实现
可以在LogSender的add方法中,在enrichedEntry被添加到buffer之前,增加一个脱敏处理函数:
// 在 LogSender 的 add 方法中
add(logEntry) {
const enrichedEntry = {
...logEntry,
...this.userContext.getContext(),
};
const sanitizedEntry = this._sanitizeLogEntry(enrichedEntry); // 调用脱敏函数
this.buffer.push(sanitizedEntry);
// ... 后续逻辑
}
_sanitizeLogEntry(logEntry) {
// 深度拷贝,避免修改原始对象
const clonedEntry = JSON.parse(JSON.stringify(logEntry));
// 示例1: 移除或脱敏特定上下文字段
if (clonedEntry.context && clonedEntry.context.password) {
clonedEntry.context.password = '********';
}
if (clonedEntry.context && clonedEntry.context.creditCard) {
clonedEntry.context.creditCard = '**** **** **** ****';
}
// 示例2: 正则匹配脱敏消息内容
if (clonedEntry.message) {
clonedEntry.message = clonedEntry.message.replace(/b(d{10,12})b/g, '***********'); // 简单脱敏长数字串,如手机号
clonedEntry.message = clonedEntry.message.replace(/email=([^&]+)/g, 'email=***'); // 脱敏URL参数中的邮箱
}
// 示例3: 脱敏 URL
if (clonedEntry.currentUrl) {
const urlObj = new URL(clonedEntry.currentUrl);
if (urlObj.searchParams.has('token')) {
urlObj.searchParams.set('token', '****');
}
if (urlObj.searchParams.has('user_id')) {
urlObj.searchParams.set('user_id', '****');
}
clonedEntry.currentUrl = urlObj.toString();
}
// ... 更多脱敏规则
return clonedEntry;
}
4.2 性能与资源消耗:平衡的艺术
日志收集本身也会消耗CPU、内存和网络资源,必须加以优化。
- 异步化: 确保日志处理(缓冲、发送)是非阻塞的,不影响主线程的渲染和用户交互。我们的实现中,
fetch和sendBeacon都是异步的。 - 批量发送: 减少网络请求次数,降低网络开销和服务器压力。我们的
LogSender已经实现了批量发送。 - 节流与防抖 (Throttling & Debouncing): 对于高频触发的事件(如
mousemove,scroll),如果需要记录,应进行节流或防抖处理,避免产生过多日志。 - 日志级别控制: 在生产环境中,将日志级别设置为
INFO或WARN以上,只记录重要的错误和信息,减少日志量。DEBUG级别只在开发环境开启。 - 缓冲区大小限制: 限制内存和
localStorage中的日志缓冲区大小,防止占用过多资源导致性能下降甚至崩溃。 - 压缩: 在发送日志前,可以考虑对JSON payload进行Gzip压缩,进一步减少网络传输量。这需要在客户端和服务器端都支持。
4.3 鲁棒性与兼容性:面对复杂环境
前端环境复杂多变,日志系统必须足够健壮。
- 错误处理: 日志系统自身也可能出错。例如,
localStorage写入失败(配额限制)、网络请求异常等。我们的LogSender中已包含基本的重试机制(将失败日志放回缓冲区)。更完善的方案可以引入指数退避等重试策略。 - 浏览器兼容性:
sendBeacon:并非所有浏览器都完全支持,需要有fetch作为降级方案。PerformanceObserver:较新的API,旧版浏览器可能不支持,需要检查'PerformanceObserver' in window。navigator.connection:获取网络信息也并非完全兼容。
- 代码防护: 对可能不存在的API进行检查,例如
if (navigator.sendBeacon) { ... }。 - 日志系统自身的日志: 确保日志系统内部的错误能够被
console.error打印出来,以便在开发和调试日志系统时发现问题。
4.4 配置与扩展性:适应需求变化
- 模块化设计: 将不同功能(logger、sender、capturer)拆分成独立模块,便于维护、测试和扩展。
- 可配置性: 通过配置对象控制日志级别、发送URL、批量大小等,方便在不同环境和需求下调整。
- 插件机制: 考虑为日志系统设计插件机制,允许开发者插入自定义的日志处理器或数据转换器。例如,在发送前加密日志,或者将日志发送到多个目的地。
- 集成框架:
- React: 利用Error Boundaries捕获组件渲染错误,并在
componentDidCatch中调用logger.error()。 - Vue: 配置
Vue.config.errorHandler和Vue.config.warnHandler来捕获Vue组件的错误和警告。 - Angular: 实现自定义的
ErrorHandler服务来统一处理应用中的错误。
- React: 利用Error Boundaries捕获组件渲染错误,并在
4.5 后端日志处理与可视化:完整链路
前端日志收集只是整个链路的第一步。
- 后端API: 需提供一个稳定的HTTP POST接口接收日志数据。
- 日志存储: 将接收到的日志存储到专业日志管理系统(如Elasticsearch、Splunk、Loki等)或时序数据库中。
- 日志分析与查询: 利用这些系统的强大查询功能,快速定位错误、分析用户行为路径、筛选性能瓶颈。
- 告警系统: 基于日志数据设置告警规则,例如,当错误率超过阈值、页面加载时间过长时,自动通知开发团队。
- 可视化仪表盘: 通过Kibana、Grafana等工具,将日志数据转化为直观的图表和仪表盘,实时监控应用健康状况和用户体验。
表格:sendBeacon vs fetch (with keepalive)
| 特性 | navigator.sendBeacon() |
fetch(..., { keepalive: true }) |
|---|---|---|
| 可靠性 | 专门设计用于页面卸载时发送数据,浏览器会尽力发送。 | 同样在页面卸载时提供高可靠性,但可能不如sendBeacon稳定(取决于浏览器实现)。 |
| 阻塞性 | 非阻塞,不会延迟页面卸载。 | 非阻塞,通常不会延迟页面卸载。 |
| 请求类型 | 只能发送POST请求,数据类型有限(Blob, ArrayBufferView, DOMString, FormData)。 | 支持所有HTTP方法,数据类型更灵活。 |
| 响应处理 | 不提供响应回调,无法处理服务器返回。 | 提供Promise接口,可处理服务器响应(例如重试逻辑)。 |
| 请求头 | 浏览器自动设置Content-Type,无法自定义更多请求头。 | 可自定义所有请求头。 |
| 浏览器支持 | 较好,但比fetch晚。 |
普遍支持。 |
| 适用场景 | 页面卸载时发送少量、不需要响应反馈的数据(如统计、日志)。 | 页面卸载时发送数据且需要处理响应,或需要自定义请求头、更多数据类型。 |
结论: 在页面卸载场景下,sendBeacon是首选,其设计目标就是为了解决这个问题。如果需要更复杂的请求定制或响应处理,fetch配合keepalive: true是一个可靠的替代方案。我们的系统优先使用sendBeacon,并提供fetch作为后备。
五、结语
至此,我们已经全面而深入地探讨了前端日志收集系统的构建。从理解其重要性、设计核心概念,到亲手实现基础日志器、错误捕获、行为追踪、性能监控,再到讨论数据隐私、性能优化和系统鲁棒性,我们覆盖了从理论到实践的每一个关键环节。
一个健壮的前端日志收集系统,不仅仅是代码库中的一个模块,更是我们理解用户、发现问题、提升产品质量的强大武器。它将帮助我们从被动响应用户反馈,走向主动发现和解决问题的境界,让我们的应用在复杂的线上环境中更加透明、可控。
技术永无止境,前端日志系统也需要持续的迭代和优化。希望今天的分享能为大家提供一个坚实的基础,启发大家根据自身业务场景,构建出更强大、更智能的前端监控体系。
感谢大家的聆听!