断网、弱网、关页都不怕:前端日志上报怎么做到不丢包
系列回顾(性能·错误·埋点三部曲):不做工具人|从 0 到 1 手搓前端监控 SDK
在前三篇文章中,我们搞定了性能、行为和错误的采集。但有掘友在评论区灵魂发问:“数据是抓到了,
发不出去有啥用?进电梯断网了咋办?页面关太快请求被掐了咋办?”今天这篇,我们就来聊聊如何上报数据?
- 用什么方式上报最稳、最省事?
- 什么时候上报最合适?
- 遇到断网/弱网/关页怎么兜底?
一、上报方式与策略:如何选出最优解?
我们平时上报数据主要有三种方式:Image(图片请求)、sendBeacon 和 XHR/Fetch。
1. 三种上报方式详解
1. GIF/Image
这招就是利用图片请求(new Image().src)来传数据。
- 原理很简单:把要上报的数据拼在 URL 后面(如
https://log.demo.com/gif?id=123),浏览器发起请求,服务器解析参数拿到数据,然后返回一张 1×1 的透明GIF图(体积小、看不见),浏览器收到后触发onload回调即完成上报。 - 特点:天然支持跨域,绝无“预检”请求(因为是简单请求)。
- 局限:只能发 GET 请求,URL 长度有限(通常 < 2KB),无法携带大数据。
2. sendBeacon
- 原理:
navigator.sendBeacon(url, data)。浏览器会将数据放入后台队列,即使页面关闭了,浏览器也会尽力发送。 - 特点:异步非阻塞(不卡主线程),且可靠性极高。
- 局限:数据量有限(约 64KB),且无法自定义复杂的请求头。
3. XHR / Fetch
普通的网络请求。
- 原理:使用
XMLHttpRequest或fetch发送 POST 请求。 - 特点:容量极大(几兆都没问题),适合发送录屏、长堆栈。
- 局限:跨域时通常会触发
OPTIONS预检(成本高),且页面关闭时请求容易被掐断(fetch需配合keepalive)。
注: 所谓的预检,就是浏览器在发送跨域且非简单请求前,先偷偷发个 OPTIONS 问服务器:“大佬,我能发这个请求吗?”。只要你用了自定义 Header 或 application/json 就会触发。这会导致请求量直接翻倍,在弱网下多一次往返就多一分失败的风险。
2. 策略篇:如何组合使用?
怎么选并不是随意决定的,而是为了解决两个核心痛点:
- 成本问题(CORS 预检):所谓的预检,就是浏览器在发送跨域且非简单请求前,先偷偷发个
OPTIONS问服务器:“大佬,我能发这个请求吗?”。- 什么时候触发? 只要你用了自定义 Header(如
X-Token)或者Content-Type: application/json,就会触发。 - 后果是啥? 请求量直接翻倍,弱网下成功率腰斩。
- 避坑指南:这也是为什么很多监控SDK通常都故意使用
text/plain来发送 JSON 数据。虽然数据格式是 JSON,但告诉浏览器“这是纯文本”,就能骗过预检,直接发送!
- 存活问题(页面卸载):用户关闭页面时,浏览器通常会直接掐断挂起的异步请求,导致“临终遗言”发不出去。
基于这两个维度,我们将三种方式排个序,也就形成了我们的降级策略:
1. 首选方案:sendBeacon(六边形战士)
这是现代浏览器的首选方案。
- 优势:专为监控设计,页面关闭了也能发(浏览器将其放入后台队列)。
- 特点:容量适中(~64KB),且通常不触发预检,完美平衡了“存活”与“成本”。
- 适用:绝大多数监控事件。
2. 降级方案:GIF/Image(老牌救星)
当 sendBeacon 不可用(如 IE)或数据极小的时候用它。
- 优势:天然跨域,绝无预检。利用
new Image().src发起请求,服务器返回一张 1x1 透明图即可。 - 特点:兼容性无敌,但数据量受 URL 长度限制(~2KB),且页面关闭时发送成功率低。
- 适用:PV、点击、心跳等轻量指标。
3. 兜底方案:XHR / Fetch
只有前两招搞不定时(数据量太大)才用它。
- 优势:容量极大,适合传录屏、大段错误堆栈。
- 劣势:跨域麻烦(需配 CORS),有预检成本。
- 注意:使用 Fetch 时务必加
keepalive: true,告诉浏览器“就算页面关了也别杀我”,尽量提升卸载时的成功率。
选型对比表
| 方案 | 跨域/预检 | 卸载可靠性 | 数据容量 | 核心优势 | 适用场景 |
|---|---|---|---|---|---|
| sendBeacon | 支持 / 无预检 | 高 | 中 (~64KB) | 关页也能发,不占主线程 | 首选,大多数监控事件 |
| GIF/Image | 支持 / 无预检 | 低 | 小 (~2KB) | 兼容性强,无预检 | 降级方案,PV/点击/心跳 |
| XHR/Fetch | 需 CORS / 有 | 低 | 大 | 能传大数据 | 错误堆栈、录屏 |
总结我们的代码套路(降级策略):
- 小包(< 2KB,单条事件):优先
sendBeacon;若不支持,再走ImageGET(附_ts防缓存)。 - 中包(≤ 64KB):
sendBeacon为首选;若不支持,回退到Fetch/XHR,Content-Type: text/plain+keepalive: true。 - 大包(> 64KB):
Fetch/XHR承载,必要时拆包分批发送。
下面是封装好的 transport上报函数,直接拿去用:
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<void>} - 成功 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 秒发一次(防止数量不够一直不发)。
代码怎么写?其实就是一个简单的双保险调度器:
let queue = [];
let timer = null;
const QUEUE_MAX = 10;
const QUEUE_WAIT = 5000;
function flush() {
if (!queue.length) return;
// 1. 把当前队列的数据复制出来
const batch = queue.slice();
// 2. 清空队列与定时器
queue.length = 0;
clearTimeout(timer);
timer = null;
// 3. 利用空闲时间发送(性能优化点)
if ('requestIdleCallback' in window) {
requestIdleCallback(() => transport(batch), { timeout: 2000 });
} else {
// 降级兼容
setTimeout(() => transport(batch), 0);
}
}
function report(log, immediate = false) {
// 1. 紧急情况:绕过队列,直接发
if (immediate) {
transport(log);
return;
}
// 2. 普通情况:进入队列(如 点击、PV)
queue.push({ ...log, ts: Date.now() });
// 3. 检查触发条件(双重保险)
if (queue.length >= QUEUE_MAX) {
flush();
} else if (!timer) {
timer = setTimeout(flush, QUEUE_WAIT);
}
}
// 4. 临终兜底:页面关闭/隐藏时,强制把剩下的都发走
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'hidden') flush();
});
window.addEventListener('pagehide', flush);
整体思路:队列暂存 + 多重触发
我们用一个数组(queue)来暂存日志,然后通过 “量够了”、“时间到了”或“页面要关了” 这三个时机来触发发送,确保既不积压也不频繁打扰服务器。
性能优化:闲时优先
发送时,我们首选 requestIdleCallback。告诉浏览器你先忙你的(渲染、响应点击),等你有空了再帮我发监控数据
- 这样能最大限度减少对业务主线程的阻塞,让用户感觉不到监控的存在。
- 当然,如果浏览器不支持这个 API,我们再降级用
setTimeout兜底。
2. 容灾层:断网了,日志怎么办?
如果在电梯里断网了或者弱网环境下,请求发不出去怎么办?日志丢了怎么办。
我们的策略是 “先记在本子上,等有网了再补交作业”:
- 断网时:把日志存到
localStorage里(注意设置上限,别把用户浏览器撑爆了,可用IndexedDB优化)。 - 连网时:监听
online事件,把存的日志拿出来,分批发给服务器(别一次性全发过去,容易把后端打挂)。
具体怎么判断有没有网呢?
通常我们用 navigator.onLine 来看。如果返回值是 false ,那肯定是没网,直接存本地。
但坑就坑在,这玩意儿有时候会 “撒谎” —— 比如连上了酒店 WiFi 但没登录,或者宽带欠费了。这时候它虽然显示 true (在线),但其实根本上不了网。
所以咱们得留一手:
哪怕它说“在线”,我们也先试着上报一下。 要是报错了发不出去,别管三七二十一,先把这条日志存本地保底(千万别丢数据),然后再去 Ping 一下看看到底是不是真断网了 ,顺便更新一下网络状态。这样最稳。
1. 网络状态的检测
NetworkManager这个模块专门负责盯着网络,它很聪明,只有在发送日志失败的时候才会去复核网络真伪。
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;
}
}
};
2. 核心上报:能发就发,不行就存本地
上报函数现在变得非常有弹性。
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. 然后再来诊断网络,决定后续策略
// 只有当是网络层面的错误(如 fetch throw Error)才去怀疑网络
// 如果是 500 错误,其实网是通的,不用 forceOffline
if (isNetworkError(err)) {
// 5. Ping 确认
NetworkManager.verify().then(res => NetworkManager.online = res);
}
}
}
/**
* 判断是否为网络层面的错误
*/
function isNetworkError(err) {
// 原生 fetch 的网络错误通常是 TypeError: Failed to fetch
// 如果是使用 Axios,则可以通过 !err.response 来判断
return err instanceof TypeError || (err.request && !err.response);
}
const RETRY_KEY = 'RETRY_LOGS';
const RETRY_MAX_ITEMS = 1000;
function saveToLocal(data) {
const raws = localStorage.getItem(RETRY_KEY);
const logs = raws ? JSON.parse(raws) : [];
logs.push(data);
if (logs.length > RETRY_MAX_ITEMS) {
logs.splice(0, logs.length - RETRY_MAX_ITEMS);
}
localStorage.setItem(RETRY_KEY, JSON.stringify(logs));
}
3. 补传逻辑:别把服务器干崩了
等到网络恢复,本地攒了一堆“欠账”,千万别一股脑儿全发过去(万一本地存了 500 条,一次全发会把服务器打爆的)。
我们要有节奏地补传:
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));
}
}
三、总结与实战建议
监控上报这事儿看着不难,其实门道不少。要在数据不丢和不打扰用户之间找平衡,咱们得来一套“组合拳”:
- 上报方式:sendBeacon 为主,Image 为辅,XHR/Fetch 兜底。利用
sendBeacon的特性解决页面卸载时的丢包问题,利用Image解决跨域预检的成本问题。 - 上报时机:闲时上报 + 批量打包。利用
requestIdleCallback不占用主线程,通过队列机制减少 HTTP 请求频次。 - 断网处理:本地缓存 + 网络侦测。断网时将数据持久化到 LocalStorage,待网络恢复后分批补传,确保“一条都不丢”。
最后,给开发者的 3 个避坑小贴士:
- 不要迷信
navigator.onLine:它只能判断有没有连接到局域网,不能判断是否真的能上网。一定要配合实际的请求探测。 - 控制补传节奏:网络恢复后,千万别一次性把积压的几百条日志全发出去,这属于“DDoS 攻击”自家服务器。要分批、甚至加随机延迟发送。
- 隐私与合规:上报数据前,务必对敏感信息(如 Token、用户手机号)进行脱敏处理,这是红线。
如果你有更好的思路,欢迎在评论区交流!
来源:juejin.cn/post/7596247009815412762