Chrome 插件实战:如何实现“杀不死”的可靠数据上报?
最近有一个需求:“监控用户怎么用标签组(Tab Groups),打开了啥,关闭了啥,统统都要记下来上报给服务器!”
如下就是一个标签组:
初看这个需求,似乎很简单:监听一下事件,调接口上报一下完事儿。
但仔细一想,为了保证数据的可靠性,还有几个“隐形坑”必须填上:
- 用户网断了怎么办? 数据不能丢,等网好了得自动补发
- 用户直接 Alt+F4 关浏览器怎么办? 必须在浏览器被杀死的瞬间,或者下次启动时把关闭日志数据发出去。
- 高频操作怎么办? 如果用户一秒钟关了 20 个组,不能卡顿,数据写入也不能错乱、丢失。
- 服务器挂了怎么办? 本地不能无限存,否则会把用户浏览器撑爆。
核心策略:
解决方案一句话总结:
监听标签组的开启和关闭,开启或关闭的时候,产生的日志第一时间先写到本地硬盘(Storage)中,然后再尝试上报到服务端,只有当上报成功了才从本地存储删。只要没删,就依靠定时任务死磕到底。
流程设计
-
拦截事件:监听
chrome.tabGroups的onCreated和onRemoved。 -
持久化 (Persist):第一时间将数据写入
chrome.storage.local。哪怕下一毫秒浏览器崩溃,数据也在硬盘里。 -
上报 (Report):使用fetch尝试发送 HTTP 请求(开启
keepalive)。 -
提交 (Commit):
- 如果成功:从本地存储中删除该条记录。
- 如果失败:保留记录,等待重试。
为什么不用 navigator.sendBeacon?
你可能会想到用 navigator.sendBeacon 来解决关闭浏览器时的数据丢失问题。
确实,sendBeacon 是为了“页面卸载”场景设计的,但它有两个致命缺点:
-
无法获取服务器响应:它只返回
true/false表示“是否放入队列”,不代表服务器处理成功。 -
无法做“成功即删”:我们的 WAL 策略要求 只有服务器返回 200 OK,才从本地删除数据。如果用
sendBeacon,我们不知道是否发送成功,就无法安全地删除本地数据(删了可能丢,不删可能重)。
因此,我们选择 fetch 配合 keepalive: true。
一句话总结:fetch + keepalive 能覆盖 sendBeacon 的“卸载场景尽量发出去”的能力,同时我们还能拿到响应状态码,从而做到“确认服务端收到了才删除本地”。
参考链接:developer.mozilla.org/en-US/docs/…
关键实现细节
Chrome 插件里的 chrome.storage 读写是异步的,所以会有竞态问题。
前提: 我们为了管理方便,通常会把所有日志放在同一个 Key(例如 logs)下的一个数组里。正是因为大家抢着改这同一个数组,才出了事。
为什么单线程也有竞态?
JS 是单线程的,但 await 会挂起当前任务并释放主线程的控制权。在 await get() 和 await set() 之间,其他事件处理函数可能插入执行并修改数据。
const task = (group) => {
// ...
const data = await chrome.storage.local.get(...); // 暂停,释放控制权
// ... (此时其他事件可能插入执行,修改了 storage) ...
await chrome.storage.local.set(...); // 写入,可能会覆盖别人的修改
// ...
}
// 标签组关闭的时候触发
chrome.tabGroups.onRemoved.addListener(task);
举个例子:假设你创建了两个标签页分组,这两个标签组同时关闭(A 和 B),就触发标签组关闭事件,就会触发两次task函数的执行。
-
Task A:执行
get(),读取到[1]。准备写入3,遇await挂起。 -
Task B:因为 A 暂停了,JS 引擎转而处理 taskB。执行
get()。因 A 尚未写入3,B 读取到的仍是[1]。准备写入4,遇await挂起。 -
Task A:恢复。内存数据变为
[1,3]。执行set()写入硬盘。 -
Task B:恢复。内存数据变为
[1, 4]。执行set()写入硬盘。
结果:最终设置进存储的是 [1, 4],数据 3 被 B 的写入覆盖丢失了!这就是经典的“读-改-写”竞争。
解决方案
为了解决这个问题,我们可以利用 Promise 链实现一个简单的“任务队列”,强制所有存储操作排队执行:
// 全局任务队列
let globalTaskQueue = Promise.resolve();
/**
* 串行执行器:无论外界如何并发调用,内部永远排队执行
*/
function runSequentially(task) {
// 1. 把新任务拼到队列尾部
// 无论之前的任务有没有做完,新任务都得排在 globalTaskQueue 后面执行
const next = globalTaskQueue.then(() => task());
// 2. 更新队列指针
// 关键点:如果 next 失败(Rejected),catch错误,防止一个任务失败阻塞整个队列,
// catch会返回一个新的 Resolved Promise
// 所以 globalTaskQueue 总是指向一个“健康”的 Promise,确保后续任务能接上
globalTaskQueue = next.catch(() => {});
return next;
}
// 使用示例
async function saveReport(report) {
const task = async () => {
const data = await chrome.storage.local.get(['reports']);
// ... 读写逻辑 ...
await chrome.storage.local.set({ reports: newData });
};
return runSequentially(task);
}
原理解析:
这就好比 排队做核酸。globalTaskQueue 就是队伍的最后一个人。
-
初始状态:队伍里没人(
Promise.resolve())。 -
A 来了:调用
runSequentially(TaskA)。-
globalTaskQueue.then(() => TaskA()):A 站在了队伍最后。 -
globalTaskQueue更新指向 A。
-
-
B 来了:调用
runSequentially(TaskB)。- 此时
globalTaskQueue指向 A。 -
A.then(() => TaskB()):B 站在了 A 后面。哪怕 A 还在做(pending),B 也得等着。 -
globalTaskQueue更新指向 B。
- 此时
为什么要 .catch(() => {})?
如果不加 catch,万一 A 做核酸时晕倒了(抛出 Error),整个 Promise 链就会中断(Rejected),导致排在后面的 B、C、D 全都无法执行。 加上 catch 后,相当于把晕倒的 A 抬走,队伍继续往下走,B 依然能正常执行。
思考:能不能通过拆分 Key 来避免竞态问题?
你可能会提出:“能不能把每个标签组日志存成独立的 Key(如 report-分组id),读取时遍历所有 report- 开头的 Key?这样不就完全避免了数组并发读写冲突了吗?”
这方案可行,且非常巧妙!
优点:
-
天然无锁(各写各的):A 写入
report-A,B 写入report-B。这就好比大家各自在自己的本子上写字,而不是去抢同一块黑板。既然资源不共享,自然就不需要“排队”或“加锁”,彻底根治了并发冲突。 -
性能极高:写入是 O(1) 的纯追加操作。
缺点:
- Key 污染:chrome.storage` 就像一个抽屉。如果你往里塞了 1000 张“小纸条”(独立的 Key),当你想要找别的东西(比如配置项)时,会被这些碎纸条淹没,调试的时候简直要疯。
-
找起来慢(全量扫描):虽然写的时候快,但读的时候慢死了。每次启动补发数据,你必须把抽屉彻底翻个底朝天(
get(null)),把所有东西倒在桌上,再一张张挑出是日志的纸条。数据一多,这操作卡得要命。
2. 双重重试机制 (保证最终一致性)
当浏览器被直接关闭时,插件进程不会瞬间消失。浏览器会先关闭所有标签和分组,这会触发插件的 onRemoved 事件。我们利用这最后几百毫秒的“回光返照”时间,接收关闭消息并将数据抢先存入本地硬盘,然后再尝试进行数据上报。
不过还是会有数据积压到本地的情况,“不是说日志上报成功了就删吗?为什么本地还会有积压数据?”
没错,理想情况下本地存储应该是空的。但在现实世界中,意外无处不在:
- 用户断网了(比如连着 Wi-Fi 但没外网,或者在飞机上)。
- 服务器挂了(接口返回 500 或超时)。
- 浏览器崩溃:虽然崩溃瞬间插件无法监听新事件,但之前已经存入硬盘的任务可能还没来得及发出去(或者发到一半进程没了),这些数据依然安全地躺在硬盘里。
在这些情况下,数据发不出去,就必须滞留在本地等待下一次机会。我们需要建立一套机制,把这些“漏网之鱼”捞出来重发。
-
时机一:浏览器启动时 (
onStartup) 用户再次打开浏览器时,说明环境可能恢复了(比如连上了网),这是补发积压数据的绝佳时机。 -
时机二:定时器轮询 (
alarms) 如果用户一直不关闭浏览器,我们也不能干等。利用chrome.alarms设置一个每 5 分钟的定时任务。灵魂拷问:为什么不用
setInterval?说白了就一句话:MV3版本插件的 Service Worker 不会一直在线。
它是事件驱动的:浏览器有事件推送到Service Worker的话就起来干活;活干完、并且一会儿没新事件,浏览器就把它挂起/回收(内存清空)省资源。
-
setInterval/setTimeout:本质是“内存里自己数秒”。Service Worker 一被挂起/回收,计时器直接断电,你就别指望它“每 5 分钟准点打卡”了。 -
chrome.alarms:浏览器帮你托管的闹钟。时间到了就发alarms.onAlarm事件,必要时还能把 Service Worker 叫醒来处理。
结论很简单:想要靠谱的定时重试,用
chrome.alarms;setInterval适合页面这种常驻环境里的小轮询。 -
// 监听定时器触发:浏览器到点会派发事件,必要时唤醒 SW
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === ALARM_NAME) processPendingReports();
});
// JS 版伪代码:读本地 -> 丢弃过期 -> 逐条上报 -> 上报成功才删除(失败继续留着等下次)
async function processPendingReports() {
const reports = (await storageGet('pending_reports')) ?? [];
const pending = removeExpired(reports);
if (pending.length !== reports.length) {
await storageSet('pending_reports', pending);
}
for (const report of pending) {
const ok = await sendReport(report);
if (ok) await storageRemove('pending_reports', report.id);
}
}
// 说明:这里的 storageGet/storageSet/storageRemove 是为了讲清流程的伪函数,
// 这几个是对chrome.storage.local.get/set的封装。
3. 自我保护机制 (防堆积)
背景知识:
数据存储在
storage.local中,并在移除扩展程序时自动清除。存储空间限制为 10 MB(在 Chrome 113 及更早版本中为 5 MB),但可以通过请求"unlimitedStorage"权限来增加此限制。默认情况下,它会向内容脚本公开,但可以通过调用chrome.storage.local.setAccessLevel()来更改此行为。 参考:Chrome Storage API
尽管有 10MB 甚至无限的空间,但如果服务器彻底挂了,或者用户处于断网环境、秒关浏览器,本地数据依然会无限膨胀,最终影响性能。
所以我们需要设置熔断机制。
- 容量限制:最多保留 N 条(例如 1000 条),新数据挤占旧数据。
- 有效期限制:数据产生超过 7 天未上报成功,视为过期数据直接丢弃。
- 数据压缩:如果单条日志比较大(URL 很长/字段很多),可以考虑把数据压缩后再存。
代码示例:Step 1 - 存储与压缩
const MAX_REPORTS = 1000;
const REPORT_EXPIRATION_MS = 7 * 24 * 60 * 60 * 1000; // 7天
const STORAGE_KEY = 'pending_reports';
async function saveReport(newReport) {
// 使用之前定义的串行锁,防止并发冲突
return runSequentially(async () => {
// 1. 读取现有数据
const result = await chrome.storage.local.get([STORAGE_KEY]);
let reports = result[STORAGE_KEY] || [];
// 2. 追加新报告 (可选:先进行压缩)
// 使用原生 CompressionStream (Gzip) 进行压缩,能大幅节省空间
const reportToSave = await compressReport(newReport);
reports.push(reportToSave);
// 3. 执行熔断策略(自我保护)
const now = Date.now();
// 3.1 有效期限制:过滤掉过期的
reports = reports.filter(r => (now - r.timestamp) <= REPORT_EXPIRATION_MS);
// 3.2 容量限制:如果还超标,剔除最旧的
if (reports.length > MAX_REPORTS) {
reports.shift();
}
// 4. 写回硬盘
await chrome.storage.local.set({ [STORAGE_KEY]: reports });
});
}
/**
* 使用原生 CompressionStream API 进行 Gzip 压缩
* 流程:JSON -> String -> Gzip Stream -> ArrayBuffer -> Base64
*
* 为什么要这么转?
* 1. CompressionStream 只接受流(Stream)作为输入。
* 2. chrome.storage 只能存储 JSON 安全的数据(字符串/数字/对象),不能直接存二进制(ArrayBuffer/Blob)。
* 3. 所以必须把压缩后的二进制数据转成 Base64 字符串才能存进去。
*/
async function compressReport(report) {
// 1. 转字符串
const jsonStr = JSON.stringify(report);
// 2. 创建压缩流
const stream = new Blob([jsonStr]).stream().pipeThrough(new CompressionStream('gzip'));
// 3. 读取流为 ArrayBuffer
const compressedResponse = await new Response(stream);
const blob = await compressedResponse.blob();
const buffer = await blob.arrayBuffer();
// 4. 转 Base64 存储 (storage 不支持直接存二进制 Blob)
return {
id: report.id || Date.now(),
timestamp: report.timestamp,
// 标记这是压缩数据
isCompressed: true,
data: arrayBufferToBase64(buffer)
};
}
// 辅助函数:ArrayBuffer 转 Base64
function arrayBufferToBase64(buffer) {
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
代码解释:
-
关于
saveReport的熔断逻辑:- runSequentially:这就是我们前面提到的“排队做核酸”,防止同时写文件导致数据错乱。
- filter 过期数据:每次写入前,顺手把 7 天前的“老古董”清理掉,保持队列新鲜。
-
shift 剔除旧数据:如果队列满了(超过 1000 条),就狠心把最老的那条删掉,给新数据腾位置。 (注意:虽然可以申请
unlimitedStorage获得无限空间,但CPU/内存和序列化/反序列化开销仍然存,。如果队列太长,每次读取都会卡顿,所以必须限制数量。)
-
关于
compressReport的二进制转换:-
new Response(stream):这其实是个偷懒的小技巧。
CompressionStream吐出来的是个流(Stream),要把它变成我们能处理的二进制块(ArrayBuffer),按理说得写个循环一点点读。但浏览器的Response对象自带了“把流一口气吸干并转成 Blob”的功能,所以我们借用它来省去写循环读取的麻烦。 -
Base64 转码:
chrome.storage比较娇气,它只能存字符串或 JSON 对象,存不了二进制数据(ArrayBuffer)。如果你直接把压缩后的二进制扔进去,它会变成一个空对象{}。所以我们需要把二进制数据“编码”成一串长长的字符串(Base64),存的时候存字符串,取的时候再还原回去。
-
new Response(stream):这其实是个偷懒的小技巧。
代码示例:Step 2 - 读取与上报
既然存进去了,怎么发出来呢?可以直接发 Base64 吗?
可以,但没必要。 即使算上 Base64 的 33% 膨胀,压缩后(100KB -> 26.6KB)依然血赚。但转回 Binary 有两个核心优势:
- 极致省流:把那 33% 的膨胀再压回去(26.6KB -> 20KB)。
-
不给后端找麻烦:只要加上
Content-Encoding: gzip,服务器网关(Nginx)会自动解压,后端业务代码拿到的直接就是 JSON。如果你发 Base64,后端还得专门写代码先解码再解压,容易被同事吐槽。
这里就轮到 base64ToUint8Array 登场了:
// 辅助函数:Base64 转 Uint8Array
function base64ToUint8Array(base64) {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
async function sendReport(report) {
let body = report;
const headers = { 'Content-Type': 'application/json' };
if (report.isCompressed) {
// 1. Base64 -> 二进制 (还原体积)
// 这一步至关重要!如果不转回二进制直接发 Base64,流量会白白增加 33%
const binaryData = base64ToUint8Array(report.data);
// 2. 直接发送二进制,并告诉服务器:“我发的是 Gzip 哦”
body = binaryData;
headers['Content-Encoding'] = 'gzip';
} else {
// 兼容旧数据
body = JSON.stringify(report);
}
await fetch('https://api.example.com/log', {
method: 'POST',
headers: headers,
body: body,
keepalive: true
});
}
总结:数据流转全景
-
存(Storage):
JSON->Gzip->Base64(为了存 Storage) -
发(Network):
Base64->Binary->Network(利用 Content-Encoding: gzip)
完整流程图
总结
在前端(尤其是离线优先或插件环境)做数据上报,“即时发送”是不可靠的。通过引入本地存储作为缓冲区,配合串行锁、定时重试和容量控制,我们构建了一个健壮的日志上报系统。