时间:2023-06-24 作者:剧中人
一年前,小剧曾总结过在小剧起始页中关于 IndexedDB 的使用经验。提到了前端本地持久化常见的类型和方案,以及为什么小剧起始页需要用 IndexedDB。用了些简单的代码片段来介绍 IndexedDB 的使用方式。
感兴趣的话可以查看这篇文章《小剧起始页,离线数据篇》。
如果你还没有使用过小剧起始页,可以点击这个链接 https://e.bh-lay.com/。
而小剧起始页虽然迭代了很多版本,数据结构仍然稳定在最初的版本,所以关于数据库升降级操作小剧仅仅知道很重要、需要做,但是并没有实际操作经验。
希望后面随着版本的迭代有机会处理这部分的逻辑。
这段话是上面文章里的原文,解释了在当时版本里对 IndexedDB 的版本升级并没有处理的原因。因此你在阅读的这篇文章可以视作离线数据篇的补充。
相信很多同学会有这个疑问。
作为传统前端开发,代码向来是 always online 模式的最新版本,根本不用关心数据库的版本与升级。
如果你有服务端开发经验,数据库的相关改动也只是在一次次的上线操作中被执行,最多写一些回滚数据库的脚本。
可能只有作为客户端开发的同学,对数据库升级操作不陌生。
举个例子:
你正在操盘两个产品,一个是 WEB APP,一个是 IOS APP。在历次迭代中对数据结构都有过三次大的改动。
WEB 端每次调整好代码,适配完新的版本,连同服务端一起上线就完事了。此类操作重复三次,不会有什么问题。
客户端就不一样,因为需要在弱网、离线情况下提供良好的使用体验,绝大多数的 APP 需要缓存部分数据以供离线时使用。参考微信在飞行模式下依然可以查看聊天记录。
或者有一部分 APP 的数据完全就是离线的,例如本地版本的小游戏。
问题来了,APP 启动时,有可能是设备第一次安装该应用,也有可能是从最早,或者中间的某个版本,缓存了很多离线数据之后,升级而来的。
因为新版本 APP 的处理逻辑和旧版本的数据库已经有了很大的差异,例如将某个 time 字段更新成了 createTime。新版本的 APP 并不能兼容老版本的数据库。
所以需要在 APP 启动时,检查当前 APP 支持的数据库版本与当前数据库版本,如果不一致,就需要进行数据库升级操作。
Web 端在支持离线数据库之后,和客户端要解决的数据库版本问题几乎一模一样。因此在开发 IndexedDB 时,参考客户端的开发模式,也需要做数据库版本升级。
在写这篇文章之前,如果你有用过小剧起始页的话,可能你会知道小剧起始页的书签结构和现在是有很大不同的。
在此之前你可以在桌面创建书签、小工具(小工具本质上也是书签),也可以创建书签分组。
还可以在 小书房 这个特殊的书签下,以树形结构管理书签。
这种模式可以很好的组织书签结构,做为起始页能很方便的通过排列常用链接,来定制日常使用的界面。
实际上线一年多以来,小剧起始页已经越来越多的被身边的小伙伴所使用。这套桌面书签管理模式也被很多小伙伴夸了又夸。
随着书签越来越多,最近在使用小剧起始页的时候,一直暗暗觉得用起来不是特别爽,但又说不上来是哪里不够好。直到上个月(2023/05)才突然意识到这个点是什么。
这个点就是就是场景化。
以小剧自己举例,目前小剧起始页上堆满了各种图标。其中绝大多数是工作中需要用到个各个平台链接、很多开发环境的地址,零散项目需要用到的资料。
还有一部分是学习所用的一些网址,和各工具的官网链接,以及少部分消遣平台。
根本原因就是小剧起始页只有一个桌面,所有图标都要在这一个地方去陈列。
如果能分场景管理,将工作的时候需要用到的书签放置在一个桌面,摸鱼的时候用到的书签集中在一个桌面,学习时用到的网址集中在一起。既能保证任何时间内桌面的干净整洁,又能在不同场景下更方便去查找和管理书签。
小剧起始页的树形结构的实现还算简单,所有节点通过 parent
字段描述上级节点。
通过给定节点 ID,递归查询下级节点就可以生成出一棵树形结构。
在此之前小剧起始页的所有 parent
字段为空的节点,均被视作桌面上的书签。parent
为 root
的节点被视作小书房下的书签。
羸弱的 parent
定义很难承载多桌面的数据结构。
通过对数据结构的整理,决定对书签树形结构重新规划,具体如下:
root
更名为 bookmark-collection
root
虚拟节点,作为所有节点的共同根结点desktop
虚拟节点,用来管理多桌面结构desktop-default
、desktop-fish
节点,作为两个初始桌面parent
字段为空的节点,指向 desktop-default
桌面这种破坏性的改动需要修改库表中的数据,因此需要先行对数据库进行升级后,才能在上层,对交互模式做优化。
如上面 2.3 部分描述,此次小剧起始页的数据库升级,其实并不需要对库表结构作调整。仅需要对现存数据的部分字段做调整,再增加两个桌面节点,即可完成。
但是前期封装的 IndexedDB 代码并未对数据库升级逻辑做任何适配,因此需要做好数据库升级的基础建设后,才能将此次的数据库升级逻辑进行配置。
此部分的代码均在下面的目录下,可以参考代码一起阅读。
这里是简化后的打开数据库连接的逻辑,其中 onblocked
和 onupgradeneeded
是和数据库升级相关的两个事件。
onblocked
指有其他页面正在使用数据库,无法进行数据库升级。onupgradeneeded
是当数据库需要进行升级或者初始化时,会进入此回调。export const dbVersioon = 4
const request = window.indexedDB.open('data-store', dbVersioon)
// request.onerror = function() {}
// request.onerror = function() {}
// request.onsuccess = function() {}
request.onblocked = function(event) {
alert('本地数据升级,请关闭全部页面重新打开!');
}
request.onupgradeneeded = async function(event) {
const target = event.target
const db = target.result
if (!target || !db) {
return reject(new Error('could not find target'))
}
const newVersion = event.newVersion || 0
const oldVersion = event.oldVersion || 0
if (oldVersion === 0) {
// 数据库初始化逻辑
// bookmarkEntityInit(db)
} else {
// 数据库升级逻辑
// await bookmarkUpgrade(newVersion, oldVersion, db, target.transaction)
}
}
在 onupgradeneeded
回调内,真实情况会比较复杂。
当前 newVersion
为 4,浏览器内的 oldVersion
有可能是 0、1、2、3 这四种情况。
oldVersion
是 0 的情况比较简单,说明当前浏览器没有旧的数据库,直接初始化即可。
而当 oldVersion
是 1、2、3 时,我们有两种方案去实现数据库的升级。
看起来方案一最高效,然而真实项目中往往是方案二最实用。
因为在实际项目开发中,一次版本发布中最多跨一个数据库版本。因此我们最清楚上一个版本和当前版本的差异,在步步高升的走台阶过程中,开发人员的学习成本是最低的,无需考虑历史上所有的版本情况。
另一个原因是,方案一实际运行效率虽高,但开发效率极低且出错率很高。
例如下一次数据库版本升级到 5 时,之前写的所有升级函数都无法再使用,需要重新去写 1、2、3、4 升级到 5 的脚本。
基于上面提到的原因,方案二就成了首选,我们可以用一段非常简单的逻辑去维护版本升级的步骤。
import one from './1'
import two from './2'
import three from './3'
const upgradeVersionFnMap = {
// 1-> 2
1: one,
// 2 -> 3
2: two,
// 2 -> 4
3: three,
}
export async function bookmarkUpgrade (newVersion, oldVersion, db, transaction) {
async function upgradeNext(currentVersion) {
const currentUpgradeFn = upgradeVersionFnMap[currentVersion]
if (currentUpgradeFn) {
await currentUpgradeFn(db, transaction)
}
const nextVersion = currentVersion + 1
if (nextVersion < newVersion) {
await upgradeNext(nextVersion)
}
}
await upgradeNext(oldVersion)
}
前面的基础建设准备好了,就可以正式编写当前版本的升级逻辑了。
此次版本升级因为不涉及到对库、表结构的改动,因此代码对常规的数据库升级不具备参考价值,可以选择性跳过。
// upgrade: 1 -> 2
// 将第一版本中 bookmark 的 parentId 转换为标准结构的 parentId
function upgradeParentId(currentBookmark, objectStore) {
return new Promise((resolve) => {
let newParentId = ''
if (!currentBookmark.parent) {
// 将为空的 parentId,强制指定为默认桌面
newParentId = 'desktop-default'
} else if (currentBookmark.parent === 'root') {
// 将为 root 的 parentId,强制指定为书签收藏
newParentId = 'bookmark-collection'
}
if (newParentId) {
currentBookmark.parent = newParentId
const putRequest = objectStore.put(currentBookmark)
putRequest.onsuccess = function () {
resolve(true)
}
putRequest.onerror = function () {
resolve(false)
}
} else {
resolve(true)
}
})
}
function fixAllParentId(objectStore) {
return new Promise((resolve) => {
const request = objectStore.openCursor()
request.onsuccess = function (event) {
if (!event.target) {
resolve(false)
return
}
const cursor = event.target.result
if (cursor) {
upgradeParentId(cursor.value, objectStore).finally(() => {
cursor.continue()
})
} else {
resolve(true)
}
}
request.onerror = function () {
resolve(false)
}
})
}
function addBookmark(objectStore, data) {
return new Promise((resolve) => {
const request = objectStore.add(data)
request.onsuccess = function () {
resolve(true)
}
request.onerror = function () {
resolve(false)
}
})
}
async function addDesktopDefaultBookmark(objectStore) {
await addBookmark(objectStore, {
id: 'desktop-default',
parent: 'desktop',
name: '在搬砖',
undercoat: '#177cb0',
type: 4,
size: 1,
icon: 'mdi:worker',
sort: 0,
value: '',
desc: ''
})
await addBookmark(objectStore, {
id: 'desktop-fish',
parent: 'desktop',
name: '在摸鱼',
undercoat: '#4caf50',
type: 4,
size: 1,
icon: 'mdi:fish',
sort: 0,
value: '',
desc: ''
})
}
export default async function bookmarkUpgrade (db, transaction) {
if (!db.objectStoreNames.contains('bookmark')) {
return false
}
const objectStore = transaction.objectStore('bookmark')
await fixAllParentId(objectStore)
await addDesktopDefaultBookmark(objectStore)
}
自此数据库升级的逻辑就全部写完了,但是一些查询、修改等操作针对的还是老版本的数据库。
例如查询桌面图标的方法依旧还是使用的 parent
为空,而非 desktop-default
。
将这些逻辑处理完毕,再完整走一遍小剧起始页的流程,确认对业务没有其他影响,数据库升级逻辑就算完成了。
这个场景可以被构造出来,例如线上使用版本 5,用户访问 5 这个版本后,数据库就被存储到浏览器中。然后再用比 5 更小的版本发布前端代码,比如 4。
用户再次访问时,就会出现 WEB APP 支持版本为 4,而浏览器中的旧版本为 5 的情况。
可能在客户端开发时会处理此问题,因为在 CS 模式下,用户可以用不同版本的 APP 反复覆盖安装。而作为 BS 模式下的 WEB 开发,我们可以控制 WEB APP 的版本,保证版本永远向上累加而非降低。
事实上这这个场景 IndexedDB 会直接报错,不会进入到 onupgradeneeded
逻辑中。实际业务在回滚代码的时候,如果有涉及到跨数据库版本,需要格外注意这一点。
数据库升级是为了保证本地数据安全性,避免丢失数据而进行的操作。
如果你的业务中,存储在本地的数据是不重要的 LOG 日志,或者已经同步到服务端的缓存数据,在遇到数据库版本不一致的时候,可以无脑丢弃,没必要折腾自己也折腾浏览器。
但如果涉及到用户数据需要严格同步到服务端,但又可能因各种原因尚未同步至服务端,那还是得老老实实进行数据库升级。
如前文 2.2、这个结构有什么限制? 提到的,此次升级数据库是为了为多桌面开发作准备的。
因此小剧起始页 IndexedDB 数据库升级完成后,第一个功能就是增加多桌面支持。事实上截止本文发布时,多桌面已经上线,分别为:在工作、在摸鱼。
你可以在这两个桌面上进行组织图标,更换壁纸,不同桌面的壁纸是相互独立的。
接下来的时间,小剧会开发自定义桌面功能,提供桌面的创建、删除、更名、调整顺序等功能。
也许下周,也许下个月或更久,这个不做保证,除非你打钱催我,期待更自由的桌面管理功能早日上线。