实现一个版本自动控制的 IndexedDB

随着现代大型项目复杂度的提升,渲染一个 WEB 页面需要的数据越来越多,在多次打开并渲染的过程中,有许多数据都是重复并且不常更新的,因此这部分的数据需要通过浏览器缓存来缓解网络压力,同时提升页面打开速度。

IndexedDB 的存储方案比较

在 IndexedDB 推出以前,浏览器数据的存储方案就已经有了一些实现,例如 cookie,localStorage 等等。

cookie 不用多说,每次都需要随着请求全部带给服务端,并且大小只有可怜的 4KB。cookie 用来做存储数据缓存必然会给网络请求带来更大的压力,因此在该种情况下不是一个合适的载体。

localStorage 作为一个 HTML5 标准,很适合用来做存储数据的本地缓存,并且它能够在不同的标签页之间共享数据,一些网站利用这个特点能够实现一些神奇的操作。它的存储限制比 cookie 要大,根据浏览器的实现不同,大部分浏览器至少支持 5MB - 50MB 的存储。但是,由于 localStorage 的实现与 cookie 类似,存储格式只能为 key-value, 并且 value 只能为 string 类型。因此需要存储复杂类型时,还必须得进行一次 JSON 的序列化转换。于此同时,localStorage 的读写是同步的,会阻塞主线程的执行,因此在存取复杂类型或大数据量的缓存数据时,localStorage 并不是一个很合适的选择。

为了解决 localStorage 存在的上述问题,W3C 提出了浏览器数据库 —— IndexedDB 标准。一个无大小限制的(一般只取决于硬盘容量)、异步的、支持存储任意类型数据的浏览器存储方案。

IndexedDB 的基本概念

要学习 IndexedDB 的使用,首先得了解它的一些核心概念。

数据库版本

和所有数据库一样,IndexedDB 也有 Database 的概念。每个同源策略下,都可以有多个数据库。

由于 IndexedDB 存在于客户端,数据存储在浏览器中。因此开发人员不能直接访问它。因此 IndexedDB 有一个独特的 scheme 版本控制机制,引申出来数据库版本的概念。同一时间统一数据库只保留唯一且最新的版本,低于此版本的标签页会触发 upgradeneeded 事件升级版本库。修改数据库结构的操作(如增删表、索引等),只能通过升级数据库版本完成。

ObjectStore

IndexedDB 用来存储数据集的单位是 ObjectStore,相当于关系型数据库的表,或是非关系型数据库的集合。

事务

事务相当于是一个原子操作,在一个事务中若出现报错,整个事务之中执行的所有功能都不会生效。从而使得数据库能够保证数据一致性,提升业务可靠性。

IndexedDB 的一大特点就是事务化,所有的数据操作都必须被包裹在事务之内执行。IndexedDB 的层级关系为:请求 -> 事务 -> 数据库,我们也可以通过这个关系链来进行错误处理的事件委托,从而集中错误捕获逻辑处理。

IndexedDB API 的原生使用

IndexedDB 的 API 较为繁杂,由于并不是本文要讲的重点,在此不展开,对原生 API 感兴趣的可以参考一下 MDN 的文档:https://developer.mozilla.org/zh-CN/docs/Web/API/IndexedDB_API

由于原生 API 的异步过程采用的是监听回调机制,在现代项目中使用起来不是很方便,一般来说推荐使用 Promise 的方式在外部封装一层,更能够贴合现代项目的使用场景。

建立版本自动控制的 IndexedDB

解决思路

从使用文档中可以知道,IDBFactory.open 方法用于打开一个数据库连接,它通过传入数据库名称以及版本号 version 两个参数,执行以下步骤,并在相应的时期触发指定回调的钩子。

  1. 指定数据库已经存在时,等待 versionchange 操作完成。如果数据库已计划删除,那等着删除完成。
  2. 如果已有数据库版本高于给定的 version,中止操作并返回 Error。
  3. 如果已有数据库版本,且版本低于给定的 version,触发一个 versionchange 操作。
  4. 如果数据库不存在,创建指定名称的数据库,将版本号设置为给定版本,如果没有给定版本号,则设置为 1。
  5. 创建数据库连接。

从这里可以看出,这个方法兼具了创建数据库与建立数据库连接两个功能,这里与我们常用数据库的操作不太一致,因此使用起来会有些奇怪。

事实上,IndexedDB 的设计初衷及推荐用法是让我们在代码中硬编码 version 这个版本号,从而在触发的 versionchange 事件中根据版本号不同给出确定的响应。

    const openRequest: IDBOpenDBRequest = this.dbFactory.open(this.dbName, version);

    openRequest.onupgradeneeded = (e) => {
        versionChangeCb(e);
        if (e.oldVersion < 1) {
            const objectStore = db.createObjectStore('test_objectStore');
        } else if(e.oldVersion === 1) {
            ...
        } else {
            ...
        }
    };

这与我们对熟悉的数据库认知是不一致的。有的时候,我们希望 IndexedDB 只像一个建立在浏览器本地的普通的数据库一样在项目执行时进行任意的增删表操作,并不想关心当前最新的版本号是多少,希望能自动控制版本。

而现有的IndexedDB能力对于这样的使用场景来说就变得非常艰难。因为在不知道当前最新版本号的情况下根本没法打开最新版本的数据库,并且,在不打开数据库得到数据库实例之前也没法获取当前数据库的最新版本!这就形成了一个死结,我们必须在某个本地位置记录下当前数据库的最新版本,以便下次打开表时能够直接读取到。

理清了处理思路,接下来就是具体的实现环节。

本地存取某个数据库的最新版本

首先我们需要解决的就是在本地存储版本号的问题。

本地存取的方式有很多,在之前也简单介绍过各种本地存储的解决方案。在这里,考虑到最大的兼容性,使用的是多使用一个版本固定不变的IndexedDB数据库。(这里使用 localStorage 等存储方案也同样合适)

    private getDBLatestVersion(dbName: string): Promise<number> {
        return new Promise(async (resolve, reject) => {
            const openRequest: IDBOpenDBRequest = this.dbFactory.open('DBVersion', 1);
            openRequest.onerror = () => {
                reject(INDEXEDDB_ERROR.OPEN_FAILED);
            };

            openRequest.onsuccess = () => {
                const db = openRequest.result;
                const objectStore = db.transaction(['version'], 'readonly').objectStore('version');
                const request = objectStore.get(dbName);
                // 找不到说明应该是新建的数据库
                request.onerror = function () {
                    resolve(0);
                };
                request.onsuccess = function () {
                    if (request.result?.version) {
                        resolve(request.result.version);
                    } else {
                        resolve(0);
                    }
                };
            };

            openRequest.onupgradeneeded = () => {
                const db = openRequest.result;
                const objectStore = db.createObjectStore('version', {
                    keyPath: 'dbName',
                });
                objectStore.createIndex('dbName', 'dbName', { unique: true });
                objectStore.createIndex('version', 'version', { unique: false });
            };
        });
    }

    private updateDBLatestVersion(dbName: string, newVersion: number) {
        return new Promise(async (resolve, reject) => {
            const openRequest: IDBOpenDBRequest = this.dbFactory.open('DBVersion', 1);
            openRequest.onerror = () => {
                reject(INDEXEDDB_ERROR.OPEN_FAILED);
            };

            openRequest.onsuccess = () => {
                const db = openRequest.result;
                const objectStore = db.transaction(['version'], 'readwrite').objectStore('version');
                // 更新数据库版本字段
                const updateRequest = objectStore.put({ dbName, version: newVersion });
                updateRequest.onerror = reject;
                updateRequest.onsuccess = resolve;
            };

            openRequest.onupgradeneeded = () => {
                const db = openRequest.result;
                const objectStore = db.createObjectStore('version', {
                    keyPath: 'dbName',
                });
                objectStore.createIndex('dbName', 'dbName', { unique: true });
                objectStore.createIndex('version', 'version', { unique: false });
            };
        });
    }

这里使用dbNameversion两个字段来对每一个数据库以及其最新版本进行存储映射。这里需要注意的是,若是无法在这里找到该数据库名称,说明应该是数据库在新建过程中,也是正常情况,根据建表方法所需,返回0。

建立数据库连接

为了像普通数据库一样操作,首先我们需要拆分IndexedDB.open这个API的建立连接和新增表这两个功能,先来看建连部分。

    private getDBConnection(version?: number): Promise<IDBDatabase> {
        if (this.hasDBOpened && this.db) {
            return Promise.resolve(this.db);
        }

        const openRequest: IDBOpenDBRequest = this.dbFactory.open(this.dbName, version || this.dbVersion);

        return new Promise((resolve, reject) => {
            openRequest.onerror = () => {
                this.close();
                reject(INDEXEDDB_ERROR.CONNECTION_FAILED);
            };

            openRequest.onblocked = () => {
                this.close();
                reject(INDEXEDDB_ERROR.CONNECTION_FAILED);
            };

            openRequest.onsuccess = () => {
                this.db = openRequest.result;
                this.hasDBOpened = true;
                resolve(openRequest.result);
            };

            // 此时会新建一个数据库,不正确的调用
            openRequest.onupgradeneeded = () => {
                this.close();
                reject(INDEXEDDB_ERROR.CONNECTION_FAILED);
            };
        });
    }

    public close() {
        if (this.db) {
            this.db.close();
        }
        this.db = null;
        this.hasDBOpened = false;
    }

这块逻辑挺好理解,在取得最新版本号后打开数据库,并对高于或低于当前版本的输入均抛出报错。目的是为了确保该方法仅执行打开连接的操作。

断开连接即使用 IDBDatabase.close 方法,并重置标记位即可。

增删表操作

新建表的逻辑为,再打开数据库前,先获取到当前数据库的最新版本,并在该基础上+1,这是为了确保触发onupgradeneeded事件,从而在这里进行更新数据库版本与创建新表的操作。

由于版本号是一个 unsigned long long 类型,因此不要使用浮点数来记录它的版本,否则会被强行取整。

    public createTable(options: {
        tableName: string;
        objectStoreOptions
    }): Promise<IDBDatabase> {
        if (this.hasDBOpened) this.close();

        const { tableName, createIndexParamsArr, primaryKey } = options;

        return new Promise(async (resolve, reject) => {
            const version = await this.getDBLatestVersion(this.dbName);
            const newVersion = version + 1;

            const openRequest: IDBOpenDBRequest = this.dbFactory.open(this.dbName, newVersion);

            openRequest.onupgradeneeded = () => {
                // 版本更新
                this.dbVersion = newVersion;
                this.updateDBLatestVersion(this.dbName, newVersion);

                db.createObjectStore(tableName, objectStoreOptions);
            };
            openRequest.onsuccess = () => {
                this.db = openRequest.result;
                this.hasDBOpened = true;
                resolve(openRequest.result);
            };
            openRequest.onerror = () => {
                this.close();
                reject(INDEXEDDB_ERROR.OPEN_FAILED);
            };
            openRequest.onblocked = () => {
                this.close();
            };
        });
    }

删表也是同理

    public deleteTable(tableName: string): Promise<IDBDatabase> {
        if (this.hasDBOpened) this.close();
        return new Promise(async (resolve, reject) => {
            const version = await this.getDBLatestVersion(this.dbName);
            const newVersion = version + 1;

            const openRequest: IDBOpenDBRequest = this.dbFactory.open(this.dbName, newVersion);
            openRequest.onupgradeneeded = () => {
                // 版本更新
                this.dbVersion = newVersion;
                this.updateDBLatestVersion(this.dbName, newVersion);

                const db = openRequest.result;
                if (db.objectStoreNames.contains(tableName)) {
                    db.deleteObjectStore(tableName);
                    resolve(db);
                } else {
                    reject(INDEXEDDB_ERROR.CAN_NOT_FIND_TABLE);
                }
            };
            openRequest.onsuccess = () => {
                this.db = openRequest.result;
                resolve(openRequest.result);
            };
            openRequest.onerror = () => {
                this.close();
                reject(INDEXEDDB_ERROR.OPEN_FAILED);
            };
        });
    }

至此,就能实现一个能够进行自动版本控制的 IndexedDB promise 封装了。

当然,接下来还需要对表的增删改查进行promise化处理,并支持批量增删、索引与主键查询、多条件查询等等,就能封装成一个完整可用的库了。由于跟本次主题无关,就不将代码贴上来了,感兴趣的可以自己实现一下。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,366评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,521评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,689评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,925评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,942评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,727评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,447评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,349评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,820评论 1 317
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,990评论 3 337
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,127评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,812评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,471评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,017评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,142评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,388评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,066评论 2 355