Vue 3 和 TypeScript 结合 IndexedDB 使用,能满足你大量结构化数据的本地存储需求(比如离线应用、缓存、用户数据)。这里为你提供一份使用指南,包含原生写法和封装库两种方式。

🗃️ Vue 3 与 TypeScript 中的 IndexedDB 使用指南

IndexedDB 是浏览器内置的一个事务型、面向对象的 NoSQL 数据库,允许你在客户端存储大量结构化数据(包括文件/BLOB)。其异步 API 的设计避免了阻塞 UI,相比 localStorage 和 SessionStorage,它提供了更大的存储空间、更丰富的数据操作方式以及更健壮的事务支持。

🔍 1. IndexedDB 基础概念

在 Vue 3 和 TypeScript 项目中使用 IndexedDB 前,需理解其核心概念。

1.1 与 localStorage 的简单对比

特性 IndexedDB localStorage
存储容量 通常更大(数百MB甚至更多) 通常较小(几MB)
数据结构 对象存储(类似 NoSQL) 仅键值对
查询能力 支持索引和复杂查询 仅按键查询
事务支持
阻塞性 异步操作,不阻塞 UI 同步操作,可能阻塞 UI
适用场景 大量结构化数据、离线应用 小量简单数据

1.2 核心概念

  • 数据库 (Database):每个源(协议+域名+端口)可以创建多个数据库,每个数据库有唯一名称和版本号。

  • 对象存储 (Object Store):数据库中的主要数据存储机制,类似于集合(MongoDB)或表(SQL)。每个对象存储可以定义一个键路径 (keyPath) 或使用键生成器 (key generator)

  • 索引 (Index):基于对象存储中对象的属性创建,允许高效查询特定字段。

  • 事务 (Transaction):所有数据库操作都必须在事务中执行,确保数据的完整性(原子性、一致性、隔离性、持久性)。

  • 游标 (Cursor):用于迭代对象存储或索引中的多条记录。

  • 操作(CRUD):即创建 (Create)、读取 (Read)、更新 (Update)、删除 (Delete) 操作。

📦 2. 安装与设置

2.1 使用原生 IndexedDB API

IndexedDB 是浏览器内置 API,无需额外安装。但其基于事件的回调 API 可能使代码显得冗长。

2.2 使用封装库(推荐)

封装库能简化操作。以下是两个常见选择:

  • idb: 一个轻量级的、基于 Promise 的 IndexedDB 封装库。

    bash

    npm install idb
  • Dexie.js: 功能更丰富的 IndexedDB 封装,提供简洁的 API 和强大的查询能力。

    bash

    npm install dexie

🫷 3. 原生 IndexedDB API 的基本用法(配合 idb)

以下示例使用 idb 库和 TypeScript。

3.1 初始化数据库

首先创建一个数据库工具文件,例如 src/utils/indexedDb.ts

typescript

// src/utils/indexedDb.ts
import { openDB, DBSchema, IDBPDatabase } from 'idb';

// 定义数据库结构(可选,但强烈推荐用于TypeScript类型检查)
interface MyDB extends DBSchema {
  'users': { // 对象存储名
    key: number;    // 主键类型
    value: {        // 存储值的类型
      id?: number;  // 由于keyPath是id,此处需对应
      name: string;
      email: string;
      createdAt: Date;
    };
    indexes: { 'by-email': string }; // 索引定义
  };
  'messages': {
    key: number;
    value: {
      id?: number;
      userId: number;
      content: string;
      timestamp: Date;
    };
    indexes: { 'by-user-id': number };
  };
}

let dbPromise: Promise<IDBPDatabase<MyDB>> | null = null;

export const initDB = (): Promise<IDBPDatabase<MyDB>> => {
  if (dbPromise) {
    return dbPromise;
  }

  dbPromise = openDB<MyDB>('MyVueAppDB', 1, {
    upgrade(db, oldVersion, newVersion, transaction) {
      // 创建或升级对象存储
      if (!db.objectStoreNames.contains('users')) {
        const userStore = db.createObjectStore('users', { keyPath: 'id', autoIncrement: true });
        userStore.createIndex('by-email', 'email', { unique: true }); // 唯一索引
      }
      if (!db.objectStoreNames.contains('messages')) {
        const messageStore = db.createObjectStore('messages', { keyPath:id, autoIncrement: true });
        messageStore.createIndex('by-user-id', 'userId', { unique: false });
      }
    },
  });

  return dbPromise;
};

// 获取数据库实例的辅助函数
const getDB = async (): Promise<IDBPDatabase<MyDB>> => {
  return await initDB();
};

3.2 定义数据模型(TypeScript 接口)

在 src/types/index.ts 或类似文件中定义 TypeScript 接口以确保类型安全:

typescript

// src/types/models.ts
export interface User {
  id?: number; // 自增主键,创建时可选
  name: string;
  email: string;
  createdAt: Date;
}

export interface Message {
  id?: number;
  userId: number;
  content: string;
  timestamp: Date;
}

3.3 通用 CRUD 操作封装

继续在 src/utils/indexedDb.ts 中添加通用操作函数:

typescript

// ... (接之前的 initDB 和 getDB)

// CRUD 操作
export const dbOperations = {
  // 添加数据
  async addItem<T extends keyof MyDB>(
    storeName: T,
    item: MyDB[T]['value']
  ): Promise<number> {
    const db = await getDB();
    return await db.add(storeName, item);
  },

  // 读取数据(根据主键)
  async getItem<T extends keyof MyDB>(
    storeName: T,
    key: number
  ): Promise<MyDB[T]['value'] | undefined> {
    const db = await getDB();
    return await db.get(storeName, key);
  },

  // 通过索引读取数据
  async getItemByIndex<T extends keyof MyDB>(
    storeName: T,
    indexName: keyof MyDB[T]['indexes'],
    key: any
  ): Promise<MyDB[T]['value'] | undefined> {
    const db = await getDB();
    const tx = db.transaction(storeName, 'readonly');
    const index = tx.store.index(indexName as string);
    return await index.get(key);
  },

  // 更新数据
  async updateItem<T extends keyof MyDB>(
    storeName: T,
    item: MyDB[T]['value']
  ): Promise<number> {
    const db = await getDB();
    return await db.put(storeName, item);
  },

  // 删除数据(根据主键)
  async deleteItem<T extends keyof MyDB>(
    storeName: T,
    key: number
  ): Promise<void> {
    const db = await getDB();
    await db.delete(storeName, key);
  },

  // 获取所有数据
  async getAllItems<T extends keyof MyDB>(storeName: T): Promise<MyDB[T]['value'][]> {
    const db = await getDB();
    return await db.getAll(storeName);
  },

  // 使用游标遍历(示例:获取特定用户的所有消息)
  async getMessagesByUserId(userId: number): Promise<Message[]> {
    const db = await getDB();
    const tx = db.transaction('messages', 'readonly');
    const index = tx.store.index('by-user-id');
    let cursor = await index.openCursor(IDBKeyRange.only(userId));
    const messages: Message[] = [];

    while (cursor) {
      messages.push(cursor.value);
      cursor = await cursor.continue();
    }

    await tx.done;
    return messages;
  },
};

3.4 在 Vue 组件中使用

在 main.ts 中初始化数据库,或在首个需要的组件中初始化:

typescript

// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { initDB } from './utils/indexedDb';

const app = createApp(App);

// 异步初始化数据库
initDB().then(() => {
  app.mount('#app');
}).catch((error) => {
  console.error('Failed to initialize database:', error);
});

在 Vue 组件(例如 src/components/UserManager.vue)中使用:

vue

<template>
  <div class="user-manager">
    <h2>用户管理</h2>
    <form @submit.prevent="addUser">
      <input v-model="newUser.name" placeholder="姓名" required />
      <input v-model="newUser.email" type="email" placeholder="邮箱" required />
      <button type="submit">添加用户</button>
    </form>
    <ul v-if="users.length">
      <li v-for="user in users" :key="user.id">
        {{ user.name }} - {{ user.email }}
        <button @click="deleteUser(user.id!)">删除</button>
      </li>
    </ul>
    <p v-else>暂无用户数据</p>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { User } from '@/types/models';
import { dbOperations } from '@/utils/indexedDb';

const users = ref<User[]>([]);
const newUser = ref<Omit<User, 'id' | 'createdAt'>>({
  name: '',
  email: '',
});

const loadUsers = async () => {
  try {
    users.value = await dbOperations.getAllItems('users');
  } catch (error) {
    console.error('Failed to load users:', error);
  }
};

const addUser = async () => {
  try {
    const userToAdd: Omit<User, 'id'> = {
      ...newUser.value,
      createdAt: new Date(),
    };
    await dbOperations.addItem('users', userToAdd as User);
    newUser.value = { name: '', email: '' }; // 重置表单
    await loadUsers(); // 重新加载用户列表
  } catch (error) {
    console.error('Failed to add user:', error);
    if ((error as Error).name === 'ConstraintError') {
      alert('邮箱已存在!');
    }
  }
};

const deleteUser = async (userId: number) => {
  try {
    await dbOperations.deleteItem('users', userId);
    await loadUsers(); // 重新加载用户列表
  } catch (error) {
    console.error('Failed to delete user:', error);
  }
};

onMounted(() => {
  loadUsers();
});
</script>

🧰 4. 使用 Dexie.js 简化操作

Dexie.js 提供了更简洁的 API。

4.1 安装与配置 Dexie

bash

npm install dexie

4.2 创建数据库类

创建 src/utils/MyDatabase.ts 文件:

typescript

// src/utils/MyDatabase.ts
import Dexie, { Table } from 'dexie';
import { User, Message } from '@/types/models';

export class MyAppDatabase extends Dexie {
  users!: Table<User, number>; // number 是主键类型
  messages!: Table<Message, number>;

  constructor() {
    super('MyVueAppDBWithDexie');
    this.version(1).stores({
      users: '++id, name, email, createdAt', // ++ 表示自增主键
      messages: '++id, userId, content, timestamp, *userId' // * 表示多条目索引(非唯一)
    });
    
    // 可选:为索引添加更详细的配置
    this.version(2).stores({
      users: '++id, &email, name, createdAt', // & 表示唯一索引
      messages: '++id, userId, content, timestamp'
    }).upgrade(tx => {
      // 版本升级逻辑,例如数据迁移
    });
  }
}

export const db = new MyAppDatabase();

4.3 使用 Dexie 进行 CRUD 操作

在 Vue 组件中使用 Dexie:

vue

<template>
  <!-- 类似之前的模板 -->
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { User } from '@/types/models';
import { db } from '@/utils/MyDatabase';

const users = ref<User[]>([]);
const newUser = ref<Omit<User, 'id' | 'createdAt'>>({ name: '', email: '' });

const loadUsers = async () => {
  try {
    // Dexie 提供了丰富的查询API
    users.value = await db.users
      .where('createdAt').above(new Date(2020, 0, 1)) // 条件查询示例
      .toArray();
      
    // 或者简单获取所有用户
    // users.value = await db.users.toArray();
  } catch (error) {
    console.error('Failed to load users:', error);
  }
};

const addUser = async () => {
  try {
    await db.users.add({
      ...newUser.value,
      createdAt: new Date(),
    });
    newUser.value = { name: '', email: '' };
    await loadUsers();
  } catch (error) {
    console.error('Failed to add user:', error);
  }
};

const deleteUser = async (userId: number) => {
  try {
    await db.users.delete(userId);
    await loadUsers();
  } catch (error) {
    console.error('Failed to delete user:', error);
  }
};

// 使用索引查询
const findUserByEmail = async (email: string) => {
  try {
    const user = await db.users.where('email').equals(email).first();
    return user;
  } catch (error) {
    console.error('Failed to find user by email:', error);
    return null;
  }
};

onMounted(() => {
  loadUsers();
});
</script>

💡 5. 高级用法与最佳实践

5.1 复杂查询与索引使用

有效的索引能显著提升查询性能。

typescript

// 使用原生API的范围查询(idb)
async getUsersCreatedAfter(date: Date): Promise<User[]> {
  const db = await getDB();
  const tx = db.transaction('users', 'readonly');
  const index = tx.store.index('by-created-at'); // 假设你创建了这个索引
  const range = IDBKeyRange.lowerBound(date);
  return await index.getAll(range);
}

// 使用Dexie的范围查询
const recentUsers = await db.users
  .where('createdAt')
  .aboveOrEqual(new Date(2024, 0, 1))
  .toArray();

// 复合条件查询(Dexie更简便)
const specificUsers = await db.users
  .where('name').equals('John')
  .and(user => user.email.endsWith('@example.com'))
  .toArray();

5.2 事务处理

事务对于保证数据一致性至关重要。

typescript

// 使用原生API的事务(添加用户并同时添加一条消息)
async function addUserWithInitialMessage(userData: Omit<User, 'id'>, messageContent: string) {
  const db = await getDB();
  const tx = db.transaction(['users', 'messages'], 'readwrite');
  try {
    const userId = await tx.objectStore('users').add({
      ...userData,
      createdAt: new Date(),
    });
    
    await tx.objectStore('messages').add({
      userId,
      content: messageContent,
      timestamp: new Date(),
    });
    
    await tx.done; // 等待事务完成
    return userId;
  } catch (error) {
    tx.abort(); // 中止事务
    throw error;
  }
}

// 使用Dexie的事务
await db.transaction('rw', db.users, db.messages, async () => {
  const userId = await db.users.add({
    name: 'John',
    email: 'john@example.com',
    createdAt: new Date(),
  });
  
  await db.messages.add({
    userId,
    content: 'Welcome message!',
    timestamp: new Date(),
  });
});

5.3 错误处理

稳健的错误处理能提升用户体验。

typescript

// 统一的错误处理函数
function handleDBError(error: unknown, operation: string) {
  console.error(`Database error during ${operation}:`, error);
  
  if (error instanceof DOMException) {
    switch (error.name) {
      case 'QuotaExceededError':
        alert('存储空间不足,请清理后重试。');
        break;
      case 'ConstraintError':
        alert('数据已存在或违反唯一约束。');
        break;
      default:
        alert(`操作失败: ${error.message}`);
    }
  } else {
    alert('发生未知错误,请重试或联系支持。');
  }
}

// 在组件中使用
const addUser = async () => {
  try {
    // ... 添加用户的逻辑
  } catch (error) {
    handleDBError(error, '添加用户');
  }
};

🛠️ 6. 调试与浏览器开发者工具

现代浏览器提供了良好的 IndexedDB 调试支持。

  • Chrome/Edge: 打开开发者工具(F12)→ Application 标签页 → IndexedDB

  • Firefox: 开发者工具(F12)→ Storage 标签页 → IndexedDB

  • Safari: 开发者工具(⌥⌘I)→ Storage 标签页 → IndexedDB

在这里你可以:

  • 👀 查看所有数据库和对象存储。

  • 🔍 检查存储的数据。

  • 🗑️ 手动删除或修改数据(谨慎操作)。

  • ⚠️ 检查数据库结构和索引。

📊 原生 API 与 Dexie.js 对比

操作 原生 API (配合 idb) Dexie.js
初始化 需手动定义 schema 和升级逻辑 声明式 schema 定义,简化升级
查询 需使用游标和索引,代码稍显繁琐 链式查询 API,类似 LINQ,非常直观
事务 需显式创建和管理事务 db.transaction 简化事务处理
TypeScript 支持 良好,但需自行定义复杂类型 优秀,原生支持
学习曲线 较陡峭,需理解底层概念 较平缓,API 设计直观
灵活性 极高,可精细控制 较高,但受限于库提供的 API

📝 7. 总结与实践建议

  1. 项目评估:对于简单应用或需要极致控制的情况,可考虑原生 API(配合 idb)。对于大多数需要复杂查询良好开发体验的中大型项目,推荐使用 Dexie.js

  2. 类型安全:务必使用 TypeScript 接口定义模型,充分利用类型检查。

  3. 版本管理:在应用发布初期仔细规划数据库 schema。后期 schema 变更需通过 version 升级处理,并编写数据迁移逻辑。

  4. 错误处理:始终处理异步操作可能出现的错误,为用户提供友好提示。

  5. 性能考量

    • 为常用查询字段创建索引。

    • 避免在单个事务中操作过多数据。

    • 考虑大数据集的分页查询。

  6. 清理策略:对于缓存性质的数据,实现适当的清理机制(如基于时间或数量),避免占用过多磁盘空间。

更多推荐