两年前帮一个客户搞移动端 H5 项目,那家伙,每天早上用户挤地铁的时候,填表单填到一半,网络一断,页面直接白掉。用户骂完产品产品就来骂我,我只能默默在 loading 动画上加个转圈——转得再好看,数据丢了就是丢了。
其实浏览器不是不能存数据,但大多数人用的方案实在太糙。localStorage 确实方便,setItem 加 getItem 两行代码走天下,可它容量上限就 5MB,存几个用户资料还行。一旦你的表单稍微复杂点,带几个图片字段,或者要缓存操作日志队列,5MB 连牙缝都塞不满。更坑的是它是同步 API,主线程一读写,页面直接掉帧,用户滑都滑不动。
Web SQL?那玩意 2011 年就被 W3C 判了死刑。Chrome 里还能苟着,Safari 和 Firefox 早就不维护了。拿一个废弃规范做生产项目,哪天浏览器升级直接罢工,你连摔锅的机会都没有。
IndexedDB 才是正经的浏览器原生数据库。异步 API,读写不阻塞 UI;容量只受磁盘限制,几百 MB 随便用;支持索引、事务、游标,能存结构化数据——对象、二进制、文件 Blob 都能塞进去。更重要的是,它和 Service Worker 搭配,可以实现真正的离线全流程:用户在断网时创建的数据,先落库,再通过同步队列等网络恢复后逐条发出去。
Vue 3 的组合式 API 正好给了我们一个优雅的封装外壳。把 IndexedDB 的打开连接、事务读写、版本迁移这些琐碎逻辑塞进一个 composable 里,组件里只需要调用 const { save, load, syncQueue } = useOfflineDB(),剩下的交给响应式状态去驱动 UI。
先记住一句话:离线缓存不是给 App 锦上添花,是给弱网环境下的用户一条活路。而 IndexedDB + Vue 3 的组合,是目前前端圈把这条路铺得最平的工具。
废话不多说,直接上手。
封装 IndexedDB:Vue 3 组合式 API 终结回调地狱
原生 IndexedDB 的 API 有多劝退?打开数据库、建表、跑事务、听 onsuccess 和 onerror,一套流程走下来,回调嵌套能把人绕晕。更麻烦的是数据库版本升级——这玩意看似简单,一旦没处理好,整库直接报错,页面数据全挂。
Google 出的 idb 库把 Promise 化和版本管理这两件最烦的事做了标准化封装。它对外暴露的是清晰的 async/await 接口,底层仍然操作 IndexedDB,但你再也不用手写 onupgradeneeded 和事务回调了。安装只需一条命令:pnpm add idb@5.0.6。注意锁定版本,5.x 在 Safari 15+ 和 Chrome 96+ 之间踩过兼容性坑,别随手拉 latest。
import { openDB } from 'idb/dist/idb.mjs';
我们的目标是让组件里只关心业务数据,不碰数据库琐事。创建一个泛型的 composable,传入数据库名、版本号以及需要自动创建的对象仓库配置。内部用 shallowRef 把查询结果包起来,Vue 的响应系统就能自动跟踪变化,界面跟着更新,无需手动调 $forceUpdate。
export function useIndexedDB<T = any>(
dbName: string,
version: number,
stores: DBSchema[]
) {
const state = shallowRef<T | null>(null);
// 连接、升级、CRUD 都在内部处理
return { state, get: () => state.value, set: (val: T) => { /* 写入逻辑 */ } };
}
有了 composable 外壳,增删改查就能按业务语义暴露成方法:getById、upsert、remove。所有参数都走泛型约束,TypeScript 在开发期就能帮你挡掉字段拼写错误。实际调用时,组件里只需要 const { state, upsert } = useIndexedDB(…),然后 await upsert(record),省去了 import DB 和 transaction 那套样板。
这套封装写完,你会发现离线缓存的使用体验已经接近内存变量——读写都是 Promise,没有回调深渊;数据变更自动触发 UI 刷新;刷新页面或重新打开标签,记录还在。下一章我们把同步队列塞进去,让断网重连后的数据合并不再靠人工轮询。
你在地铁里填的表单,怎么无声无息地推上去
CRUD composable 写好后,本地读写确实爽了。但离线场景真正的硬骨头不是「存」,而是「同步」——用户在地铁里填了表单,点了提交,网络断了。数据落进了 IndexedDB,可服务端那边啥也没收到。等他走出地铁站,Wi-Fi 连上了,这些「待发」的请求怎么无声无息地推上去,还不能丢、不能乱序?
核心就三个字:存 + 重试 + 推。但实现起来,细节多到想骂人。
每个待同步的操作,在 IndexedDB 里是一条记录。我给它塞了这几个字段:id(自增主键)、type(create / update / delete)、payload(业务数据序列化后的 JSON)、timestamp(客户端操作时间)、version(乐观锁版本号)、status。
status 只有三个值:pending、syncing、failed。刚插入时是 pending。开始推的时候立马改成 syncing——这一步很多人忽略,结果重试机制把同一条记录并发推了三次,服务端收到三份重复数据。改成 syncing 后,其他消费者看到这个状态就跳过。推成功了直接删记录,失败了回退成 failed 并累加重试次数。
enum SyncStatus {
Pending = 'pending',
Syncing = 'syncing',
Failed = 'failed'
}
interface SyncTask {
id?: number;
type: 'create' | 'update' | 'delete';
payload: Record<string, unknown>;
timestamp: number;
version: number;
status: SyncStatus;
retryCount: number;
maxRetries?: number;
}
重试次数我是硬编码 5 次。超过 5 次还失败?我会把它扔进一个专门的「冲突仲裁」表,等用户手动处理。别想着全自动——有些冲突机器解决不了。
轮询太丑了。浏览器给了两个好东西: 属性和 online/offline 事件。监听 online 事件后立即扫描一次队列,比每 5 秒查一次优雅得多,还省电。
window.addEventListener('online', () => {
syncQueue.processPendingTasks();
});
window.addEventListener('offline', () => {
console.warn('网络断开,操作已缓存,等待重连后自动同步');
});
但光靠这个不够。用户可能在页面加载时就已经在线了,但之前有失败的遗留任务。所以我还会在 onMounted 里调一次 syncQueue.processPendingTasks()——把 failed 状态的也捞出来重试。
还有一个坑: 在 iOS Safari 上不太靠谱,用 online 事件更准确。实测 iOS 14.x 的 WKWebView 里,属性值有时候滞后好几秒。
如果两个人离线各改了一条记录,同时推上服务端,谁的生效?
我见过最简单的做法:纯时间戳对比。谁的时间戳大,谁的覆盖谁的。但在分布式场景里,客户端时钟可能不准——用户改了系统时间、跨时区飞了一圈,时间戳就成了笑话。
更稳妥的是版本号递增。每张表(或者说每个对象仓库)加一个 version 字段,初始 1。客户端推同步请求时带上当前版本号,服务端比对:如果服务端版本号 ≥ 客户端版本号,说明发生过更新,直接拒绝。客户端收到 409 后,拉取最新的服务端数据,让用户自己决定怎么合并。
完全自动化的合并策略?别想了。CRDT 处理纯文本协同确实有一套,但换成业务对象——像订单状态从「待支付」变成「已取消」——基本就抓瞎了。最后我定的方案很粗暴:常规字段直接让服务端覆盖,用户自定义字段弹个对话框,你自己挑。界面是丑了点,至少不出数据事故。
async function handleConflict(localTask: SyncTask, serverRecord: unknown): Promise<boolean> {
if (localTask.version <= (serverRecord as any).version) {
return false;
}
return true;
}
这套队列写完,配合上一章的离线缓存 composable,整个离线读写 + 自动同步的闭环就通了。用户在地铁里填了十张表单,出站后打开手机,数据已经无声无息地推了上去。他根本不知道中间断过网。
别问我怎么知道的——我在地铁站台调试了三个下午。
一个真实 Demo:离线待办事项怎么跑起来
先搭环境:Node 18 + pnpm 9,执行 npx create-vite@latest todo-offline --template vue-ts,装个 idb 依赖:pnpm add idb@5.0.6。
在 src/composables/useTodoStore.ts 里,我用 openDB('todoDB', 1) 建立版本 1 的数据库。核心表叫 todos,主键就是 id(远程返回的 UUID),再加 createdAt、updatedAt、version、syncStatus(online | pending | failed)几个字段。
import { openDB, DBSchema } from 'idb';
interface TodoDB extends DBSchema {
todos: {
value: {
id: string;
title: string;
done: boolean;
createdAt: number;
updatedAt: number;
version: number;
syncStatus: 'online' | 'pending' | 'failed';
};
keys: string;
};
}
const db = await openDB<TodoDB>('todoDB', 1, {
upgrade(db) {
if (!db.objectStoreNames.contains('todos')) {
db.createObjectStore('todos', { keyPath: 'id' });
}
},
});
对外暴露 useTodoStore(),内部维护 todos 的 shallowRef,再配一个 pendingQueue 的 readonly ref。新增待办时直接塞入 local store,同时把这条变更推进 pendingQueue;网络在线就立刻出队并调用远端接口,失败则保留 status=failed 并打上重试标记。
export function useTodoStore() {
const todos = shallowRef<Todo[]>([]);
const pendingQueue = ref<SyncTask[]>([]) as Ref<SyncTask[]>;
await db.add('todos', newTodo);
todos.value.push(newTodo);
pendingQueue.value.push({ type: 'ADD', payload: newTodo });
return { todos, pendingQueue };
}
列表渲染走 <li :key="todo.id">,同一行的完成状态用 v-model 双向绑 done,失焦即更新本地 store 并推一条 UPDATE 任务进队列。顶部巴掌大的提示条实时展示 isIdle / pendingCount / lastSyncTime。标签页之间靠 new BroadcastChannel('todo-channel') 广播变更事件,收到 message 后拉一遍 indexedDB,确保 A 页改完 B 页马上能看到。
性能优化:别把 IndexedDB 用成拖后腿的
东西能跑了,但跑得顺不顺是另一回事。我上线跑了三天,Chrome 的 Performance 面板给我上了一课——IndexedDB 读写太频繁的时候,页面帧率直接崩到个位数,滚动都卡。
老实说,IndexedDB 的读写本身不是瓶颈,真正拖慢的是没脑子的乱读乱写。每一次 db.add() 或者 db.put() 背后都是一次完整的事务提交,你写 100 条待办,开 100 个事务,每个事务都走一遍磁盘刷写,不卡才怪。
如果用户一次性导入了 200 条历史待办,你一条一条 await db.add(),浏览器会给你开 200 个独立事务。200 次磁盘 I/O,哪怕 SSD 也吃不消。
// 反面教材
for (const todo of todoList) {
await db.add('todos', todo)
}
// 正确做法:用一个事务打包
const tx = db.transaction('todos', 'readwrite')
const store = tx.objectStore('todos')
for (const todo of todoList) {
store.add(todo)
}
await tx.done
一个事务搞定全部。事务内部的 add() 按顺序排队,最后 tx.done 统一落盘。实际测试下来,200 条数据从 3.2 秒压缩到 200 毫秒以内。差距就是这么大。
同样的思路也适用于读操作。如果列表页需要展示 500 条待办,别一次性 getAll() 全拉回来。Vue 的 shallowRef 虽然能避免深层响应式开销,但 500 个对象的渲染和 diff 照样吃性能。
我刚开始的做法很粗暴:用户打开页面,cursor.getAll() 一把梭,然后 todos.value = result。Chrome 告诉我主线程被 Block 了 80 多毫秒。80 毫秒看起来不多,但假如你同时还在处理 BroadcastChannel 的消息和同步队列的定时器,帧率就开始抖了。
export function usePagedTodos(pageSize = 20) {
const page = ref(1)
const todos = ref<Todo[]>([])
const hasMore = ref(true)
async function loadNextPage() {
const range = IDBKeyRange.lowerBound(todos.value.length, true)
const cursor = await db.transaction('todos', 'readonly')
.objectStore('todos')
.openCursor(range)
const batch: Todo[] = []
let count = 0
while (cursor && count < pageSize) {
batch.push(cursor.value)
cursor.continue()
count++
}
todos.value.push(...batch)
hasMore.value = cursor !== null
}
return { todos, page, hasMore, loadNextPage }
}
这里用了 IDBKeyRange 配合游标分页,每次只拉 20 条。用户滚动到底部时触发 loadNextPage(),渲染线程的压力被均匀分散。配合 shallowRef,Vue 不会递归追踪每个 todo 对象的属性变化,只会在数组引用变更时触发视图更新。
别忘了配合虚拟滚动。列表超过 200 条之后,即使是 shallowRef,DOM 节点数量本身也会拖慢布局。用 @tanstack/vue-virtual 或者自己写个简单的可视窗口,只渲染当前视口内的 20 条数据,其他用占位高度撑住。
离线缓存最容易被忽视的就是数据过期。用户三个月前完成的任务,早就同步到服务端了,本地还躺在那占空间。IndexedDB 单库上限通常是几百 MB,但你不清理,查询速度会随着数据量增加肉眼可见地变慢——因为索引维护的成本上去了。
做法很简单:写入时带一个 expiresAt 时间戳,或者在对象上加版本号。每次应用启动或者空闲时跑一次清理。
function createTTLIndex(db: IDBObjectStore) {
db.createIndex('expiresAt', 'expiresAt', { unique: false })
}
async function purgeExpiredData() {
const now = Date.now()
const range = IDBKeyRange.upperBound(now)
const store = db.transaction('todos', 'readwrite').objectStore('todos')
const index = store.index('expiresAt')
let cursor = await index.openCursor(range)
let count = 0
while (cursor) {
cursor.delete()
cursor = await cursor.continue()
count++
}
console.log(`已清理 ${count} 条过期数据`)
}
版本号方案更灵活:每次数据结构变更时升级版本号,旧版本数据在 upgrade 里直接丢弃或者迁移。我一般两者结合着用——日常靠 TTL 清理,大版本迭代靠版本号做数据迁移。
同步队列里最怕什么?网络刚断的时候,队列里瞬间堆积几十条任务,然后你每隔 1 秒重试一次,手机发烫,用户骂娘。更糟的是,服务端如果刚好在重启,你这一秒一冲的请求等于在给人家做 DDoS。
指数退避的核心思路很粗暴:第一次失败后等 1 秒,第二次等 2 秒,第三次 4 秒,以此类推,直到一个上限(比如 30 秒)。每次成功后重置回初始值。
const BASE_DELAY = 1000
const MAX_DELAY = 30000
function getNextRetryDelay(retryCount: number): number {
const delay = Math.min(BASE_DELAY * Math.pow(2, retryCount), MAX_DELAY)
return delay + Math.random() * 500
}
export class SyncQueue {
private retryCount = 0
private timer: number | null = null
async processQueue() {
try {
await this.flushPendingTasks()
this.retryCount = 0
} catch {
const delay = getNextRetryDelay(this.retryCount)
this.retryCount++
this.timer = window.setTimeout(() => this.processQueue(), delay)
}
}
}
注意加的那一点随机抖动——500 毫秒内的随机偏移。如果你的应用在多标签页下运行,每个标签页的同步队列可能同时触发重试,没有抖动的话,服务端会在同一毫秒内收到 N 个一模一样的重试请求。加一点噪声,世界和平。
重试次数超过阈值(比如 10 次)之后,把任务标记为 status: 'failed',不再自动重试,留给用户手动操作。有些数据确实就是冲突了,再重试一百次也是白搭。
性能优化说到底是个取舍题:事务打包牺牲了一点写代码的便利,换来了十几倍的写入速度;分页懒加载牺牲了首屏数据量的完整,换来了流畅的滚动;指数退避牺牲了重试的及时性,换来了资源的体面使用。想清楚你要什么,IndexedDB 其实挺老实的。
走到这里,我们的离线数据方案已经能在弱网甚至断网下稳住阵脚。最后一站,不妨把视野再抬高一点:把“数据离线”升级成“应用离线”,让用户在飞机模式下也能打开你的页面,像原生应用一样留在原地。
从离线缓存到渐进式 Web 应用:让 Service Worker 接管门面
想象地铁进站信号骤失,用户点开你的表单却看到旋转的加载圈。与其等待超时,不如提前给页面套上一层“门面”。注册 Service Worker 后,可以把 HTML、CSS、JS 以及关键路由壳页塞进 Cache Storage;当网络不可用时,SW 直接返回最近一次成功的快照,白屏时间瞬间归零。配合我们之前封装好的 useIndexedDB,冷启动先读本地数据库,再用后台同步队列默默追平云端,体验接近原生。
// 简化示意:注册并拦截 fetch
self.addEventListener('fetch', (e) => {
if (e.request.mode === 'navigate') {
e.respondWith(caches.match(e.request.url) || fetch(e.request));
}
});
很多团队把 IndexedDB 当图片仓库,结果数据库膨胀到上百 MB,查询性能雪崩。其实浏览器缓存体系应该分三层:资源层(Cache Storage)管静态文件;数据层(IndexedDB)管业务记录;状态层(localStorage 或 SessionStorage)管用户偏好和会话 token。各管各的,别混为一谈。
最让我意外的是上线后的用户反馈——有人直接问“你们这个网页怎么跟装了原生App似的?” 后来追了一下场景,发现他在隧道里、地铁上填了快半小时的表单,中间断网好几次,出站后数据自己悄悄发走了,全程没什么感知。所以这玩意儿真的不只是一个“能存”的东西,它解决的是那种你没法跟用户直说的痛点:你总不能让人家先点一下“保存”,等有网了再点“提交”吧?IndexedDB + 同步队列就是把这层操作给吞掉了。
这才是我想要的离线体验——不依赖后端,完全在浏览器里把数据存稳了。我最近用 Vue 3 的组合式 API 搭了一套 IndexedDB 缓存层,顺便塞了个同步队列进去,专门对付那种网络忽好忽坏的场景。搞定这个之后,弱网下用户的操作再也不怕丢失了,数据先落本地,等网络恢复再逐个推给服务端。
评论