Skip to content

使用 IndexedDB 保存和加载游戏状态

IndexedDB 是在浏览器中持久化游戏数据的最佳选择。它可以处理大量数据、支持离线使用,而且不会阻塞主线程。

1) 为什么选择 IndexedDB 而不是 localStorage?

功能localStorageIndexedDB
存储上限约 5-10 MB50+ MB(通常可达 GB 级)
数据类型仅字符串对象、Blob、数组
异步否(会阻塞)
索引查询

对于游戏,IndexedDB 几乎总是正确的选择。

2) 打开数据库

js
function openGameDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('MyGame', 1)
    
    request.onerror = () => reject(request.error)
    request.onsuccess = () => resolve(request.result)
    
    request.onupgradeneeded = (event) => {
      const db = event.target.result
      
      // 创建存储区
      if (!db.objectStoreNames.contains('saves')) {
        db.createObjectStore('saves', { keyPath: 'slot' })
      }
      if (!db.objectStoreNames.contains('settings')) {
        db.createObjectStore('settings', { keyPath: 'key' })
      }
    }
  })
}

3) 保存游戏状态

js
async function saveGame(slot, gameState) {
  const db = await openGameDB()
  
  return new Promise((resolve, reject) => {
    const tx = db.transaction('saves', 'readwrite')
    const store = tx.objectStore('saves')
    
    const saveData = {
      slot,
      state: gameState,
      timestamp: Date.now(),
    }
    
    const request = store.put(saveData)
    request.onsuccess = () => resolve()
    request.onerror = () => reject(request.error)
  })
}

4) 加载游戏状态

js
async function loadGame(slot) {
  const db = await openGameDB()
  
  return new Promise((resolve, reject) => {
    const tx = db.transaction('saves', 'readonly')
    const store = tx.objectStore('saves')
    
    const request = store.get(slot)
    request.onsuccess = () => resolve(request.result?.state || null)
    request.onerror = () => reject(request.error)
  })
}

5) 列出所有存档

js
async function listSaves() {
  const db = await openGameDB()
  
  return new Promise((resolve, reject) => {
    const tx = db.transaction('saves', 'readonly')
    const store = tx.objectStore('saves')
    
    const request = store.getAll()
    request.onsuccess = () => resolve(request.result)
    request.onerror = () => reject(request.error)
  })
}

6) 删除存档

js
async function deleteSave(slot) {
  const db = await openGameDB()
  
  return new Promise((resolve, reject) => {
    const tx = db.transaction('saves', 'readwrite')
    const store = tx.objectStore('saves')
    
    const request = store.delete(slot)
    request.onsuccess = () => resolve()
    request.onerror = () => reject(request.error)
  })
}

7) 完整的 GameStorage 类

js
class GameStorage {
  constructor(dbName = 'GameData', version = 1) {
    this.dbName = dbName
    this.version = version
    this.db = null
  }
  
  async init() {
    this.db = await this.openDB()
  }
  
  openDB() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.version)
      request.onerror = () => reject(request.error)
      request.onsuccess = () => resolve(request.result)
      request.onupgradeneeded = (e) => {
        const db = e.target.result
        if (!db.objectStoreNames.contains('saves')) {
          db.createObjectStore('saves', { keyPath: 'slot' })
        }
        if (!db.objectStoreNames.contains('settings')) {
          db.createObjectStore('settings', { keyPath: 'key' })
        }
        if (!db.objectStoreNames.contains('assets')) {
          db.createObjectStore('assets', { keyPath: 'url' })
        }
      }
    })
  }
  
  async save(slot, data) {
    const tx = this.db.transaction('saves', 'readwrite')
    tx.objectStore('saves').put({ slot, data, timestamp: Date.now() })
    return tx.complete
  }
  
  async load(slot) {
    const tx = this.db.transaction('saves', 'readonly')
    const result = await this.promisify(tx.objectStore('saves').get(slot))
    return result?.data || null
  }
  
  async setSetting(key, value) {
    const tx = this.db.transaction('settings', 'readwrite')
    tx.objectStore('settings').put({ key, value })
  }
  
  async getSetting(key, defaultValue = null) {
    const tx = this.db.transaction('settings', 'readonly')
    const result = await this.promisify(tx.objectStore('settings').get(key))
    return result?.value ?? defaultValue
  }
  
  promisify(request) {
    return new Promise((resolve, reject) => {
      request.onsuccess = () => resolve(request.result)
      request.onerror = () => reject(request.error)
    })
  }
}

8) 存储二进制数据(纹理、音频)

IndexedDB 可以处理 Blob 和 ArrayBuffer:

js
async function cacheAsset(url, blob) {
  const tx = db.transaction('assets', 'readwrite')
  tx.objectStore('assets').put({ url, blob, cached: Date.now() })
}

async function getCachedAsset(url) {
  const tx = db.transaction('assets', 'readonly')
  const result = await promisify(tx.objectStore('assets').get(url))
  return result?.blob || null
}

9) 自动保存模式

js
class AutoSave {
  constructor(storage, interval = 60000) {
    this.storage = storage
    this.interval = interval
    this.timer = null
    this.dirty = false
  }
  
  markDirty() {
    this.dirty = true
  }
  
  start(getState) {
    this.timer = setInterval(async () => {
      if (this.dirty) {
        await this.storage.save('autosave', getState())
        this.dirty = false
        console.log('已自动保存')
      }
    }, this.interval)
  }
  
  stop() {
    clearInterval(this.timer)
  }
}

10) 错误处理与回退方案

js
async function safeLoad(slot, defaultState) {
  try {
    const saved = await loadGame(slot)
    if (saved) {
      // 根据需要验证或迁移旧存档
      return migrateSave(saved)
    }
  } catch (err) {
    console.warn('加载存档失败:', err)
  }
  return defaultState
}

function migrateSave(save) {
  // 处理旧存档格式
  if (!save.version) {
    save.version = 1
    save.settings = save.settings || {}
  }
  return save
}

11) 防止存档被清理

IndexedDB 中的数据并不保证会永久保留。默认情况下,来源使用“尽力而为”存储;当磁盘空间不足时,浏览器可能会清理这些数据。在 Safari/WebKit 中,如果用户有一段时间未与你的网站互动,数据也可能被清理。对于游戏存档来说,这恰恰是你最不希望丢失的数据。

请求持久化存储,使浏览器不会在缺少用户明确操作的情况下清除你的数据:

js
async function makeStoragePersistent() {
  if (navigator.storage && navigator.storage.persist) {
    const persisted = await navigator.storage.persist()
    console.log(persisted ? '存档已受到保护,不会被自动清理' : '存储空间紧张时,存档可能会被清理')
    return persisted
  }
  return false
}

浏览器会根据互动情况来决定是否批准请求,例如用户与你的网站互动频率,以及网站是否已安装为 PWA。因此,不要假定请求总会成功。在写入大型存档或缓存资源之前,你还可以检查可用空间:

js
async function checkStorage() {
  if (navigator.storage && navigator.storage.estimate) {
    const { usage, quota } = await navigator.storage.estimate()
    console.log(`已使用 ${usage} 字节,总配额为 ${quota} 字节`)
  }
}

这两个 API 都需要安全上下文(HTTPS 或 localhost)。

相关内容

外部资源