不说话,装高手。
Maintain silence and pretend to be an experta
前端本地储存是是现代 web 应用程序中一个重要功能,他允许数据在客户端进行存储,实现更快的加载速度、离线操作,改善用户体验。现阶段前端常用的离线储存技术有 Web Storage、Web SQL、IndexedDB、SQLite 等。
Web Storage 是 localStorage 和 sessionStorage 的统称,它是 HTML5 专门为浏览器存储而提供的数据存储机制,通过 “键值对” 的形式存储数据,并且一般是以文本格式储存。Web Storage 限制储存大小一般为 5M 左右(不同浏览器限制不同),容量限制的目的是防止滥用本地存储空间,导致用户浏览器变慢。
两者的区别在于生命周期与作用域的不同
localStorage | sessionStorage | |
---|---|---|
生命周期 | 持久化储存,除非用户自行删除或者清除浏览器缓存,否则一直存在 | 会话级储存,当前标签页关闭或浏览器关闭时清空储存数据 |
作用域 | 同浏览器、同域名、不同标签、不同窗口 | 同浏览器、同域名、同标签、同窗口 |
优点 | 缺点 |
---|---|
简单易用、浏览器支持广泛、快速的读写性能 | 存量太小,并且不同浏览器存量不一样 |
本地储存,减少服务器压力,提高程序响应速度 | 没有数据加密、过期时间,容易造成数据泄漏 |
独立储存空间,不会造成数据混淆 | 储存的格式只能是字符串,需要增加序列化操作 |
目前主流的浏览器大部分都支持这两种 API
使用过程中可以添加一段检测代码检测 API 是否被支持
if(window.localStorage){
try {
alert("浏览器支持 localStorage");
} catch (e) {
alert("浏览器支持 localStorage 后不可使用");
}
} else {
alert("浏览器不支持 localStorage");
}
localStorage 和 sessionStorage 的使用方式是一致的,下面是对使用的二次封装,主要是为了增强其功能,提升代码的可维护性、可扩展性和安全性。
在下面封装的代码中,除了常用的操作,还添加了监听操作,可以监听 set、remove 和 clear 操作,也可以自己拓展其他操作
// 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 数据库是一种在浏览器环境中使用 SQL 语言来操作的本地数据库。它允许开发者在客户端存储和检索结构化数据,为 Web 应用提供了一种强大的数据存储解决方案
优点 | 缺点 |
---|---|
本地储存,减少服务器压力,提高程序响应速度 | 已被废弃、兼容性有限 |
SQL 操作,对于熟悉数据库的开发者来说易于上手 | 缺乏灵活性,数据模式固定,需要通过 SQL 预定义表结构 |
支持事务,保证数据完整性 | 安全性不足,无加密机制 |
由于他的兼容性问题以及它自身的设计缺陷以及不符合现代 Web 标准的要求,最终被时代所抛弃(后续被 IndexDB 代替),一些旧的浏览器依旧支持。
打开数据库:使用 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 的出现是为了替代 Web SQL,是浏览器提供的一种客户端数据库 API,它允许 Web 应用存储大量结构化数据并进行高效的查询和检索操作。IndexedDB 采用键值对的存储方式,并且是事务性的数据库,适合存储大量的非结构化和结构化数据
IndexDB 的储存大小没有明确的限制,在不同设备不同客户端也有不同体现,详情可以查看这篇文章《IndexDB 数据储存限制》
优点 | 缺点 |
---|---|
本地储存、大容量储存,并且支持结构化 | API 使用复杂,需要大量的回调或者 async/await 操作 |
异步操作,防止阻塞主线程 | 性能问题,大量频繁的会导致性能下降 |
支持事务、索引操作 | 潜在的储存策略和删除策略,当数据操作储存限制时会被删除,而且收不到任何通知 |
因为 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 是一个非常受欢迎的轻量级的嵌入式关系型数据库管理系统。它使用 C 语言 开发,是一个小型、快速、独立、高可靠性、功能齐全的SQL数据库引擎。它最开始的设计目标是嵌入式系统,它可以在不需要单独的服务器进程的情况下,直接嵌入到客户端中,并且像 MySQL 一样,SQLite也是开源且免费的。
SQLite 本身对数据库的大小没有理论上的限制。实际上,一个 SQLite 数据库可以存储的数据量受到文件系统的限制。在大多数现代操作系统上,这意味着您可以拥有高达数百TB的数据库(取决于文件系统的配置和硬件能力)。但是,处理非常大的数据库可能需要考虑性能和其他因素。
优点 | 缺点 |
---|---|
轻量、简单易用、可直接嵌入程序 | 性能限制,处理大型数据集合时受限 |
跨平台支持,可移植性 | 并发访问限制,出现并发操作时数据库可能会被操作占用,导致其它读写操作阻塞或出错 |
支持事物,读写速度快 | 不支持复杂的功能,如分布式、集群、存储过程、触发器 |
单文件储存,可以随时把结构数据移植到另一个库 | 适用场景有限,更适合嵌入式或单机应用 |
因为 SQLite 不是直接运行在浏览器的数据库,所以我们可以通过 WebAssembly 的方式实现他的兼容。这样做的优点是 WebAssembly 运行速度接近原生应用,并且无需额外的服务器开销,缺点是并非所有浏览器都完全支持 WebAssembly。下面是浏览器对 WebAssembly 的支持:
去官网的下载页面选择 WebAssembly & JavaScript 下载
下载下来的 demo 可以使用 vscode Live Server 打开查看演示案例
在下载下来的 demo 中我们查看 jswasm 目录可以看到 sqlite3.wasm 和 sqlite3.js 两个文件,其中 sqlite3.wasm 这是核心的 WebAssembly 模块,包含 SQLite 的核心逻辑,用于在浏览器中高效运行 SQLite,sqlite3.js 提供了 JavaScript 的包装,作为与 WebAssembly 交互的桥梁,使开发者可以通过 JavaScript 调用 SQLite 的功能。
需要将sqlite3.wasm 和 sqlite3.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)
}
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)
}
对于 web 应用来说,我个人是比较倾向于 indexDB
在 web 中应该尽量选择 indexDB,除非有复杂业务逻辑需要多表关联场景
在混合应用如 uniapp、cordova 这类通过 web 开发打包跨平台技术中,选择哪种技术看具体场景:
适合使用 IndexedDB 的场景:
适合使用 SQLite 的场景:
另外某些情况也可以将两种方案混合起来,各司其职,由 indexDB 对简单数据集缓存,操作记录、日志等重要数据使用 SQLite 持久化处理