注册
web

断网、弱网、关页都不怕:前端日志上报怎么做到不丢包


系列回顾(性能·错误·埋点三部曲):不做工具人|从 0 到 1 手搓前端监控 SDK


在前三篇文章中,我们搞定了性能、行为和错误的采集。但有掘友在评论区灵魂发问:“数据是抓到了,
发不出去有啥用?进电梯断网了咋办?页面关太快请求被掐了咋办?”


今天这篇,我们就来聊聊如何上报数据



  • 用什么方式上报最稳、最省事?
  • 什么时候上报最合适?
  • 遇到断网/弱网/关页怎么兜底?


一、上报方式与策略:如何选出最优解?


我们平时上报数据主要有三种方式:Image(图片请求)sendBeaconXHR/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


普通的网络请求。



  • 原理:使用 XMLHttpRequestfetch 发送 POST 请求。
  • 特点容量极大(几兆都没问题),适合发送录屏、长堆栈。
  • 局限:跨域时通常会触发 OPTIONS 预检(成本高),且页面关闭时请求容易被掐断(fetch需配合 keepalive)。

: 所谓的预检,就是浏览器在发送跨域且非简单请求前,先偷偷发个 OPTIONS 问服务器:“大佬,我能发这个请求吗?”。只要你用了自定义 Header 或 application/json 就会触发。这会导致请求量直接翻倍,在弱网下多一次往返就多一分失败的风险。


2. 策略篇:如何组合使用?


怎么选并不是随意决定的,而是为了解决两个核心痛点:



  1. 成本问题(CORS 预检):所谓的预检,就是浏览器在发送跨域且非简单请求前,先偷偷发个 OPTIONS 问服务器:“大佬,我能发这个请求吗?”。

    • 什么时候触发? 只要你用了自定义 Header(如 X-Token)或者 Content-Type: application/json,就会触发。
    • 后果是啥? 请求量直接翻倍,弱网下成功率腰斩。
    • 避坑指南:这也是为什么很多监控SDK通常都故意使用 text/plain 来发送 JSON 数据。虽然数据格式是 JSON,但告诉浏览器“这是纯文本”,就能骗过预检,直接发送!


  2. 存活问题(页面卸载):用户关闭页面时,浏览器通常会直接掐断挂起的异步请求,导致“临终遗言”发不出去。

基于这两个维度,我们将三种方式排个序,也就形成了我们的降级策略


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 / 有能传大数据错误堆栈、录屏



总结我们的代码套路(降级策略):



  1. 小包(< 2KB,单条事件):优先 sendBeacon;若不支持,再走 Image GET(附 _ts 防缓存)。
  2. 中包(≤ 64KB)sendBeacon 为首选;若不支持,回退到 Fetch/XHRContent-Type: text/plain + keepalive: true
  3. 大包(> 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. 容灾层:断网了,日志怎么办?


如果在电梯里断网了或者弱网环境下,请求发不出去怎么办?日志丢了怎么办。
我们的策略是 “先记在本子上,等有网了再补交作业”



  1. 断网时:把日志存到 localStorage 里(注意设置上限,别把用户浏览器撑爆了,可用IndexedDB优化)。
  2. 连网时:监听 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));
}
}

三、总结与实战建议


监控上报这事儿看着不难,其实门道不少。要在数据不丢不打扰用户之间找平衡,咱们得来一套“组合拳”:



  1. 上报方式sendBeacon 为主,Image 为辅,XHR/Fetch 兜底。利用 sendBeacon 的特性解决页面卸载时的丢包问题,利用 Image 解决跨域预检的成本问题。
  2. 上报时机闲时上报 + 批量打包。利用 requestIdleCallback 不占用主线程,通过队列机制减少 HTTP 请求频次。
  3. 断网处理本地缓存 + 网络侦测。断网时将数据持久化到 LocalStorage,待网络恢复后分批补传,确保“一条都不丢”。

最后,给开发者的 3 个避坑小贴士:



  • 不要迷信 navigator.onLine:它只能判断有没有连接到局域网,不能判断是否真的能上网。一定要配合实际的请求探测。
  • 控制补传节奏:网络恢复后,千万别一次性把积压的几百条日志全发出去,这属于“DDoS 攻击”自家服务器。要分批、甚至加随机延迟发送。
  • 隐私与合规:上报数据前,务必对敏感信息(如 Token、用户手机号)进行脱敏处理,这是红线。

如果你有更好的思路,欢迎在评论区交流!


作者:不一样的少年_
来源:juejin.cn/post/7596247009815412762

0 个评论

要回复文章请先登录注册