前言

localStorage 使用是一个老生常谈的话题,本文不讲解基础 api,主要教你如何封装一个优雅的localStorage 工具,以及一些 localStorage中一些你不知道的知识点。

优雅的 Storage 工具如何封装(前缀、加密、过期时间等)

该工具函数设计

  1. 采用工厂方法+闭包设计模式,不直接实例化类,而是根据传入的参数来配置和返回一个 SmartStorage 的实例。
  2. 支持带前缀的键:通过 prefixKey 参数可以为存储的键名添加一个前缀,默认为空字符串。这个功能可以帮助避免键名冲突,特别是当在同一个域下的不同应用或组件中使用同一种存储方式时。
  3. 支持过期时间:在存储数据时,可以为每项数据设置一个过期时间(单位为秒),存储的数据结构中会包括实际的值、存储时间戳以及过期时间戳。在读取数据时,会检查数据是否过期,如果已经过期,则自动删除
  4. 支持加密存储:存储数据时根据参数配置可先进行加密,读取数据时再解密,加密使用的 crypto 模块
  5. 错误处理:在读取数据时,如果解密过程出错或数据格式不正确,会捕获异常并返回默认值,这提高了程序的健壮性。
  6. 支持常用的 apiset get remove clear
  7. TypeScript 实现

接下来是代码实现:在未进行代码实现前可以基于上面的设计自己实现一下,然后对照下我的代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
/**
 * 封装一个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;
}

/**
 * 加密类简单实现
 */
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 {
            modeCTR// 加密部分不赘余,自行搜索参数学习
            padding: pkcs7, // 加密部分不赘余,自行搜索参数学习
            ivthis.iv,
        };
    }

    encrypt(plainText: string) {
        return aesEncrypt(plainText, this.keythis.getOptions).toString();
    }

    decrypt(cipherText: string) {
        return aesDecrypt(cipherText, this.keythis.getOptions).toString(UTF8);
    }
}


export interface CreateSmartStorageParams extends EncryptionParams {
    prefixKey: string;
    storageStorage;
    hasEncrypt: boolean;
    timeout?: number;
}
/**
 * localStorage工厂方法实现
 * @param param0 
 * @returns 
 */
export const createSmartStorage = ({
    prefixKey = '',
    storage = localStorage, // 这里其实也可以支持sessionStorage,自行配置
    key = cacheConfig.key, // 修改为自己项目cacheConfig中的key
    iv = cacheConfig.iv, // 修改为自己项目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 persistEncryptionEncryption = new AesEncryption({
        key: cacheConfig.key,// 修改为自己项目cacheConfig中的key
        iv: cacheConfig.iv,// 修改为自己项目cacheConfig中的iv
    })
    /**
     * Cache class
     * Construction parameters can be passed intolocalStorage,
     * @class Cache
     * @example
     */
    const SmartStorage = class SmartStorage {
        private storageStorage;
        private prefixKey?: string;
        private encryptionEncryption;
        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 {stringkey
         * @param {*value
         * @param {*} expire Expiration time in seconds
         * @memberof Cache
         */
        set(key: string, value: any, expire: number | null = timeout) {
            const stringData = JSON.stringify({
                value,
                timeDate.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 {stringkey
         * @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 {stringkey
         * @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();
}; 

再补充几个 localStorage 相关可能你不知道的知识点。

localStorage 存储大小

  1. localStorage 的存储空间是 5M,但是单位是字符串的长度值, 或者 utf-16 的编码单元,也可以说是 10M 字节空间。
  2. localStoragekey 键也是占存储空间的。
  3. localStorage 如何统计已使用空间
1
2
3
function sieOfLS() {
    return Object.entries(localStorage).map(v => v.join('')).join('').length;
}

这个函数也可以加到storage工具函数中

1
2
3
4
5
localStorage.clear();
localStorage.setItem("🌞"1);
localStorage.setItem("🌞🌞🌞🌞"1111);
console.log("size:"sieOfLS())   // 15
// 🌞*5 + 1 *5 = 2*5 + 1*5 = 15

localStorage 如何监听

  1. 原生 api 监听
1
2
3
window.addEventListener('storage'() => {
  // callback
})

每次 localStorage 中有任何变动都会触发一个 storage 事件,即使是同域下的不同页面A、B都会监听这个事件,一旦有窗口更新 localStorage,其他窗口都会收到通知。

  1. 基于我们前面封装的 localStorage 工具类 在封装后每一个函数内部可以进行监听,同时如果想要统计监听一些内容,可以给一些函数增加 aop 装饰器来完成。
1
2
3
4
5
6
7
8
9
10
@aop
set(key: string, value: any, expire: number | null = timeout) {
            const stringData = JSON.stringify({
                value,
                timeDate.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 装饰器相关内容可以看我另一篇文章,本文只讲解 localStorage

localStorage 同源

只有来自同一个源的网页才能访问相同的 localStorage 对应 key 的数据,这也是前面工具类封装,这个参数 prefixKey 的作用,同源项目可以加一个唯一 key,保证同源下的 localStorage 不冲突。

这是一个需要避免的问题,有时候也会基于这些实现一些功能,比如下面的同源窗口通信

同源窗口通信

我们就可以只有一个窗口与后台建立连接,收到更新后,广播给其他窗口就可以。想象这样一个场景:当localStorage 中的数据发生变化时,浏览器会触发一个 storage 事件,这个事件能够被同一源下所有的窗口监听到。这意味着,如果一个窗口更新了 localStorage ,其他窗口可以实时接收到这一变动的通知。虽然这个机制的原理相对简单——基于事件的广播,但是要搭建一个功能完备的跨窗口通信机制,则需要考虑更多的细节和挑战。

  1. 每个窗口都需要有一个独一无二的标识符(ID),以便在众多窗口中准确识别和管理它们。
  2. 为了避免同一个消息被重复处理,必须有机制确保消息的唯一性。
  3. 还需要确保只有那些真正需要接收特定消息的窗口才会收到通知,这就要求消息分发机制能够有效地过滤掉不相关的窗口。
  4. 考虑到窗口可能会因为各种原因关闭或变得不响应,引入窗口的“心跳”机制来监控它们的活跃状态变得尤为重要。
  5. 当涉及到需要从多个窗口中选举出一个主窗口来协调操作时,主窗口的选举机制也是不可或缺的一环。

尽管这些要求听起来可能令人望而却步,不过开源社区已经提供了一些优秀的解决方案来简化这个过程。例如,diy/intercom.js和tejacques/crosstab 这两个库就是专门为了解决跨窗口通信而设计的。感兴趣的学习下

扩展知识 同源策略

  • 协议相同:网页地址的协议必须相同。例如,http://example.comhttps://example.com 被视为不同的源,因此如果一个页面是通过 HTTP 加载的,它不能访问通过HTTPS加载的页面的 LocalStorage 数据,反之亦然。
  • 域名相同:网页的域名必须完全相同。子域与主域也被视为不同的源(例如,sub.example.comexample.com ),默认情况下,它们无法共享 LocalStorage 数据。
  • 端口相同:即使协议和域名相同,端口号的不同也会使它们成为不同的源。例如,http://example.com:80http://example.com:8080 是不同的源。

===以上===