本文你能學(xué)到
- 優(yōu)雅的
Storage
工具類如何封裝(支持前綴key、加密存儲(chǔ)、過(guò)期時(shí)間,ts封裝等) localStorage
真實(shí)存儲(chǔ)大小/存儲(chǔ)統(tǒng)計(jì)localStorage
同源問(wèn)題與同源窗口通信
前言
localStorage
使用是一個(gè)老生常談的話題,本文不講解基礎(chǔ) api
,主要教你如何封裝一個(gè)優(yōu)雅的localStorage
工具,以及一些 localStorage
中一些你不知道的知識(shí)點(diǎn)。
優(yōu)雅的 Storage 工具如何封裝(前綴、加密、過(guò)期時(shí)間等)
該工具函數(shù)設(shè)計(jì)
- 采用工廠方法+閉包設(shè)計(jì)模式,不直接實(shí)例化類,而是根據(jù)傳入的參數(shù)來(lái)配置和返回一個(gè)
SmartStorage
的實(shí)例。 - 支持帶前綴的鍵:通過(guò)
prefixKey
參數(shù)可以為存儲(chǔ)的鍵名添加一個(gè)前綴,默認(rèn)為空字符串。這個(gè)功能可以幫助避免鍵名沖突,特別是當(dāng)在同一個(gè)域下的不同應(yīng)用或組件中使用同一種存儲(chǔ)方式時(shí)。 - 支持過(guò)期時(shí)間:在存儲(chǔ)數(shù)據(jù)時(shí),可以為每項(xiàng)數(shù)據(jù)設(shè)置一個(gè)過(guò)期時(shí)間(單位為秒),存儲(chǔ)的數(shù)據(jù)結(jié)構(gòu)中會(huì)包括實(shí)際的值、存儲(chǔ)時(shí)間戳以及過(guò)期時(shí)間戳。在讀取數(shù)據(jù)時(shí),會(huì)檢查數(shù)據(jù)是否過(guò)期,如果已經(jīng)過(guò)期,則自動(dòng)刪除
- 支持加密存儲(chǔ):存儲(chǔ)數(shù)據(jù)時(shí)根據(jù)參數(shù)配置可先進(jìn)行加密,讀取數(shù)據(jù)時(shí)再解密,加密使用的
crypto
模塊 - 錯(cuò)誤處理:在讀取數(shù)據(jù)時(shí),如果解密過(guò)程出錯(cuò)或數(shù)據(jù)格式不正確,會(huì)捕獲異常并返回默認(rèn)值,這提高了程序的健壯性。
- 支持常用的
api
(set get remove clear
)
接下來(lái)是代碼實(shí)現(xiàn):在未進(jìn)行代碼實(shí)現(xiàn)前可以基于上面的設(shè)計(jì)自己實(shí)現(xiàn)一下,然后對(duì)照下我的代碼實(shí)現(xiàn)
/**
* 封裝一個(gè)local
*/
import { decrypt as aesDecrypt, encrypt as aesEncrypt } from 'crypto-js/aes';
import UTF8, { parse } from 'crypto-js/enc-utf8';
import pkcs7 from 'crypto-js/pad-pkcs7';
import CTR from 'crypto-js/mode-ctr';
import {isNil} from 'lodash';
interface EncryptionParams {
key: string;
iv: string;
}
export interface Encryption {
encrypt(plainText: string): string;
decrypt(cipherText: string): string;
}
/**
* 加密類簡(jiǎn)單實(shí)現(xiàn)
*/
class AesEncryption implements Encryption {
private readonly key;
private readonly iv;
constructor({ key, iv }: EncryptionParams) {
this.key = parse(key);
this.iv = parse(iv);
}
get getOptions() {
return {
mode: CTR, // 加密部分不贅余,自行搜索參數(shù)學(xué)習(xí)
padding: pkcs7, // 加密部分不贅余,自行搜索參數(shù)學(xué)習(xí)
iv: this.iv,
};
}
encrypt(plainText: string) {
return aesEncrypt(plainText, this.key, this.getOptions).toString();
}
decrypt(cipherText: string) {
return aesDecrypt(cipherText, this.key, this.getOptions).toString(UTF8);
}
}
export interface CreateSmartStorageParams extends EncryptionParams {
prefixKey: string;
storage: Storage;
hasEncrypt: boolean;
timeout?: number;
}
/**
* localStorage工廠方法實(shí)現(xiàn)
* @param param0
* @returns
*/
export const createSmartStorage = ({
prefixKey = '',
storage = localStorage, // 這里其實(shí)也可以支持sessionStorage,自行配置
key = cacheConfig.key, // 修改為自己項(xiàng)目cacheConfig中的key
iv = cacheConfig.iv, // 修改為自己項(xiàng)目cacheConfig中的iv
timeout = null,
hasEncrypt = true,
}: Partial<CreateSmartStorageParams> = {}) => {
if (hasEncrypt && [key.length, iv.length].some((item) => item !== 16)) {
throw new Error('When hasEncrypt is true, the key or iv must be 16 bits!');
}
//
const persistEncryption: Encryption = new AesEncryption({
key: cacheConfig.key,// 修改為自己項(xiàng)目cacheConfig中的key
iv: cacheConfig.iv,// 修改為自己項(xiàng)目cacheConfig中的iv
})
/**
* Cache class
* Construction parameters can be passed intolocalStorage,
* @class Cache
* @example
*/
const SmartStorage = class SmartStorage {
private storage: Storage;
private prefixKey?: string;
private encryption: Encryption;
private hasEncrypt: boolean;
/**
*
* @param {*} storage
*/
constructor() {
this.storage = storage;
this.prefixKey = prefixKey;
this.encryption = persistEncryption;
this.hasEncrypt = hasEncrypt;
}
private getKey(key: string) {
return `${this.prefixKey}${key}`.toUpperCase();
}
/**
* Set cache
* @param {string} key
* @param {*} value
* @param {*} expire Expiration time in seconds
* @memberof Cache
*/
set(key: string, value: any, expire: number | null = timeout) {
const stringData = JSON.stringify({
value,
time: Date.now(),
expire: !isNil(expire) ? new Date().getTime() + expire * 1000 : null,
});
const stringifyValue = this.hasEncrypt ? this.encryption.encrypt(stringData) : stringData;
this.storage.setItem(this.getKey(key), stringifyValue);
}
/**
* Read cache
* @param {string} key
* @param {*} def
* @memberof Cache
*/
get(key: string, def: any = null): any {
const val = this.storage.getItem(this.getKey(key));
if (!val) return def;
try {
const decVal = this.hasEncrypt ? this.encryption.decrypt(val) : val;
const data = JSON.parse(decVal);
const { value, expire } = data;
if (isNil(expire) || expire >= new Date().getTime()) {
return value;
}
this.remove(key);
} catch (e) {
return def;
}
}
/**
* Delete cache based on key
* @param {string} key
* @memberof Cache
*/
remove(key: string) {
this.storage.removeItem(this.getKey(key));
}
/**
* Delete all caches of this instance
*/
clear(): void {
this.storage.clear();
}
};
return new SmartStorage();
};
再補(bǔ)充幾個(gè) localStorage
相關(guān)可能你不知道的知識(shí)點(diǎn)。
localStorage 存儲(chǔ)大小
localStorage
的存儲(chǔ)空間是 5M
,但是單位是字符串的長(zhǎng)度值, 或者 utf-16
的編碼單元,也可以說(shuō)是 10M
字節(jié)空間。localStorage
的 key
鍵也是占存儲(chǔ)空間的。localStorage
如何統(tǒng)計(jì)已使用空間
function sieOfLS() {
return Object.entries(localStorage).map(v => v.join('')).join('').length;
}
這個(gè)函數(shù)也可以加到storage工具函數(shù)中
localStorage.clear();
localStorage.setItem("🌞", 1);
localStorage.setItem("🌞🌞🌞🌞", 1111);
console.log("size:", sieOfLS()) // 15
// 🌞*5 + 1 *5 = 2*5 + 1*5 = 15
localStorage 如何監(jiān)聽
window.addEventListener('storage', () => {
// callback
})
每次 localStorage
中有任何變動(dòng)都會(huì)觸發(fā)一個(gè) storage
事件,即使是同域下的不同頁(yè)面A、B都會(huì)監(jiān)聽這個(gè)事件,一旦有窗口更新 localStorage
,其他窗口都會(huì)收到通知。
- 基于我們前面封裝的
localStorage
工具類 在封裝后每一個(gè)函數(shù)內(nèi)部可以進(jìn)行監(jiān)聽,同時(shí)如果想要統(tǒng)計(jì)監(jiān)聽一些內(nèi)容,可以給一些函數(shù)增加 aop
裝飾器來(lái)完成。
@aop
set(key: string, value: any, expire: number | null = timeout) {
const stringData = JSON.stringify({
value,
time: Date.now(),
expire: !isNil(expire) ? new Date().getTime() + expire * 1000 : null,
});
const stringifyValue = this.hasEncrypt ? this.encryption.encrypt(stringData) : stringData;
this.storage.setItem(this.getKey(key), stringifyValue);
}
具體 aop
裝飾器相關(guān)內(nèi)容可以看我另一篇文章,本文只講解 localStorage
localStorage 同源
只有來(lái)自同一個(gè)源的網(wǎng)頁(yè)才能訪問(wèn)相同的 localStorage
對(duì)應(yīng) key
的數(shù)據(jù),這也是前面工具類封裝,這個(gè)參數(shù) prefixKey
的作用,同源項(xiàng)目可以加一個(gè)唯一 key
,保證同源下的 localStorage
不沖突。
這是一個(gè)需要避免的問(wèn)題,有時(shí)候也會(huì)基于這些實(shí)現(xiàn)一些功能,比如下面的同源窗口通信
同源窗口通信
我們就可以只有一個(gè)窗口與后臺(tái)建立連接,收到更新后,廣播給其他窗口就可以。想象這樣一個(gè)場(chǎng)景:當(dāng)localStorage
中的數(shù)據(jù)發(fā)生變化時(shí),瀏覽器會(huì)觸發(fā)一個(gè) storage
事件,這個(gè)事件能夠被同一源下所有的窗口監(jiān)聽到。這意味著,如果一個(gè)窗口更新了 localStorage
,其他窗口可以實(shí)時(shí)接收到這一變動(dòng)的通知。雖然這個(gè)機(jī)制的原理相對(duì)簡(jiǎn)單——基于事件的廣播,但是要搭建一個(gè)功能完備的跨窗口通信機(jī)制,則需要考慮更多的細(xì)節(jié)和挑戰(zhàn)。
- 每個(gè)窗口都需要有一個(gè)獨(dú)一無(wú)二的標(biāo)識(shí)符
(ID)
,以便在眾多窗口中準(zhǔn)確識(shí)別和管理它們。 - 為了避免同一個(gè)消息被重復(fù)處理,必須有機(jī)制確保消息的唯一性。
- 還需要確保只有那些真正需要接收特定消息的窗口才會(huì)收到通知,這就要求消息分發(fā)機(jī)制能夠有效地過(guò)濾掉不相關(guān)的窗口。
- 考慮到窗口可能會(huì)因?yàn)楦鞣N原因關(guān)閉或變得不響應(yīng),引入窗口的“心跳”機(jī)制來(lái)監(jiān)控它們的活躍狀態(tài)變得尤為重要。
- 當(dāng)涉及到需要從多個(gè)窗口中選舉出一個(gè)主窗口來(lái)協(xié)調(diào)操作時(shí),主窗口的選舉機(jī)制也是不可或缺的一環(huán)。
盡管這些要求聽起來(lái)可能令人望而卻步,不過(guò)開源社區(qū)已經(jīng)提供了一些優(yōu)秀的解決方案來(lái)簡(jiǎn)化這個(gè)過(guò)程。例如,diy/intercom
.js和tejacques/crosstab
這兩個(gè)庫(kù)就是專門為了解決跨窗口通信而設(shè)計(jì)的。感興趣的學(xué)習(xí)下
擴(kuò)展知識(shí) 同源策略
協(xié)議相同:網(wǎng)頁(yè)地址的協(xié)議必須相同。例如,http://example.com
和 https://example.com
被視為不同的源,因此如果一個(gè)頁(yè)面是通過(guò) HTTP
加載的,它不能訪問(wèn)通過(guò)HTTPS加載的頁(yè)面的 LocalStorage
數(shù)據(jù),反之亦然。
域名相同:網(wǎng)頁(yè)的域名必須完全相同。子域與主域也被視為不同的源(例如,sub.example.com
與 example.com
),默認(rèn)情況下,它們無(wú)法共享 LocalStorage
數(shù)據(jù)。
端口相同:即使協(xié)議和域名相同,端口號(hào)的不同也會(huì)使它們成為不同的源。例如,http://example.com:80
和 http://example.com:8080
是不同的源。
總結(jié)
相信學(xué)習(xí)完本文你對(duì) LocalStorage
有一個(gè)徹底的掌握,創(chuàng)作不易歡迎三連支持。
該文章在 2024/4/12 23:17:25 編輯過(guò)