断网、弱网、关页都不怕:前端日志上报怎么做到不丢包 作者: ciniao 时间: 2026-01-18 分类: AI文摘 在前端监控系统中,数据采集只是第一步,如何确保数据能够稳定上报到服务器才是关键挑战。用户可能面临断网、弱网环境,或者快速关闭页面,这些情况都可能导致日志数据丢失。 ## 一、上报方式与策略:如何选出最优解? 前端数据上报主要有三种方式:Image(图片请求)、sendBeacon 和 XHR/Fetch。 ### 1. 三种上报方式详解 **GIF/Image** 利用图片请求(`new Image().src`)来传输数据,将上报数据拼在URL后面,服务器返回一张1×1的透明GIF图完成上报。 特点:天然支持跨域,绝无"预检"请求(因为是简单请求)。 局限:只能发GET请求,URL长度有限(通常<2KB),无法携带大数据。 **sendBeacon** 使用`navigator.sendBeacon(url, data)`,浏览器将数据放入后台队列,即使页面关闭也会尽力发送。 特点:异步非阻塞(不卡主线程),可靠性极高。 局限:数据量有限(约64KB),无法自定义复杂的请求头。 **XHR/Fetch** 使用`XMLHttpRequest`或`fetch`发送POST请求。 特点:容量极大(几兆都没问题),适合发送录屏、长堆栈。 局限:跨域时通常会触发`OPTIONS`预检(成本高),页面关闭时请求容易被掐断(fetch需配合`keepalive`)。 ### 2. 策略篇:如何组合使用? 选择策略需要解决两个核心痛点: 1. 成本问题(CORS预检):跨域且非简单请求会触发OPTIONS预检,导致请求量翻倍 2. 存活问题(页面卸载):用户关闭页面时,浏览器会掐断挂起的异步请求 基于这两个维度,形成降级策略: **首选方案:sendBeacon(六边形战士)** 优势:专为监控设计,页面关闭了也能发,容量适中(~64KB),通常不触发预检 适用:绝大多数监控事件 **降级方案:GIF/Image(老牌救星)** 优势:天然跨域,绝无预检,兼容性无敌 特点:数据量受URL长度限制(~2KB),页面关闭时发送成功率低 适用:PV、点击、心跳等轻量指标 **兜底方案:XHR/Fetch** 优势:容量极大,适合传录屏、大段错误堆栈 劣势:跨域麻烦(需配CORS),有预检成本 ### 选型对比表 | 方案 | 跨域/预检 | 卸载可靠性 | 数据容量 | 核心优势 | 适用场景 | |------|-----------|------------|----------|----------|----------| | sendBeacon | 支持/无预检 | 高 | 中(~64KB) | 关页也能发,不占主线程 | 首选,大多数监控事件 | | GIF/Image | 支持/无预检 | 低 | 小(~2KB) | 兼容性强,无预检 | 降级方案,PV/点击/心跳 | | XHR/Fetch | 需CORS/有 | 低 | 大 | 能传大数据 | 错误堆栈、录屏 | 总结代码套路(降级策略): - 小包(<2KB,单条事件):优先`sendBeacon`;若不支持,再走`Image` GET - 中包(≤64KB):`sendBeacon`为首选;若不支持,回退到`Fetch/XHR` - 大包(>64KB):`Fetch/XHR`承载,必要时拆包分批发送 ``` const REPORT_URL = 'https://log.your-domain.com/collect'; const MAX_URL_LENGTH = 2048; const MAX_BEACON_BYTES = 64 * 1024; function byteLen(s) { try { return new TextEncoder().encode(s).length; } catch (e) { return s.length; } } /** * 通用上报函数 * @param {Object|Array} data - 上报数据 * @returns {Promise} - 成功 resolve,失败 reject */ function transport(data) { const isArray = Array.isArray(data); const json = JSON.stringify(data); return new Promise((resolve, reject) => { // 1. 优先尝试 sendBeacon // 注意:sendBeacon 是同步入队,返回 true 仅代表入队成功,不一定是发送成功 if (navigator.sendBeacon && byteLen(json) <= MAX_BEACON_BYTES) { const blob = new Blob([json], { type: 'text/plain' }); // 如果入队成功,直接 resolve(乐观策略) if (navigator.sendBeacon(REPORT_URL, blob)) { resolve(); return; } // 如果入队失败(如队列已满),不 reject,而是继续往下走降级方案 console.warn('[Beacon] 入队失败,尝试降级...'); } // 2. 单条小数据尝试 Image (GET) if (!isArray) { const params = new URLSearchParams(data); params.append('_ts', String(Date.now())); const qs = params.toString(); const sep = REPORT_URL.includes('?') ? '&' : '?'; if (REPORT_URL.length + sep.length + qs.length < MAX_URL_LENGTH) { const img = new Image(); img.onload = () => resolve(); // 成功 img.onerror = () => reject(new Error('Image 上报失败')); // 失败 img.src = REPORT_URL + sep + qs; return; } } // 3. 兜底方案:Fetch > XHR if (window.fetch) { fetch(REPORT_URL, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: json, keepalive: true, // 关键:允许页面关闭后继续发送 }) .then((res) => { if (res.ok) resolve(); else reject(new Error(`Fetch 失败: ${res.status}`)); }) .catch(reject); } else { // IE 兼容 const xhr = new XMLHttpRequest(); xhr.open('POST', REPORT_URL, true); xhr.setRequestHeader('Content-Type', 'text/plain'); xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) resolve(); else reject(new Error(`XHR 失败: ${xhr.status}`)); }; xhr.onerror = () => reject(new Error('XHR 网络错误')); xhr.send(json); } }); } ``` ## 二、上报时机:不阻塞主线程干扰业务,断网了也不丢数据 ### 1. 调度层:区分优先级,关键时刻不等待 根据重要程度将日志分为两类: **即时上报(Immediate)**:收集到立即上报 场景:JS报错阻断了流程、用户点击了"支付"按钮、接口返回500等 原因:这些数据对实时性要求极高,关系到监控系统的报警 **批量上报(Batch)**:攒一波再发 场景:用户点击、滚动、性能指标、API成功日志 策略:"量"与"时"双重触发(竞态关系),比如:攒够10条立马发,或者每隔5秒发一次 整体思路:队列暂存 + 多重触发 用一个数组(`queue`)来暂存日志,通过"量够了"、"时间到了"或"页面要关了"这三个时机来触发发送 性能优化:闲时优先 发送时首选`requestIdleCallback`,告诉浏览器先忙渲染、响应点击,等有空了再发监控数据,最大限度减少对业务主线程的阻塞 ### 2. 容灾层:断网了,日志怎么办? 策略是"先记在本子上,等有网了再补交作业": **断网时**:把日志存到`localStorage`里(注意设置上限,可用IndexedDB优化) **连网时**:监听`online`事件,把存的日志拿出来,分批发给服务器 具体网络判断策略: - 使用`navigator.onLine`初步判断,但要注意它可能"撒谎" - 哪怕显示"在线",也先试着上报,如果报错发不出去,先把这条日志存本地保底 - 然后再去Ping一下看看到底是不是真断网了,更新网络状态 ``` const NetworkManager = { online: navigator.onLine, // 初始化:盯着系统的 online/offline 事件 init(onBackOnline) { window.addEventListener('online', async () => { // 别高兴太早,先看看是不是真的能上网 const realWait = await this.verify(); if (realWait) { this.online = true; onBackOnline(); // 真的回网了,赶紧补传! } }); window.addEventListener('offline', () => this.online = false); }, // “测谎仪”:发个 HEAD 请求看看 async verify() { try { // 请求个 favicon 或者 1x1 图片,只要响应了说明网通了 await fetch('/favicon.ico', { method: 'HEAD', cache: 'no-store' }); return true; } catch { return false; } } }; ``` 核心上报逻辑:能发就发,不行就存本地 ```javascript export async function reportData(data) { // 1. 如果明确知道没网,直接存本地 if (!NetworkManager.online) { saveToLocal(data); return; } // 2. 尝试发送 try { await transport(data); } catch (err) { console.error('上报请求失败:', err); // 3. 只要没成功,第一件事就是存本地! saveToLocal(data); // 4. 然后再来诊断网络 if (isNetworkError(err)) { NetworkManager.verify().then(res => NetworkManager.online = res); } } } ``` **补传逻辑:别把服务器干崩了** 网络恢复后,本地攒了一堆"欠账",要有节奏地补传: - 每次只取5条,小碎步走 - 只有成功了,才把这5条从logs里剔除 - 如果失败了,保留剩余欠账,等下次唤醒 - 歇半秒钟,给正常业务请求让个道 ``` async function flushLogs() { let logs = JSON.parse(localStorage.getItem('RETRY_LOGS') || '[]'); if (!logs.length) return; console.log(`[回血] 发现 ${logs.length} 条欠账,开始补传...`); while (logs.length > 0) { // 1. 每次只取 5 条,小碎步走 const batch = logs.slice(0, 5); try { // 2. 调用上报中心 await transport(batch); // 3. 只有成功了,才把这 5 条从 logs 里剔除 logs.splice(0, 5); localStorage.setItem(RETRY_LOGS, JSON.stringify(logs)); } catch (err) { // 4. 如果失败了(断网或服务器挂了) // 此时 logs 里面还保留着那 5 条数据,所以不用担心丢失 // 记录一下状态,直接跳出循环,等下次 NetworkManager 唤醒 console.error('补传中途失败,保留剩余欠账'); break; } // 2. 歇半秒钟,给正常业务请求让个道 await new Promise(r => setTimeout(r, 500)); } } ``` ## 三、总结与实战建议 监控上报要在数据不丢和不打扰用户之间找平衡,需要一套"组合拳": 1. **上报方式**:sendBeacon为主,Image为辅,XHR/Fetch兜底 2. **上报时机**:闲时上报 + 批量打包 3. **断网处理**:本地缓存 + 网络侦测 ### 给开发者的3个避坑小贴士: 1. **不要迷信`navigator.onLine`**:它只能判断有没有连接到局域网,不能判断是否真的能上网,一定要配合实际的请求探测 2. **控制补传节奏**:网络恢复后,千万别一次性把积压的几百条日志全发出去,要分批、甚至加随机延迟发送 3. **隐私与合规**:上报数据前,务必对敏感信息(如Token、用户手机号)进行脱敏处理 标签: none
评论已关闭