场景
油猴脚本 Hook XHR/fetch 后捕获接口数据,需要持久化保存。常见方案是 POST 到本机 http://127.0.0.1:PORT,但本地服务可能未启动或崩溃,导致丢失数据。
双写方案:先写 IndexedDB(浏览器本地,无外部依赖),再异步 POST 到本地服务,两者互为备份。
IndexedDB 封装
const DB_NAME = 'XhrBackup';
const STORE_NAME = 'records';
let db = null;
function initDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = (e) => {
const store = e.target.result.createObjectStore(STORE_NAME, {
keyPath: 'id',
autoIncrement: true
});
store.createIndex('fileName', 'fileName', { unique: false });
};
req.onsuccess = (e) => {
db = e.target.result;
resolve(db);
};
req.onerror = (e) => reject(e.target.error);
});
}
function saveToIDB(fileName, content) {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
store.add({ fileName, content, savedAt: Date.now() });
tx.oncomplete = resolve;
tx.onerror = (e) => reject(e.target.error);
});
}
队列去重与批量 flush
频繁触发(如分页列表接口每页都调用)时需要去重,避免重复保存同一数据:
const savedSet = new Set();
const queue = [];
let flushTimer = null;
function enqueueSave(fileName, content) {
if (savedSet.has(fileName)) return; // 去重
savedSet.add(fileName);
queue.push({ fileName, content });
// 1 秒防抖,批量 flush
if (!flushTimer) {
flushTimer = setTimeout(flushQueue, 1000);
}
}
function flushQueue() {
const batch = [...queue];
queue.length = 0;
flushTimer = null;
batch.forEach(item => saveItem(item.fileName, item.content));
}
双写实现
async function saveItem(fileName, content) {
// 1. 先写 IndexedDB(不依赖本地服务)
try {
await saveToIDB(fileName, content);
console.log('✅ IDB 保存成功:', fileName);
} catch (e) {
console.error('❌ IDB 保存失败:', fileName, e);
}
// 2. 再 POST 到本地服务(可选)
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
fetch('http://127.0.0.1:8099/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName, content }),
signal: controller.signal
})
.then(res => {
clearTimeout(timeout);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
console.log('✅ 服务保存成功:', fileName);
})
.catch(err => {
clearTimeout(timeout);
if (err.name === 'AbortError') {
console.warn('⚠️ 服务超时,已保留 IDB 备份:', fileName);
} else {
console.error('❌ 服务保存失败(IDB 已备份):', fileName, err.message);
}
});
}
XHR Hook 集成
const originOpen = XMLHttpRequest.prototype.open;
const originSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this._method = method;
this._url = url;
return originOpen.apply(this, [method, url, ...rest]);
};
XMLHttpRequest.prototype.send = function (body) {
this.addEventListener('readystatechange', function () {
if (this.readyState !== 4) return;
// 匹配目标接口 URL,按业务修改正则
if (/\/api\/data\/\d+/.test(this._url)) {
const url = new URL(this._url, location.origin);
const id = url.searchParams.get('id') || Date.now();
enqueueSave(`data/${id}_response.json`, this.responseText);
}
});
return originSend.apply(this, arguments);
};
读取 IndexedDB 中已保存的数据
function getAllRecords() {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = (e) => reject(e.target.error);
});
}
// 控制台查看所有备份
getAllRecords().then(records => {
console.log(`共 ${records.length} 条备份`);
console.table(records.map(r => ({ id: r.id, fileName: r.fileName, savedAt: new Date(r.savedAt).toLocaleString() })));
});
初始化入口
// 油猴脚本主入口
(async function () {
await initDB();
// 在这里挂 XHR Hook
console.log('备份系统就绪');
})();
IndexedDB 在同源页面(同协议+域名+端口)下持久存储,浏览器关闭后数据不丢失,只有手动清除浏览器数据时才会清空。
