Skip to content

存储、URL 与历史:哪些状态应该留到下一次 ​

输入框里正在编辑的草稿、服务器认可的登录会话、可分享的筛选条件,都叫“状态”,但生命周期完全不同。先决定谁需要读取、保存多久、能否丢失,再选择存储 API。

四种存储不是大小不同的同一个抽屉 ​

能力特征适合保存
Cookie匹配条件时随 HTTP 请求携带;可有 HttpOnly、Secure、SameSite服务器会话相关信息
localStorage同源字符串键值、同步 API,可跨会话保留小型偏好设置
sessionStorage按源及顶层浏览上下文划分,通常随标签页会话结束当前页会话中的临时状态
IndexedDB异步、结构化数据、对象库、索引与事务较大量本地数据

“持久”并不是永不丢失:用户可以清理,浏览器可能受配额和存储策略影响,隐私模式也有不同约束。重要数据应有恢复或同步方案。

localStorage/sessionStorage 仅保存字符串。给对象直接 setItem 得到的往往是 [object Object];用 JSON 编解码时还应验证读取结果,别把存储区当成可信数据库。

js
function loadTheme(storage) {
  try {
    const value = storage.getItem('reader-theme');
    return value === 'dark' || value === 'light' ? value : 'light';
  } catch {
    return 'light'; // 存储不可用时仍能阅读
  }
}

在 HTTP 页面中传入 localStorage 即可使用。写入也可能因配额失败,应同样设计降级。跨标签页的 storage 事件发生在其他相关文档,不会让发起写入的当前文档自动收到自己的同一事件。它也不是数据库事务或强一致消息总线。

Cookie 的 HttpOnly 由服务器设置,脚本不能读取;这降低脚本窃取该 Cookie 的风险,却不能让发生 XSS 的页面安全无忧。任何脚本可读存储都不适合被当成秘密保险箱。

IndexedDB:请求成功还不是事务完成 ​

下面函数可在 HTTP 页面控制台定义后调用。它只演示一条记录写入;使用独立的学习数据库名。

js
function saveReading(id, title) {
  return new Promise((resolve, reject) => {
    const opening = indexedDB.open('coderpath-reading-demo', 1);
    opening.onupgradeneeded = () => {
      opening.result.createObjectStore('notes', { keyPath: 'id' });
    };
    opening.onerror = () => reject(opening.error);
    opening.onblocked = () => console.warn('请关闭占用旧数据库的标签页');
    opening.onsuccess = () => {
      const db = opening.result;
      db.onversionchange = () => db.close();
      const tx = db.transaction('notes', 'readwrite');
      tx.oncomplete = () => { db.close(); resolve(); };
      tx.onabort = () => { db.close(); reject(tx.error ?? new Error('写入已取消')); };
      tx.objectStore('notes').put({ id, title });
    };
  });
}
await saveReading('scope', '作用域');
console.log('事务已完成');

open 发起异步打开;升级回调中创建对象库;readwrite 事务负责写入。put 在键已存在时更新,add 在重复键时通常失败。单个请求成功后,后续请求仍可能使事务回滚,因此最终成功信号看 tx.oncomplete。

事务活动时机也很关键:创建事务后先去 await 一个网络请求,回来再写数据库,事务可能已经不活跃。先准备外部数据,再创建短事务集中写入。事务通常自动提交,现代 API 也有 commit();它不是读取数据后才能调用的必要步骤,更不会把已经失败的事务变成功。

升级数据库时其他标签页可能仍持有旧连接。blocked 和 versionchange 对应的就是这类协作问题,不能用无限刷新掩盖。

让一次成功请求随后回滚 ​

只有看见回滚,才容易理解为什么 success 不等于最终保存。下面在 HTTP 页面控制台运行;它只使用专门的 coderpath-transaction-lab 学习数据库,并在每次实验开始清空其中的 rows 对象库,不涉及其他数据库。

js
async function rollbackLab() {
  const db = await new Promise((resolve, reject) => {
    const request = indexedDB.open('coderpath-transaction-lab', 1);
    request.onupgradeneeded = () => request.result.createObjectStore('rows');
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
  const done = tx => new Promise((resolve, reject) => {
    tx.oncomplete = resolve;
    tx.onabort = () => reject(tx.error ?? new Error('事务中止'));
  });
  try {
    const reset = db.transaction('rows', 'readwrite');
    const resetDone = done(reset);
    reset.objectStore('rows').clear();
    await resetDone;

    const tx = db.transaction('rows', 'readwrite');
    const transactionDone = done(tx);
    const store = tx.objectStore('rows');
    store.add('第一份', 'same').onsuccess = () => console.log('第一条请求成功');
    store.add('重复键', 'same');
    try { await transactionDone; }
    catch (error) { console.log('事务结果', error.name); }

    const check = db.transaction('rows', 'readonly');
    const checked = done(check);
    const value = await new Promise((resolve, reject) => {
      const request = check.objectStore('rows').get('same');
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
    await checked;
    console.log('最终记录', value); // undefined
  } finally { db.close(); }
}
await rollbackLab();

观察顺序:第一条请求成功,第二条 add 因重复键失败,事务以 ConstraintError 中止,最后查不到第一条记录。它不是先永久保存再偷偷删除,而是事务未能整体提交,前面的修改没有成为最终结果。

默认请求 error 会导致事务中止;确有恢复策略时可以处理错误并取消这一默认行为,但不能为了“控制台不红”就 preventDefault 所有错误。把 add 改成 put 会变成允许覆盖的业务语义,也不是仅修改一种错误处理写法。

活动窗口为什么不能无限延长 ​

创建事务的当前任务,以及该事务请求的 success/error 处理期间,是安排后续请求的重要活动窗口。事务在其他任务中不活跃;当没有待处理请求、也没有继续安排新请求时,可以自动提交。

因此 const tx = ...; await fetch(...); tx.objectStore(...).put(...) 有结构性问题:网络等待不属于数据库在等的请求,回来时事务可能已失效。不是“所有 await 都禁止”,而是不能把任意外部等待误当成保活事务。先完成网络读取与解析,再创建短事务安排写入,顺序会更可靠。

如果写入依赖库中现有记录,可在读取请求的 success 回调中安排下一条写请求,使它们属于同一事务。若跨多个事务先读后写,则还要面对其他标签页或代码在中间修改状态的可能性。事务是并发语义的一部分,不只是一个 API 必填参数。

现有事务的 commit 请求也不意味着“立刻成功落盘且其他失败消失”。提交完成仍看 complete;数据库连接 close 与事务提交也是不同职责。

可分享状态适合进入 URL ​

js
const url = new URL('https://example.test/notes?chapter=scope');
url.searchParams.set('q', '闭包 与 this');
console.log(url.searchParams.get('q')); // 闭包 与 this
console.log(url.pathname); // /notes

URLSearchParams 处理查询参数编码;不要自己用字符串拼接来猜空格、& 和中文怎样编码。需要重复键时用 append/getAll。URL 可能出现在历史、日志与分享链接中,不要放秘密。

页面中 history.pushState 增加同源历史记录,replaceState 替换当前记录,两者本身都不重新下载页面,也不会为这次调用自动触发 popstate。用户前进或后退时再根据 popstate 恢复界面;初次渲染也必须主动读取当前 URL。

location.assign 会导航,location.replace 导航并替换当前历史项;这与 history API 只修改同文档记录不同。hash 变化也可承载简单页内状态,但仍需考虑回退与可访问性。

练习 ​

  1. 把一个对象直接写进 localStorage,再用 JSON.parse 读取会有什么问题?

  2. 为什么数据库 put 请求 success 不能作为整组写入最终成功的证据?

  3. pushState 后界面没变,是浏览器出错了吗?

  4. 回滚实验中第一条add输出success,最后为何读到undefined?若改第二条为put,业务语义如何变化?

  5. 实现 chapterFromURL(address):只接受查询参数 chapter 为 1 到 10 的十进制整数字符串,其余返回 1。验证 ?chapter=3、?chapter=0、?chapter=2.5、?chapter=3abc 和缺失参数;不要误把 parseInt 的部分解析当完整校验。

查看本节答案

用心记录每一步成长