Vue 3 与 IndexedDB 深度集成

概述

IndexedDB 是一种浏览器内置的 NoSQL 数据库,提供了强大的客户端存储能力,适用于需要存储大量结构化数据的 Web 应用。在 Vue 3 应用中集成 IndexedDB 可以实现离线数据存储、本地缓存、状态持久化、复杂查询等功能,显著提升应用的性能、可靠性和用户体验。本教程将深入探讨 IndexedDB 的核心概念、高级特性以及如何在 Vue 3 中高效使用 IndexedDB。

核心知识

1. IndexedDB 基本概念

  • 作用:提供大量结构化数据的客户端存储能力
  • 特点
    • 支持存储大量数据(理论上无上限)
    • 支持事务处理
    • 支持索引和复杂查询
    • 异步 API,不阻塞主线程
    • 支持存储多种数据类型(对象、数组、Blob、File 等)
    • 支持版本控制
  • 数据模型
    • 数据库(Database):包含多个对象存储
    • 对象存储(Object Store):类似关系数据库的表
    • 索引(Index):用于加速查询
    • 事务(Transaction):确保数据一致性
    • 游标(Cursor):用于遍历对象存储中的数据
    • 键(Key):唯一标识对象存储中的记录

2. IndexedDB 操作流程

  • 打开数据库:使用 indexedDB.open() 方法打开或创建数据库
  • 版本管理:通过 onupgradeneeded 事件处理数据库结构变更
  • 创建对象存储:在版本升级事件中创建或修改对象存储
  • 创建索引:为对象存储创建索引以加速查询
  • 事务处理:使用 transaction() 方法创建事务
  • CRUD 操作
    • add():添加新记录
    • put():添加或更新记录
    • get():根据键获取记录
    • delete():删除记录
    • clear():清空对象存储
  • 查询操作
    • getAll():获取所有记录
    • getAllKeys():获取所有键
    • count():计数
    • 使用游标遍历数据
    • 使用索引进行范围查询

3. IndexedDB 高级特性

  • 事务类型
    • readwrite:可读写事务
    • readonly:只读事务
    • versionchange:版本变更事务
  • 索引类型
    • 普通索引:每个键对应一条记录
    • 唯一索引:每个键只允许对应一条记录
    • 复合索引:基于多个属性创建索引
    • 多值索引:一个键对应多条记录
  • 游标类型
    • 普通游标:顺序遍历
    • 索引游标:基于索引遍历
    • 唯一游标:遍历唯一键
    • 键范围游标:基于键范围查询
  • 并发控制:通过事务机制实现并发控制
  • 错误处理:完善的错误处理机制

4. IndexedDB 与其他存储方案比较

特性 IndexedDB LocalStorage SessionStorage Cookies
存储大小 理论无上限 约 5MB 约 5MB 约 4KB
数据类型 多种类型 仅字符串 仅字符串 仅字符串
异步 API
事务支持
索引支持
查询能力 强大
适用场景 大量结构化数据 简单键值对 会话数据 服务器通信

前端实现(Vue 3)

4.1 基础 IndexedDB 封装

// utils/indexedDB.js

class IndexedDBWrapper {
  constructor(dbName, version = 1) {
    this.dbName = dbName;
    this.version = version;
    this.db = null;
    this.connections = 0;
  }
  
  // 打开数据库
  async open() {
    if (this.db) {
      this.connections++;
      return this.db;
    }
    
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.version);
      
      // 数据库版本升级
      request.onupgradeneeded = (event) => {
        const db = event.target.result;
        this.onUpgrade(db, event.oldVersion, this.version);
      };
      
      // 数据库打开成功
      request.onsuccess = (event) => {
        this.db = event.target.result;
        this.connections++;
        resolve(this.db);
      };
      
      // 数据库打开失败
      request.onerror = (event) => {
        reject(event.target.error);
      };
    });
  }
  
  // 数据库升级回调(需要子类实现)
  onUpgrade(db, oldVersion, newVersion) {
    throw new Error('onUpgrade 方法必须被实现');
  }
  
  // 关闭数据库
  close() {
    this.connections--;
    if (this.connections <= 0 && this.db) {
      this.db.close();
      this.db = null;
      this.connections = 0;
    }
  }
  
  // 删除数据库
  async deleteDatabase() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.deleteDatabase(this.dbName);
      
      request.onsuccess = () => {
        resolve();
      };
      
      request.onerror = (event) => {
        reject(event.target.error);
      };
    });
  }
  
  // 创建事务
  transaction(storeNames, mode = 'readonly') {
    if (!this.db) {
      throw new Error('数据库未打开');
    }
    return this.db.transaction(storeNames, mode);
  }
  
  // 获取对象存储
  getObjectStore(storeName, mode = 'readonly') {
    const transaction = this.transaction(storeName, mode);
    return transaction.objectStore(storeName);
  }
  
  // 添加记录
  async add(storeName, data) {
    return new Promise((resolve, reject) => {
      const store = this.getObjectStore(storeName, 'readwrite');
      const request = store.add(data);
      
      request.onsuccess = (event) => {
        resolve(event.target.result);
      };
      
      request.onerror = (event) => {
        reject(event.target.error);
      };
    });
  }
  
  // 添加或更新记录
  async put(storeName, data) {
    return new Promise((resolve, reject) => {
      const store = this.getObjectStore(storeName, 'readwrite');
      const request = store.put(data);
      
      request.onsuccess = (event) => {
        resolve(event.target.result);
      };
      
      request.onerror = (event) => {
        reject(event.target.error);
      };
    });
  }
  
  // 根据键获取记录
  async get(storeName, key) {
    return new Promise((resolve, reject) => {
      const store = this.getObjectStore(storeName);
      const request = store.get(key);
      
      request.onsuccess = (event) => {
        resolve(event.target.result);
      };
      
      request.onerror = (event) => {
        reject(event.target.error);
      };
    });
  }
  
  // 根据键删除记录
  async delete(storeName, key) {
    return new Promise((resolve, reject) => {
      const store = this.getObjectStore(storeName, 'readwrite');
      const request = store.delete(key);
      
      request.onsuccess = () => {
        resolve();
      };
      
      request.onerror = (event) => {
        reject(event.target.error);
      };
    });
  }
  
  // 清空对象存储
  async clear(storeName) {
    return new Promise((resolve, reject) => {
      const store = this.getObjectStore(storeName, 'readwrite');
      const request = store.clear();
      
      request.onsuccess = () => {
        resolve();
      };
      
      request.onerror = (event) => {
        reject(event.target.error);
      };
    });
  }
  
  // 获取所有记录
  async getAll(storeName, query = null, count = null) {
    return new Promise((resolve, reject) => {
      const store = this.getObjectStore(storeName);
      const request = store.getAll(query, count);
      
      request.onsuccess = (event) => {
        resolve(event.target.result);
      };
      
      request.onerror = (event) => {
        reject(event.target.error);
      };
    });
  }
  
  // 使用索引获取记录
  async getByIndex(storeName, indexName, key) {
    return new Promise((resolve, reject) => {
      const store = this.getObjectStore(storeName);
      const index = store.index(indexName);
      const request = index.get(key);
      
      request.onsuccess = (event) => {
        resolve(event.target.result);
      };
      
      request.onerror = (event) => {
        reject(event.target.error);
      };
    });
  }
  
  // 使用游标遍历记录
  async iterate(storeName, callback, indexName = null, range = null, direction = 'next') {
    return new Promise((resolve, reject) => {
      const store = this.getObjectStore(storeName);
      let request;
      
      if (indexName) {
        const index = store.index(indexName);
        request = index.openCursor(range, direction);
      } else {
        request = store.openCursor(range, direction);
      }
      
      request.onsuccess = (event) => {
        const cursor = event.target.result;
        if (cursor) {
          callback(cursor.value, cursor.key, cursor);
          cursor.continue();
        } else {
          resolve();
        }
      };
      
      request.onerror = (event) => {
        reject(event.target.error);
      };
    });
  }
}

export default IndexedDBWrapper;

4.2 自定义 IndexedDB 数据库类

// services/todoDatabase.js
import IndexedDBWrapper from '../utils/indexedDB';

class TodoDatabase extends IndexedDBWrapper {
  constructor() {
    // 数据库名称和版本
    super('todoApp', 2);
  }
  
  // 数据库升级处理
  onUpgrade(db, oldVersion, newVersion) {
    console.log(`数据库升级: 从版本 ${oldVersion} 到 ${newVersion}`);
    
    // 版本 1: 创建 todo 对象存储
    if (oldVersion < 1) {
      // 创建 todo 对象存储,使用 autoIncrement 键
      const todoStore = db.createObjectStore('todos', {
        keyPath: 'id',
        autoIncrement: true
      });
      
      // 创建索引
      todoStore.createIndex('by_status', 'status', { unique: false });
      todoStore.createIndex('by_priority', 'priority', { unique: false });
      todoStore.createIndex('by_dueDate', 'dueDate', { unique: false });
      todoStore.createIndex('by_createdAt', 'createdAt', { unique: false });
    }
    
    // 版本 2: 添加用户对象存储
    if (oldVersion < 2) {
      const userStore = db.createObjectStore('users', {
        keyPath: 'id',
        autoIncrement: true
      });
      
      userStore.createIndex('by_email', 'email', { unique: true });
    }
  }
  
  // Todo 相关方法
  async addTodo(todo) {
    const now = new Date().toISOString();
    const todoWithTimestamps = {
      ...todo,
      createdAt: now,
      updatedAt: now
    };
    return this.add('todos', todoWithTimestamps);
  }
  
  async updateTodo(id, updates) {
    const todo = await this.get('todos', id);
    if (!todo) {
      throw new Error(`Todo with id ${id} not found`);
    }
    
    const updatedTodo = {
      ...todo,
      ...updates,
      updatedAt: new Date().toISOString()
    };
    return this.put('todos', updatedTodo);
  }
  
  async deleteTodo(id) {
    return this.delete('todos', id);
  }
  
  async getTodo(id) {
    return this.get('todos', id);
  }
  
  async getAllTodos() {
    return this.getAll('todos');
  }
  
  async getTodosByStatus(status) {
    return new Promise((resolve, reject) => {
      const store = this.getObjectStore('todos');
      const index = store.index('by_status');
      const request = index.getAll(status);
      
      request.onsuccess = (event) => {
        resolve(event.target.result);
      };
      
      request.onerror = (event) => {
        reject(event.target.error);
      };
    });
  }
  
  async getTodosByPriority(priority) {
    return new Promise((resolve, reject) => {
      const store = this.getObjectStore('todos');
      const index = store.index('by_priority');
      const request = index.getAll(priority);
      
      request.onsuccess = (event) => {
        resolve(event.target.result);
      };
      
      request.onerror = (event) => {
        reject(event.target.error);
      };
    });
  }
  
  async getTodosByDateRange(startDate, endDate) {
    return new Promise((resolve, reject) => {
      const store = this.getObjectStore('todos');
      const index = store.index('by_dueDate');
      
      // 创建日期范围
      const range = IDBKeyRange.bound(startDate, endDate);
      const request = index.getAll(range);
      
      request.onsuccess = (event) => {
        resolve(event.target.result);
      };
      
      request.onerror = (event) => {
        reject(event.target.error);
      };
    });
  }
  
  async countTodos() {
    return new Promise((resolve, reject) => {
      const store = this.getObjectStore('todos');
      const request = store.count();
      
      request.onsuccess = (event) => {
        resolve(event.target.result);
      };
      
      request.onerror = (event) => {
        reject(event.target.error);
      };
    });
  }
  
  // User 相关方法
  async addUser(user) {
    return this.add('users', user);
  }
  
  async getUserByEmail(email) {
    return this.getByIndex('users', 'by_email', email);
  }
  
  async updateUser(id, updates) {
    return this.put('users', { id, ...updates });
  }
}

// 导出单例实例
const todoDB = new TodoDatabase();
export default todoDB;

4.3 Vue 3 组件中使用 IndexedDB

<template>
  <div class="todo-app">
    <h2>Vue 3 与 IndexedDB Todo 应用</h2>
    
    <div class="app-header">
      <div class="todo-stats">
        <span>总数: {{ totalTodos }}</span>
        <span>已完成: {{ completedTodos }}</span>
        <span>未完成: {{ pendingTodos }}</span>
      </div>
      <div class="controls">
        <button @click="refreshTodos" :disabled="loading">刷新</button>
        <button @click="clearCompleted" :disabled="loading || completedTodos === 0">清除已完成</button>
      </div>
    </div>
    
    <div class="todo-form">
      <input 
        type="text" 
        v-model="newTodo.title" 
        placeholder="输入新任务..." 
        @keyup.enter="addTodo"
      />
      <select v-model="newTodo.priority">
        <option value="low">低优先级</option>
        <option value="medium">中优先级</option>
        <option value="high">高优先级</option>
      </select>
      <input type="date" v-model="newTodo.dueDate" />
      <button @click="addTodo" :disabled="!newTodo.title.trim()">添加</button>
    </div>
    
    <div class="filters">
      <button 
        v-for="filter in filters" 
        :key="filter.value"
        :class="['filter-btn', activeFilter === filter.value ? 'active' : '']"
        @click="setFilter(filter.value)"
      >
        {{ filter.label }}
      </button>
    </div>
    
    <div class="todo-list">
      <div v-if="loading" class="loading">加载中...</div>
      <div v-else-if="filteredTodos.length === 0" class="empty-state">
        暂无任务,开始添加吧!
      </div>
      <div 
        v-for="todo in filteredTodos" 
        :key="todo.id" 
        class="todo-item"
        :class="{ completed: todo.status === 'completed' }"
      >
        <div class="todo-content">
          <input 
            type="checkbox" 
            v-model="todo.status" 
            :true-value="'completed'" 
            :false-value="'pending'"
            @change="updateTodoStatus(todo)"
          />
          <div class="todo-text">{{ todo.title }}</div>
        </div>
        <div class="todo-meta">
          <span class="priority" :class="todo.priority">{{ todo.priority }}</span>
          <span class="due-date">{{ todo.dueDate }}</span>
          <span class="created-at">{{ formatDate(todo.createdAt) }}</span>
        </div>
        <div class="todo-actions">
          <button @click="deleteTodo(todo.id)" class="delete-btn">删除</button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import todoDB from './services/todoDatabase';

// 状态管理
const todos = ref([]);
const loading = ref(false);
const activeFilter = ref('all');
const newTodo = ref({
  title: '',
  priority: 'medium',
  dueDate: new Date().toISOString().split('T')[0],
  status: 'pending'
});

// 过滤选项
const filters = [
  { label: '全部', value: 'all' },
  { label: '未完成', value: 'pending' },
  { label: '已完成', value: 'completed' },
  { label: '高优先级', value: 'high' },
  { label: '中优先级', value: 'medium' },
  { label: '低优先级', value: 'low' }
];

// 计算属性
const totalTodos = computed(() => todos.value.length);
const completedTodos = computed(() => 
  todos.value.filter(todo => todo.status === 'completed').length
);
const pendingTodos = computed(() => 
  todos.value.filter(todo => todo.status === 'pending').length
);

// 过滤后的任务列表
const filteredTodos = computed(() => {
  switch (activeFilter.value) {
    case 'pending':
      return todos.value.filter(todo => todo.status === 'pending');
    case 'completed':
      return todos.value.filter(todo => todo.status === 'completed');
    case 'high':
    case 'medium':
    case 'low':
      return todos.value.filter(todo => todo.priority === activeFilter.value);
    default:
      return todos.value;
  }
});

// 初始化
onMounted(async () => {
  await loadTodos();
});

// 组件销毁前关闭数据库连接
onBeforeUnmount(() => {
  todoDB.close();
});

// 加载所有任务
const loadTodos = async () => {
  try {
    loading.value = true;
    await todoDB.open();
    todos.value = await todoDB.getAllTodos();
  } catch (error) {
    console.error('加载任务失败:', error);
  } finally {
    loading.value = false;
  }
};

// 刷新任务列表
const refreshTodos = async () => {
  await loadTodos();
};

// 添加新任务
const addTodo = async () => {
  if (!newTodo.value.title.trim()) return;
  
  try {
    loading.value = true;
    await todoDB.addTodo(newTodo.value);
    // 重置表单
    newTodo.value = {
      title: '',
      priority: 'medium',
      dueDate: new Date().toISOString().split('T')[0],
      status: 'pending'
    };
    // 重新加载任务
    await loadTodos();
  } catch (error) {
    console.error('添加任务失败:', error);
  } finally {
    loading.value = false;
  }
};

// 更新任务状态
const updateTodoStatus = async (todo) => {
  try {
    await todoDB.updateTodo(todo.id, { status: todo.status });
  } catch (error) {
    console.error('更新任务状态失败:', error);
    // 恢复原状态
    todo.status = todo.status === 'completed' ? 'pending' : 'completed';
  }
};

// 删除任务
const deleteTodo = async (id) => {
  try {
    loading.value = true;
    await todoDB.deleteTodo(id);
    await loadTodos();
  } catch (error) {
    console.error('删除任务失败:', error);
  } finally {
    loading.value = false;
  }
};

// 清除已完成任务
const clearCompleted = async () => {
  try {
    loading.value = true;
    const completedIds = todos.value
      .filter(todo => todo.status === 'completed')
      .map(todo => todo.id);
    
    // 批量删除
    for (const id of completedIds) {
      await todoDB.deleteTodo(id);
    }
    
    await loadTodos();
  } catch (error) {
    console.error('清除已完成任务失败:', error);
  } finally {
    loading.value = false;
  }
};

// 设置过滤器
const setFilter = (filter) => {
  activeFilter.value = filter;
};

// 格式化日期
const formatDate = (dateString) => {
  const date = new Date(dateString);
  return date.toLocaleDateString();
};
</script>

<style scoped>
.todo-app {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem;
  background-color: #f5f7fa;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.app-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 1rem;
  padding: 1rem;
  background-color: white;
  border-radius: 4px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.todo-stats {
  display: flex;
  gap: 1rem;
  font-weight: bold;
}

.controls {
  display: flex;
  gap: 0.5rem;
}

.todo-form {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1rem;
  padding: 1rem;
  background-color: white;
  border-radius: 4px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.todo-form input,
.todo-form select {
  padding: 0.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  flex: 1;
}

.todo-form button {
  padding: 0.5rem 1rem;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.todo-form button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}

.filters {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1rem;
  padding: 1rem;
  background-color: white;
  border-radius: 4px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.filter-btn {
  padding: 0.3rem 0.8rem;
  background-color: #f0f0f0;
  border: none;
  border-radius: 20px;
  cursor: pointer;
  transition: all 0.2s;
}

.filter-btn.active {
  background-color: #42b883;
  color: white;
}

.todo-list {
  background-color: white;
  border-radius: 4px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  min-height: 200px;
}

.todo-item {
  display: flex;
  align-items: center;
  padding: 1rem;
  border-bottom: 1px solid #eee;
  transition: all 0.2s;
}

.todo-item:last-child {
  border-bottom: none;
}

.todo-item.completed .todo-text {
  text-decoration: line-through;
  color: #999;
}

.todo-content {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex: 1;
}

.todo-text {
  font-size: 1rem;
  flex: 1;
}

.todo-meta {
  display: flex;
  gap: 0.5rem;
  font-size: 0.8rem;
  color: #666;
}

.priority {
  padding: 0.2rem 0.5rem;
  border-radius: 10px;
  font-size: 0.7rem;
  font-weight: bold;
  text-transform: uppercase;
}

.priority.high {
  background-color: #fff2f0;
  color: #f5222d;
}

.priority.medium {
  background-color: #fff7e6;
  color: #fa8c16;
}

.priority.low {
  background-color: #f6ffed;
  color: #52c41a;
}

.todo-actions {
  display: flex;
  gap: 0.5rem;
}

.delete-btn {
  padding: 0.3rem 0.6rem;
  background-color: #ff4d4f;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.8rem;
}

.loading {
  text-align: center;
  padding: 2rem;
  color: #1890ff;
}

.empty-state {
  text-align: center;
  padding: 2rem;
  color: #999;
  font-style: italic;
}

button {
  padding: 0.5rem 1rem;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}
</style>

4.4 创建可复用的 IndexedDB Composable

// composables/useIndexedDB.js
import { ref, onBeforeUnmount } from 'vue';

// 通用 IndexedDB Composable
export function useIndexedDB(dbName, version = 1, upgradeCallback = null) {
  const db = ref(null);
  const isOpen = ref(false);
  const error = ref(null);
  const connections = ref(0);
  
  // 打开数据库
  const openDB = async () => {
    if (db.value) {
      connections.value++;
      isOpen.value = true;
      return db.value;
    }
    
    return new Promise((resolve, reject) => {
      try {
        const request = indexedDB.open(dbName, version);
        
        // 数据库升级事件
        request.onupgradeneeded = (event) => {
          const database = event.target.result;
          if (upgradeCallback) {
            upgradeCallback(database, event.oldVersion, event.newVersion);
          }
        };
        
        // 打开成功
        request.onsuccess = (event) => {
          db.value = event.target.result;
          connections.value++;
          isOpen.value = true;
          resolve(db.value);
        };
        
        // 打开失败
        request.onerror = (event) => {
          error.value = event.target.error;
          reject(event.target.error);
        };
        
        // 阻止默认错误处理
        request.onblocked = () => {
          console.error('数据库被阻塞,请关闭其他标签页');
        };
        
      } catch (err) {
        error.value = err;
        reject(err);
      }
    });
  };
  
  // 关闭数据库
  const closeDB = () => {
    connections.value--;
    if (connections.value <= 0 && db.value) {
      db.value.close();
      db.value = null;
      isOpen.value = false;
      connections.value = 0;
    }
  };
  
  // 删除数据库
  const deleteDB = async () => {
    return new Promise((resolve, reject) => {
      const request = indexedDB.deleteDatabase(dbName);
      
      request.onsuccess = () => {
        resolve();
      };
      
      request.onerror = (event) => {
        reject(event.target.error);
      };
      
      request.onblocked = () => {
        console.error('删除数据库被阻塞,请关闭其他标签页');
      };
    });
  };
  
  // 创建事务
  const transaction = (storeNames, mode = 'readonly') => {
    if (!db.value) {
      throw new Error('数据库未打开');
    }
    return db.value.transaction(storeNames, mode);
  };
  
  // 获取对象存储
  const getObjectStore = (storeName, mode = 'readonly') => {
    const tx = transaction(storeName, mode);
    return tx.objectStore(storeName);
  };
  
  // 通用 CRUD 操作
  const add = async (storeName, data) => {
    return new Promise((resolve, reject) => {
      const store = getObjectStore(storeName, 'readwrite');
      const request = store.add(data);
      
      request.onsuccess = (event) => resolve(event.target.result);
      request.onerror = (event) => reject(event.target.error);
    });
  };
  
  const put = async (storeName, data) => {
    return new Promise((resolve, reject) => {
      const store = getObjectStore(storeName, 'readwrite');
      const request = store.put(data);
      
      request.onsuccess = (event) => resolve(event.target.result);
      request.onerror = (event) => reject(event.target.error);
    });
  };
  
  const get = async (storeName, key) => {
    return new Promise((resolve, reject) => {
      const store = getObjectStore(storeName);
      const request = store.get(key);
      
      request.onsuccess = (event) => resolve(event.target.result);
      request.onerror = (event) => reject(event.target.error);
    });
  };
  
  const deleteRecord = async (storeName, key) => {
    return new Promise((resolve, reject) => {
      const store = getObjectStore(storeName, 'readwrite');
      const request = store.delete(key);
      
      request.onsuccess = () => resolve();
      request.onerror = (event) => reject(event.target.error);
    });
  };
  
  const clear = async (storeName) => {
    return new Promise((resolve, reject) => {
      const store = getObjectStore(storeName, 'readwrite');
      const request = store.clear();
      
      request.onsuccess = () => resolve();
      request.onerror = (event) => reject(event.target.error);
    });
  };
  
  const getAll = async (storeName, query = null, count = null) => {
    return new Promise((resolve, reject) => {
      const store = getObjectStore(storeName);
      const request = store.getAll(query, count);
      
      request.onsuccess = (event) => resolve(event.target.result);
      request.onerror = (event) => reject(event.target.error);
    });
  };
  
  // 使用索引查询
  const getByIndex = async (storeName, indexName, key) => {
    return new Promise((resolve, reject) => {
      const store = getObjectStore(storeName);
      const index = store.index(indexName);
      const request = index.get(key);
      
      request.onsuccess = (event) => resolve(event.target.result);
      request.onerror = (event) => reject(event.target.error);
    });
  };
  
  // 使用游标遍历
  const iterate = async (storeName, callback, indexName = null, range = null, direction = 'next') => {
    return new Promise((resolve, reject) => {
      const store = getObjectStore(storeName);
      let request;
      
      if (indexName) {
        const index = store.index(indexName);
        request = index.openCursor(range, direction);
      } else {
        request = store.openCursor(range, direction);
      }
      
      request.onsuccess = (event) => {
        const cursor = event.target.result;
        if (cursor) {
          callback(cursor.value, cursor.key, cursor);
          cursor.continue();
        } else {
          resolve();
        }
      };
      
      request.onerror = (event) => {
        reject(event.target.error);
      };
    });
  };
  
  // 组件销毁前关闭数据库
  onBeforeUnmount(() => {
    closeDB();
  });
  
  return {
    db,
    isOpen,
    error,
    openDB,
    closeDB,
    deleteDB,
    transaction,
    getObjectStore,
    add,
    put,
    get,
    delete: deleteRecord,
    clear,
    getAll,
    getByIndex,
    iterate
  };
}

4.5 使用 Composable 实现笔记应用

<template>
  <div class="notes-app">
    <h2>Vue 3 IndexedDB 笔记应用</h2>
    
    <div class="app-header">
      <button @click="loadNotes" :disabled="loading">加载笔记</button>
      <button @click="createNote" :disabled="loading">新建笔记</button>
      <button @click="deleteDB" class="danger-btn" :disabled="loading">删除数据库</button>
    </div>
    
    <div class="notes-container">
      <!-- 左侧笔记列表 -->
      <div class="notes-list">
        <div 
          v-for="note in notes" 
          :key="note.id"
          class="note-item"
          :class="{ active: activeNoteId === note.id }"
          @click="selectNote(note)"
        >
          <div class="note-title">{{ note.title || '无标题' }}</div>
          <div class="note-preview">{{ truncateText(note.content, 50) }}</div>
          <div class="note-date">{{ formatDate(note.updatedAt) }}</div>
        </div>
      </div>
      
      <!-- 右侧笔记编辑区 -->
      <div class="note-editor" v-if="activeNote">
        <div class="editor-header">
          <input 
            type="text" 
            v-model="activeNote.title" 
            placeholder="笔记标题..." 
            class="note-title-input"
            @input="saveNote"
          />
          <div class="note-meta">
            <span>创建于: {{ formatDate(activeNote.createdAt) }}</span>
            <span>更新于: {{ formatDate(activeNote.updatedAt) }}</span>
          </div>
        </div>
        <textarea 
          v-model="activeNote.content" 
          placeholder="开始编写笔记..." 
          class="note-content"
          @input="saveNote"
        ></textarea>
        <div class="editor-footer">
          <button @click="deleteNote" class="danger-btn">删除当前笔记</button>
        </div>
      </div>
      
      <!-- 空状态 -->
      <div class="empty-state" v-else>
        <h3>选择或创建笔记</h3>
        <p>点击"新建笔记"开始编写,或从左侧选择现有笔记</p>
      </div>
    </div>
    
    <div class="loading" v-if="loading">加载中...</div>
    <div class="error" v-if="db.error">{{ db.error.message }}</div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue';
import { useIndexedDB } from './composables/useIndexedDB';

// 初始化 IndexedDB
const db = useIndexedDB('notesApp', 1, (db, oldVersion, newVersion) => {
  // 创建笔记对象存储
  if (oldVersion < 1) {
    const noteStore = db.createObjectStore('notes', {
      keyPath: 'id',
      autoIncrement: true
    });
    
    noteStore.createIndex('by_updatedAt', 'updatedAt', { unique: false });
    noteStore.createIndex('by_createdAt', 'createdAt', { unique: false });
  }
});

// 状态管理
const notes = ref([]);
const activeNoteId = ref(null);
const loading = ref(false);
const savingTimeout = ref(null);

// 计算当前激活的笔记
const activeNote = computed(() => {
  return notes.value.find(note => note.id === activeNoteId.value) || null;
});

// 加载所有笔记
const loadNotes = async () => {
  try {
    loading.value = true;
    await db.openDB();
    const allNotes = await db.getAll('notes');
    // 按更新时间排序,最新的在前面
    notes.value = allNotes.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
    // 默认选择第一个笔记
    if (notes.value.length > 0 && !activeNoteId.value) {
      activeNoteId.value = notes.value[0].id;
    }
  } catch (err) {
    console.error('加载笔记失败:', err);
  } finally {
    loading.value = false;
  }
};

// 创建新笔记
const createNote = async () => {
  try {
    loading.value = true;
    await db.openDB();
    
    const now = new Date().toISOString();
    const newNote = {
      title: '',
      content: '',
      createdAt: now,
      updatedAt: now
    };
    
    const id = await db.add('notes', newNote);
    newNote.id = id;
    
    notes.value.unshift(newNote);
    activeNoteId.value = id;
  } catch (err) {
    console.error('创建笔记失败:', err);
  } finally {
    loading.value = false;
  }
};

// 选择笔记
const selectNote = (note) => {
  activeNoteId.value = note.id;
};

// 保存笔记(防抖)
const saveNote = () => {
  if (!activeNote.value) return;
  
  // 防抖处理,避免频繁保存
  if (savingTimeout.value) {
    clearTimeout(savingTimeout.value);
  }
  
  savingTimeout.value = setTimeout(async () => {
    try {
      const updatedNote = {
        ...activeNote.value,
        updatedAt: new Date().toISOString()
      };
      
      await db.put('notes', updatedNote);
      
      // 更新本地列表
      const index = notes.value.findIndex(note => note.id === updatedNote.id);
      if (index !== -1) {
        notes.value[index] = updatedNote;
        // 重新排序
        notes.value.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
      }
      
    } catch (err) {
      console.error('保存笔记失败:', err);
    }
  }, 500);
};

// 删除当前笔记
const deleteNote = async () => {
  if (!activeNote.value) return;
  
  try {
    loading.value = true;
    await db.delete('notes', activeNote.value.id);
    
    // 从本地列表中移除
    const index = notes.value.findIndex(note => note.id === activeNote.value.id);
    if (index !== -1) {
      notes.value.splice(index, 1);
    }
    
    // 重置激活状态
    activeNoteId.value = notes.value.length > 0 ? notes.value[0].id : null;
  } catch (err) {
    console.error('删除笔记失败:', err);
  } finally {
    loading.value = false;
  }
};

// 删除数据库
const deleteDB = async () => {
  if (confirm('确定要删除所有笔记和数据库吗?此操作不可恢复。')) {
    try {
      loading.value = true;
      await db.deleteDB();
      notes.value = [];
      activeNoteId.value = null;
      console.log('数据库已删除');
    } catch (err) {
      console.error('删除数据库失败:', err);
    } finally {
      loading.value = false;
    }
  }
};

// 辅助函数
const truncateText = (text, maxLength) => {
  if (!text) return '';
  return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
};

const formatDate = (dateString) => {
  const date = new Date(dateString);
  return date.toLocaleString();
};

// 初始化
onMounted(async () => {
  await loadNotes();
});
</script>

<style scoped>
.notes-app {
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;
  background-color: #f5f7fa;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.app-header {
  display: flex;
  gap: 1rem;
  margin-bottom: 2rem;
  padding: 1rem;
  background-color: white;
  border-radius: 4px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

button {
  padding: 0.5rem 1rem;
  background-color: #42b883;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.2s;
}

button:hover {
  background-color: #389e70;
}

button.danger-btn {
  background-color: #ff4d4f;
}

button.danger-btn:hover {
  background-color: #d9363e;
}

button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}

.notes-container {
  display: grid;
  grid-template-columns: 300px 1fr;
  gap: 2rem;
  background-color: white;
  border-radius: 4px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  min-height: 500px;
}

.notes-list {
  border-right: 1px solid #eee;
  overflow-y: auto;
  max-height: 600px;
}

.note-item {
  padding: 1rem;
  border-bottom: 1px solid #eee;
  cursor: pointer;
  transition: all 0.2s;
}

.note-item:hover {
  background-color: #f5f5f5;
}

.note-item.active {
  background-color: #e6f7ff;
  border-right: 3px solid #1890ff;
}

.note-title {
  font-weight: bold;
  margin-bottom: 0.5rem;
}

.note-preview {
  font-size: 0.9rem;
  color: #666;
  margin-bottom: 0.5rem;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.note-date {
  font-size: 0.8rem;
  color: #999;
}

.note-editor {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.editor-header {
  padding: 1rem;
  border-bottom: 1px solid #eee;
}

.note-title-input {
  width: 100%;
  padding: 0.5rem;
  font-size: 1.2rem;
  font-weight: bold;
  border: none;
  outline: none;
  margin-bottom: 0.5rem;
}

.note-meta {
  display: flex;
  gap: 1rem;
  font-size: 0.8rem;
  color: #999;
}

.note-content {
  flex: 1;
  padding: 1rem;
  border: none;
  outline: none;
  font-size: 1rem;
  line-height: 1.6;
  resize: none;
}

.editor-footer {
  padding: 1rem;
  border-top: 1px solid #eee;
  display: flex;
  justify-content: flex-end;
}

.empty-state {
  grid-column: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 2rem;
  text-align: center;
  color: #999;
}

.loading {
  text-align: center;
  padding: 1rem;
  color: #1890ff;
  font-weight: bold;
}

.error {
  text-align: center;
  padding: 1rem;
  color: #f5222d;
  background-color: #fff2f0;
  border: 1px solid #ffccc7;
  border-radius: 4px;
}
</style>

最佳实践

1. 性能优化

  • 批量操作:将多个操作合并到单个事务中,减少事务开销
  • 合理使用索引:为频繁查询的字段创建索引,但避免过多索引
  • **使用游标而非 getAll()**:对于大量数据,使用游标遍历而不是一次性获取所有数据
  • 分页查询:实现分页机制,避免一次性加载大量数据
  • 使用 get() 而非游标:根据键获取单条记录时,使用 get() 方法更高效
  • 避免频繁保存:使用防抖或节流机制,避免频繁保存数据
  • 合理设置事务作用域:事务作用域应尽可能小,避免长时间持有锁

2. 数据管理

  • 版本控制:合理设计数据库版本,避免频繁升级
  • 数据迁移:在版本升级事件中实现数据迁移逻辑
  • 数据清理:定期清理过期或不再使用的数据
  • 数据备份:实现数据导出和导入功能
  • 数据验证:在客户端实现数据验证,确保数据一致性
  • 事务回滚:正确处理事务失败情况,确保数据一致性

3. 开发体验

  • 封装 API:创建封装良好的 API,简化 IndexedDB 使用
  • 类型安全:使用 TypeScript 为 IndexedDB 添加类型定义
  • 错误处理:实现全面的错误处理机制
  • 调试工具:使用浏览器开发者工具调试 IndexedDB
    • Chrome DevTools:Application > IndexedDB
    • Firefox DevTools:Storage > IndexedDB
  • 日志记录:在开发环境中添加详细的日志记录
  • 测试策略:编写单元测试和集成测试

4. 安全考虑

  • 数据加密:对于敏感数据,使用 Web Crypto API 进行加密
  • 访问控制:实现适当的访问控制机制
  • 防止注入攻击:验证所有用户输入,避免恶意数据
  • 定期清理:定期清理不再需要的数据
  • 备份策略:实现数据备份和恢复机制

5. 兼容性和降级方案

  • 检查浏览器支持:使用 &#39;indexedDB&#39; in window 检测支持情况
  • 提供降级方案:在不支持 IndexedDB 的浏览器中,使用 LocalStorage 或其他存储方案
  • 处理不同浏览器差异:不同浏览器对 IndexedDB 的实现可能存在差异,需要进行测试和兼容处理
  • 使用 polyfill:考虑使用 IndexedDB polyfill,但注意性能开销

常见问题与解决方案

1. 数据库打开失败

  • 原因
    • 浏览器不支持 IndexedDB
    • 数据库版本冲突
    • 浏览器隐私设置限制
    • 存储空间不足
  • 解决方案
    • 检测浏览器支持,提供降级方案
    • 正确处理版本升级事件
    • 提示用户检查隐私设置
    • 实现数据清理机制

2. 事务失败

  • 原因
    • 违反唯一约束
    • 键不存在
    • 事务超时
    • 数据库连接关闭
  • 解决方案
    • 实现适当的错误处理
    • 验证唯一约束
    • 检查键是否存在
    • 确保数据库连接正常

3. 性能问题

  • 原因
    • 频繁的事务操作
    • 大量数据一次性加载
    • 缺少索引
    • 复杂查询
  • 解决方案
    • 批量操作
    • 分页查询
    • 合理使用索引
    • 优化查询逻辑

4. 数据一致性问题

  • 原因
    • 事务未正确处理
    • 并发操作冲突
    • 错误的事务类型
  • 解决方案
    • 使用正确的事务类型
    • 实现适当的并发控制
    • 正确处理事务失败情况

5. 调试困难

  • 原因
    • 异步 API 难以调试
    • 错误信息不够详细
    • 数据结构复杂
  • 解决方案
    • 使用浏览器开发者工具
    • 添加详细的日志记录
    • 实现数据导出功能
    • 使用 Promise 或 async/await 简化异步代码

高级学习资源

1. 官方文档

2. 深度教程

3. 相关库和工具

4. 示例项目

5. 视频教程

实践练习

1. 基础练习:创建 IndexedDB 封装

  • 实现一个简单的 IndexedDB 封装类
  • 支持基本的 CRUD 操作
  • 实现事务管理
  • 在 Vue 3 组件中使用

2. 进阶练习: Todo 应用

  • 创建一个完整的 Todo 应用,使用 IndexedDB 存储数据
  • 支持添加、编辑、删除、标记完成等功能
  • 实现过滤和排序功能
  • 支持优先级和截止日期
  • 实现数据导出和导入功能

3. 高级练习:笔记应用

  • 创建一个笔记应用,支持富文本编辑
  • 实现笔记分类和标签功能
  • 支持搜索和筛选
  • 实现自动保存功能
  • 支持数据同步(可选)

4. 综合练习:离线博客应用

  • 创建一个离线博客应用,使用 IndexedDB 存储文章
  • 支持文章的创建、编辑、删除
  • 实现分类、标签、搜索功能
  • 支持离线访问和在线同步
  • 实现 PWA 功能

5. 挑战练习:复杂数据关系

  • 创建一个具有复杂数据关系的应用(如社交网络)
  • 实现多对象存储和复杂查询
  • 实现事务处理和数据一致性
  • 优化性能和查询效率
  • 实现数据备份和恢复功能

总结

IndexedDB 是 Vue 3 应用中实现本地数据存储的强大工具,适用于需要存储大量结构化数据的场景。通过合理设计数据库结构、优化查询性能、实现良好的错误处理和事务管理,可以构建高性能、可靠的离线应用。掌握 IndexedDB 的核心概念和高级特性,结合 Vue 3 的响应式系统和组合式 API,可以创建出色的用户体验,实现真正的离线优先应用。随着 PWA 技术的发展,IndexedDB 将在现代 Web 应用中发挥越来越重要的作用。

« 上一篇 Vue 3 与 SharedArrayBuffer 下一篇 » Vue 3 与 File System Access API