移动端和弱网环境下的数据提交一直是Web应用的薄弱环节。想象一个外勤人员填报表单的场景:填了二十多个字段点提交,结果信号中断,请求失败,数据也跟着丢了。要解决这个问题,比较成熟的做法是离线优先架构——数据先落本地库,再择机同步服务端。本地库选SQLite,网络状态感知交给浏览器原生的Network Information API,两者配合可以做出体验相当不错的方案。这篇文章就围绕这套组合,从原理到代码完整走一遍。

一、为什么选SQLite作为本地缓存层
先说存储选型。浏览器端可选的本地存储方案不少,localStorage容量小且只能存字符串,IndexedDB功能够用但API繁琐、事务模型难驾驭。SQLite的优势在于它是真正的关系型数据库,SQL能力完整,支持事务、索引、联表查询,如果你后端本来就用关系型数据库,本地端用同一套建模思路几乎可以无缝迁移。
在浏览器端运行SQLite,目前主流方案是sql.js。它把SQLite编译成WebAssembly,体积大约1MB左右,初始化后整个数据库在内存中运行,需要配合localStorage或IndexedDB做持久化导出。而在Node.js端,比如Electron桌面应用或者本地服务,直接用better-sqlite3这个原生绑定包,性能和稳定性都更好。下面是一个Node.js端建立同步队列表的例子:
const Database = require('better-sqlite3');
const db = new Database('local-cache.db');
// 同步队列表:status为0表示待同步,1表示已同步,2表示冲突
db.exec(`
CREATE TABLE IF NOT EXISTS sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
status INTEGER DEFAULT 0,
retry_count INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_status ON sync_queue(status);
`);
function enqueue(payload) {
const stmt = db.prepare(
'INSERT INTO sync_queue (payload, created_at) VALUES (?, ?)'
);
return stmt.run(JSON.stringify(payload), Date.now());
}
enqueue({ form: 'inspection', data: { site: 'A区', score: 92 } });
console.log('数据已写入本地队列');这个表结构的关键点在于status字段和retry_count字段。断网期间数据堆积在队列里,网络恢复后批量读取status为0的记录逐条上传;某条数据上传失败则retry_count加一,超过阈值标记为冲突状态,留给人工处理。这种队列化设计比简单的"断网就存一份"要健壮得多。
二、Network Information API的能用与不能用
Network Information API通过navigator.connection对象暴露网络信息,核心属性有effectiveType(返回slow-2g、2g、3g、4g中的一个)、downlink(下行带宽估计值,单位Mbps)、rtt(往返延迟估计值)以及saveData(用户是否开启了省流模式)。它还提供了change事件,网络状况变化时会触发。
但这个API有几个坑必须提前知道。第一,它不是精确值,downlink和rtt都是浏览器基于近期请求的估算值,不能拿来当测速结果用。第二,Firefox和Safari长期不支持navigator.connection,所以判空是必须的。第三,判断"有没有网"这件事,Network Information API反而不是最佳工具,应该结合navigator.onLine属性和window上的online、offline事件一起用:
// 网络状态监听器:兼容性优先,增强信息可选
class NetworkWatcher {
constructor(onChange) {
this.onChange = onChange;
this.conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
}
getInfo() {
return {
online: navigator.onLine, // 是否在线(基础判断)
effectiveType: this.conn ? this.conn.effectiveType : 'unknown', // 网络类型估算
saveData: this.conn ? this.conn.saveData : false
};
}
start() {
window.addEventListener('online', () => this.onChange(this.getInfo()));
window.addEventListener('offline', () => this.onChange(this.getInfo()));
// 网络类型变化(如从4g降到3g)也会触发
if (this.conn) {
this.conn.addEventListener('change', () => this.onChange(this.getInfo()));
}
}
}
new NetworkWatcher(info => {
if (info.online) {
console.log('网络恢复,类型:' + info.effectiveType);
} else {
console.log('已断网,切换到本地缓存模式');
}
}).start();实践中的一个经验是:不要只在online事件触发时同步一次就完事。online事件只说明系统网络栈认为自己上线了,实际请求仍可能失败,所以同步逻辑要做成可重试的循环,把"事件触发"当作"尝试同步的时机"而不是"同步成功的保证"。
三、完整实战:断网写入、联网批量同步
把前两部分拼起来,就是一个完整的离线采集页面。整体流程是:用户提交表单时先判断网络状态,在线则直接请求服务端、失败再降级入队;离线则直接写SQLite队列并给用户明确的"已保存到本地"提示。网络恢复事件触发后,从队列批量取数据上传,成功则更新status,失败则累加重试计数。
// 浏览器端基于sql.js的简化实现
let db, SQL;
async function initDB() {
SQL = await initSqlJs({ locateFile: f => f + '.wasm' });
db = new SQL.Database();
db.run(`CREATE TABLE IF NOT EXISTS sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
status INTEGER DEFAULT 0
)`);
}
function saveOffline(payload) {
db.run('INSERT INTO sync_queue (payload, created_at) VALUES (?, ?)',
[JSON.stringify(payload), Date.now()]);
persistToIndexedDB(); // 把内存数据库导出持久化,防止刷新丢失
showToast('当前离线,数据已保存到本地');
}
async function syncQueue() {
const rows = db.exec("SELECT id, payload FROM sync_queue WHERE status = 0 LIMIT 50");
if (!rows.length) return;
for (const row of rows[0].values) {
const [id, payload] = row;
try {
const res = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload
});
if (res.ok) {
db.run('UPDATE sync_queue SET status = 1 WHERE id = ?', [id]);
}
} catch (e) {
// 网络又断了,下次再试
break;
}
}
persistToIndexedDB();
}
window.addEventListener('online', syncQueue);
// 页面加载时也尝试一次,覆盖"用户在离线状态下关闭页面"的情况
window.addEventListener('load', () => navigator.onLine && syncQueue());提交入口的判断逻辑建议写成"先乐观请求、失败兜底入队"的形式。也就是不管在线与否都先尝试发请求,请求失败或超时再写入本地队列。这样能避免对navigator.onLine的过度依赖——这个属性在某些虚拟网卡环境下会误报在线,真实请求的结果才是最可靠的状态来源。
四、冲突处理与数据一致性
离线同步绕不开冲突问题。用户在断网时改了一条数据,服务端这条数据可能已经被别人改过。常见的处理策略有三种:服务端优先(直接覆盖本地)、客户端优先(覆盖服务端)、字段级合并(按更新时间戳逐字段取舍)。对于表单采集类应用,推荐给每条记录带上updated_at时间戳和一个由客户端生成的UUID,服务端收到同步请求后比对时间戳,发现服务端版本更新就返回冲突标记,前端把这条记录置为status=2并提示用户手工处理。
另一个细节是幂等性。批量同步过程中如果页面刷新,可能出现同一批数据被重复上传的情况。只要服务端接口以UUID做唯一键、重复写入时返回成功即可,这样重试就变成安全操作,整个同步流程才算真正可靠。
总结一下,SQLite提供了扎实的关系型本地存储底座,Network Information API和online/offline事件负责感知时机,中间再垫一层带重试和状态标记的同步队列,一个能扛住弱网环境的离线优先应用就成型了。建议先在自己的项目里用最简版本跑通"断网写入、联网重放"这条主线,再逐步补上冲突处理和持久化导出,风险最小、见效最快。
SQLiteNetwork Information API离线同步修改时间:2026-09-07 15:10:50