小剧起始页 IndexedDB 数据库升级记录

时间: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 时,参考客户端的开发模式,也需要做数据库版本升级。

二、为什么小剧起始页需要做数据库升级?

2.1、之前的数据结构是什么样的?

在写这篇文章之前,如果你有用过小剧起始页的话,可能你会知道小剧起始页的书签结构和现在是有很大不同的。

在此之前你可以在桌面创建书签、小工具(小工具本质上也是书签),也可以创建书签分组。

还可以在 小书房 这个特殊的书签下,以树形结构管理书签。

这种模式可以很好的组织书签结构,做为起始页能很方便的通过排列常用链接,来定制日常使用的界面。

实际上线一年多以来,小剧起始页已经越来越多的被身边的小伙伴所使用。这套桌面书签管理模式也被很多小伙伴夸了又夸。

2.2、这个结构有什么限制?

随着书签越来越多,最近在使用小剧起始页的时候,一直暗暗觉得用起来不是特别爽,但又说不上来是哪里不够好。直到上个月(2023/05)才突然意识到这个点是什么。

这个点就是就是场景化。

以小剧自己举例,目前小剧起始页上堆满了各种图标。其中绝大多数是工作中需要用到个各个平台链接、很多开发环境的地址,零散项目需要用到的资料。

还有一部分是学习所用的一些网址,和各工具的官网链接,以及少部分消遣平台。

根本原因就是小剧起始页只有一个桌面,所有图标都要在这一个地方去陈列。

如果能分场景管理,将工作的时候需要用到的书签放置在一个桌面,摸鱼的时候用到的书签集中在一个桌面,学习时用到的网址集中在一起。既能保证任何时间内桌面的干净整洁,又能在不同场景下更方便去查找和管理书签。

2.3、如何去实现新的结构?

小剧起始页的树形结构的实现还算简单,所有节点通过 parent 字段描述上级节点。

通过给定节点 ID,递归查询下级节点就可以生成出一棵树形结构。

在此之前小剧起始页的所有 parent 字段为空的节点,均被视作桌面上的书签。parentroot 的节点被视作小书房下的书签。

羸弱的 parent 定义很难承载多桌面的数据结构。

通过对数据结构的整理,决定对书签树形结构重新规划,具体如下:

数据结构优化图

这种破坏性的改动需要修改库表中的数据,因此需要先行对数据库进行升级后,才能在上层,对交互模式做优化。

三、此次数据库升级,做了哪些工作?

如上面 2.3 部分描述,此次小剧起始页的数据库升级,其实并不需要对库表结构作调整。仅需要对现存数据的部分字段做调整,再增加两个桌面节点,即可完成。

但是前期封装的 IndexedDB 代码并未对数据库升级逻辑做任何适配,因此需要做好数据库升级的基础建设后,才能将此次的数据库升级逻辑进行配置。

此部分的代码均在下面的目录下,可以参考代码一起阅读。

3.1、IndexedDB 数据库升级基础支持

这里是简化后的打开数据库连接的逻辑,其中 onblockedonupgradeneeded 是和数据库升级相关的两个事件。

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)
}

3.2、版本升级逻辑开发

前面的基础建设准备好了,就可以正式编写当前版本的升级逻辑了。

此次版本升级因为不涉及到对库、表结构的改动,因此代码对常规的数据库升级不具备参考价值,可以选择性跳过。

// 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)
}

3.3、周边逻辑处理

自此数据库升级的逻辑就全部写完了,但是一些查询、修改等操作针对的还是老版本的数据库。

例如查询桌面图标的方法依旧还是使用的 parent 为空,而非 desktop-default

将这些逻辑处理完毕,再完整走一遍小剧起始页的流程,确认对业务没有其他影响,数据库升级逻辑就算完成了。

四、其他的小问题

4.1、为什么没有数据库降级 ?

这个场景可以被构造出来,例如线上使用版本 5,用户访问 5 这个版本后,数据库就被存储到浏览器中。然后再用比 5 更小的版本发布前端代码,比如 4。

用户再次访问时,就会出现 WEB APP 支持版本为 4,而浏览器中的旧版本为 5 的情况。

可能在客户端开发时会处理此问题,因为在 CS 模式下,用户可以用不同版本的 APP 反复覆盖安装。而作为 BS 模式下的 WEB 开发,我们可以控制 WEB APP 的版本,保证版本永远向上累加而非降低。

事实上这这个场景 IndexedDB 会直接报错,不会进入到 onupgradeneeded 逻辑中。实际业务在回滚代码的时候,如果有涉及到跨数据库版本,需要格外注意这一点。

4.2、有没有更省事一点的做法 ?

数据库升级是为了保证本地数据安全性,避免丢失数据而进行的操作。

如果你的业务中,存储在本地的数据是不重要的 LOG 日志,或者已经同步到服务端的缓存数据,在遇到数据库版本不一致的时候,可以无脑丢弃,没必要折腾自己也折腾浏览器。

但如果涉及到用户数据需要严格同步到服务端,但又可能因各种原因尚未同步至服务端,那还是得老老实实进行数据库升级。

五、小剧起始页升级后会增加哪些功能?

如前文 2.2、这个结构有什么限制? 提到的,此次升级数据库是为了为多桌面开发作准备的。

因此小剧起始页 IndexedDB 数据库升级完成后,第一个功能就是增加多桌面支持。事实上截止本文发布时,多桌面已经上线,分别为:在工作、在摸鱼。

你可以在这两个桌面上进行组织图标,更换壁纸,不同桌面的壁纸是相互独立的。

接下来的时间,小剧会开发自定义桌面功能,提供桌面的创建、删除、更名、调整顺序等功能。

也许下周,也许下个月或更久,这个不做保证,除非你打钱催我,期待更自由的桌面管理功能早日上线。