创新编码

不说话,装高手。

Maintain silence and pretend to be an experta

前端离线储存技术:indexDB 与 SQLite 的选择

2024-11-25 15:34:25Javascript

前端本地储存是是现代 web 应用程序中一个重要功能,他允许数据在客户端进行存储,实现更快的加载速度、离线操作,改善用户体验。现阶段前端常用的离线储存技术有 Web StorageWeb SQLIndexedDBSQLite 等。

Web Storage

Web StoragelocalStoragesessionStorage 的统称,它是 HTML5 专门为浏览器存储而提供的数据存储机制,通过 “键值对” 的形式存储数据,并且一般是以文本格式储存。Web Storage 限制储存大小一般为 5M 左右(不同浏览器限制不同),容量限制的目的是防止滥用本地存储空间,导致用户浏览器变慢。

区别

两者的区别在于生命周期作用域的不同

localStorage sessionStorage
生命周期 持久化储存,除非用户自行删除或者清除浏览器缓存,否则一直存在 会话级储存,当前标签页关闭或浏览器关闭时清空储存数据
作用域 同浏览器、同域名、不同标签、不同窗口 同浏览器、同域名、同标签、同窗口

优缺点

优点 缺点
简单易用、浏览器支持广泛、快速的读写性能 存量太小,并且不同浏览器存量不一样
本地储存,减少服务器压力,提高程序响应速度 没有数据加密、过期时间,容易造成数据泄漏
独立储存空间,不会造成数据混淆 储存的格式只能是字符串,需要增加序列化操作

兼容性

目前主流的浏览器大部分都支持这两种 API

WX20241112-102939.png
WX20241112-103010.png

使用过程中可以添加一段检测代码检测 API 是否被支持

折叠代码 复制代码
if(window.localStorage){
  try {
    alert("浏览器支持 localStorage");
  } catch (e) {
    alert("浏览器支持 localStorage 后不可使用");
  }
} else {
  alert("浏览器不支持 localStorage");
}

使用方式

localStoragesessionStorage 的使用方式是一致的,下面是对使用的二次封装,主要是为了增强其功能,提升代码的可维护性、可扩展性和安全性。
在下面封装的代码中,除了常用的操作,还添加了监听操作,可以监听 setremoveclear 操作,也可以自己拓展其他操作

折叠代码 复制代码
// storage.ts
class CustomStorage {
  // storage类型
  private storageType: "localStorage" | "sessionStorage";
  // 前缀
  private prefix: string;
  // 监听事件
  private event: CustomEvent<{
    storageType: "localStorage" | "sessionStorage";
    operation: "" | "set" | "remove" | "clear";
    key: string;
    oldVal: any | null;
    newVal: any | null;
  }>;

  constructor(storageType: "localStorage" | "sessionStorage", prefix: string) {
    // 支持性判断
    if (window.localStorage || window.sessionStorage) {
      try {
        console.info("浏览器支持 localStorage 和 sessionStorage");
      } catch (e) {
        console.error("浏览器 localStorage 或 sessionStorage 不可使用");
      }
    } else {
      console.error("浏览器不支持 localStorage 或 sessionStorage");
    }

    this.storageType = storageType;
    this.prefix = prefix;

    this.event = new CustomEvent("storageChange", {
      detail: {
        storageType: this.storageType,
        operation: "",
        key: "",
        oldVal: null,
        newVal: null,
      },
    });
  }

  /**
   * @function setStorage
   * @description 设置值
   * @param key 键
   * @param value 值
   * @param exprie 过期时间 0 永不过期
   * @returns boolean
   */
  setStorage(key: string, value: any, exprie = 0): boolean {
    // 空值处理
    if (["", null, undefined].includes(value)) {
      value = null;
    }
    // 过期时间合理性判断
    if (isNaN(exprie) || exprie < 0) {
      throw new Error("Expire must be reasonable");
    }
    const data = {
      value,
      time: Date.now(), // 储存日期
      exprie: exprie == 0 ? exprie : Date.now() + exprie,
    };
    const oldVal = this.getStorage(key);
    window[this.storageType].setItem(this.addPrefix(key), JSON.stringify(data));
    this.dispatchStorageChange({ operation: "set", key, oldVal, newVal: data.value });
    return true;
  }

  /**
   * @function getStorage
   * @description 获取值
   * @param key 键
   * @returns any | null
   */
  getStorage(key: string): any | null {
    //不存在判断
    if (!window[this.storageType].getItem(this.addPrefix(key))) {
      return null;
    }
    const storageVal = JSON.parse(
      window[this.storageType].getItem(this.addPrefix(key)) as string
    );
    const now = Date.now();

    if (storageVal.exprie !== 0 && now > storageVal.exprie) {
      // 过期处理,删除对应内容但不触发监听
      this.removeStorage(key, false);
      return null;
    } else {
      return storageVal.value;
    }
  }

  /**
   * @function getStorageKeyByIndex
   * @description 根据下标获取 storage 中的 key
   * @param index
   * @returns string | null
   */
  getStorageKeyByIndex(index: number): string | null {
    if (isNaN(index) || index < 0) {
      throw new Error("index must be effective");
    }
    return window[this.storageType].key(index);
  }

  /**
   * @function getAllStorageKeys
   * @description 获取 storage 储存的所有的键
   * @returns Array<string>
   */
  getAllStorageKeys(): Array<string> {
    return Object.keys(window[this.storageType]);
  }

  /**
   * @function getAllStorage
   * @description 获取 storage 储存的所有内容
   * @returns any
   */
  getAllStorage(): any {
    const storageMap: { [key: string]: any } = {};
    const keys = this.getAllStorageKeys();
    keys.forEach((key) => {
      const value = this.getStorage(this.removePrefix(key));
      if (value != null) {
        storageMap[key] = value;
      }
    });
    return storageMap;
  }

  /**
   * @function removeStorage
   * @description 删除值
   * @param key 键
   * @params needDispatch 是否需要分发事件
   */
  removeStorage(key: string, needDispatch = true) {
    const oldVal = this.getStorage(this.addPrefix(key));
    window[this.storageType].removeItem(this.addPrefix(key));
    if (needDispatch) {
      this.dispatchStorageChange({
        operation: "remove",
        key,
        oldVal,
        newVal: null,
      });
    }
  }

  /**
   * @function clearStorage
   * @description 清除 storage 所有内容
   */
  clearStorage() {
    window[this.storageType].clear();

    this.dispatchStorageChange({
      operation: "clear",
      key: '',
      oldVal: null,
      newVal: null,
    });
  }

  /**
   * @function addPrefix
   * @description 添加统一头部标识
   * @param key 键
   * @returns string
   */
  addPrefix(key: string) {
    return this.prefix ? `${this.prefix}_${key}` : key;
  }

  /**
   * @function removePrefix
   * @description 删除统一前缀
   * @param key 键
   * @returns
   */
  removePrefix(key: string) {
    const lineIndex = this.prefix.length + 1;
    return key.substring(lineIndex);
  }

  /**
   * @function dispatchStorageChange
   * @description 分发监听事件
   * @param operation 当前操作 set 设置值  remove 删除值
   * @param key 当前操作的键
   * @param oldVal 操作前数据
   * @param newVal 操作后数据
   */
  dispatchStorageChange({
    operation,
    key,
    oldVal,
    newVal,
  }: {
    operation: "set" | "remove" | "clear";
    key: string;
    oldVal: any;
    newVal: any;
  }) {
    this.event.detail.operation = operation;
    this.event.detail.key = key;
    this.event.detail.oldVal = oldVal;
    this.event.detail.newVal = newVal;
    window.dispatchEvent(this.event);
  }
}

Web SQL

Web SQL 数据库是一种在浏览器环境中使用 SQL 语言来操作的本地数据库。它允许开发者在客户端存储和检索结构化数据,为 Web 应用提供了一种强大的数据存储解决方案

优缺点

优点 缺点
本地储存,减少服务器压力,提高程序响应速度 已被废弃、兼容性有限
SQL 操作,对于熟悉数据库的开发者来说易于上手 缺乏灵活性,数据模式固定,需要通过 SQL 预定义表结构
支持事务,保证数据完整性 安全性不足,无加密机制

兼容性

由于他的兼容性问题以及它自身的设计缺陷以及不符合现代 Web 标准的要求,最终被时代所抛弃(后续被 IndexDB 代替),一些旧的浏览器依旧支持。

WX20241112-160409.png

使用方式

打开数据库:使用 openDatabase 方法打开或创建一个数据库。这个方法接受数据库名称、版本号、描述和估计的数据库大小等参数

折叠代码 复制代码
const db = openDatabase('mydb', '1.0', 'My Database', 1024 * 1024);

增删改查:使用 executeSql 方法执行 SQL 语句进行对应操作

折叠代码 复制代码
db.transaction(function (tx) {    
  tx.executeSql('INSERT INTO users (id, name) VALUES (1, "John")');
});

Web SQL 使用方式基本如上,剩下都是通过 executeSql 执行 SQL 来做相对应的操作,因为已经被废弃原因(即使某些浏览器支持也不刚用,不知道哪天突然就没了),这里仅做了解。

IndexDB

IndexDB 的出现是为了替代 Web SQL,是浏览器提供的一种客户端数据库 API,它允许 Web 应用存储大量结构化数据并进行高效的查询和检索操作。IndexedDB 采用键值对的存储方式,并且是事务性的数据库,适合存储大量的非结构化和结构化数据

IndexDB 的储存大小没有明确的限制,在不同设备不同客户端也有不同体现,详情可以查看这篇文章《IndexDB 数据储存限制》

WX20241112-163915.png

优缺点

优点 缺点
本地储存、大容量储存,并且支持结构化 API 使用复杂,需要大量的回调或者 async/await 操作
异步操作,防止阻塞主线程 性能问题,大量频繁的会导致性能下降
支持事务、索引操作 潜在的储存策略和删除策略,当数据操作储存限制时会被删除,而且收不到任何通知

兼容性

WX20241112-164922.png

使用方式

因为 IndexDB 复杂的调用方式,所以我们对他的操作进行二次封装,达到简化操作,提高代码维护行的目的

下面我对我常用的操作进行封装

折叠代码 复制代码
class CustomIndexDB {
  private dbName: string;
  private version: number;
  private dbInstance: IDBDatabase | null = null;
  private transactionMap: Map<string, IDBTransaction> = new Map();

  constructor(dbName: string, version: number) {
    this.dbName = dbName;
    this.version = version;
  }

  /**
   * @function openDB
   * @description 初始化数据库
   * @param storeObjects Array<{ storeName: 表名称, keyPath: 主键 }>
   * @returns
   */
  openDB(storeObjects: Array<{ storeName: string; keyPath: string }>) {
    return new Promise((resolve, reject) => {
      // 如果已打开数据库实例则直接返回
      if (this.dbInstance) {
        return resolve(this.dbInstance);
      }
      // 打开数据库
      const request = indexedDB.open(this.dbName, this.version);
      // 当数据库不存在或者版本高于当前版本时触发 onupgradeneeded
      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBRequest).result as IDBDatabase;
        storeObjects.forEach((store) => {
          if (!db.objectStoreNames.contains(store.storeName)) {
            // 表主键默认是 id,可根据传入值修改
            const keyPath = store.keyPath || "id";
            // 建表 自动生成主键
            db.createObjectStore(store.storeName, {
              keyPath,
              autoIncrement: true,
            });
          }
        });
      };
      // 处理数据库打开成功
      request.onsuccess = () => {
        // 缓存数据库实例
        this.dbInstance = request.result as IDBDatabase;
        console.info("indexDB 数据库打开成功", this.dbInstance);
        resolve(this.dbInstance);
      };
      // 处理数据库打开失败
      request.onerror = (event) => {
        console.error(`indexDB 数据库打开失败: ${event.target}`);
        reject(false);
      };
    });
  }

  /**
   * @function getTransaction
   * @description 获取指定表事务
   * @param storeName 表名
   * @param mode 数据访问模式 "readonly" | "readwrite" | "versionchange"
   * @returns
   */
  private getTransaction(
    storeName: string,
    mode: IDBTransactionMode
  ): IDBTransaction | undefined {
    if (!this.dbInstance) {
      console.error("数据库未初始化");
      return undefined;
    }

    // 事务复用
    const transactionKey = `${storeName}_${mode}`;
    if (this.transactionMap.has(transactionKey)) {
      return this.transactionMap.get(transactionKey);
    }

    const transaction = this.dbInstance.transaction(storeName, mode);

    this.transactionMap.set(transactionKey, transaction);

    return transaction;
  }

  /**
   * @function executeTransaction
   * @description 统一管理、执行数据库事务
   * @param storeName 表名
   * @param mode 数据访问模式 "readonly" | "readwrite" | "versionchange"
   * @param requestFun 操作回调函数
   * @param successFun 操作成功回调函数
   * @param errorFun 操作失败回调函数
   * @returns
   */
  private executeTransaction(
    storeName: string,
    mode: IDBTransactionMode,
    requestFun: (store: IDBObjectStore) => IDBRequest,
    successFun?: (event: Event) => void,
    errorFun?: (error: Event) => void
  ): Promise<any> {
    return new Promise((resolve, reject) => {
      const tx = this.getTransaction(storeName, mode);

      if (!tx) {
        console.error("未能获取到事务实例");
        reject(false);
        return;
      }

      const store = tx.objectStore(storeName);
      const request = requestFun(store);

      tx.oncomplete = () => {
        console.info("事务执行完成");
      };
      tx.onerror = (err) => {
        console.error(`事务执行失败`, (err.target as IDBRequest).error);
        reject(false);
      };
      request.onsuccess = (event) => {
        let resData: any = true;
        if (successFun) {
          resData = successFun(event);
        } else {
          resData = (event.target as IDBRequest).result;
        }
        resolve(resData);
      };
      request.onerror = errorFun
        ? errorFun
        : (err) => {
            console.error(`操作失败`, (err.target as IDBRequest).error);
            reject(false);
          };
    });
  }

  /**
   * @function add
   * @description 新增数据
   * @param storeName 表名
   * @param data 数据
   * @returns
   */
  add(storeName: string, data: any): Promise<any | boolean> {
    return this.executeTransaction(
      storeName,
      "readwrite",
      (store) => store.add(data),
      (event) => {
        data.id = (event.target as IDBRequest).result;
        return data;
      }
    );
  }

  /**
   * @function update
   * @description 更新数据 不传主键 indexDB 默认视为新增操作
   * @param storeName 表名
   * @param data 数据
   * @returns
   */
  update(storeName: string, data: any): Promise<any | boolean> {
    return this.executeTransaction(
      storeName,
      "readwrite",
      (store) => store.put(data),
      (event) => {
        data.id = (event.target as IDBRequest).result;
        return data;
      }
    );
  }

  /**
   * @function delete
   * @description 根据主键删除数据
   * @param storeName 表名
   * @param key 主键
   * @returns
   */
  delete(storeName: string, key: string | number): Promise<boolean> {
    return this.executeTransaction(
      storeName,
      "readwrite",
      (store) => store.delete(key),
      () => true // delete 方法无论你删除的 key 是否存在,只要是执行成功都走 success,所以统一执行成功返回 true 用来判断是否执行成功
    );
  }

  /**
   * @function get
   * @description 根据主键查询数据
   * @param storeName 表名
   * @param key 主键
   * @returns
   */
  get(storeName: string, key: string | number): Promise<any | boolean> {
    return this.executeTransaction(storeName, "readonly", (store) =>
      store.get(key)
    );
  }

  /**
   * @function getAll
   * @description 查询当前数据表中所有数据
   * @param storeName 表名
   * @returns
   */
  getAll(storeName: string): Promise<any[] | boolean> {
    return this.executeTransaction(storeName, "readonly", (store) =>
      store.getAll()
    );
  }

  /**
   * @function getListByPages
   * @description 根据分页参数分页查询表数据
   * @param storeName 表名
   * @param pageNo 页码
   * @param pageSize 每页数量
   * @returns
   */
  getListByPages(
    storeName: string,
    pageNo: number = 1,
    pageSize: number = 10
  ): Promise<
    { result: any[]; total: number; pages: number; current: number } | boolean
  > {
    return new Promise(async (resolve, reject) => {
      try {
        const pageNoState = pageNo <= 0 || !Number.isInteger(pageNo);
        const pageSizeState = pageSize <= 0 || !Number.isInteger(pageSize);
        if (pageNoState || pageSizeState) {
          console.error(
            "pageNo 和 pageSize 必须是有效的 number 类型而且大于 0"
          );
          reject(false);
          return;
        }

        const pages = await this.executeTransaction(
          storeName,
          "readonly",
          (store) => store.count(),
          async (event) => {
            // 总数据量
            const totalCount = (event.target as IDBRequest).result;
            // 总页数
            const totalPages = Math.ceil(totalCount / pageSize);

            const pageinateData = await this.fetchPaginatedData(
              storeName,
              pageNo,
              pageSize,
              totalCount,
              totalPages
            );
            return pageinateData
          }
        );

        resolve(pages);
      } catch (error) {
        console.error(error);
        reject(false);
      }
    });
  }

  /**
   * @function fetchPaginatedData
   * @description 游标数据分页数据
   * @param storeName 表名
   * @param pageNo 页码
   * @param pageSize 每页数量
   * @param totalCount 数据总数
   * @param totalPages 分页总数
   * @returns
   */
  fetchPaginatedData(
    storeName: string,
    pageNo: number,
    pageSize: number,
    totalCount: number,
    totalPages: number
  ) {
    return new Promise((resolve, reject) => {
      const result: any[] = [];
      let count = 0;

      this.executeTransaction(
        storeName,
        "readonly",
        (store) => store.openCursor(),
        (event) => {
          const cursorRes = (event.target as IDBRequest).result;

          // 已经遍历结束
          if (!cursorRes) {
            resolve({
              result,
              total: totalCount,
              pages: totalPages,
              current: pageNo,
            });
            return;
          }

          // 判断是否在当前页
          if (count >= (pageNo - 1) * pageSize && count < pageNo * pageSize) {
            // 添加当前记录
            result.push(cursorRes.value);
          }

          count++;

          if (count < pageNo * pageSize) {
            // 继续遍历下一条记录
            cursorRes.continue();
          } else {
            const resData = {
              result,
              total: totalCount,
              pages: totalPages,
              current: pageNo,
            };
            resolve(resData);
            return resData
          }
        },
        () => reject(false)
      );
    });
  }
}

SQLite

SQLite 是一个非常受欢迎的轻量级的嵌入式关系型数据库管理系统。它使用 C 语言 开发,是一个小型、快速、独立、高可靠性、功能齐全的SQL数据库引擎。它最开始的设计目标是嵌入式系统,它可以在不需要单独的服务器进程的情况下,直接嵌入到客户端中,并且像 MySQL 一样,SQLite也是开源且免费的。

SQLite 本身对数据库的大小没有理论上的限制。实际上,一个 SQLite 数据库可以存储的数据量受到文件系统的限制。在大多数现代操作系统上,这意味着您可以拥有高达数百TB的数据库(取决于文件系统的配置和硬件能力)。但是,处理非常大的数据库可能需要考虑性能和其他因素。

SQLite官网

优缺点

优点 缺点
轻量、简单易用、可直接嵌入程序 性能限制,处理大型数据集合时受限
跨平台支持,可移植性 并发访问限制,出现并发操作时数据库可能会被操作占用,导致其它读写操作阻塞或出错
支持事物,读写速度快 不支持复杂的功能,如分布式、集群、存储过程、触发器
单文件储存,可以随时把结构数据移植到另一个库 适用场景有限,更适合嵌入式或单机应用

兼容性

因为 SQLite 不是直接运行在浏览器的数据库,所以我们可以通过 WebAssembly 的方式实现他的兼容。这样做的优点是 WebAssembly 运行速度接近原生应用,并且无需额外的服务器开销,缺点是并非所有浏览器都完全支持 WebAssembly。下面是浏览器对 WebAssembly 的支持:

WX20241120-113009.png

去官网的下载页面选择 WebAssembly & JavaScript 下载

WX20241120-105458.png

下载下来的 demo 可以使用 vscode Live Server 打开查看演示案例

使用方式

在下载下来的 demo 中我们查看 jswasm 目录可以看到 sqlite3.wasmsqlite3.js 两个文件,其中 sqlite3.wasm 这是核心的 WebAssembly 模块,包含 SQLite 的核心逻辑,用于在浏览器中高效运行 SQLite,sqlite3.js 提供了 JavaScript 的包装,作为与 WebAssembly 交互的桥梁,使开发者可以通过 JavaScript 调用 SQLite 的功能。

sqlite 使用的一些注意事项

使用官方自带的文件

需要将sqlite3.wasmsqlite3.js两个文件放在同一个目录

折叠代码 复制代码
import {default as sqlite3InitModule} from "./jswasm/sqlite3.mjs";

const sqlite3 = async () => await sqlite3InitModule()
const db = new sqlite3.oo1.DB();
try {
  db.exec([
    "create table t(a);",
    "insert into t(a) ",
    "values(10),(20),(30)"
  ]);
} catch (err) {
  console.log(err)
}

通过第三方库 sql.js

sql.js 文档

sql.js的 demo

折叠代码 复制代码
import initSqlJs from 'sql.js'

const SQL = await initSqlJs({
  locateFile: (file) => `/node_modules/sql.js/dist/${file}`,
});

const db = new SQL.Database();

try {
  let sqlstr = "CREATE TABLE hello (a int, b char); \
                INSERT INTO hello VALUES (0, 'hello'); \
                INSERT INTO hello VALUES (1, 'world');";
  db.run(sqlstr);
} catch (err) {
  console.log(err)
}

持久化大量数据使用 indexDB 还是 SQLite

对于 web 应用来说,我个人是比较倾向于 indexDB

  1. 首先对于用户体验来说,无论是网页还是 web 应用,使用 indexDB 并不会阻塞线程,因为它是异步的,而且如果需要大量操作时可以使用 web workers 进行优化
  2. 对于应用体积来说,indexDB 是浏览器内置的,无需额外安装,而 SQLite 需要通过 WebAssembly 才能在应用中使用,这无疑是增加了额外的打包体积和复杂性
  3. 操作相关,indexDB 基于对象存储的非关系型数据库,适合存储复杂的 JavaScript 对象,SQLite 是关系型数据库,它基于 SQL 语言对数据库进行操作,适合需要复杂关系表的操作,但是不如 indexDB 操作自然
  4. 性能方面,indexDB 在 web 上是优于 SQLiteSQLite 的性能方面主要表现在原生应用上而非 web

总结建议

在 web 中应该尽量选择 indexDB,除非有复杂业务逻辑需要多表关联场景

在混合应用如 uniappcordova 这类通过 web 开发打包跨平台技术中,选择哪种技术看具体场景:

适合使用 IndexedDB 的场景:

  1. 数据量较小,主要存储配置、缓存、会话信息等
  2. 数据存储对持久性要求不高,允许在特定情况下丢失数据(如缓存清理)。
  3. 应用轻量化,避免因插件集成增加复杂性或体积

适合使用 SQLite 的场景:

  1. 应用需要长期保存用户数据,如离线数据、笔记、日志、历史记录等
  2. 数据量大,涉及复杂的查询和多表关联
  3. 应用对数据存储的可靠性有较高要求,不能接受数据丢失

另外某些情况也可以将两种方案混合起来,各司其职,由 indexDB 对简单数据集缓存,操作记录、日志等重要数据使用 SQLite 持久化处理