时间:2022-06-12 作者:剧中人
可能你不太清楚,小剧起始页之所以被开发出来,是因为小剧希望在业余时间学习一些新的技能。关于这部分的背景介绍,感兴趣的话可以看 《【开发回顾】小剧起始页》,这里有详细的介绍。
其中有一个很重要的点,就是学习浏览器离线数据库 IndexedDB 相关的知识。
IndexedDB 是一种底层 API,用于在客户端存储大量的结构化数据(也包括文件/二进制大型对象(blobs))。该 API 使用索引实现对数据的高性能搜索。
这段话摘抄自 MDN Web Docs IndexedDb 介绍,介绍了 IndexedDB 是什么,有什么样的特点。
可能干瘪瘪地看这段描述会比较晦涩,下面我们从前端数据存储的发展历程,来帮助我们理解 IndexedDB。
相信各位或多或少都有过前端存取数据的经验,在用户身份认证、编辑器离线存储等场景都能发挥很大的作用。
互联网发展的早期,我们都习惯于使用兼容性最好的 Cookie 来存取数据。虽然容量很小,但是提供了前端保存数据的可能性,对于一些关键数据的存取还是够用了。
然而随着业务的发展,需要浏览器本地存储的数据类型愈发丰富,数据量也越来越多。并且因为 cookie 的特性,也暴露出对 HTTP 请求的影响以及数据安全的风险。
基于上面提到的数据容量、安全方面等问题,Cookie 一直没能成为前端数据存储的正规军。
直到以 LocalStorage 为代表的 Web Storage 问世,前端存储数据才开始大规模的应用起来。它拥有至少 2MB 的存储空间,丰富的存、取、删除、清空、迭代等 API,这些都让前端在个性化离线数据存储中大放异彩。
看起来前端已经可以为所欲为了,那要 IndexedDB 做什么呢 ?
前面也提到了,Web Storage 有至少 2MB 的存储空间,在它刚面世的时候这是一个不可思议的容量。然而在贪得无厌的前端们,既要也要的情况下,很快就被蚕食殆尽。
并且一旦不幸需要操作较大的数据量,数据的存、取、序列化都会对 UI 有明显的阻塞。
在今天的主角 IndexedDB 登场的同期,还有另一个叫 WebSQL 的很火。因为目前已经在标准中废弃,并且 Safair 已经率先从浏览器中移除了它,所以这部分就不展开聊了。
虽然 Web Storage 在存储较少量的数据很有用,但对于存储更大量的结构化数据来说力不从心。而 IndexedDB 提供了这种场景的解决方案。
这段话是前面引用 MDN 介绍的那段文本的后半句。表明了 IndexedDB 的容量更大,并且结构化的数据更有利于前端离线存储。
还有另一个优势这里没有提到,IndexebDB 的 API 是异步的,在涉及到大量数据存取操作的时候不会对 UI 有阻塞。
在 IndexedDB 中,破天荒的引入了 Blob 文件存储特性,也使得前端离线存储文件成为可能。
虽然 IndexedDB 有如此多的优势,但并不是说它一定会替代 Web Storage,就目前来说它们是两种完全不同的存储方式。
Cookie:
4K以下存储空间,会在绝大多数请求携带 cookie 数据,读写操作会阻塞UI。
Web Storage:
5M左右(最小2M)的存储空间,不会在请求中携带数据,读写操作会阻塞UI。
IndexedDB:
磁盘允许的前提下,至少2G存储空间,不会在请求中携带数据,读写操作不会阻塞UI,支持事务保证数据一致性。
小剧起始页是一款个性化很强的小网站,设计的目标就是要千人千面,每个人都可以按照自己的习惯排版自己的起始页。根据自己的工作、学习、娱乐等喜好编排自己的桌面和书签库。
如果你有过服务端的开发经验就会发现,要实现这个特点必须要有完善的用户系统,并且配合多张服务端数据表来承载用户和书签数据。
但这种模式并不是小剧想要的。
一来每个人的书签数据相对敏感,可能不会信任我这个独立的小程序员,担心我会时不时的偷窥你存了些什么。
再者用户很可能借助于小剧起始页存储黄暴恐的影音链接,这种架构下小剧势必要承担数据的存储工作,也是个不小的法律风险。
最重要的原因就和小剧起始页无关了。在开发讯飞文档 Web 版的时候小剧就想尝试离线数据存储了,可是鉴于实现的复杂度以及投入产出比,Web 版的离线存储一直没有机会尝试。
因此并不是小剧起始页选择了用 IndexedDB,而是因为小剧想要学习 IndexedDB,结合其他想尝试、学习的技能,才有了现如今的小剧起始页。
前面介绍了小剧起始页中使用离线数据存储的背景,离线数据存储的逻辑在项目中占了比较大的比重。下面就从数据库的基础结构组织、数据库的设计、存取逻辑实现来介绍 IndexedDB 在小剧起始页中的应用。
关于离线数据相关的具体代码实现,在 /src/database/ 目录内,可以打开代码参照着介绍阅读。
┣┳ database
┣┳ entity
┃┗━ bookmark.ts
┣┳ manager
┃┗━ bookmark-manager.ts
┣┳ services
┃┗━ bookmark-service.ts
┣┳ utils
┃┣━ bookmark-query-matches.ts
┃┗━ init-bookmark-to-db.ts
┗━ db.ts
数据管理目录划分的经验来自于前东家,在开发客户端时的学到的,这是比较经典的数据管理封装模型。
entity 用来定义数据实体,对应到小剧起始页中,目前只有书签数据,具体定义了图标的基础数据类型和在 IndexedDB 中的实现。
manager 用来提供对实体的访问,小剧起始页里主要体现在书签数据的增删改查、排序、导入、清空等操作。
services 和 mannager 很像,所有的方法几乎一一对应。不同的是 manager 仅提供给 services 调用,而 services 则是暴露给业务使用的代码。
utils 定义了一些工具方法,db.ts 定义了数据库连接公共方法。
这样的分层设计使得逻辑更清晰,后期更换、扩展数据存储逻辑时也更容易。
例如增加在线同步数据逻辑时,仅需要增加 bookmark-online-manger.ts
文件,在 services
中分别调用不同的 manager 即可实现兼容。
目前小剧起始页中仅用到一张表,用于存储用户书签数据,具体的列为:
今天不展开介绍 IndexedDB 的具体使用方法,仅仅以获取一条书签数据为例,粗略描述数据库存取的逻辑。
bookmark-service.ts
export function bookmarkGetService(bookmarkId: string) {
return getIDBRequest().then((db: IDBDatabase) => {
return bookmarkGetManager(db, bookmarkId)
})
}
bookmark-manager.ts
export function bookmarkGetManager(db: IDBDatabase, bookmarkId: string): Promise<Bookmark> {
return new Promise((resolve, reject) => {
const transaction = db.transaction(['bookmark'])
const objectStore = transaction.objectStore('bookmark')
const request = objectStore.get(bookmarkId)
request.onsuccess = function () {
if (request.result) {
const bookmark = new Bookmark(request.result)
// 数据读取成功
resolve(bookmark)
} else {
const error = new Error('数据读取失败')
reject(error)
}
}
request.onerror = function () {
// 数据写入失败
const error = new Error('数据写入失败')
// error.__detail = event
reject(error)
}
})
}
db.ts
import { bookmarkEntityInit } from './entity/bookmark'
function getIDBObject() {
return window.indexedDB ||
window.mozIndexedDB ||
window.webkitIndexedDB ||
window.msIndexedDB
}
export function getIDBRequest(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const indexedDB = getIDBObject()
const request = indexedDB.open('data-store', 1)
request.onerror = function() {
const error = new Error('建立数据库连接失败!')
// error.__detail = event
reject(error)
}
request.onerror = function() {
const error = new Error('建立数据库连接失败!')
// error.__detail = event
reject(error)
}
request.onsuccess = function() {
resolve(request.result)
}
request.onupgradeneeded = function(event) {
const db = request.result
const target = event.target as CustomIDBTransactionEventTarget
// db = event.target.result;
if (!target) {
reject(new Error('could not find target'))
} else {
const transaction = target.transaction
transaction.oncomplete = function() {
resolve(db)
}
// 此处处理数据库初始化、升级逻辑
bookmarkEntityInit(db)
}
}
})
}
如果你有做过服务端或者客户端开发,一定对数据库升降级不陌生。
随着服务、应用的升级迭代,数据库在必要的时候需要增删字段或者做大面积结构调整。在遇到这类改动时,势必要处理数据库的升降级。
对于服务端来说,数据库的升降级相对简单一点,因为数据库就在自己手中,随着服务的升级数据库做对应的调整即可完成。而且非极端情况不需要处理数据库的降级,更不存在一次操作数据库跨越好几个版本的升降级。
客户端相对麻烦一点,因为应用的数据一般存放在设备的用户目录下。当应用打开时,你不确定他是当前设备的新用户,还是刚跳过很多个版本升级过来的老用户。再极端一点,或者是从很新的版本降级到的一个旧版本。
如果版本差之间存在一次或多次数据库升级操作,不处理数据库的迁移操作,很容易造成应用的崩溃或者数据存取的异常。
在使用 IndexedDB 时遇到的问题,和客户端面对本地数据库时几乎是一致的,唯一的差异是,正常情况下 IndexedDB 仅需要处理数据库的升级即可,很少需要处理数据库的降级。因为作为 Web 应用,用户很难将版本长时间固定下来。
正因为这个原因,在 IndexedDB 项目的第一个版本里,是不需要处理数据库升降级相关的操作的。
而小剧起始页虽然迭代了很多版本,数据结构仍然稳定在最初的版本,所以关于数据库升降级操作小剧仅仅知道很重要、需要做,但是并没有实际操作经验。
希望后面随着版本的迭代有机会处理这部分的逻辑。
经过前面对 IndexedDB 开发迭代的介绍,以及对执行逻辑的梳理,整个实现过程已经简化很多了。然而你若是实际上手尝试,会发现还是挺麻烦的。
因为此次小剧使用 IndexedDB的目的,是以探索学习为主,所以更多的实现是裸调 IndexedDB API。如果你在业务中对 IndexedDB 有使用需求,大可不必一行行代码硬撸。
更容易上手的工具
其实在早期对 IndexedDB 调研的时候,就发现有一款叫做 Dexie 的 IndexedDB 连接库,可以很方便的进行数据结构的定义,数据记录的存取,以及相关事件的拦截。
如果你是以学习尝试为主,建议你研究下 IndexedDB。如果你想深入开发离线存储,了解 IndexedDB 的更多细节可能对你更有帮助。但如果你仅仅是项目中需要用到数据库的离线存储,可能 Dexie 会在开发效率上给你更多的保障。
Update: 2023-06-29
关于数据库升级部分,这里有一份补充文章: 《小剧起始页 IndexedDB 数据库升级记录》。