前端存储概念
- Web 应用允许使用浏览器提供的 API 将数据存储在客户端;
- 客户端存储遵守“同源策略”,不同的站点页面之间不能相互读取彼此的数据;
- 在同一个站点的不同页面之间,存储的数据是共享的;
- 数据的存储有效期可以是临时的,比如在页面刷新之后,我们的所有数据都会被清空以及关闭浏览器数据就销毁; 也可以是永久的要用到本地存储技术,就可以在客户端电脑上存储任意时间;
- 在使用数据存储是需要考虑安全问题,比如银行卡账号密码
附 - 同源策略
同源由协议、域名、端口三者来确定,如:
www.xxxx.com/ 协议http,域名xxxx.com,端口80默认端口。
www.xxxx.com/ 协议https,与上面不同源
www.yyyy.com/ 域名yyyy.com,与上面不同源
www.xxxx.com:81/ 端口81,与上面不同源
目前主流的前端本地化存储方案有以下3种:
1. Cookie
Cookie 本身并不是为了解决「在浏览器上存东西」而被发明,它的出现是为了解决 HTTP 协议无状态特性的问题
Cookie 的不足:
- Cookie 存在安全问题
- Cookie 在每次请求中都会被发送,如果不使用 HTTPS 并对其加密,其保存的信息很容易被窃取,导致安全风险。可利用 HttpOnly=true 设置无法通过 js 读取,从而减少被 XSS 攻击恶意读取 Cookie 信息的风险。
- 举个例子,在一些使用 Cookie 保持登录态的网站上,如果 Cookie 被窃取,他人很容易利用你的 Cookie 来假扮成你登录网站。
- 当然可以用 Session 配合 Cookie 来缓解这个问题,但是 Session 会占用额外的服务器资源。
- Cookie 每次请求自动发送的特性还会导致 CSRF 攻击的安全风险。
- Cookie 只允许存储 4kb 的数据。
Cookie 的应用领域:
最常见的就是用在广告中,用来跨站标记用户与跟踪用户行为,这样在你访问不同页面时,广告商也能知道是同一个用户在访问,从而实现后续的商品推荐等功能。
实现手段:第三方 Cookie,需设置 SameSite 属性为 None。
Cookie 和 Session 的异同点:
- Cookie 和 Session 都是普遍用来跟踪浏览用户身份的会话方式。
- Session 鉴权需要借助 Cookie 来存储此 Session 的唯一标识信息 SessionID。SessionID 是连接 Cookie 和 Session 的一道桥梁。
- 因为这两种方式鉴权都需要借助 Cookie 来存储用户标识,因此容易受到CRSF(跨站点请求伪造,Cross-Site Request Forgery)攻击的安全风险。
- 存储方式不同。 Cookie 数据存放在客户端,Session 数据放在服务器端。因此 Session 相比 Cookie 数据安全性更好,但同时更占用服务器资源。
- 存储大小不同。 单个 Cookie 保存的数据不能超过 4K,很多浏览器都限制一个域名最多保存 50 个 Cookie。 Session 可存储数据远高于 Cookie,但是当访问量过多,会占用过多的服务器资源。
- 存取值的类型不同。 Cookie 只支持存字符串数据,想要设置其他类型的数据,需要将其转换成字符串,Session 可以存任意数据类型。
附 - CRSF(跨站点请求伪造,Cross-Site Request Forgery)
图 CRSF 原理示意图
CSRF 攻击几种常见类型:
- GET类型的CSRF:攻击站点利用 JSONP 方式控制访问用户向被攻击站点发送跨域请求。
- POST类型的CSRF:攻击站点利用 method=POST 的自动提交表单控制访问用户向被攻击站点发送跨域请求。
CSRF的特点:
- 攻击一般发起在第三方网站,而不是被攻击的网站。被攻击的网站无法防止攻击发生。
- 攻击利用受害者在被攻击网站的登录凭证,冒充受害者提交操作;而不是直接窃取数据。
- 整个过程攻击者并不能获取到受害者的登录凭证,仅仅是“冒用”。
- 跨站请求可以用各种方式:图片URL、超链接、CORS、Form提交等等。部分请求方式可以直接嵌入在第三方论坛、文章中,难以进行追踪。
防御 CSRF 攻击主要有 3 种策略:
2. Web Storage(LocalStorage + SessionStorage)
在 Web 本地存储场景上,Cookie 的使用受到种种限制,最关键的就是存储容量太小和数据无法持久化存储。在 HTML 5 的标准下,出现了 LocalStorage 和 SessionStorage 供我们使用。
Cookie、LocalStorage 以及 SessionStorage 的异同点:
生命周期 | 作用域 | 存储容量 | 存储位置 | |
---|---|---|---|---|
Cookie | 默认保存在内存中,随浏览器关闭失效(如果设置过期时间,在到过期时间后失效) | Domain 和 Path属性用来指示其作用域,Domain本身以及Domain下的所有子域名可以共享数据 | 4KB | 保存在客户端,每次请求时都会带上。 |
LocalStorage | 理论上永久有效的,除非主动清除。 | 同一个浏览器内,同源文档之间可以共享数据。 | 4.98MB(不同浏览器情况不同,safari 2.49M) | 保存在客户端,不与服务端交互。 |
SessionStorage | 仅在当前网页会话下有效,关闭页面或浏览器后会被清除。 | 同一浏览器、同一窗口的同源文档才能共享数据。 | 4.98MB(部分浏览器没有限制) | 保存在客户端,不与服务端交互。 |
基于 TypeScript 对 LocalStorage 的功能封装:
以下是本人针对 LocalStorage 无法存储对象数据类型的一些问题寻求的解决方案,最终封装可实现便捷地对 LocalStorage 中存储的某个 key 进行读取、存储、清除操作,同时兼容了对某些对象类型数据的存储。
/**
* @title LocalStore
* @description e.g., const lsUser = new LocalStore('user'); lsUser.get(); lsUser.set('name');
*/
import { jsonStringify, jsonParse, isNull } from '@/utils/checkType'
export class LocalStore {
key: string
value: any
constructor(key: string) {
this.key = key
}
_stringifyValue(value: any = this.value): string {
return jsonStringify(value)
}
_parseValue(text: string): any {
return this.value = jsonParse(text)
}
set(value: any): boolean {
const finalKey = this.key
if (!finalKey) return false
this.value = value
const text = this._stringifyValue(value)
localStorage.setItem(this.key, text)
return true
}
get(key?: string): any {
const finalKey = key ?? this.key
if (!finalKey) return null
const localStoreVal = localStorage.getItem(finalKey)
if (isNull(localStoreVal)) return localStoreVal
return this._parseValue(localStoreVal)
}
clear(key?: string): boolean {
const finalKey = key ?? this.key
if (!finalKey) return false
localStorage.removeItem(finalKey)
return true
}
}
以上采用了_stringifyValue
和_parseValue
方法就是是为了一定程度上解决 LocalStorage 不支持存储对象的问题。其实现基于 JSON.parse / JSON.stringify 方法,解决了原生方法不支持转换undefined、NaN、Infinity和-Infinity等情况的处理,但仍不支持对Function、symbol、Date、循环引用等问题转换的处理,原理如下:
// JSON.stringify问题:
// 对象中有时间类型的时候,序列化之后会变成字符串类型。
// 对象中有undefined和Function类型以及symbol值数据的时候,序列化之后会直接丢失。
// 对象中有NaN、Infinity和-Infinity的时候,序列化之后会显示null。
// 对象循环引用的时候,会直接报错。
export const jsonStringify = (value: any): string => JSON.stringify(value, (key, value) => {
if (isUndefined(value)) return ''
if (isNaNNum(value) || isInfinity(value)) return `${value}`
return value
})
export const jsonParse = (text: string): any => JSON.parse(text, (key, value) => {
if (['-Infinity', 'Infinity', 'NaN'].includes(value)) return parseFloat(value);
return value
})
LocalStorage 和 SessionStorage 的不足:
3. IndexedDB
IndexedDB 基本概念
IndexedDB 是一种底层 API,用于在客户端存储大量的结构化数据(也包括文件/二进制大型对象(blobs))。从名字中就可以看出,它是一个数据库。其实在前端存储方案中还有一种数据库存储方案 WebSQL ,但由于操作繁琐且是一种关系型数据库,逐渐被废弃而由 IndexedDB 取代。
IndexedDB 是一个事务型数据库系统,类似于基于 SQL 的 RDBMS。然而,不像 RDBMS 使用固定列表,IndexedDB 是一个基于 JavaScript 的面向对象数据库。IndexedDB 允许您存储和检索用键索引的对象;可以存储结构化克隆算法支持的任何对象。您只需要指定数据库模式,打开与数据库的连接,然后检索和更新一系列事务。
IndexedDB 特点
IndexedDB 优势
IndexedDB 推荐使用的基本模式:
- 打开数据库。
- 在数据库中创建一个对象仓库(object store)。
- 启动一个事务,并发送一个请求来执行一些数据库操作,像增加或提取数据等。
- 通过监听正确类型的 DOM 事件以等待操作完成。
- 在操作结果上进行一些操作(可以在 request 对象中找到)。
IndexedDB 使用及基本操作封装实现(基于 TypeScript )
1. 创建或打开数据库
执行indexedDB.open
打开数据库会创建并返回一个请求,该请求将触发 3 种事件:
- onsuccess:数据库打开成功或者创建成功后的回调。
- onerror:数据库打开或创建失败后的回调。
- onupgradeneeded:当数据库版本有变化时的回调。经测试,chrome 浏览器将在初次创建该数据库时触发此事件,后续需要触发此事件必须手动更新数据库版本。并且创建存储库
**createStore**
的操作必须在此事件触发的回调中执行(在onsuccess中执行会报错),意味着要在应用中使用 IndexDB 建议在初始化过程中就创建好应用所需的存储库。
以下对打开数据库过程做了 Promise 封装,并做了 reqest timeout 处理。可通过reqDB().then(db => {})
的方式来调用。
关键处理:
- 在
reqDBFn
函数中利用闭包的方式存储了reqOpenDB
变量,可保证在后续频繁调用reqDB
时,如果当前还有reqOpenDB
正在请求将不再触发indexedDB.open
方法重复执行导致生成多个请求。 - 在
onupgradeneeded
事件回调中,执行了createAllStore
创建初始化配置好的所有 store,并在创建完后利用setTimeout
创建了一次事件循环 tick ,在下一次事件循环中才resolve(db)
,这样设置主要是为了给创建好的 db 一定缓冲时间有利于后续通过事务获取刚创建的 store 对象时不会报错(经测试,若在onupgradeneeded
事件触发后直接resolve(db)
,通过此 db 建立事务获取刚创建的 store 对象将会报错该 store 不存在)。
// 发送IndexedDB打开请求
const reqDBFn = ({
name = DB_NAME,
version = DB_VERSION,
timeout = 1500,
}: InitDBInfo = {}): ((version_?: number) => Promise<IDBDatabase>) => {
// 初始化 打开数据库请求
let reqOpenDB: IDBOpenDBRequest | null;
return (version_?: number) =>
new Promise((resolve, reject) => {
// 兼容浏览器
const indexedDB = compatIndexedDB();
// 打开数据库,若没有则会创建
if (!reqOpenDB) reqOpenDB = indexedDB.open(name, version_ ?? version);
reqOpenDB.onsuccess = (evt) => {
const db = (evt.currentTarget as IDBOpenDBRequest).result;
reqOpenDB = null;
resolve(db); // 数据库对象
};
reqOpenDB.onerror = (evt) => {
reqOpenDB = null;
reject((evt.target as IDBOpenDBRequest).error);
};
reqOpenDB.onupgradeneeded = (evt) => {
const db = (evt.currentTarget as IDBOpenDBRequest).result;
reqOpenDB = null;
createAllStore(db);
setTimeout(() => {
resolve(db)
}, timeout * 1 / 2)
};
setTimeout(() => {
reqOpenDB = null;
reject('Request IndexedDB Timeout!');
}, timeout);
});
};
const reqDB = reqDBFn();
2. 创建 ObjectStore
利用db.createObjectStore
来创建 store ,需要传入 store 名称storeName
和其主键相关配置keyPathOptions
参数,创建完成后可通过store.createIndex
方法创建索引。
创建 ObjectStore 是同步的过程,但正如我前面所说的,虽然是同步,但是刚创建完就直接通过 db 创建事务来获取该 store 是会报错的,原因不明,欢迎大神指点。
另外前面也同样提到,创建 ObjectStore 必须在数据库更新时创建,即 reqOpenDB.onupgradeneeded
或 db.onversionchange
时创建。
// 创建store(ObjectStore)
const createStore = (
db: IDBDatabase,
storeName: string,
keyPathOptions: IDBObjectStoreParameters,
indexList?: {
name: string;
options?: IDBIndexParameters;
}[]
) => {
// `createStore` must be called when the database is `onupgradeneeded` or `onversionchange`.
if (db.objectStoreNames.contains(storeName)) return false;
const store = db.createObjectStore(storeName, keyPathOptions);
indexList?.forEach(({ name, options }) => {
store.createIndex(name, name, options);
});
return store;
};
3. 删除 ObjectStore
原理同创建 ObjectStore,也是同步的过程。
// 删除store(ObjectStore)
const deleteStore = (db: IDBDatabase, storeName: string) => {
if (!db.objectStoreNames.contains(storeName)) return false;
db.deleteObjectStore(storeName);
console.info(`[indexedDB] Store ${storeName} is deleted!`);
return true;
};
4. 建立事务获取store(ObjectStore)
通过db.transaction
来创建事务并通过tx.objectStore(storeName)
,利用此事务来操作 store 对象。
// 建立事务获取store(ObjectStore)
const getStore = (
db: IDBDatabase,
storeName: string,
mode: IDBTransactionMode
) => {
let tx: IDBTransaction;
try {
tx = db.transaction(storeName, mode);
} catch (err) {
throw new Error(
`[IndexDB] Store named ${storeName} cannot be found in the database`
);
}
return tx.objectStore(storeName);
};
5. 基于事务获取的 store 实现对数据的基本操作
5.1 增
store.add(data)
,data 数据中需包含 store 应有的主键 keyPath。store 应为getStore(db, storeName, 'readwrite')
该事务模式获取得到,事务模式应为:readwrite。
// 增
export const add = (store: IDBObjectStore, data: any) =>
new Promise((resolve, reject) => {
const req = store.add(data);
req.onsuccess = resolve;
req.onerror = reject;
});
5.2 改
store.put(data)
,实现方式同上。
// 改
export const put = (store: IDBObjectStore, data: any) =>
new Promise((resolve, reject) => {
const req = store.put(data);
req.onsuccess = resolve;
req.onerror = reject;
});
5.3 根据主键值查询
store.get(keyPathValue)
,keyPathValue 为主键值store.getAll()
,查询所有数据
// 根据 主键 keyPath 查询
export const get = (
store: IDBObjectStore,
keyPathValue?: IDBValidKey | IDBKeyRange
): Promise<any> =>
new Promise((resolve, reject) => {
const req = keyPathValue ? store.get(keyPathValue) : store.getAll();
req.onsuccess = (evt) => {
resolve((evt.target as IDBRequest).result);
};
req.onerror = reject;
});
5.4 利用游标根据索引 Index 查询
store.index(indexName)
指定索引store.index(indexName).openCursor(indexValue, direction)
开启游标索引,其中 indexValue 可以为 IDBValidKey 或 IDBKeyRange 类型,direction 指定游标查询的方向。
游标依次遍历每一行数据,每成功一次都会触发查询请求成功回调,在回调中通过cursor.value
获取到当前游标的数据,存储在 list 中,最后将满足查询要求的所有数据 list 返回。
// 根据 索引 Index 查询(游标)
export const getByIndex = (
store: IDBObjectStore,
indexName: string,
indexValue: IDBValidKey | IDBKeyRange,
direction?: IDBCursorDirection
) =>
new Promise((resolve, reject) => {
const req = store.index(indexName).openCursor(indexValue, direction);
const list: any[] = [];
req.onsuccess = (evt) => {
const cursor: IDBCursorWithValue = (evt.target as IDBRequest)
.result;
if (cursor) {
list.push(cursor.value);
cursor.continue();
} else {
resolve(list);
}
};
req.onerror = reject;
});
5.5 根据主键值删除
store.delete(keyPathValue)
,实现方式同 5.3 根据主键值查询
// 根据 主键 keyPath 删除
export const remove = (
store: IDBObjectStore,
keyPathValue: IDBValidKey | IDBKeyRange
) =>
new Promise((resolve, reject) => {
const req = store.delete(keyPathValue);
req.onsuccess = (evt) => {
resolve((evt.target as IDBRequest).result);
};
req.onerror = reject;
});
5.6 利用游标根据索引 Index 删除
调用cursor.delete()
实现数据删除,其它原理同 5.4 利用游标根据索引 Index 查询
// 根据 索引 Index 删除(游标)
export const removeByIndex = (
store: IDBObjectStore,
indexName: string,
indexValue: IDBValidKey | IDBKeyRange,
direction?: IDBCursorDirection
) =>
new Promise((resolve, reject) => {
const req = store.index(indexName).openCursor(indexValue, direction);
req.onsuccess = (evt) => {
const cursor: IDBCursorWithValue = (evt.target as IDBRequest)
.result;
if (cursor) {
const reqDelete = cursor.delete();
reqDelete.onerror = () => {
console.error(
`[IndexDB] Failed to delete the record ${cursor}`
);
};
reqDelete.onsuccess = () => { };
cursor.continue();
} else {
resolve({ delete: 'done' });
}
};
req.onerror = reject;
});
6. 若干优化注意点:(important)
IndexedDB 可以通过限制事务的作用域和模式来加速数据库访问。
- 定义作用域时,只指定你用到的对象仓库。这样,你可以同时运行多个不含互相重叠作用域的事务。
- 只在必要时指定 readwrite 事务。可以同时执行多个 readonly 事务,哪怕它们的作用域有重叠;但对于在一个对象仓库上你只能运行一个 readwrite 事务。
IndexedDB 初始化数据库封装实现(基于 TypeScript )
基于前面封装的基本操作,初始化数据库setupDB
函数实现原理如下:
- 利用闭包存储
_db
变量,返回一个异步函数,该异步函数隐式地返回一个 Promise 对象,PromiseResult 为数据库_db
对象。 - 如果闭包中的
_db
已经存在则无需请求打开数据库,直接返回该数据库对象. - 如果闭包中的
_db
不存在则基于上述 1. 创建或打开数据库 封装的reqDB
方法请求打开数据库建立连接,获取到_db
对象。 - 获取到的
_db
对象为其添加成员方法db.deleteStore
和db.getStore
方便调用。 - 同时监听 db 对象的相关事件钩子:
- onerror:监听 db 操作过程中发生的错误。同时,IndexedDB 的错误事件遵循冒泡机制。错误事件都是针对产生这些错误的请求的,然后事件冒泡到事务,然后最终到达数据库对象。因此,可以通过
**db.onerror**
监听到所有的错误。 - onversionchange:监听 db 版本发生改变。
- onabort:监听 db 在应用运行过程中中断。
- onclose:监听 db 关闭。
- 监听浏览器 onbeforeunload 事件,在其触发时关闭数据库的连接。
const setupDB = (() => {
let _db: InitDB | null;
const addDBListener = (db: InitDB) => {
// `addDBListener` must be called when the database is opened.
db.onerror = (ev) => console.error(ev.target);
db.onversionchange = (ev) => {
_db = ev.currentTarget as InitDB;
addDBFcn(_db);
createAllStore(_db);
};
db.onabort = (ev) => {
_db = null;
};
db.onclose = (ev) => {
_db = null;
};
};
const addDBFcn = (db: InitDB) => {
db.deleteStore = (storeName) =>
deleteStore(db as IDBDatabase, storeName);
db.getStore = (storeName, mode) =>
getStore(db as IDBDatabase, storeName, mode);
};
return async () => {
if (_db) return _db;
return await reqDB().then((db) => {
const iDb = db as InitDB;
addDBFcn(iDb);
_db = iDb;
addDBListener(iDb);
window.onbeforeunload = () => iDb.close();
return iDb;
});
};
})();
export default setupDB;
IndexedDB 初始化 store 封装实现(基于 TypeScript )
基于上述实现,可以很快实现一个基础的 store ,实现一个基于主键对 store 中数据的增删改查。以下均基于 Promise 实现了对 store 数据的读取,写入,删除以及 store 的删除操作。
export default function initStore<T extends Record<KeyPath, string | number>, KeyPath extends string = string>(
storeName: keyof typeof DbStoreName,
) {
async function getData(keyPathValue: T[KeyPath]) {
return await setupDB().then(async db => {
return (await get(db.getStore(storeName, 'readonly'), keyPathValue)) as T;
})
}
async function putData(data: T) {
return await setupDB().then(async db => {
return await put(db.getStore(storeName, 'readwrite'), data);
})
}
async function removeData(keyPathValue: T[KeyPath]) {
return await setupDB().then(async db => {
return await remove(db.getStore(storeName, 'readwrite'), keyPathValue);
})
}
async function deleteStore() {
return await setupDB().then(db => {
return db.deleteStore(storeName);
})
}
return { getData, putData, removeData, deleteStore };
}
IndexedDB 在 Vue 项目中的实践
利用 IndexedDB 实现一个前端表单填写离开时自动保存的案例,实现如下:
- 可以将此场景业务逻辑封装为一个 hook (在 Vue 中称为 Composition API ),如下:
- 首先基于上述实现的
initStore
初始化一个 store ,指定 storeName='IDB_FORM_USER_VERIFY',keyPath='formId'(主键名),以及存储的数据类型 T 。 get
方法即通过store.getData
获取其主键为指定值的该行数据。remove
方法即通过store.removeData
移除其主键为指定值的该行数据。save
方法即通过store.putData
存入传入的改行数据。- 此外,监听了
window.onbeforeunload
和VueComponent.onUnmounted
事件,在事件触发时自动保存数据。
- 首先基于上述实现的
export default function useIdbFormUserVerify<T extends { formId: number }>(rowData: T | Ref<T>) {
// keyPath `formId`
const store = initStore<T, 'formId'>('IDB_FORM_USER_VERIFY')
const get = async () => await store.getData(unref(rowData).formId).then((val) => {
if (!val?.formId) return
const { formId, ...form } = val
return form
})
const remove = async () => await store.removeData(unref(rowData).formId).then(() => {
console.log('remove success!')
})
const save = async () => await store.putData(unref(rowData)).then(() => {
console.log('save success!')
})
window.addEventListener('beforeunload', save)
onUnmounted(() => {
save()
window.removeEventListener('beforeunload', save)
})
return { get, remove, save }
}
- 在表单组件中,调用此 hook。以下为 FormUserVerify 组件中
<script setup lang="ts"></script>
中截取的关于 indexedDb 的部分代码。- 在组件 setup 时获取 indexedDB 中的数据,并覆盖 form 中的数据(
changeForm
)。 - 在表单提交成功时,移除在 indexedDB 中存储的此条数据
- 在组件 setup 时获取 indexedDB 中的数据,并覆盖 form 中的数据(
import useIdbFormUserVerify from '@/store/indexedDB/useIdbFormUserVerify'
// ... ...
// indexedDb
const saveForm = computed(() => ({ formId: 1, ...form }))
const { get, remove } = useIdbFormUserVerify(saveForm)
get().then(val => {
console.log(val)
if (val) changeForm(val)
})
const onSubmit = () => {
if (valid()) {
dVerifyUser(format('userRole')).then(debounce(() => { remove(); router.go(-1) }, 500)).catch((err) => Toast(err.msg))
}
}
基于以上就完美的实现了一个自动保存表单内容的 IndexedDB 应用。
🍻以上基于本人查阅的资料和一些个人理解实践,希望对大家有帮助。如有理解错误的地方,还望大神指出!
📃参考资料
zhuanlan.zhihu.com/p/505031430
juejin.cn/post/712386…
tech.meituan.com/2018/10/11/…
juejin.cn/post/684490…
juejin.cn/post/684490…
developer.mozilla.org/zh-CN/docs/…
juejin.cn/post/702690…