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. 兼容性和降级方案
- 检查浏览器支持:使用
'indexedDB' in window检测支持情况 - 提供降级方案:在不支持 IndexedDB 的浏览器中,使用 LocalStorage 或其他存储方案
- 处理不同浏览器差异:不同浏览器对 IndexedDB 的实现可能存在差异,需要进行测试和兼容处理
- 使用 polyfill:考虑使用 IndexedDB polyfill,但注意性能开销
常见问题与解决方案
1. 数据库打开失败
- 原因:
- 浏览器不支持 IndexedDB
- 数据库版本冲突
- 浏览器隐私设置限制
- 存储空间不足
- 解决方案:
- 检测浏览器支持,提供降级方案
- 正确处理版本升级事件
- 提示用户检查隐私设置
- 实现数据清理机制
2. 事务失败
- 原因:
- 违反唯一约束
- 键不存在
- 事务超时
- 数据库连接关闭
- 解决方案:
- 实现适当的错误处理
- 验证唯一约束
- 检查键是否存在
- 确保数据库连接正常
3. 性能问题
- 原因:
- 频繁的事务操作
- 大量数据一次性加载
- 缺少索引
- 复杂查询
- 解决方案:
- 批量操作
- 分页查询
- 合理使用索引
- 优化查询逻辑
4. 数据一致性问题
- 原因:
- 事务未正确处理
- 并发操作冲突
- 错误的事务类型
- 解决方案:
- 使用正确的事务类型
- 实现适当的并发控制
- 正确处理事务失败情况
5. 调试困难
- 原因:
- 异步 API 难以调试
- 错误信息不够详细
- 数据结构复杂
- 解决方案:
- 使用浏览器开发者工具
- 添加详细的日志记录
- 实现数据导出功能
- 使用 Promise 或 async/await 简化异步代码
高级学习资源
1. 官方文档
2. 深度教程
3. 相关库和工具
- 封装库:
- Dexie.js:流行的 IndexedDB 封装库
- idb:Google 开发的 IndexedDB 封装库
- localForage:统一的客户端存储 API
- 调试工具:
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 应用中发挥越来越重要的作用。