【万字长文】前端储存方案指北
前言
前端的储存方案有很多,在不同的业务场景下大家会选择不同的储存方案,虽然我们大多数用的是前端储存三剑客(Cookie、LocalStorage、SessionStorage)或者在大型框架中会选择一些全局状态管理方案来储存如:Pinia、Redux等等,但是在某些情况下这些储存方案并不能够完全覆盖需求,所以本文旨在介绍目前前端存在的储存解决方案,。
前端基本储存方案介绍及其优劣
1. Cookies
HTTP Cookie(也叫 Web Cookie 或浏览器 Cookie)是服务器发送到用户浏览器并保存在本地的一小块数据。浏览器会存储 cookie 并在下次向同一服务器再发起请求时携带并发送到服务器上。通常,它用于告知服务端两个请求是否来自同一浏览器——如保持用户的登录状态。Cookie 的出现使基于无状态的 HTTP 协议记录稳定的状态信息成为了可能。
用途:
Cookie主要用于:
- 会话状态管理(登录态、购物车、Rank分等)
- 个性化设置
- 浏览器行为跟踪
在上古时期的前端,Cookie曾一度被适用于客户端数据的储存,这是因为当时没有其他合适且有些的储存方法而被迫作为的唯一储存手段。但是在现在我们更加推荐去使用现代储存API(下文介绍的其他方式)来进行客户端数据的储存。
优劣:
- 优势:
- 简单易用,浏览器支持广泛。
- 可设置过期时间,可以在客户端存储少量数据。
- 可通过设置
HttpOnly
属性保护敏感数据。
- 劣势:
- 存储容量较小(通常为 4KB),不适合存储大量数据。
- 每次请求都会自动发送到服务器,可能影响性能。
- 存储在客户端,可能被用户篡改。
2. Storage
作为 Web Storage API 的接口,Storage
提供了访问特定域名下的会话存储或本地存储的功能,例如,可以添加、修改或删除存储的数据项。
如果你想要操作一个域名的会话存储,可以使用 Window.sessionStorage;如果想要操作一个域名的本地存储,可以使用 Window.localStorage。它俩一个妈生的,大同小异,根据自己想要的生命周期进行区分即可。
他们对数据操作是同步的。
2.1 LocalStorage
- 优势:
- 大碗好用,可以存储较大量的数据(通常为 5-10MB会随着浏览器不同有所差异)。
- 数据在页面刷新后仍然保持。
- 不会随着请求自动发送到服务器。
- 劣势:
- 存储在客户端,可能被用户篡改。
- 存储量虽然比 Cookies 大,但仍有限制。
2.2 SessionStorage
- 优势:
- 与
localStorage
类似,但仅在当前会话窗口中有效,关闭窗口后数据清除。
- 与
- 劣势:
- 存储量有限,与
localStorage
类似。
- 存储量有限,与
2.3 LocalStorage和SessionStorage的区别汇总
- 【生命周期】理论上
LocalStorage
为永久,SessionStorage
在本标签页被关闭后即结束 - 【作用域】
LocalStorage
在同源的所有窗口和标签页共享,SessionStorage
仅在本窗口或标签页中共享 - 【储存容量】通常情况下,
localStorage
的存储容量比sessionStorage
更大,但具体大小限制因浏览器而异。 - 【数据访问方式】分属不同API
3. IndexedDB
IndexedDB 是一种底层 API,用于在客户端存储大量的结构化数据(也包括文件/二进制大型对象(blobs))。该 API 使用索引实现对数据的高性能搜索。虽然 Storage在存储较少量的数据很有用,但对于存储更大量的结构化数据来说力不从心。
💡 简单的说IndexedDb就是一个在客户端的数据库!
特点
- 异步操作: 所有操作都是异步的,不会阻塞主线程,适用于处理大量数据。
- 事务支持: 支持事务机制,可以确保数据的完整性和一致性。 【这意味着一系列操作步骤之中,只要有一步失败了那么整个事务都会取消,数据库将回滚到事务未发生之前的状态,不会出现只有一部分数据被改变的情况】
- 索引查询: 可以创建多个索引,以支持灵活的数据查询和排序。
- 支持二进制数据: 可以存储二进制数据,如图片、视频等。
- 跨标签访问: 不同浏览器标签页之间可以共享 IndexedDB 数据。
- 适用于大量数据: 适合存储大量数据,如离线应用、本地缓存等。
使用场景
一个技术如果没有使用场景,那就没用,但是有些同学可能一下子无法想到indexedDB的使用场景这边给大家列举以下场景。
- 笔记软件:如我常用的Notion这种类型的软件可能会使用到indexedDB这项技术,当用户没有网络时离线储存数据,并且当有网络时再将离线储存的数据上传至服务器。
- TODOList或便签软件:与笔记软件类似
- 网站上的富文本编辑器:数据量大,且需要返回上一步,草稿等功能。这类功能讲数据放在服务端会出现各种问题是不现实的,所以就需要接入客户端数据库来解决。
- 股票金融类的统计图数据:这些数据都需要实时展示且过去数据基本不会改变,可以把已加载过的数据缓存在客户端来减少服务端的压力。
- 缓存最近联系人以及聊天记录
- WebGL、3D模型资源:Storage的储存量不够
- 电子书
- 低代码编辑器(需要拖拉拽,返回上一步等)
- 前端封装的桌面应用或APP应用
- 有一些大公司用相同的一级域名,储存很容易满的情况下。
- 游戏
- 面试的时候面试官可能会问
- …
储存大小
不同的浏览器厂商对于indexedDB的大小设置各不相同。
并且浏览器的最大存储空间是动态的——它取决于硬盘大小。全局限制为可用磁盘空间的 50%。在 Firefox 中,有一个名为 Quota Manager 的内部浏览器工具会跟踪每个源用尽的磁盘空间,并在必要时删除数据。
因此,如果硬盘驱动器是 500GB,那么浏览器的总存储容量为 250GB。如果超过此范围,则会发起称为源回收的过程,删除整个源的数据,直到存储量再次低于限制。删除源数据没有只删一部分的说法——因为这样可能会导致不一致的问题。
优劣:
- 优势:
- 可以存储大量结构化数据,容量较大。
- 提供异步 API,不会阻塞主线程。
- 劣势:
- API 相对复杂,使用起来可能较为复杂,学习成本较高。
- 不支持所有浏览器,需要考虑兼容性问题。
4. Web SQL Database
WebSQL(已于 2010 年 9 月 18 日起弃用)
-
优势:
- 提供 SQL 数据库的操作方式,适用于复杂的数据操作。
-
劣势:
- 已被废弃,不被推荐使用,不支持所有浏览器。
5. Cache
当你在网站上浏览页面时,浏览器会下载许多资源,例如 HTML、CSS、JavaScript 文件和图像。为了提高网页加载速度和降低网络流量,浏览器使用缓存来存储这些资源,以便在将来的页面访问中能够更快地获取它们。而 Cache API
就是一种 Web API,使开发者能够更精细地控制缓存的行为,从而提供更好的性能和用户体验。
🚀 实验性:这是一项实验性技术在将其用于生产之前,请仔细检查浏览器兼容性表格
首先对于常见的几种Cache相关的专业词汇做一个区分
cache
是浏览器提供的一种缓存机制,用于存储资源。CacheStorage
是Cache API
中的一个对象,用于管理命名缓存的集合。Cache API
是用于控制和管理缓存的浏览器 API,其中核心是Cache
对象,用于存储和管理特定类型的响应数据。
使用场景
其实基本所有的使用场景都是为了去提高性能和用户体验,一般情况下我们作缓存也主要是为了这些。
- 静态资源缓存: 将网站的静态资源(例如 CSS、JavaScript、图片等)缓存起来,以便在用户再次访问时能够更快地加载页面。
- 离线访问: 使用 Cache API 可以缓存应用程序的核心资源,使用户在离线状态下仍然能够访问内容,提供更好的离线体验。
- Service Worker 缓存: Service Worker 是一种运行在浏览器后台的脚本,可以拦截网络请求并将其缓存起来,从而实现更高级的缓存控制和离线功能。
- 数据预取和预加载: 在用户访问页面之前,提前缓存可能需要的资源,以减少页面加载时间。
- 动态内容缓存: 缓存 API 还可以用于缓存动态生成的内容,如 API 响应,以减轻服务器负担并提高响应速度。
- 减少网络请求: 缓存 API 可以减少重复的网络请求,从而降低服务器压力,提高性能。
- 图片懒加载: 缓存 API 可以用于懒加载图片,即只在用户需要时才加载图片资源,从而节省带宽和提高页面加载速度。
- 数据更新与刷新策略: 缓存 API 可以用于实现数据更新和刷新策略,确保用户始终获取最新的数据。
- 资源预加载: 提前缓存可能需要的资源,以便在用户访问相关页面时能够更快地加载内容。
- 响应速度优化: 缓存可以显著提高页面加载速度,从而改善用户体验和 SEO。
优劣:
-
优势:
- 适用于缓存资源,如图片、样式等,提高性能。
- 可以灵活控制缓存过程,支持更复杂的缓存策略。
-
劣势:
- 主要用于缓存资源,不适合存储业务数据。
6. Redux、VueX、MobX、Pinia 等状态管理库
-
优势:
- 适用于大型应用,用于管理全局状态。
- 可以跨组件共享状态,方便数据管理和更新。
-
劣势:
- 不适合存储大量非全局的数据。
- 使用时需要引入额外的库和概念。
选择适当的储存方案取决于应用的需求。如果只需要存储少量数据,可以使用 Cookies、localStorage 或 sessionStorage。如果需要存储大量数据或进行复杂的数据操作,可以考虑 IndexedDB。如果需要全局状态管理,可以使用状态管理库。在选择时,还需考虑浏览器兼容性、数据安全性等因素。
基本储存的用法
1. Cookie
服务端在收到HTTP请求后,会在响应标头里添加一个或者多个Set-Cookie
选项。当浏览器收到响应后就会保存下Cookie了,并将其放在HTTP的Cookie
标头中(当存在多个时用;
分割),向统一服务器发送请求时一起发出。(此处可以设置过期日期或者时间段)
// Cookie格式
Cookie: yummy_cookie=choco; tasty_cookie=strawberry
Cookie: <cookie-name>=<cookie-value>
生命周期
- 会话期 Cookie 会在当前的会话结束之后删除。因为是浏览器定义了“当前会话”结束的时间,有一些浏览器在重启时会使用会话恢复。这可能导致会话 cookie 无限延长。
- 持久性 Cookie 在过期时间(
Expires
)指定的日期或有效期(Max-Age
)指定的一段时间后被删除。
Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT;
备注: 当 Cookie 的过期时间( Expires
)被设定时,设定的日期和时间只与客户端相关,而不是服务端。这将会导致当用户修改了电脑的时间时可能会影响Cookie的过期时间,另外,由于服务端无法直接控制客户端的时间,所以 Cookie 的过期时间只能作为一种简单的机制来实现在客户端浏览器中的数据过期和失效。
发送位置
Domain
和 Path
标识定义了 Cookie 的作用域:即允许 Cookie 应该发送给哪些 URL。
-
Domain 属性
Domain
指定了哪些主机可以接受 Cookie。如果不指定,该属性默认为同一 host设置 cookie,不包含子域名。如果指定了Domain
,则一般包含子域名。所以一般情况下我们都会选择指定Domain
。例如,如果设置
Domain=mozilla.org
,则 Cookie 也包含在子域名中(如developer.mozilla.org
)。 -
Path 属性
Path
属性指定了一个 URL 路径,该 URL 路径必须存在于请求的 URL 中,以便发送Cookie
标头。以字符%x2F
(“/”) 作为路径分隔符,并且子路径也会被匹配。例如,设置
Path=/docs
,则以下地址都会匹配:/docs
/docs/
/docs/Web/
/docs/Web/HTTP
但是这些请求路径不会匹配以下地址:
/
/docsets
/fr/docs
-
SameSite 属性
SameSite 属性允许服务器指定是否/何时通过跨站点请求发送(其中站点由注册的域和方案定义:http 或 https)。这提供了一些针对跨站点请求伪造攻击(CSRF)的保护。它采用三个可能的值:
Strict
、Lax
和None
。使用
Strict
,cookie 仅发送到它来源的站点。Lax
与 Strict 相似,只是在用户导航到 cookie 的源站点时发送 cookie。例如,通过跟踪来自外部站点的链接。None
指定浏览器会在同站请求和跨站请求下继续发送 cookie,但仅在安全的上下文中(即,如果SameSite=None
,且还必须设置Secure
属性)。如果没有设置SameSite
属性,则将 cookie 视为Lax
。
JavaScript中的使用
通过 Document.cookie
属性可创建新的 Cookie。如果未设置 HttpOnly
标记,你也可以从 JavaScript 访问现有的 Cookie。
document.cookie = "yummy_cookie=choco";
document.cookie = "tasty_cookie=strawberry";
console.log(document.cookie);
// logs "yummy_cookie=choco; tasty_cookie=strawberry"
通过 JavaScript 创建的 Cookie 不能包含 HttpOnly
标志。
请留意安全隐患问题,JavaScript 可以通过跨站脚本攻击(XSS)的方式来窃取 Cookie。
2. Storage
储存的方式为键值对。
2.1 LocalStorage
使用方式:
// 添加
localStorage.setItem("myCat", "Tom");
// 读取
let cat = localStorage.getItem("myCat");
// 移除
localStorage.removeItem("myCat");
// 清除所有clear
localStorage.clear();
当然你也可以这样使用
localStorage.colorSetting = "#a4509b";
localStorage["colorSetting"] = "#a4509b";
2.2 SessionStorage
与2.1相同,替换localStorage
为sessionStorage
即可。
3. IndexedDB
基本使用流程
IndexedDB 鼓励使用的基本模式如下所示:
- 打开数据库。
- 在数据库中创建一个对象存储(object store)。
- 启动事务,并发送一个请求来执行一些数据库操作,如添加或获取数据等。
- 通过监听正确类型的 DOM 事件以等待操作完成。
- 对结果进行一些操作(可以在 request 对象中找到)
主要API
- 数据库:IDBDatabase 对象,数据库有版本概念,同一时刻只能有一个版本,每个域名可以建多个数据库
- 对象仓库:IDBObjectStore 对象,类似于关系型数据库的表格
- 索引: IDBIndex 对象,可以在对象仓库中,为不同的属性建立索引,主键建立默认索引
- 事务: IDBTransaction 对象,增删改查都需要通过事务来完成,事务对象提供了error,abord,complete三个回调方法,监听操作结果
- 操作请求:IDBRequest 对象
- 指针: IDBCursor 对象
- 主键集合:IDBKeyRange 对象,主键是默认建立索引的属性,可以取当前层级的某个属性,也可以指定下一层对象的属性,还可以是一个递增的整数
数据库对比解析
为了让大家对IndexedDB的结构能够了解的更加清晰,这边使用MySQL说发来说明IndexedDB内各种数据的维度
图中一共是四个方框从第二个框开始分别是
- 库维度
- 表维度
- 字段维度
但是我们都一道一句话叫做JS万物皆对象所以在这里也不例外,表维度中储存的实际上是key-value对象,其value值实际是就是一个对象,字段就是该对象内的属性
💡 注意,表中的对象即使有没有索引的属性也依旧可以被存入。 即:可以在表中存入没有该字段属性的对象或者有冗余字段属性的对象。
使用示例
包含了基本的增删改查的方式,自己去尝试写了才知道他所谓的劣势,确实用起来比较麻烦,而且需要自己去做很多封装后使用起来才能够方便。可能这个就是IndexedDB的劣势,并且它的API有很多,这里只是介绍了基本使用的一部分。
有兴趣可以自己去MDN或者参考文献中的文章看。
const dbName = 'myDatabase';
const storeName = 'myObjectStore';
// 打开或创建数据库
const request = indexedDB.open(dbName, 1);
// 该事件仅在最新的浏览器中实现,仅在新数据库或新版本号时触发,onupgradeneeded是唯一可以修改数据库结构的地方!
request.onupgradeneeded = event => {
const db = event.target.result;
// 创建对象存储空间
const objectStore = db.createObjectStore(storeName, { keyPath: 'id', autoIncrement: true });
// 创建索引
objectStore.createIndex('nameIndex', 'name', { unique: false });
};
request.onsuccess = event => {
const db = event.target.result;
// 添加数据
function addData(name, age) {
// 打开事务
const transaction = db.transaction(storeName, 'readwrite');
// 指定store
const objectStore = transaction.objectStore(storeName);
const newItem = { name, age };
const addRequest = objectStore.add(newItem);
addRequest.onsuccess = () => {
console.log('【ADD】Data added successfully');
};
}
// 删除数据
function deleteData(id) {
const transaction = db.transaction(storeName, 'readwrite');
const objectStore = transaction.objectStore(storeName);
const deleteRequest = objectStore.delete(id);
deleteRequest.onsuccess = () => {
console.log('【DELETE】Data deleted successfully');
};
}
// 更新数据
function updateData(id, newData) {
const transaction = db.transaction(storeName, 'readwrite');
const objectStore = transaction.objectStore(storeName);
const getRequest = objectStore.get(id);
getRequest.onsuccess = () => {
const data = getRequest.result;
if (data) {
const updatedData = { ...data, ...newData };
const updateRequest = objectStore.put(updatedData);
updateRequest.onsuccess = () => {
console.log('【UPDATE】Data updated successfully');
};
}
};
}
// 查询数据
function fetchData() {
const transaction = db.transaction(storeName, 'readonly');
const objectStore = transaction.objectStore(storeName);
const data = [];
const cursorRequest = objectStore.openCursor();
cursorRequest.onsuccess = event => {
const cursor = event.target.result;
if (cursor) {
data.push(cursor.value);
cursor.continue();
} else {
console.log('Fetched data:', data);
}
};
}
// 添加示例数据
addData('Alice', 25);
addData('Bob', 30);
// 删除示例数据
deleteData(1);
// 更新示例数据
updateData(2, { age: 31 });
// 查询所有数据
fetchData();
// 关闭数据库连接
db.close();
};
request.onerror = event => {
console.error('Database error:', event.target.error);
};
前面有提到过只有在onupgradeneeded
方法中才能够修改库的数据结构,以下是获取原有的store进行数据结构修改的示例。注意:仓库升级之后无法看到之前的数据(会合并数据),如果你想两个版本的数据都保留的话,需要在修改数据结构时新建仓库来储存上一个版本的数据。
// 升级数据库版本
const request = indexedDB.open(dbName, 2);
request.onupgradeneeded = event => {
// 获取原有
const objectStore = event.target.transaction.objectStore(storeName);
// 创建索引
objectStore.createIndex('phoneIndex', 'phone', { unique: true });
};
// 保留两份数据
request.onupgradeneeded = function(event) {
const db = event.target.result;
// 创建一个临时的对象仓库用于存储旧版本数据
const tempStore = db.createObjectStore('tempStore');
// 获取旧版本的对象仓库
const oldUsersStore = event.target.transaction.objectStore('users');
// 遍历旧版本的数据,并将其复制到临时对象仓库中
oldUsersStore.openCursor().onsuccess = function(event) {
const cursor = event.target.result;
if (cursor) {
tempStore.add(cursor.value);
cursor.continue();
}
};
};
库推荐
既然他的劣势主要是在不易使用等,主要是自己使用起来麻烦,我们就需要踩在巨人的肩膀上。这里为大家来介绍一个巨人Dexie.js
官网:dexie.org/
Dexie.js
主要用途有:
- 简化 IndexedDB 操作: IndexedDB 是一个强大但相对复杂的浏览器本地数据库,使用原生 IndexedDB 进行操作可能较为繁琐。Dexie.js 提供了更简洁的 API,封装了复杂的操作,使得数据库的创建、数据的增删改查等操作变得更加友好。
- Promise 支持: Dexie.js 使用 Promises 来处理异步操作,这样可以更好地管理数据操作流程,避免回调地狱。
- 版本管理: Dexie.js 允许你定义数据库的版本和结构,使得数据库的升级和迁移变得更加容易。
- 支持查询: Dexie.js 提供了强大的查询功能,可以方便地进行数据的筛选和检索。
- 跨浏览器兼容性: Dexie.js 能够在现代浏览器以及 IE 等旧版本的浏览器中运行,保证了跨浏览器的兼容性。
总之,Dexie.js 是一种简化 IndexedDB 操作的工具库,适用于需要在前端进行本地数据存储和操作的场景,如离线应用、浏览器扩展等。它使得使用 IndexedDB 变得更加容易,并且提供了一种更加现代和清晰的编程方式。
Dexie的基本使用:
-
创建
db.js/ts
文件并且声明模块// db.js import Dexie from 'dexie'; export const db = new Dexie('myDatabase'); db.version(1).stores({ friends: '++id, name, age', // 声明主键和索引 });
注意:不要像 SQL 中那样声明所有列。您只需声明要索引的属性,即要在 where(...) 查询中使用的属性。
++ 自增主键 & Unique * 多条目索引即数组索引 🔗 [A+B] 复合索引 🔗 // 例子: db.version(1).stores({ friends: "++id,name,age,*tags", gameSessions: "id,score" }); db.version(2).stores({ friends: "++id, [firstName+lastName], yearOfBirth, *tags", // 升级版本时候改变索引 gameSessions: null // 删除表 }).upgrade(tx => { // 仅仅在版本小于2时会生效 return tx.table("friends").modify(friend => { friend.firstName = friend.name.split(' ')[0]; friend.lastName = friend.name.split(' ')[1]; friend.birthDate = new Date(new Date().getFullYear() - friend.age, 0); delete friend.name; delete friend.age; }); });
// db.ts import Dexie, { Table } from 'dexie'; export interface Friend { id?: number; name: string; age: number; } export class MySubClassedDexie extends Dexie { // 'friends'是通过dexie在声明时添加的store // 我们只是给typescript声明 friends!: Table<Friend>; constructor() { super('myDatabase'); this.version(1).stores({ friends: '++id, name, age' // 声明主键和索引 }); } } export const db = new MySubClassedDexie();
-
基本增删改查API的使用🔗
// 新增数据 await db.friends.add({name: "Josephine", age: 21}); // 新增多个 await db.friends.bulkAdd([ {name: "Foo", age: 31}, {name: "Bar", age: 32} ]); // 更新数据 await db.friends.update(4, {name: "Bar"}); // 删除 await db.friends.delete(4); // 批量删除 await db.friends.bulkDelete([1,2,4]); // 查询 链式调用更方便 理解上更贴近常用的SQL const someFriends = await db.friends .where("age").between(20, 25) .offset(150).limit(25) .toArray(); await db.friends .where("name").equalsIgnoreCase("josephine") .each(friend => { console.log("Found Josephine", friend); });
4. Cache API
因为会有兼容性问题所以建议使用之前先看看浏览器是否支持,不支持就只能寻求其他方案了。
const useCache = 'caches' in window;
Cache常用方法
.add()
接受URL作为参数,返回promise,只响应status为200范围内的响应.addAll()
接受URL数组作为参数,返回promise.put()
接受一个键值对作为参数,返回promise.delete()
接受一个URL作为删除的参数,返回promise.keys()
返回一个解析为Cache
键数组的Promise
.match()
接受一个对象或者URL,返回第一个匹配请求的Response
对象,如果没有匹配到,则解析到undefined
.matchAll()
类似于addAll
使用方法
// 打开一个命名为 "my-cache" 的缓存, 如果没有则会新建
caches.open('my-cache').then(cache => {
// 将 URL 为 "<https://example.com/data>" 的响应添加到缓存中
cache.add('<https://example.com/data>');
});
// 在 fetch 事件中,先从缓存中查找响应,如果缓存中没有,则发起网络请求并缓存响应
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
// 如果在缓存中找到了响应,则返回缓存的响应
if (response) {
return response;
}
// 否则,发起网络请求并缓存响应
return fetch(event.request).then(response => {
// 克隆响应,因为响应是只能使用一次的对象
const responseToCache = response.clone();
// 将响应添加到缓存中
caches.open('my-cache').then(cache => {
cache.put(event.request, responseToCache);
});
return response;
});
})
);
});
// 删除名为 "my-cache" 的缓存
caches.delete('my-cache');
转载自:https://juejin.cn/post/7267441882396540967