前端老鸟血泪史:本地存储爆雷怎么办?5招搞定缓存顽疾还能让页面秒开

说实话,写这篇文章之前我抽了三根烟。不是因为别的,就是想起这些年被本地存储坑过的那些夜晚,血压有点上来了。你们懂那种感受吗?凌晨两点,用户群里突然炸锅,说数据丢了、页面白了、刷新一下东西全没了。你一边陪着笑说"马上修复",一边疯狂翻代码,最后发现是LocalStorage存满了,或者哪个手贱的同事把key写错了。

行吧,既然都聊到这儿了,咱们就把这块儿的老底儿掀个干净。从最开始那个"存个字符串而已能有多难"的 naive 想法,到现在看到存储相关的PR就条件反射式地紧张,这中间的血泪史,够写本书了。

别整那些虚的,聊聊咱们天天都要面对的"存数据"这点破事

我刚入行那会儿,觉得浏览器存储不就是 localStorage.setItem('name', '张三') 吗?简单得跟个1一样。直到有天产品经理跑过来说:“用户反馈每次刷新页面,购物车里的东西就没了,你查查咋回事。”

我当时就懵了——我明明存了啊!代码写得板板正正的:

// 当年那个天真的我写的代码
function addToCart(item) {
    let cart = localStorage.getItem('cart');
    cart.push(item); // 等等,这里好像有问题?
    localStorage.setItem('cart', cart);
}

看到bug在哪了吗?getItem 拿出来的是字符串啊兄弟!"null".push() 直接报错,但我当时居然没加try-catch,用户那边就是静默失败,数据直接蒸发。更要命的是,有些浏览器在隐私模式下会直接抛出 QuotaExceededError,你不去catch它,整个脚本都崩了。

还有那种"明明存了却读不到"的玄学问题。有次测试妹子跟我说,她明明点了保存,刷新后设置项全恢复了默认。我在自己电脑上复现了八百遍都没问题,最后跑到她工位一看——她用的是Safari的无痕模式。那玩意儿对LocalStorage的支持就跟薛定谔的猫一样,有时候能用有时候不能用,全看苹果那天心情怎么样。

说到这个我就来气。你们有没有遇到过控制台报错报到你怀疑人生的情况?比如这个:

// 这段代码在95%的情况下工作正常
try {
    localStorage.setItem('bigData', JSON.stringify(hugeObject));
} catch (e) {
    console.error('存储失败:', e);
}

看起来没问题对吧?但如果是iOS的WebView,在某些版本里,存储满了不会抛错,而是直接静默失败。你看着控制台干干净净,以为数据存进去了,用户一刷新,啥也没有。这种"假成功"才是最可怕的,你连排查方向都没有。

再说说选型的问题。LocalStorage、SessionStorage、IndexedDB、Cookie,还有那个Cache Storage,到底该用哪个?我见过太多项目里滥用LocalStorage的,把用户头像的base64往里塞,把整个表格数据往里怼,最后页面加载慢得像蜗牛爬。5MB的空间看着挺大,但算上UTF-16的编码,实际能存的东西真没多少。而且它是同步的!主线程就这么被卡住了,用户点个按钮没反应,其实后台正在拼命写硬盘呢。

SessionStorage更是个"渣男",标签页一关就翻脸不认人。有次我们做个多步骤表单,用户填到第三步,手贱点了个外链,回来一看,第一步的数据还在,第二步第三步没了。为啥?因为新开标签页SessionStorage不共享啊!这设计就离谱,但你还不能说它错,毕竟人家文档写得清清楚楚,只怪我们没仔细看。

IndexedDB呢?功能确实强大,能存结构化数据,支持索引、事务、游标,简直就是浏览器里的SQLite。但那个API写得,我怀疑设计者是故意的。打开数据库要 indexedDB.open(),然后要处理 onupgradeneededonsuccessonerror 三个回调,升级版本还要手动迁移数据。新手第一次看官方文档,直接就想劝退。我贴段代码你们感受下:

// 打开IndexedDB的标准姿势,这还只是个开始
const request = indexedDB.open('MyDatabase', 2);

request.onupgradeneeded = function(event) {
    const db = event.target.result;
    
    // 创建对象仓库,相当于表
    if (!db.objectStoreNames.contains('users')) {
        const objectStore = db.createObjectStore('users', { keyPath: 'id', autoIncrement: true });
        // 创建索引
        objectStore.createIndex('name', 'name', { unique: false });
        objectStore.createIndex('email', 'email', { unique: true });
    }
    
    // 版本升级时的数据迁移逻辑,这里能写出一堆bug
    if (event.oldVersion < 2) {
        // 从版本1升级到版本2,可能要改结构
        const store = event.target.transaction.objectStore('users');
        // ... 迁移代码
    }
};

request.onsuccess = function(event) {
    const db = event.target.result;
    console.log('数据库打开成功');
    
    // 增删改查还得另写函数
    addUser(db, { name: '老王', email: 'wang@example.com' });
};

request.onerror = function(event) {
    console.error('数据库打开失败:', event.target.error);
};

// 添加数据的函数,这复杂度感受一下
function addUser(db, user) {
    const transaction = db.transaction(['users'], 'readwrite');
    const store = transaction.objectStore('users');
    const request = store.add(user);
    
    request.onsuccess = function() {
        console.log('用户添加成功');
    };
    
    request.onerror = function() {
        console.error('用户添加失败');
    };
    
    // 事务完成监听
    transaction.oncomplete = function() {
        console.log('事务已完成');
    };
}

就上面这一坨,还只是"Hello World"级别的操作。要是涉及到分页查询、范围检索、索引优化,代码量能翻三倍。所以后来社区出了好多封装库,像localForage、Dexie.js,但引入第三方库又有新的问题——包体积、维护性、兼容性,头疼得很。

Cookie就更别提了,带着服务器到处跑,每个请求都要夹带。存个几KB的JWT token还好,要是有人把用户偏好设置全塞Cookie里,每次接口请求都带上几百字节,移动端用户流量不要钱的吗?而且Cookie还有同源限制、Secure属性、HttpOnly属性、SameSite属性,稍微配错一点,要么存不进去,要么安全漏洞,要么跨域问题。

至于HTTP缓存策略,强缓存和协商缓存那套,我到现在有时候还得翻MDN确认。Cache-Control的max-age和no-cache、no-store啥区别?ETag和Last-Modified用哪个?304状态码到底算不算成功?这些问题面试常考,但真到项目里,经常是"先禁用缓存试试,能跑就行"。

扒一扒浏览器给咱们留的那些"储物柜"底细

咱们挨个把这些"储物柜"的底细扒清楚,免得以后再用错地方。

LocalStorage:简单粗暴但只有5MB的小肚量

这玩意儿最大的特点就是简单,键值对存储,API就四个方法:setItemgetItemremoveItemclear。但简单是有代价的。首先是容量,标准说是5MB,但不同浏览器实现不一样。Safari桌面版可能给10MB,iOS WebView可能只给2MB,而且这5MB是域名级别的,你所有页面共享。要是搞个单页应用,路由多了,一不小心就超配额。

其次是同步阻塞。LocalStorage的读写都是同步的,直接走主线程。你存个几KB的数据可能没感觉,但要是存个大的JSON字符串,比如几万条日志记录,页面直接卡死。我有次 profiling 的时候发现,一个 localStorage.setItem 调用了200多毫秒,那段时间用户点击完全没响应,体验烂透了。

还有那个让人崩溃的存储限制错误处理。超出配额时,不同浏览器表现不一样:

// 一个比较健壮的存储函数,考虑了各种边界情况
function safeSetItem(key, value) {
    try {
        const serialized = JSON.stringify(value);
        const size = new Blob([serialized]).size;
        
        // 预估一下大小,虽然不准确但总比没有强
        if (size > 4.5 * 1024 * 1024) {
            console.warn('数据太大,建议分片存储或使用IndexedDB');
            return false;
        }
        
        localStorage.setItem(key, serialized);
        return true;
    } catch (e) {
        if (e.name === 'QuotaExceededError' || 
            e.name === 'NS_ERROR_DOM_QUOTA_REACHED') {
            console.error('存储空间已满,需要清理旧数据');
            // 这里可以触发清理逻辑
            cleanupOldData();
            // 重试一次
            try {
                localStorage.setItem(key, JSON.stringify(value));
                return true;
            } catch (retryError) {
                console.error('清理后仍然无法存储:', retryError);
                return false;
            }
        }
        console.error('存储失败:', e);
        return false;
    }
}

// 清理旧数据的示例逻辑
function cleanupOldData() {
    // 策略1:删除最久未使用的
    // 策略2:删除特定前缀的缓存
    // 策略3:只保留最近N条
    const keys = Object.keys(localStorage);
    const cacheKeys = keys.filter(k => k.startsWith('cache_'));
    
    // 按时间戳排序,删掉最老的
    cacheKeys.sort((a, b) => {
        const itemA = JSON.parse(localStorage.getItem(a) || '{}');
        const itemB = JSON.parse(localStorage.getItem(b) || '{}');
        return (itemA.timestamp || 0) - (itemB.timestamp || 0);
    });
    
    // 删掉一半
    const toDelete = cacheKeys.slice(0, Math.floor(cacheKeys.length / 2));
    toDelete.forEach(key => localStorage.removeItem(key));
    
    console.log(`清理了 ${toDelete.length} 项旧数据`);
}

看到没?就一个 setItem,为了健壮性要包这么多层。而且LocalStorage还有个致命缺陷:不支持过期时间。你存进去的数据,除非手动删,否则永久存在。这在很多业务场景下是反人类的,比如缓存接口数据,总得有个有效期吧?所以后来大家都自己封装带过期时间的版本,后面我会贴代码。

SessionStorage:标签页一关就翻脸不认人的短期记忆

SessionStorage和LocalStorageAPI完全一样,但生命周期不同。它只在当前标签页有效,关闭标签页数据就消失。而且它有个很诡异的特性:不同标签页之间不共享,哪怕你是通过 window.open 打开的新标签页,在Chrome里有时候能共享,有时候不能,取决于你怎么打开的。

这玩意儿适合存那种临时状态,比如表单填写到一半的数据、多步骤向导的当前步骤。但千万别用来存重要信息,用户手一抖关了浏览器,数据就没了。

// 用SessionStorage做表单草稿的示例
class FormDraft {
    constructor(formId) {
        this.key = `draft_${formId}`;
        this.saveInterval = null;
    }
    
    // 开始自动保存,每30秒存一次
    startAutoSave(getDataFn) {
        this.saveInterval = setInterval(() => {
            const data = getDataFn();
            this.save(data);
            console.log('草稿已自动保存', new Date().toLocaleTimeString());
        }, 30000);
        
        // 页面关闭前再存一次
        window.addEventListener('beforeunload', () => {
            const data = getDataFn();
            this.save(data);
        });
    }
    
    save(data) {
        try {
            sessionStorage.setItem(this.key, JSON.stringify({
                data,
                timestamp: Date.now(),
                url: window.location.href
            }));
        } catch (e) {
            console.error('保存草稿失败:', e);
        }
    }
    
    load() {
        try {
            const saved = sessionStorage.getItem(this.key);
            if (saved) {
                const parsed = JSON.parse(saved);
                // 检查是否是当前页面的草稿(防止跨页面污染)
                if (parsed.url === window.location.href) {
                    // 检查是否过期(比如超过7天)
                    if (Date.now() - parsed.timestamp < 7 * 24 * 60 * 60 * 1000) {
                        return parsed.data;
                    }
                }
            }
        } catch (e) {
            console.error('读取草稿失败:', e);
        }
        return null;
    }
    
    clear() {
        sessionStorage.removeItem(this.key);
        if (this.saveInterval) {
            clearInterval(this.saveInterval);
        }
    }
}

// 使用示例
const draft = new FormDraft('user-profile');
const savedData = draft.load();
if (savedData) {
    // 恢复表单数据
    restoreForm(savedData);
    showToast('已恢复上次未提交的草稿');
}

draft.startAutoSave(() => collectFormData());

IndexedDB:功能强大但API写得像天书一样的诺亚方舟

IndexedDB是真正的数据库,支持结构化数据、索引、事务、游标。适合存大量数据,比如离线应用的完整数据集、复杂的用户生成内容。但它的API确实反人类,全是异步的基于事件的API,直到后来出了Promise包装器才稍微好点。

IndexedDB的容量限制比LocalStorage宽松很多,一般能达到50MB以上,甚至几百MB,具体取决于浏览器和磁盘空间。但申请更大空间时,浏览器可能会弹窗询问用户权限,这点要注意。

事务机制是IndexedDB的核心,也是最容易出错的地方。所有读写操作必须在事务中进行,事务有三种模式:readonlyreadwriteversionchange。如果你在一个readonly事务里尝试写入,会直接报错。而且事务有自动提交机制,如果一段时间内没有操作,事务会自动关闭,这时候你再想读写就会报错。

// 一个更完整的IndexedDB封装类,带Promise支持
class IndexedDBWrapper {
    constructor(dbName, version) {
        this.dbName = dbName;
        this.version = version;
        this.db = null;
    }
    
    // 初始化数据库
    async init(stores) {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(this.dbName, this.version);
            
            request.onupgradeneeded = (event) => {
                const db = event.target.result;
                
                // 根据配置创建或更新对象仓库
                stores.forEach(storeConfig => {
                    const { name, keyPath, indexes, deleteOnUpgrade } = storeConfig;
                    
                    if (db.objectStoreNames.contains(name)) {
                        if (deleteOnUpgrade) {
                            db.deleteObjectStore(name);
                        } else {
                            // 如果存在且不删除,检查是否需要新增索引
                            const store = event.target.transaction.objectStore(name);
                            indexes?.forEach(idx => {
                                if (!store.indexNames.contains(idx.name)) {
                                    store.createIndex(idx.name, idx.keyPath, idx.options);
                                }
                            });
                            return;
                        }
                    }
                    
                    const store = db.createObjectStore(name, { 
                        keyPath: keyPath || 'id',
                        autoIncrement: !keyPath 
                    });
                    
                    // 创建索引
                    indexes?.forEach(idx => {
                        store.createIndex(idx.name, idx.keyPath, idx.options);
                    });
                });
            };
            
            request.onsuccess = (event) => {
                this.db = event.target.result;
                
                // 监听数据库异常
                this.db.onerror = (event) => {
                    console.error('数据库错误:', event.target.error);
                };
                
                resolve(this.db);
            };
            
            request.onerror = (event) => {
                reject(event.target.error);
            };
        });
    }
    
    // 添加数据
    async add(storeName, data) {
        return new Promise((resolve, reject) => {
            const transaction = this.db.transaction([storeName], 'readwrite');
            const store = transaction.objectStore(storeName);
            const request = store.add(data);
            
            request.onsuccess = () => resolve(request.result);
            request.onerror = () => reject(request.error);
        });
    }
    
    // 批量添加,使用游标优化性能
    async addBatch(storeName, dataArray, onProgress) {
        return new Promise((resolve, reject) => {
            const transaction = this.db.transaction([storeName], 'readwrite');
            const store = transaction.objectStore(storeName);
            let completed = 0;
            
            // 分批处理,避免单次事务过长
            const batchSize = 100;
            const batches = [];
            for (let i = 0; i < dataArray.length; i += batchSize) {
                batches.push(dataArray.slice(i, i + batchSize));
            }
            
            let currentBatch = 0;
            
            const processBatch = () => {
                if (currentBatch >= batches.length) {
                    resolve({ total: dataArray.length, success: completed });
                    return;
                }
                
                const batch = batches[currentBatch];
                batch.forEach(item => {
                    const request = store.add(item);
                    request.onsuccess = () => {
                        completed++;
                        if (onProgress) {
                            onProgress(completed, dataArray.length);
                        }
                    };
                    request.onerror = () => {
                        console.warn('单条数据添加失败:', item, request.error);
                    };
                });
                
                currentBatch++;
                // 使用setTimeout让出主线程,避免阻塞
                setTimeout(processBatch, 0);
            };
            
            transaction.oncomplete = () => {
                console.log('批量添加事务完成');
            };
            
            transaction.onerror = () => {
                reject(transaction.error);
            };
            
            processBatch();
        });
    }
    
    // 使用索引查询
    async getByIndex(storeName, indexName, value) {
        return new Promise((resolve, reject) => {
            const transaction = this.db.transaction([storeName], 'readonly');
            const store = transaction.objectStore(storeName);
            const index = store.index(indexName);
            const request = index.getAll(value); // 获取所有匹配项
            
            request.onsuccess = () => resolve(request.result);
            request.onerror = () => reject(request.error);
        });
    }
    
    // 范围查询,支持分页
    async getRange(storeName, options = {}) {
        const { indexName, lower, upper, direction = 'next', offset = 0, limit = 50 } = options;
        
        return new Promise((resolve, reject) => {
            const transaction = this.db.transaction([storeName], 'readonly');
            const store = transaction.objectStore(storeName);
            const source = indexName ? store.index(indexName) : store;
            
            const results = [];
            let skipped = 0;
            
            // 创建范围
            let range = null;
            if (lower !== undefined && upper !== undefined) {
                range = IDBKeyRange.bound(lower, upper);
            } else if (lower !== undefined) {
                range = IDBKeyRange.lowerBound(lower);
            } else if (upper !== undefined) {
                range = IDBKeyRange.upperBound(upper);
            }
            
            const request = source.openCursor(range, direction);
            
            request.onsuccess = (event) => {
                const cursor = event.target.result;
                
                if (!cursor) {
                    resolve(results);
                    return;
                }
                
                // 跳过offset条
                if (skipped < offset) {
                    skipped++;
                    cursor.continue();
                    return;
                }
                
                // 收集数据直到达到limit
                if (results.length < limit) {
                    results.push(cursor.value);
                    cursor.continue();
                } else {
                    resolve(results);
                }
            };
            
            request.onerror = () => reject(request.error);
        });
    }
    
    // 删除旧数据,支持按时间戳清理
    async deleteOldData(storeName, timestampField, maxAge) {
        const cutoff = Date.now() - maxAge;
        const transaction = this.db.transaction([storeName], 'readwrite');
        const store = transaction.objectStore(storeName);
        
        return new Promise((resolve, reject) => {
            const request = store.openCursor();
            let deletedCount = 0;
            
            request.onsuccess = (event) => {
                const cursor = event.target.result;
                if (cursor) {
                    const record = cursor.value;
                    if (record[timestampField] < cutoff) {
                        cursor.delete();
                        deletedCount++;
                    }
                    cursor.continue();
                } else {
                    resolve(deletedCount);
                }
            };
            
            request.onerror = () => reject(request.error);
        });
    }
    
    // 获取数据库统计信息
    async getStats(storeName) {
        const transaction = this.db.transaction([storeName], 'readonly');
        const store = transaction.objectStore(storeName);
        
        return new Promise((resolve, reject) => {
            const countRequest = store.count();
            const sizeEstimate = new Promise((res) => {
                // 估算大小:遍历所有数据并计算JSON长度
                let totalSize = 0;
                const cursorRequest = store.openCursor();
                
                cursorRequest.onsuccess = (event) => {
                    const cursor = event.target.result;
                    if (cursor) {
                        totalSize += JSON.stringify(cursor.value).length;
                        cursor.continue();
                    } else {
                        res(totalSize);
                    }
                };
            });
            
            Promise.all([countRequest, sizeEstimate]).then(([count, size]) => {
                resolve({
                    count,
                    estimatedSize: size,
                    avgItemSize: count > 0 ? Math.round(size / count) : 0
                });
            }).catch(reject);
        });
    }
}

// 使用示例:创建一个用户数据仓库
const db = new IndexedDBWrapper('MyAppDB', 1);

await db.init([
    {
        name: 'users',
        keyPath: 'id',
        indexes: [
            { name: 'email', keyPath: 'email', options: { unique: true } },
            { name: 'lastLogin', keyPath: 'lastLogin', options: { unique: false } },
            { name: 'status', keyPath: 'status', options: { unique: false } }
        ]
    },
    {
        name: 'logs',
        autoIncrement: true,
        indexes: [
            { name: 'timestamp', keyPath: 'timestamp', options: { unique: false } },
            { name: 'level', keyPath: 'level', options: { unique: false } }
        ]
    }
]);

// 批量导入用户数据,带进度回调
const users = Array.from({length: 1000}, (_, i) => ({
    id: i,
    email: `user${i}@example.com`,
    name: `User ${i}`,
    lastLogin: Date.now() - Math.random() * 86400000,
    status: 'active'
}));

await db.addBatch('users', users, (done, total) => {
    console.log(`导入进度: ${done}/${total} (${Math.round(done/total*100)}%)`);
});

// 查询最近登录的用户(分页)
const recentUsers = await db.getRange('users', {
    indexName: 'lastLogin',
    lower: Date.now() - 7 * 24 * 60 * 60 * 1000, // 7天内
    direction: 'prev', // 倒序
    offset: 0,
    limit: 20
});

上面这个封装类已经考虑了事务管理、批量操作、分页查询、索引使用等常见场景,但代码量已经相当可观了。这就是为什么很多项目宁愿用LocalStorage凑合,也不想上IndexedDB——学习成本和开发成本确实高。

Cookie:带着服务器到处跑,还要被每个请求夹带的累赘

Cookie的设计初衷是服务端和客户端共享状态,所以它会自动随每个HTTP请求发送给服务器。这既是优点也是缺点。优点是简单,服务端可以直接读写;缺点是浪费带宽,而且大小限制严格(一般4KB)。

现代Web开发中,Cookie主要用于身份认证(Session ID、JWT)和追踪( analytics )。但 SameSite 属性的引入让跨域场景变得复杂,再加上 Chrome 逐步淘汰第三方 Cookie,这块儿的兼容性坑越来越多。

// 一个健壮的Cookie操作工具,考虑了编码和安全性
const CookieUtil = {
    // 设置Cookie,支持各种属性
    set(name, value, options = {}) {
        let cookieString = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
        
        if (options.expires) {
            if (typeof options.expires === 'number') {
                // 天数
                const date = new Date();
                date.setTime(date.getTime() + options.expires * 24 * 60 * 60 * 1000);
                cookieString += `; expires=${date.toUTCString()}`;
            } else {
                cookieString += `; expires=${options.expires.toUTCString()}`;
            }
        }
        
        if (options.path) cookieString += `; path=${options.path}`;
        if (options.domain) cookieString += `; domain=${options.domain}`;
        if (options.secure) cookieString += '; secure';
        if (options.sameSite) cookieString += `; samesite=${options.sameSite}`;
        if (options.httpOnly) {
            // 注意:前端JS无法设置HttpOnly,这是服务端专用的
            console.warn('HttpOnly属性必须由服务器设置');
        }
        
        document.cookie = cookieString;
        return this.get(name) === value; // 验证是否设置成功
    },
    
    get(name) {
        const cookies = document.cookie.split(';');
        for (let cookie of cookies) {
            const [cookieName, cookieValue] = cookie.trim().split('=');
            if (decodeURIComponent(cookieName) === name) {
                return decodeURIComponent(cookieValue);
            }
        }
        return null;
    },
    
    remove(name, options = {}) {
        // 通过设置过期时间为过去来删除
        this.set(name, '', {
            ...options,
            expires: new Date(0)
        });
    },
    
    // 获取所有Cookie
    getAll() {
        const cookies = {};
        if (document.cookie) {
            document.cookie.split(';').forEach(cookie => {
                const [name, value] = cookie.trim().split('=');
                cookies[decodeURIComponent(name)] = decodeURIComponent(value);
            });
        }
        return cookies;
    },
    
    // 检查Cookie是否可用(考虑第三方Cookie限制)
    checkEnabled() {
        const testKey = '__cookie_test__';
        this.set(testKey, '1');
        const enabled = this.get(testKey) === '1';
        this.remove(testKey);
        return enabled;
    }
};

// 使用示例:设置一个7天有效期的JWT,只允许HTTPS,SameSite=Lax
CookieUtil.set('auth_token', jwtString, {
    expires: 7,
    path: '/',
    secure: true, // 生产环境必须开启
    sameSite: 'lax' // 防止CSRF攻击的基础保护
});

Cache Storage:PWA的好基友,专门囤积静态资源的仓库

这是Service Worker的配套API,专门用来缓存静态资源(JS、CSS、图片)。和前面几个不一样,它是基于请求的缓存,支持HTTP缓存语义(Headers、Methods),适合实现离线应用。

但Cache Storage只在HTTPS环境下可用(或者localhost开发环境),而且操作相对复杂,需要配合Service Worker使用。后面讲PWA的时候会详细说。

深究那些让你又爱又恨的存储黑科技

现在咱们往深里挖挖,看看这些存储机制背后的原理,以及那些让人头秃的坑。

为什么LocalStorage是同步的,存多了直接卡死主线程的真相

LocalStorage的同步特性源于它的简单设计。它本质上是一个同步的键值对存储,读写操作直接阻塞JavaScript主线程。当你调用 setItem 时,浏览器需要将数据序列化、编码,然后写入磁盘(或SQLite数据库,取决于浏览器实现)。这个过程虽然快,但如果是大量数据或者磁盘IO繁忙时,就会明显卡顿。

更坑的是,LocalStorage没有内置的批量操作API。你要存100条数据,就得循环调用100次 setItem,每次都要走一遍完整的IO流程。相比之下,IndexedDB的事务机制允许你在一个事务里批量提交多个操作,效率高出不少。

// 测试LocalStorage同步阻塞的示例
console.time('localStorage-write');
for (let i = 0; i < 1000; i++) {
    localStorage.setItem(`key_${i}`, 'x'.repeat(1000)); // 存1KB数据
}
console.timeEnd('localStorage-write'); // 在我的电脑上大概200-500ms

// 对比:IndexedDB批量写入
console.time('indexedDB-write');
const db = await openDB('test', 1);
const tx = db.transaction('store', 'readwrite');
const store = tx.objectStore('store');
for (let i = 0; i < 1000; i++) {
    store.add({ id: i, data: 'x'.repeat(1000) });
}
await tx.done;
console.timeEnd('indexedDB-write'); // 通常快3-5倍

IndexedDB的事务机制怎么搞,别再写出脏数据了

IndexedDB的事务自动提交机制是个双刃剑。好处是你不用手动commit,坏处是如果你忘了事务的生命周期,很容易在事务关闭后还尝试操作,导致报错。

事务的三种模式:

  • readonly:并发性能好,多个readonly事务可以同时执行
  • readwrite:独占式,同一时间只能有一个readwrite事务
  • versionchange:数据库升级时用,会阻塞所有其他事务

脏数据通常发生在异步操作里。比如你在一个事务中先读了数据,然后异步修改,再写回去,这时候如果另一个事务已经修改了同一条数据,你就覆盖了别人的修改(Lost Update)。

// 错误示例:可能导致脏数据
async function updateUserWrong(userId, updates) {
    const tx = db.transaction('users', 'readwrite');
    const store = tx.objectStore('users');
    
    const user = await store.get(userId); // 读取
    
    // 假设这里有个异步操作,比如验证数据
    await validateData(updates); // 事务可能在这里自动提交了!
    
    // 如果这时候另一个事务修改了user,这里就覆盖掉了
    Object.assign(user, updates);
    await store.put(user); // 可能覆盖了别人的修改
}

// 正确做法:把读取和写入放在同一个同步块里,或者使用事务的完整性
async function updateUserCorrect(userId, updateFn) {
    const tx = db.transaction('users', 'readwrite');
    const store = tx.objectStore('users');
    
    // 读取和修改要在事务的同一个事件循环里完成
    const user = await store.get(userId);
    const updatedUser = updateFn(user); // 同步修改
    
    await store.put(updatedUser);
    await tx.done; // 确保事务完成
}

HTTP缓存头Cache-Control和ETag怎么配合打组合拳

HTTP缓存是前端性能优化的重头戏,但很多人(包括我)经常搞混强缓存和协商缓存。

强缓存(200 from cache):浏览器直接从本地缓存拿数据,不发请求给服务器。通过 Cache-Control: max-age=3600Expires 控制。

协商缓存(304 Not Modified):缓存过期后,浏览器带着缓存标识(ETag或Last-Modified)问服务器"这玩意儿还能用吗",服务器说能用就返回304,浏览器继续用本地缓存。

// 一个支持HTTP缓存的fetch封装
async function cachedFetch(url, options = {}) {
    const cacheKey = `http_cache_${url}`;
    const cacheMetaKey = `${cacheKey}_meta`;
    
    // 尝试读取本地缓存
    const cached = localStorage.getItem(cacheKey);
    const cachedMeta = JSON.parse(localStorage.getItem(cacheMetaKey) || '{}');
    
    const headers = new Headers(options.headers || {});
    
    // 如果有缓存且需要验证,添加条件请求头
    if (cached && cachedMeta.etag) {
        headers.set('If-None-Match', cachedMeta.etag);
    }
    if (cached && cachedMeta.lastModified) {
        headers.set('If-Modified-Since', cachedMeta.lastModified);
    }
    
    try {
        const response = await fetch(url, {
            ...options,
            headers
        });
        
        // 304 Not Modified,使用缓存
        if (response.status === 304 && cached) {
            console.log('使用协商缓存:', url);
            return new Response(cached, {
                status: 200,
                headers: {
                    'Content-Type': cachedMeta.contentType || 'application/json'
                }
            });
        }
        
        // 200 OK,更新缓存
        if (response.ok) {
            const clone = response.clone();
            const body = await clone.text();
            const etag = response.headers.get('ETag');
            const lastModified = response.headers.get('Last-Modified');
            const cacheControl = response.headers.get('Cache-Control');
            
            // 只缓存允许缓存的响应
            if (cacheControl && !cacheControl.includes('no-store')) {
                const maxAge = parseMaxAge(cacheControl);
                const meta = {
                    etag,
                    lastModified,
                    contentType: response.headers.get('Content-Type'),
                    cachedAt: Date.now(),
                    maxAge: maxAge * 1000 // 转为毫秒
                };
                
                localStorage.setItem(cacheKey, body);
                localStorage.setItem(cacheMetaKey, JSON.stringify(meta));
                console.log('更新HTTP缓存:', url, 'Max-Age:', maxAge);
            }
        }
        
        return response;
    } catch (error) {
        // 网络错误时尝试使用过期缓存(Stale-While-Revalidate策略)
        if (cached) {
            console.warn('网络错误,使用过期缓存:', url);
            return new Response(cached, {
                status: 200,
                headers: {
                    'X-From-Cache': 'stale'
                }
            });
        }
        throw error;
    }
}

function parseMaxAge(cacheControl) {
    const match = cacheControl.match(/max-age=(\d+)/);
    return match ? parseInt(match[1]) : 0;
}

Service Worker怎么拦截请求,实现离线也能嗨的骚操作

Service Worker是PWA的核心,它运行在独立线程,可以拦截网络请求、操作Cache Storage。但它的生命周期复杂,安装、激活、更新都有特定的时机和事件。

// service-worker.js
const CACHE_NAME = 'my-app-v1';
const STATIC_ASSETS = [
    '/',
    '/index.html',
    '/app.js',
    '/styles.css',
    '/icon.png'
];

// 安装时缓存静态资源
self.addEventListener('install', event => {
    console.log('Service Worker 安装中...');
    
    event.waitUntil(
        caches.open(CACHE_NAME)
            .then(cache => {
                console.log('缓存静态资源');
                return cache.addAll(STATIC_ASSETS);
            })
            .then(() => self.skipWaiting()) // 立即激活
            .catch(err => console.error('预缓存失败:', err))
    );
});

// 激活时清理旧缓存
self.addEventListener('activate', event => {
    console.log('Service Worker 激活中...');
    
    event.waitUntil(
        caches.keys().then(cacheNames => {
            return Promise.all(
                cacheNames
                    .filter(name => name !== CACHE_NAME)
                    .map(name => {
                        console.log('删除旧缓存:', name);
                        return caches.delete(name);
                    })
            );
        }).then(() => self.clients.claim()) // 立即控制所有客户端
    );
});

// 拦截网络请求
self.addEventListener('fetch', event => {
    const { request } = event;
    
    // 只处理GET请求
    if (request.method !== 'GET') return;
    
    // 策略1:缓存优先(Cache First)- 适合静态资源
    if (isStaticAsset(request.url)) {
        event.respondWith(cacheFirst(request));
        return;
    }
    
    // 策略2:网络优先(Network First)- 适合API数据
    if (isAPIRequest(request.url)) {
        event.respondWith(networkFirst(request));
        return;
    }
    
    // 策略3:仅网络(Network Only)- 其他请求
    event.respondWith(fetch(request));
});

// 缓存优先策略
async function cacheFirst(request) {
    const cache = await caches.open(CACHE_NAME);
    const cached = await cache.match(request);
    
    if (cached) {
        // 后台更新缓存(Stale-While-Revalidate)
        fetch(request).then(response => {
            if (response.ok) {
                cache.put(request, response.clone());
            }
        }).catch(() => {});
        
        return cached;
    }
    
    // 缓存未命中,走网络并缓存结果
    try {
        const response = await fetch(request);
        if (response.ok) {
            cache.put(request, response.clone());
        }
        return response;
    } catch (error) {
        // 完全离线且没有缓存时返回离线页面
        if (request.mode === 'navigate') {
            return cache.match('/offline.html');
        }
        throw error;
    }
}

// 网络优先策略
async function networkFirst(request) {
    const cache = await caches.open(CACHE_NAME);
    
    try {
        const networkResponse = await fetch(request);
        if (networkResponse.ok) {
            // 更新缓存
            cache.put(request, networkResponse.clone());
        }
        return networkResponse;
    } catch (error) {
        console.log('网络请求失败,尝试缓存:', request.url);
        const cached = await cache.match(request);
        if (cached) {
            return cached;
        }
        throw error;
    }
}

function isStaticAsset(url) {
    return STATIC_ASSETS.some(asset => url.includes(asset));
}

function isAPIRequest(url) {
    return url.includes('/api/');
}

// 后台同步(Background Sync)- 离线提交表单
self.addEventListener('sync', event => {
    if (event.tag === 'sync-forms') {
        event.waitUntil(syncFormSubmissions());
    }
});

async function syncFormSubmissions() {
    const db = await openDB('form-queue', 1);
    const submissions = await db.getAll('pending-forms');
    
    for (const submission of submissions) {
        try {
            await fetch(submission.url, {
                method: 'POST',
                body: JSON.stringify(submission.data),
                headers: { 'Content-Type': 'application/json' }
            });
            await db.delete('pending-forms', submission.id);
            console.log('后台同步成功:', submission.id);
        } catch (error) {
            console.error('后台同步失败:', error);
            // 保留在队列中,下次再试
        }
    }
}

内存泄漏是怎么发生的,存着存着浏览器就崩了

存储相关的内存泄漏主要有几种情况:

  1. LocalStorage无限增长:没有清理机制,数据只增不减
  2. IndexedDB连接未关闭:数据库连接是资源,不关闭会累积
  3. Cache Storage版本堆积:Service Worker更新后旧缓存没清理
  4. 事件监听器未移除:特别是storage事件,页面多了会互相影响
// 内存泄漏检测和防护示例
class StorageMonitor {
    constructor() {
        this.checkInterval = null;
        this.thresholds = {
            localStorage: 4 * 1024 * 1024, // 4MB预警
            indexedDB: 50 * 1024 * 1024,   // 50MB预警
            memory: 100 * 1024 * 1024      // JS堆内存100MB预警
        };
    }
    
    startMonitoring() {
        this.checkInterval = setInterval(() => this.checkHealth(), 60000); // 每分钟检查
        
        // 监听存储变化
        window.addEventListener('storage', (e) => {
            console.log('跨标签页存储变化:', e.key, e.newValue?.length);
            this.checkQuota();
        });
    }
    
    checkHealth() {
        this.checkLocalStorage();
        this.checkMemory();
    }
    
    checkLocalStorage() {
        let totalSize = 0;
        for (let key in localStorage) {
            if (localStorage.hasOwnProperty(key)) {
                totalSize += localStorage[key].length * 2; // UTF-16,每个字符2字节
            }
        }
        
        console.log(`LocalStorage使用: ${(totalSize/1024/1024).toFixed(2)}MB`);
        
        if (totalSize > this.thresholds.localStorage) {
            console.warn('LocalStorage接近上限,触发清理');
            this.cleanupLocalStorage();
        }
        
        return totalSize;
    }
    
    cleanupLocalStorage() {
        // 策略:删除最旧的缓存数据
        const items = [];
        for (let key in localStorage) {
            if (key.startsWith('cache_')) {
                try {
                    const data = JSON.parse(localStorage[key]);
                    items.push({
                        key,
                        timestamp: data.timestamp || 0,
                        size: localStorage[key].length
                    });
                } catch (e) {
                    // 非JSON数据,按字符串长度估算
                    items.push({
                        key,
                        timestamp: 0,
                        size: localStorage[key].length
                    });
                }
            }
        }
        
        // 按时间排序,删除最旧的50%
        items.sort((a, b) => a.timestamp - b.timestamp);
        const toDelete = items.slice(0, Math.floor(items.length / 2));
        
        toDelete.forEach(item => {
            localStorage.removeItem(item.key);
            console.log('清理旧缓存:', item.key);
        });
    }
    
    checkMemory() {
        if (performance.memory) {
            const used = performance.memory.usedJSHeapSize;
            const total = performance.memory.totalJSHeapSize;
            const limit = performance.memory.jsHeapSizeLimit;
            
            console.log(`内存使用: ${(used/1024/1024).toFixed(2)}MB / ${(limit/1024/1024).toFixed(2)}MB`);
            
            if (used > this.thresholds.memory) {
                console.warn('JS堆内存过高,可能存在泄漏');
                // 可以触发垃圾回收建议(虽然不能强制GC)
                if (window.gc) {
                    window.gc();
                    console.log('建议垃圾回收');
                }
            }
        }
    }
    
    checkQuota() {
        // 检查存储配额(需要用户授权)
        if (navigator.storage && navigator.storage.estimate) {
            navigator.storage.estimate().then(estimate => {
                const usage = (estimate.usage / 1024 / 1024).toFixed(2);
                const quota = (estimate.quota / 1024 / 1024).toFixed(2);
                const percent = ((estimate.usage / estimate.quota) * 100).toFixed(1);
                
                console.log(`存储配额: ${usage}MB / ${quota}MB (${percent}%)`);
                
                if (percent > 80) {
                    console.warn('存储配额使用超过80%,建议清理');
                }
            });
        }
    }
    
    destroy() {
        if (this.checkInterval) {
            clearInterval(this.checkInterval);
        }
    }
}

// 使用
const monitor = new StorageMonitor();
monitor.startMonitoring();

这几种方案各有各的坑,踩平了才是真本事

LocalStorage虽然好用但不仅裸奔还不支持过期时间,简直反人类

LocalStorage的数据是明文存储的,任何能访问你页面的人都能在控制台里 localStorage.getItem 把所有数据扒出来。所以千万别存敏感信息,比如用户密码、身份证号、银行卡号。就算存个JWT token,也要做好XSS防护,不然一个 <script>alert(localStorage.getItem('token'))</script> 就全完了。

不支持过期时间也是个大坑。你存个"今日不再提示"的标记,结果用户一年后打开页面,还在用去年的逻辑。所以必须自己实现过期机制:

// 带过期时间的LocalStorage封装,生产环境必备
class ExpirableStorage {
    constructor(namespace = 'app') {
        this.ns = namespace;
        this.cleanup(); // 初始化时清理过期数据
    }
    
    // 生成带命名空间的key
    _key(key) {
        return `${this.ns}:${key}`;
    }
    
    // 包装数据,带上过期时间
    _wrap(value, ttlSeconds) {
        return JSON.stringify({
            value,
            expires: ttlSeconds ? Date.now() + ttlSeconds * 1000 : null,
            created: Date.now()
        });
    }
    
    // 解包数据,检查是否过期
    _unwrap(wrapped) {
        try {
            const data = JSON.parse(wrapped);
            if (data.expires && Date.now() > data.expires) {
                return { expired: true, data: null };
            }
            return { expired: false, data: data.value };
        } catch (e) {
            return { expired: true, data: null };
        }
    }
    
    set(key, value, ttlSeconds = null) {
        try {
            const wrapped = this._wrap(value, ttlSeconds);
            localStorage.setItem(this._key(key), wrapped);
            return true;
        } catch (e) {
            if (e.name === 'QuotaExceededError') {
                this.cleanup(); // 空间不足时先清理
                try {
                    localStorage.setItem(this._key(key), this._wrap(value, ttlSeconds));
                    return true;
                } catch (e2) {
                    console.error('存储失败,即使清理后空间仍不足');
                    return false;
                }
            }
            return false;
        }
    }
    
    get(key) {
        const wrapped = localStorage.getItem(this._key(key));
        if (!wrapped) return null;
        
        const result = this._unwrap(wrapped);
        if (result.expired) {
            this.remove(key); // 自动清理过期数据
            return null;
        }
        return result.data;
    }
    
    remove(key) {
        localStorage.removeItem(this._key(key));
    }
    
    // 清理所有过期的数据
    cleanup() {
        const keys = Object.keys(localStorage);
        let cleaned = 0;
        
        keys.forEach(key => {
            if (key.startsWith(this.ns + ':')) {
                const wrapped = localStorage.getItem(key);
                const result = this._unwrap(wrapped);
                if (result.expired) {
                    localStorage.removeItem(key);
                    cleaned++;
                }
            }
        });
        
        if (cleaned > 0) {
            console.log(`清理了 ${cleaned} 条过期数据`);
        }
        return cleaned;
    }
    
    // 获取所有未过期的key
    keys() {
        return Object.keys(localStorage)
            .filter(key => key.startsWith(this.ns + ':'))
            .map(key => key.replace(this.ns + ':', ''))
            .filter(key => this.get(key) !== null); // 过滤掉已过期但还没清理的
    }
    
    // 清空命名空间下的所有数据
    clear() {
        this.keys().forEach(key => this.remove(key));
    }
}

// 使用示例
const storage = new ExpirableStorage('myApp');

// 存一个1小时有效的验证码
storage.set('verification_code', '123456', 3600);

// 存一个永久有效的用户偏好
storage.set('theme', 'dark');

// 读取
const code = storage.get('verification_code');
if (!code) {
    console.log('验证码已过期或不存在');
}

IndexedDB学习曲线陡峭得像攀岩,新手上来就想劝退

IndexedDB的难点在于它的异步事件模型和事务管理。虽然现在有Promise封装,但理解其底层原理仍然很重要。常见的坑包括:

  • 忘记处理 onupgradeneeded,导致数据库版本不匹配
  • 在事务外操作数据
  • 没有处理并发写入的冲突
  • 游标使用不当导致内存溢出

缓存更新不及时,用户看着昨天的旧新闻以为是Bug

这是缓存策略设计的问题。如果缓存时间设得太长,用户看到的就是旧数据;如果太短,又失去缓存的意义。解决方案是版本控制+后台更新:

// 带版本控制的缓存管理器
class VersionedCache {
    constructor() {
        this.currentVersion = this.getAppVersion(); // 从构建信息获取
    }
    
    getAppVersion() {
        // 假设构建时注入了版本号
        return window.APP_VERSION || '1.0.0';
    }
    
    async getData(key, fetchFn, options = {}) {
        const { ttl = 3600, backgroundUpdate = true } = options;
        const cacheKey = `v2_${key}`;
        const versionKey = `${cacheKey}_ver`;
        
        const cached = localStorage.getItem(cacheKey);
        const cachedVersion = localStorage.getItem(versionKey);
        const cachedTime = localStorage.getItem(`${cacheKey}_time`);
        
        const isExpired = !cachedTime || (Date.now() - parseInt(cachedTime)) > ttl * 1000;
        const isOldVersion = cachedVersion !== this.currentVersion;
        
        // 如果有缓存且未过期且版本匹配,直接返回
        if (cached && !isExpired && !isOldVersion) {
            console.log('使用缓存:', key);
            
            // 后台更新(Stale-While-Revalidate)
            if (backgroundUpdate) {
                this.backgroundUpdate(key, fetchFn);
            }
            
            return JSON.parse(cached);
        }
        
        // 需要重新获取
        try {
            const fresh = await fetchFn();
            this.setData(key, fresh);
            return fresh;
        } catch (error) {
            // 网络错误时返回过期缓存
            if (cached) {
                console.warn('网络错误,使用过期缓存:', key);
                return JSON.parse(cached);
            }
            throw error;
        }
    }
    
    setData(key, data) {
        const cacheKey = `v2_${key}`;
        try {
            localStorage.setItem(cacheKey, JSON.stringify(data));
            localStorage.setItem(`${cacheKey}_ver`, this.currentVersion);
            localStorage.setItem(`${cacheKey}_time`, Date.now().toString());
        } catch (e) {
            console.error('缓存写入失败:', e);
        }
    }
    
    async backgroundUpdate(key, fetchFn) {
        try {
            const fresh = await fetchFn();
            this.setData(key, fresh);
            console.log('后台更新完成:', key);
        } catch (e) {
            console.log('后台更新失败:', key);
        }
    }
    
    // 版本升级时清理旧版本缓存
    migrate() {
        const keys = Object.keys(localStorage);
        keys.forEach(key => {
            if (key.startsWith('v2_') && key.endsWith('_ver')) {
                const ver = localStorage.getItem(key);
                if (ver !== this.currentVersion) {
                    const baseKey = key.replace('_ver', '');
                    localStorage.removeItem(baseKey);
                    localStorage.removeItem(key);
                    localStorage.removeItem(`${baseKey}_time`);
                    console.log('清理旧版本缓存:', baseKey);
                }
            }
        });
    }
}

跨域存储那些弯弯绕绕,第三方Cookie被禁用的后路在哪

随着隐私保护增强,第三方Cookie越来越受限。Storage Access API 和 Partitioned Cookies 是新方向,但兼容性还不行。目前比较稳妥的方案是:

  • 同域部署:把相关服务放在同一主域下
  • 使用POST Message:跨域页面间通信
  • 后端代理:通过同域接口中转数据

真实项目里大家都是怎么"缝缝补补"过日子的

封装一个带过期时间的Storage工具类,再也不用手动算时间戳

上面的 ExpirableStorage 类已经展示了基础实现,但在真实项目中,你可能还需要:

  • 命名空间隔离(多团队协作时防止key冲突)
  • 压缩存储(大数据量时减少占用)
  • 加密存储(敏感数据)
  • 读写统计(监控使用频率)

大列表数据怎么分片存入IndexedDB,避免一次读写卡成PPT

// 大数据分片存储方案
class ChunkedStorage {
    constructor(dbName) {
        this.db = new IndexedDBWrapper(dbName, 1);
        this.CHUNK_SIZE = 1000; // 每片1000条
    }
    
    async init() {
        await this.db.init([{
            name: 'chunks',
            keyPath: 'id',
            indexes: [
                { name: 'dataId', keyPath: 'dataId', options: { unique: false } },
                { name: 'index', keyPath: 'index', options: { unique: false } }
            ]
        }, {
            name: 'metadata',
            keyPath: 'dataId'
        }]);
    }
    
    // 分片存储大数据
    async saveLargeData(dataId, items, onProgress) {
        const totalChunks = Math.ceil(items.length / this.CHUNK_SIZE);
        
        // 保存元数据
        await this.db.add('metadata', {
            dataId,
            total: items.length,
            chunks: totalChunks,
            created: Date.now()
        });
        
        // 分批存储
        for (let i = 0; i < totalChunks; i++) {
            const chunk = items.slice(i * this.CHUNK_SIZE, (i + 1) * this.CHUNK_SIZE);
            await this.db.add('chunks', {
                id: `${dataId}_${i}`,
                dataId,
                index: i,
                data: chunk,
                count: chunk.length
            });
            
            if (onProgress) {
                onProgress(i + 1, totalChunks);
            }
            
            // 每存10片让出主线程,避免阻塞UI
            if (i % 10 === 0) {
                await new Promise(resolve => setTimeout(resolve, 0));
            }
        }
        
        return { dataId, chunks: totalChunks };
    }
    
    // 分片读取,支持流式加载
    async *streamLargeData(dataId) {
        const meta = await this.getMetadata(dataId);
        if (!meta) throw new Error('数据不存在');
        
        for (let i = 0; i < meta.chunks; i++) {
            const chunk = await this.db.getByIndex('chunks', 'id', `${dataId}_${i}`);
            yield* chunk[0].data; // 使用生成器逐条产出
        }
    }
    
    // 分页读取特定范围
    async getRange(dataId, start, end) {
        const meta = await this.getMetadata(dataId);
        const startChunk = Math.floor(start / this.CHUNK_SIZE);
        const endChunk = Math.floor(end / this.CHUNK_SIZE);
        
        const results = [];
        for (let i = startChunk; i <= endChunk && i < meta.chunks; i++) {
            const chunk = await this.db.getByIndex('chunks', 'id', `${dataId}_${i}`);
            const data = chunk[0].data;
            
            // 计算边界
            const chunkStart = i * this.CHUNK_SIZE;
            const sliceStart = Math.max(0, start - chunkStart);
            const sliceEnd = Math.min(data.length, end - chunkStart + 1);
            
            results.push(...data.slice(sliceStart, sliceEnd));
        }
        
        return results;
    }
    
    async getMetadata(dataId) {
        const result = await this.db.getByIndex('metadata', 'dataId', dataId);
        return result[0];
    }
    
    // 删除大数据
    async deleteLargeData(dataId) {
        const meta = await this.getMetadata(dataId);
        if (!meta) return;
        
        // 删除所有分片
        for (let i = 0; i < meta.chunks; i++) {
            await this.db.delete('chunks', `${dataId}_${i}`);
        }
        
        await this.db.delete('metadata', dataId);
    }
}

接口数据怎么利用HTTP缓存,减少服务器压力还能省流量

除了前面提到的 cachedFetch,还可以结合 Service Worker 实现更精细的控制:

// 在Service Worker中实现API缓存策略
const API_CACHE = 'api-cache-v1';

// 安装时不需要预缓存API,动态缓存即可
self.addEventListener('fetch', event => {
    if (isAPIRequest(event.request)) {
        event.respondWith(cacheAPI(event.request));
    }
});

async function cacheAPI(request) {
    const cache = await caches.open(API_CACHE);
    const url = new URL(request.url);
    
    // 对列表数据使用Stale-While-Revalidate
    if (url.pathname.includes('/list')) {
        const cached = await cache.match(request);
        
        // 后台更新
        const fetchPromise = fetch(request).then(response => {
            if (response.ok) {
                cache.put(request, response.clone());
            }
            return response;
        }).catch(() => cached); // 网络失败返回缓存
        
        return cached || await fetchPromise;
    }
    
    // 对详情数据使用Cache First,但设置较短有效期
    if (url.pathname.includes('/detail')) {
        const cached = await cache.match(request);
        if (cached) {
            // 检查缓存时间
            const dateHeader = cached.headers.get('sw-cached-date');
            if (dateHeader) {
                const age = Date.now() - parseInt(dateHeader);
                if (age < 5 * 60 * 1000) { // 5分钟内有效
                    return cached;
                }
            }
        }
        
        const response = await fetch(request);
        if (response.ok) {
            // 添加自定义头部记录缓存时间
            const modified = new Response(response.body, {
                status: response.status,
                statusText: response.statusText,
                headers: {
                    ...Object.fromEntries(response.headers),
                    'sw-cached-date': Date.now().toString()
                }
            });
            cache.put(request, modified);
            return response;
        }
    }
    
    return fetch(request);
}

版本更新时如何优雅地清理旧缓存,防止新旧代码打架

这是PWA的痛点。如果Service Worker更新后,旧缓存没清理,用户可能看到新旧资源混合的页面,导致JS报错。解决方案是在activate阶段清理,并使用版本号隔离:

// 版本控制策略
const VERSION = '2.1.0'; // 每次发版更新
const CACHE_PREFIX = 'my-app';
const CACHE_NAME = `${CACHE_PREFIX}-${VERSION}`;

self.addEventListener('activate', event => {
    event.waitUntil(
        caches.keys().then(cacheNames => {
            return Promise.all(
                cacheNames
                    .filter(name => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME)
                    .map(name => {
                        console.log('删除旧版本缓存:', name);
                        return caches.delete(name);
                    })
            );
        }).then(() => {
            // 通知所有客户端新版本已激活
            return self.clients.matchAll().then(clients => {
                clients.forEach(client => {
                    client.postMessage({
                        type: 'NEW_VERSION',
                        version: VERSION
                    });
                });
            });
        })
    );
});

// 前端监听版本更新
navigator.serviceWorker.addEventListener('message', event => {
    if (event.data.type === 'NEW_VERSION') {
        // 提示用户刷新
        showUpdateNotification('新版本已就绪,请刷新页面');
    }
});

敏感信息千万别往LocalStorage里扔,XSS攻击教你做人

XSS(跨站脚本攻击)可以轻易读取LocalStorage里的所有数据。防护措施:

  1. HttpOnly Cookie:敏感token放Cookie里,设置HttpOnly,JS读不到
  2. 输入过滤:严格过滤用户输入,防止注入
  3. CSP策略:限制内联脚本执行
  4. 短有效期:即使被盗,也很快失效
// 安全的Token管理方案
class SecureTokenManager {
    // 使用Cookie存储refresh token(HttpOnly,服务端设置)
    // 使用内存存储access token(页面刷新丢失,需要重新获取)
    
    constructor() {
        this.accessToken = null;
        this.refreshPromise = null;
    }
    
    // 从Cookie获取refresh token(实际上JS读不到HttpOnly Cookie,这里只是示意流程)
    async getAccessToken() {
        if (this.accessToken) {
            // 检查是否即将过期
            const payload = this.parseJwt(this.accessToken);
            if (payload.exp - Date.now()/1000 > 60) { // 还有1分钟以上有效期
                return this.accessToken;
            }
        }
        
        // 需要刷新
        return this.refreshAccessToken();
    }
    
    async refreshAccessToken() {
        // 防止重复刷新
        if (this.refreshPromise) {
            return this.refreshPromise;
        }
        
        this.refreshPromise = fetch('/api/refresh', {
            method: 'POST',
            credentials: 'include' // 携带Cookie
        }).then(res => {
            if (!res.ok) throw new Error('刷新失败');
            return res.json();
        }).then(data => {
            this.accessToken = data.accessToken;
            // 不存LocalStorage,只存内存
            return this.accessToken;
        }).finally(() => {
            this.refreshPromise = null;
        });
        
        return this.refreshPromise;
    }
    
    parseJwt(token) {
        try {
            return JSON.parse(atob(token.split('.')[1]));
        } catch (e) {
            return null;
        }
    }
    
    // 登出时清除
    logout() {
        this.accessToken = null;
        // 调用服务端清除HttpOnly Cookie
        fetch('/api/logout', { method: 'POST', credentials: 'include' });
    }
}

遇到诡异的缓存问题别慌,按这个路子查准没错

开发者工具Application面板怎么看,一眼识别谁在占坑

Chrome DevTools的Application面板是调试存储的利器:

  • Local Storage:查看键值对,注意Size列显示的是字符数,不是字节数
  • Session Storage:同上,但标签页关闭就没了
  • IndexedDB:可以查看数据库结构、对象仓库、索引,甚至执行查询
  • Cookies:查看所有Cookie的属性,检查HttpOnly、Secure、SameSite
  • Cache Storage:查看Service Worker缓存的具体内容
  • Service Workers:检查SW的状态,模拟离线,跳过等待

清除了缓存还是没变?可能是Service Worker在后台作祟

这是最常见的坑。用户说"我清除了浏览器缓存还是旧页面",其实是因为Service Worker还在拦截请求。解决方案:

  1. 打开DevTools -> Application -> Service Workers
  2. 勾选"Update on reload"(开发时)
  3. 点击"Unregister"删除当前SW
  4. 或者长按刷新按钮选择"清空缓存并硬性重新加载"

沙箱环境下的存储限制,无痕模式里的"薛定谔的存储"

无痕模式(隐私模式)下,各浏览器对存储的处理不同:

  • Chrome:LocalStorage可用,但关闭标签页后清除;IndexedDB可用但容量受限
  • Safari:LocalStorage和IndexedDB都可能被禁用,或者表现为"写成功但读不到"
  • Firefox:类似Chrome,但IndexedDB在无痕模式下可能行为异常

检测方法:

async function checkStorageInIncognito() {
    try {
        const testKey = '__incognito_test__';
        localStorage.setItem(testKey, '1');
        const result = localStorage.getItem(testKey);
        localStorage.removeItem(testKey);
        
        if (result !== '1') {
            return { available: false, reason: '写入后读取不一致' };
        }
        
        // 检查IndexedDB
        const db = await indexedDB.open('test');
        await new Promise((resolve, reject) => {
            db.onsuccess = resolve;
            db.onerror = reject;
        });
        
        return { available: true };
    } catch (e) {
        return { available: false, reason: e.message };
    }
}

移动端WebView的奇葩行为,安卓和iOS各自为政的坑

移动端WebView的存储问题更多:

  • iOS WKWebView:IndexedDB在某些版本有bug,数据可能随机丢失;LocalStorage在内存不足时可能被清理
  • Android WebView:不同厂商定制差异大,有些会限制存储配额;清除App数据会同时清除所有Web存储
  • 微信/支付宝内置浏览器:有额外的缓存层,有时候需要特定的清理策略

线上用户反馈数据丢失,怎么通过日志还原现场抓鬼

建立存储操作的日志系统:

// 存储操作日志系统
class StorageLogger {
    constructor() {
        this.logs = [];
        this.maxLogs = 100;
    }
    
    log(operation, key, success, details = {}) {
        const entry = {
            time: new Date().toISOString(),
            operation, // 'read' | 'write' | 'delete' | 'clear'
            key: key?.substring(0, 50), // 截断避免过大
            success,
            userAgent: navigator.userAgent.substring(0, 100),
            url: window.location.href,
            ...details
        };
        
        this.logs.push(entry);
        
        // 限制日志数量
        if (this.logs.length > this.maxLogs) {
            this.logs.shift();
        }
        
        // 同步到服务器(如果是关键错误)
        if (!success && details.critical) {
            this.reportToServer(entry);
        }
    }
    
    // 包装LocalStorage
    wrapLocalStorage() {
        const original = {
            setItem: localStorage.setItem.bind(localStorage),
            getItem: localStorage.getItem.bind(localStorage),
            removeItem: localStorage.removeItem.bind(localStorage)
        };
        
        localStorage.setItem = (key, value) => {
            try {
                original.setItem(key, value);
                this.log('write', key, true, { size: value?.length });
            } catch (e) {
                this.log('write', key, false, { 
                    error: e.name, 
                    message: e.message,
                    critical: true 
                });
                throw e;
            }
        };
        
        localStorage.getItem = (key) => {
            const value = original.getItem(key);
            this.log('read', key, true, { hit: value !== null });
            return value;
        };
    }
    
    // 导出日志给用户下载(排查问题时用)
    export() {
        return JSON.stringify(this.logs, null, 2);
    }
    
    reportToServer(entry) {
        // 发送到错误监控服务
        if (window.Sentry) {
            window.Sentry.captureMessage('Storage Operation Failed', {
                extra: entry
            });
        }
    }
}

// 初始化
const storageLogger = new StorageLogger();
storageLogger.wrapLocalStorage();

几个让代码更健壮、性能更起飞的老司机经验

别把所有鸡蛋放在一个篮子里,组合拳出击才稳当

根据数据特点选择存储方案:

  • 用户配置:LocalStorage + 内存缓存
  • 大列表数据:IndexedDB + 分页加载
  • 静态资源:Cache Storage + Service Worker
  • 敏感信息:HttpOnly Cookie + 内存
  • 临时状态:SessionStorage 或 内存

序列化大对象前先压缩一下,空间利用率直接翻倍

// 使用LZ-string进行客户端压缩
const LZString = require('lz-string');

class CompressedStorage {
    set(key, value) {
        const json = JSON.stringify(value);
        const compressed = LZString.compressToUTF16(json); // 压缩为UTF-16字符串
        localStorage.setItem(key, compressed);
        
        console.log(`压缩率: ${(compressed.length / json.length * 100).toFixed(1)}%`);
        return compressed.length < json.length;
    }
    
    get(key) {
        const compressed = localStorage.getItem(key);
        if (!compressed) return null;
        
        const json = LZString.decompressFromUTF16(compressed);
        return JSON.parse(json);
    }
}

利用requestIdleCallback在浏览器空闲时慢慢存,别阻塞渲染

// 空闲时批量写入
class IdleStorage {
    constructor() {
        this.queue = [];
        this.isProcessing = false;
    }
    
    addToQueue(key, value) {
        this.queue.push({ key, value });
        this.scheduleProcess();
    }
    
    scheduleProcess() {
        if (this.isProcessing || this.queue.length === 0) return;
        
        if ('requestIdleCallback' in window) {
            requestIdleCallback(() => this.processQueue(), { timeout: 2000 });
        } else {
            setTimeout(() => this.processQueue(), 100);
        }
    }
    
    processQueue() {
        this.isProcessing = true;
        
        // 每次空闲时处理一部分
        const batch = this.queue.splice(0, 5);
        
        batch.forEach(({ key, value }) => {
            try {
                localStorage.setItem(key, JSON.stringify(value));
            } catch (e) {
                console.error('写入失败:', key, e);
            }
        });
        
        this.isProcessing = false;
        
        // 如果还有队列,继续调度
        if (this.queue.length > 0) {
            this.scheduleProcess();
        }
    }
}

给缓存数据加个版本号,升级逻辑写得明明白白

前面已经展示过版本控制,这里再强调一下数据结构的版本兼容:

// 数据迁移示例
function migrateUserData(oldData) {
    const version = oldData._v || 1;
    
    if (version === 1) {
        // v1 -> v2: 重命名字段
        oldData.fullName = oldData.name;
        delete oldData.name;
        oldData._v = 2;
    }
    
    if (version === 2) {
        // v2 -> v3: 添加新字段
        oldData.preferences = oldData.preferences || { theme: 'light' };
        oldData._v = 3;
    }
    
    return oldData;
}

监控存储空间配额,快满的时候提前给用户提个醒

// 存储空间监控
async function checkStorageQuota() {
    if (!navigator.storage || !navigator.storage.estimate) return;
    
    const estimate = await navigator.storage.estimate();
    const usage = estimate.usage || 0;
    const quota = estimate.quota || Infinity;
    const percent = (usage / quota * 100).toFixed(1);
    
    if (percent > 90) {
        showWarning('存储空间即将用尽,建议清理缓存');
    } else if (percent > 70) {
        console.warn(`存储空间使用: ${percent}%`);
    }
    
    return { usage, quota, percent };
}

// 定期监控
setInterval(checkStorageQuota, 5 * 60 * 1000); // 每5分钟检查

要是看完你还觉得缓存简单,那一定是你遇到的坑还不够多

说真的,写这篇文章的时候,我脑子里像放电影一样闪过无数次凌晨被叫起来修bug的画面。有次是因为LocalStorage存满了导致整个单页应用白屏;有次是IndexedDB版本升级逻辑写错了,用户数据全"消失"了(其实还在,只是读不出来);还有一次是Service Worker缓存了HTML,但JS文件没缓存,结果新旧代码不匹配,页面按钮点了没反应。

每次解决完问题,我都觉得"这次总该学乖了吧",但下次总能遇到新花样。这就是前端开发的魅力(或者叫折磨)——你以为你懂了,其实你只是熟悉了上次那个坑的形状。

所以下次再有人跟你吹嘘"缓存很简单,不就是set和get吗",请把这篇文章甩他脸上。然后微笑着问他:“兄弟,处理过QuotaExceededError吗?写过IndexedDB的版本迁移吗?调试过Service Worker的激活状态吗?”

记住,前端没有银弹,只有填不完的坑和修不完的Bug。但正是这些坑,让我们从"写页面的"变成了"工程师"。每次解决一个存储相关的诡异bug,你对浏览器的理解就深了一层,下次就能少熬一个夜。

愿你的代码永远没有缓存污染,愿用户的浏览器永远不抽风。如果抽风了,愿你能快速定位问题,而不是在控制台面面相觑。

共勉。

在这里插入图片描述

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐