IndexedDB实战:手把手教你打造离线笔记应用(含事务处理技巧)
你是否曾遇到过这样的场景:在地铁上灵感迸发,打开手机上的笔记应用奋笔疾书,突然网络中断,刚才写下的几百字瞬间消失?或者开发一个需要离线使用的PWA应用时,发现localStorage那可怜的5MB存储空间根本不够用?这些痛点正是现代Web应用从“在线工具”向“可靠应用”演进时必须跨越的鸿沟。
IndexedDB作为浏览器内置的NoSQL数据库,正是解决这些问题的利器。它不仅能存储GB级别的结构化数据,还支持事务、索引、游标等高级功能,让前端开发者也能在浏览器中构建真正意义上的离线应用。但很多开发者面对IndexedDB原生API的复杂性望而却步——回调地狱、事务管理、版本升级,每一个环节都可能成为拦路虎。
本文将带你从零开始,通过构建一个完整的离线笔记应用,深入掌握IndexedDB的核心用法。我们不仅会讲解基础操作,更会聚焦于实际开发中最关键的事务处理、冲突解决和性能优化策略。无论你是需要为现有应用添加离线功能,还是正在规划一个全新的PWA项目,这里都有你需要的实战经验。
1. 环境准备与项目架构设计
在开始编码之前,我们需要明确离线笔记应用的核心需求。一个合格的离线笔记应用应该具备以下能力:在网络不稳定或完全断开时仍能正常使用;支持富文本或Markdown格式的笔记内容;能够自动保存草稿并同步到云端;提供高效的搜索和分类功能;确保数据在浏览器崩溃或页面关闭后不会丢失。
1.1 技术选型与工具准备
对于现代前端项目,我们通常会选择TypeScript来获得更好的类型安全性和开发体验。同时,为了简化IndexedDB的操作,我们可以选择一个轻量级的封装库。这里我推荐使用idb库,它是IndexedDB API的Promise封装,由Google的Jake Archibald维护,API设计简洁且符合现代JavaScript习惯。
# 初始化项目并安装依赖
npm init -y
npm install typescript idb
npm install --save-dev @types/idb
提示:虽然可以直接使用原生IndexedDB API,但在实际项目中,使用封装库可以显著减少样板代码,并避免常见的陷阱。
idb库只有约1KB的gzip大小,几乎不会增加打包体积。
1.2 数据库架构设计
合理的数据库设计是应用性能的基础。对于笔记应用,我们需要考虑以下几个核心实体:
- 笔记(Notes):存储笔记的标题、内容、创建时间、修改时间等
- 标签(Tags):支持笔记的分类管理
- 附件(Attachments):存储图片、文件等二进制数据
- 同步状态(SyncStatus):记录笔记的同步状态,用于冲突解决
下面是我们数据库的初始设计:
// types/database.ts
export interface Note {
id?: number; // 自增主键
title: string; // 笔记标题
content: string; // 笔记内容(Markdown格式)
tags: string[]; // 标签数组
createdAt: Date; // 创建时间
updatedAt: Date; // 最后修改时间
isArchived: boolean; // 是否归档
syncStatus: 'synced' | 'pending' | 'conflict'; // 同步状态
}
export interface Tag {
id?: number;
name: string;
color?: string; // 标签颜色
noteCount: number; // 关联笔记数量
}
export interface Attachment {
id?: number;
noteId: number; // 外键,关联笔记
fileName: string;
fileType: string;
data: ArrayBuffer; // 二进制数据
size: number;
uploadedAt: Date;
}
export interface SyncLog {
id?: number;
noteId: number;
action: 'create' | 'update' | 'delete';
timestamp: Date;
serverVersion?: number; // 服务器版本号
}
1.3 数据库初始化策略
IndexedDB的版本管理机制是其强大之处,也是容易出错的地方。我们需要精心设计数据库的升级策略,确保用户在不同版本间迁移时数据不会丢失。
// db/init.ts
import { openDB, DBSchema, IDBPDatabase } from 'idb';
interface NotesDBSchema extends DBSchema {
notes: {
key: number;
value: Note;
indexes: {
'by-updated': Date;
'by-tag': string;
'by-sync-status': string;
};
};
tags: {
key: number;
value: Tag;
indexes: { 'by-name': string };
};
attachments: {
key: number;
value: Attachment;
indexes: { 'by-note-id': number };
};
syncLogs: {
key: number;
value: SyncLog;
indexes: { 'by-timestamp': Date };
};
}
const DB_NAME = 'notes-app';
const DB_VERSION = 3; // 每次修改数据库结构时递增
export async function initDatabase(): Promise<IDBPDatabase<NotesDBSchema>> {
return openDB<NotesDBSchema>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion, newVersion, transaction) {
console.log(`数据库从版本 ${oldVersion} 升级到 ${newVersion}`);
// 版本1:创建基础表
if (oldVersion < 1) {
const notesStore = db.createObjectStore('notes', {
keyPath: 'id',
autoIncrement: true
});
// 创建索引
notesStore.createIndex('by-updated', 'updatedAt');
notesStore.createIndex('by-tag', 'tags', { multiEntry: true });
notesStore.createIndex('by-sync-status', 'syncStatus');
db.createObjectStore('tags', {
keyPath: 'id',
autoIncrement: true
}).createIndex('by-name', 'name', { unique: true });
}
// 版本2:添加附件表
if (oldVersion < 2) {
const attachmentsStore = db.createObjectStore('attachments', {
keyPath: 'id',
autoIncrement: true
});
attachmentsStore.createIndex('by-note-id', 'noteId');
}
// 版本3:添加同步日志表
if (oldVersion < 3) {
const syncLogsStore = db.createObjectStore('syncLogs', {
keyPath: 'id',
autoIncrement: true
});
syncLogsStore.createIndex('by-timestamp', 'timestamp');
// 为notes表添加syncStatus字段的默认值
const notesStore = transaction.objectStore('notes');
notesStore.getAll().then(notes => {
notes.forEach(note => {
if (!note.syncStatus) {
note.syncStatus = 'synced';
notesStore.put(note);
}
});
});
}
}
});
}
注意:数据库版本升级是IndexedDB中最容易出错的环节。务必确保升级逻辑的健壮性,特别是当需要迁移现有数据时。建议在开发阶段频繁测试升级路径,确保用户从任意旧版本都能平滑升级到最新版本。
2. 核心数据操作与事务管理
掌握了数据库初始化后,我们进入最核心的部分:数据操作。IndexedDB的所有操作都必须在事务中进行,这是保证数据一致性的关键,也是初学者最容易困惑的地方。
2.1 事务的基本概念与类型
IndexedDB支持三种事务模式:readonly、readwrite和versionchange。理解它们的区别对编写正确的代码至关重要:
| 事务模式 | 描述 | 使用场景 |
|---|---|---|
readonly | 只读事务,性能最佳 | 查询、统计等不修改数据的操作 |
readwrite | 读写事务,支持修改 | 增删改操作 |
versionchange | 数据库结构变更 | 仅在onupgradeneeded中自动创建 |
// db/operations.ts
import { IDBPDatabase } from 'idb';
export class NoteRepository {
constructor(private db: IDBPDatabase<NotesDBSchema>) {}
// 只读事务示例:获取所有笔记
async getAllNotes(): Promise<Note[]> {
// 使用只读事务,性能更好
const tx = this.db.transaction('notes', 'readonly');
const store = tx.objectStore('notes');
// 按更新时间倒序排列
const index = store.index('by-updated');
return index.getAll(undefined, 100); // 限制返回100条
}
// 读写事务示例:创建新笔记
async createNote(noteData: Omit<Note, 'id'>): Promise<number> {
const tx = this.db.transaction(['notes', 'syncLogs'], 'readwrite');
try {
const notesStore = tx.objectStore('notes');
const syncLogsStore = tx.objectStore('syncLogs');
// 设置时间戳和同步状态
const now = new Date();
const note: Note = {
...noteData,
createdAt: now,
updatedAt: now,
syncStatus: 'pending' // 新创建的笔记需要同步
};
// 添加笔记
const noteId = await notesStore.add(note);
// 记录同步日志
await syncLogsStore.add({
noteId,
action: 'create',
timestamp: now
});
// 等待事务完成
await tx.done;
return noteId;
} catch (error) {
// 事务会自动回滚
console.error('创建笔记失败:', error);
throw error;
}
}
}
2.2 复杂事务与原子性操作
在实际应用中,我们经常需要执行多个相关操作,这些操作要么全部成功,要么全部失败。IndexedDB的事务机制完美支持这种需求。
// db/operations.ts - 继续扩展
export class NoteRepository {
// 复杂事务示例:移动笔记到新分类并更新标签
async moveNoteToCategory(
noteId: number,
newTags: string[],
removeOldTags: boolean = false
): Promise<void> {
const tx = this.db.transaction(
['notes', 'tags', 'syncLogs'],
'readwrite'
);
try {
const notesStore = tx.objectStore('notes');
const tagsStore = tx.objectStore('tags');
const syncLogsStore = tx.objectStore('syncLogs');
// 1. 获取原始笔记
const note = await notesStore.get(noteId);
if (!note) {
throw new Error(`笔记 ${noteId} 不存在`);
}
const oldTags = [...note.tags];
// 2. 更新笔记标签
note.tags = removeOldTags ? newTags : [...new Set([...note.tags, ...newTags])];
note.updatedAt = new Date();
note.syncStatus = 'pending';
await notesStore.put(note);
// 3. 更新标签统计(需要原子性操作)
const tagUpdates = newTags.map(async tagName => {
// 查找或创建标签
const tagIndex = tagsStore.index('by-name');
let tag = await tagIndex.get(tagName);
if (tag) {
tag.noteCount += 1;
} else {
tag = {
name: tagName,
noteCount: 1,
color: this.generateTagColor(tagName)
};
}
await tagsStore.put(tag);
});
// 如果需要移除旧标签,减少计数
if (removeOldTags) {
const removeUpdates = oldTags.map(async tagName => {
const tagIndex = tagsStore.index('by-name');
const tag = await tagIndex.get(tagName);
if (tag && tag.noteCount > 1) {
tag.noteCount -= 1;
await tagsStore.put(tag);
} else if (tag) {
// 如果没有笔记使用这个标签,删除它
await tagsStore.delete(tag.id!);
}
});
await Promise.all(removeUpdates);
}
await Promise.all(tagUpdates);
// 4. 记录同步日志
await syncLogsStore.add({
noteId,
action: 'update',
timestamp: new Date()
});
await tx.done;
} catch (error) {
console.error('移动笔记失败:', error);
throw error;
}
}
private generateTagColor(tagName: string): string {
// 简单的颜色生成算法
const colors = [
'#FF6B6B', '#4ECDC4', '#FFD166', '#06D6A0',
'#118AB2', '#073B4C', '#EF476F', '#7209B7'
];
const hash = tagName.split('').reduce((acc, char) => {
return char.charCodeAt(0) + ((acc << 5) - acc);
}, 0);
return colors[Math.abs(hash) % colors.length];
}
}
2.3 事务错误处理与重试机制
网络不稳定或用户操作冲突可能导致事务失败。合理的错误处理和重试机制能显著提升应用稳定性。
// utils/transaction-retry.ts
export async function withTransactionRetry<T>(
operation: () => Promise<T>,
maxRetries: number = 3,
baseDelay: number = 100
): Promise<T> {
let lastError: Error;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error as Error;
// 判断是否为可重试的错误
if (this.isRetryableError(error)) {
if (attempt < maxRetries - 1) {
// 指数退避策略
const delay = baseDelay * Math.pow(2, attempt);
console.warn(`事务失败,${delay}ms后重试 (${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
}
// 不可重试的错误或达到最大重试次数
break;
}
}
throw lastError;
}
function isRetryableError(error: any): boolean {
// IndexedDB的可重试错误
const retryableErrors = [
'TransactionInactiveError',
'ReadOnlyError',
'ConstraintError'
];
return retryableErrors.some(errorName =>
error.name?.includes(errorName) ||
error.message?.includes(errorName)
);
}
// 使用示例
export class NoteRepository {
async safeCreateNote(noteData: Omit<Note, 'id'>): Promise<number> {
return withTransactionRetry(async () => {
return this.createNote(noteData);
});
}
async batchUpdateNotes(updates: Array<{id: number, changes: Partial<Note>}>): Promise<void> {
return withTransactionRetry(async () => {
const tx = this.db.transaction('notes', 'readwrite');
const store = tx.objectStore('notes');
const promises = updates.map(async ({id, changes}) => {
const note = await store.get(id);
if (note) {
Object.assign(note, changes, {
updatedAt: new Date(),
syncStatus: 'pending'
});
await store.put(note);
}
});
await Promise.all(promises);
await tx.done;
});
}
}
3. 高级查询与性能优化
当笔记数量增长到数千甚至数万条时,高效的查询和索引策略变得至关重要。IndexedDB提供了多种查询方式,我们需要根据具体场景选择最合适的方案。
3.1 索引设计与查询优化
合理的索引设计能大幅提升查询性能。对于笔记应用,我们通常需要支持以下几种查询模式:
- 按时间范围查询(最近修改、某时间段内的笔记)
- 按标签筛选(查找包含特定标签的笔记)
- 全文搜索(在标题和内容中搜索关键词)
- 复合条件查询(同时满足多个条件)
// db/query.ts
export class NoteQueryService {
constructor(private db: IDBPDatabase<NotesDBSchema>) {}
// 1. 按时间范围查询(使用索引)
async getNotesByDateRange(
startDate: Date,
endDate: Date,
limit: number = 50
): Promise<Note[]> {
const tx = this.db.transaction('notes', 'readonly');
const store = tx.objectStore('notes');
const index = store.index('by-updated');
// 使用IDBKeyRange创建范围
const range = IDBKeyRange.bound(startDate, endDate);
// 按时间倒序排列
return index.getAll(range, limit);
}
// 2. 按标签查询(多值索引)
async getNotesByTag(tagName: string): Promise<Note[]> {
const tx = this.db.transaction('notes', 'readonly');
const store = tx.objectStore('notes');
const index = store.index('by-tag');
// 对于多值索引,使用精确匹配
return index.getAll(IDBKeyRange.only(tagName));
}
// 3. 复合查询:多个标签 + 时间范围
async getNotesByTagsAndDate(
tags: string[],
startDate?: Date,
endDate?: Date
): Promise<Note[]> {
const tx = this.db.transaction('notes', 'readonly');
const store = tx.objectStore('notes');
// 先按时间范围筛选
let notes: Note[];
if (startDate || endDate) {
const dateIndex = store.index('by-updated');
const dateRange = IDBKeyRange.bound(
startDate || new Date(0),
endDate || new Date()
);
notes = await dateIndex.getAll(dateRange);
} else {
notes = await store.getAll();
}
// 在内存中进一步筛选标签
return notes.filter(note =>
tags.every(tag => note.tags.includes(tag))
);
}
// 4. 分页查询(游标方式,性能最佳)
async getNotesPaginated(
page: number = 1,
pageSize: number = 20,
sortBy: 'updatedAt' | 'createdAt' = 'updatedAt'
): Promise<{notes: Note[], total: number}> {
const tx = this.db.transaction('notes', 'readonly');
const store = tx.objectStore('notes');
// 获取总数
const total = await store.count();
// 计算偏移量
const offset = (page - 1) * pageSize;
// 使用游标进行分页(性能优于getAll + slice)
const index = store.index(`by-${sortBy}`);
const notes: Note[] = [];
let cursor = await index.openCursor(null, 'prev'); // 倒序,最新的在前
// 跳过前面的记录
let skipped = 0;
while (cursor && skipped < offset) {
cursor = await cursor.continue();
skipped++;
}
// 获取当前页的记录
let fetched = 0;
while (cursor && fetched < pageSize) {
notes.push(cursor.value);
cursor = await cursor.continue();
fetched++;
}
return { notes, total };
}
}
3.2 全文搜索的实现策略
IndexedDB本身不支持全文搜索,但我们可以通过几种策略来实现:
// db/search.ts
export class NoteSearchService {
constructor(private db: IDBPDatabase<NotesDBSchema>) {}
// 策略1:简单的内存搜索(适用于数据量较小的情况)
async simpleSearch(query: string): Promise<Note[]> {
const tx = this.db.transaction('notes', 'readonly');
const store = tx.objectStore('notes');
const allNotes = await store.getAll();
const lowerQuery = query.toLowerCase();
return allNotes.filter(note =>
note.title.toLowerCase().includes(lowerQuery) ||
note.content.toLowerCase().includes(lowerQuery)
);
}
// 策略2:构建搜索索引(适用于数据量较大的情况)
async buildSearchIndex(): Promise<Map<string, Set<number>>> {
const tx = this.db.transaction('notes', 'readonly');
const store = tx.objectStore('notes');
const allNotes = await store.getAll();
const index = new Map<string, Set<number>>();
allNotes.forEach(note => {
// 分词函数
const words = this.tokenize(`${note.title} ${note.content}`);
words.forEach(word => {
if (!index.has(word)) {
index.set(word, new Set());
}
index.get(word)!.add(note.id!);
});
});
// 将索引存储到IndexedDB中
const indexTx = this.db.transaction('searchIndex', 'readwrite');
const indexStore = indexTx.objectStore('searchIndex');
// 清空旧索引
await indexStore.clear();
// 存储新索引
for (const [word, noteIds] of index) {
await indexStore.put({
word,
noteIds: Array.from(noteIds)
});
}
await indexTx.done;
return index;
}
// 策略3:使用Web Workers进行后台搜索
async searchWithWorker(query: string): Promise<Note[]> {
return new Promise((resolve, reject) => {
const worker = new Worker('/workers/search.worker.js');
worker.postMessage({
type: 'SEARCH',
query,
dbName: 'notes-app'
});
worker.onmessage = (event) => {
if (event.data.type === 'SEARCH_RESULT') {
resolve(event.data.results);
worker.terminate();
} else if (event.data.type === 'SEARCH_ERROR') {
reject(new Error(event.data.error));
worker.terminate();
}
};
worker.onerror = (error) => {
reject(error);
worker.terminate();
};
});
}
private tokenize(text: string): string[] {
// 简单的分词实现
return text
.toLowerCase()
.replace(/[^\w\s]/g, ' ') // 移除标点
.split(/\s+/) // 按空白字符分割
.filter(word => word.length > 2) // 过滤短词
.filter((word, index, array) => array.indexOf(word) === index); // 去重
}
}
// workers/search.worker.js
self.onmessage = async (event) => {
if (event.data.type === 'SEARCH') {
try {
const { query, dbName } = event.data;
// 在Worker中打开数据库
const db = await openDB(dbName);
const tx = db.transaction('notes', 'readonly');
const store = tx.objectStore('notes');
const allNotes = await store.getAll();
const results = allNotes.filter(note =>
note.title.toLowerCase().includes(query.toLowerCase()) ||
note.content.toLowerCase().includes(query.toLowerCase())
);
self.postMessage({
type: 'SEARCH_RESULT',
results
});
} catch (error) {
self.postMessage({
type: 'SEARCH_ERROR',
error: error.message
});
}
}
};
3.3 性能监控与优化建议
对于数据量较大的应用,性能监控至关重要。以下是一些实用的性能优化技巧:
// utils/performance-monitor.ts
export class IndexedDBPerformanceMonitor {
private metrics = {
readOperations: 0,
writeOperations: 0,
totalReadTime: 0,
totalWriteTime: 0,
slowQueries: [] as Array<{
operation: string;
duration: number;
timestamp: Date;
}>
};
// 包装数据库操作,添加性能监控
wrapOperation<T>(
operation: () => Promise<T>,
operationName: string
): Promise<T> {
const startTime = performance.now();
return operation().finally(() => {
const duration = performance.now() - startTime;
if (operationName.startsWith('read')) {
this.metrics.readOperations++;
this.metrics.totalReadTime += duration;
} else if (operationName.startsWith('write')) {
this.metrics.writeOperations++;
this.metrics.totalWriteTime += duration;
}
// 记录慢查询
if (duration > 100) { // 超过100ms视为慢查询
this.metrics.slowQueries.push({
operation: operationName,
duration,
timestamp: new Date()
});
// 只保留最近100条慢查询记录
if (this.metrics.slowQueries.length > 100) {
this.metrics.slowQueries.shift();
}
}
});
}
getPerformanceReport() {
const avgReadTime = this.metrics.readOperations > 0
? this.metrics.totalReadTime / this.metrics.readOperations
: 0;
const avgWriteTime = this.metrics.writeOperations > 0
? this.metrics.totalWriteTime / this.metrics.writeOperations
: 0;
return {
...this.metrics,
avgReadTime,
avgWriteTime,
readThroughput: avgReadTime > 0 ? 1000 / avgReadTime : 0,
writeThroughput: avgWriteTime > 0 ? 1000 / avgWriteTime : 0
};
}
}
// 使用示例
export class OptimizedNoteRepository extends NoteRepository {
private monitor = new IndexedDBPerformanceMonitor();
async getNotesWithMonitoring(): Promise<Note[]> {
return this.monitor.wrapOperation(
() => super.getAllNotes(),
'read-all-notes'
);
}
async createNoteWithMonitoring(noteData: Omit<Note, 'id'>): Promise<number> {
return this.monitor.wrapOperation(
() => super.createNote(noteData),
'write-create-note'
);
}
}
4. 离线同步与冲突解决策略
离线应用的核心挑战在于数据同步。当用户在不同设备上编辑同一份笔记,或者离线编辑后重新联网时,我们需要处理可能的数据冲突。
4.1 同步状态管理与变更追踪
首先,我们需要一个机制来追踪哪些数据需要同步,以及同步的状态。
// sync/sync-manager.ts
export interface SyncOperation {
id?: number;
noteId: number;
action: 'create' | 'update' | 'delete';
localVersion: number; // 本地版本号
serverVersion?: number; // 服务器版本号
timestamp: Date;
data?: Partial<Note>; // 变更数据(用于冲突解决)
resolved?: boolean; // 是否已解决冲突
}
export class SyncManager {
private pendingOperations = new Map<number, SyncOperation>();
private isSyncing = false;
private syncInterval: number | null = null;
constructor(
private db: IDBPDatabase<NotesDBSchema>,
private apiClient: NoteApiClient
) {}
// 开始自动同步(每30秒检查一次)
startAutoSync(intervalMs: number = 30000): void {
if (this.syncInterval) {
clearInterval(this.syncInterval);
}
this.syncInterval = window.setInterval(() => {
if (navigator.onLine && !this.isSyncing) {
this.sync().catch(console.error);
}
}, intervalMs);
// 监听网络状态变化
window.addEventListener('online', () => {
console.log('网络恢复,开始同步');
this.sync().catch(console.error);
});
}
// 停止自动同步
stopAutoSync(): void {
if (this.syncInterval) {
clearInterval(this.syncInterval);
this.syncInterval = null;
}
}
// 记录本地变更
async recordChange(
noteId: number,
action: SyncOperation['action'],
data?: Partial<Note>
): Promise<void> {
const tx = this.db.transaction(['notes', 'syncLogs'], 'readwrite');
const notesStore = tx.objectStore('notes');
const syncLogsStore = tx.objectStore('syncLogs');
// 获取当前笔记
const note = await notesStore.get(noteId);
if (!note) {
throw new Error(`笔记 ${noteId} 不存在`);
}
// 更新本地版本号
const localVersion = (note.localVersion || 0) + 1;
note.localVersion = localVersion;
note.syncStatus = 'pending';
note.updatedAt = new Date();
await notesStore.put(note);
// 记录同步操作
const operation: SyncOperation = {
noteId,
action,
localVersion,
timestamp: new Date(),
data
};
await syncLogsStore.add(operation);
await tx.done;
// 添加到待同步队列
this.pendingOperations.set(noteId, operation);
// 如果在线,立即尝试同步
if (navigator.onLine && !this.isSyncing) {
setTimeout(() => this.sync().catch(console.error), 100);
}
}
// 执行同步
async sync(): Promise<void> {
if (this.isSyncing || this.pendingOperations.size === 0) {
return;
}
this.isSyncing = true;
try {
const operations = Array.from(this.pendingOperations.values());
// 分批同步,每批10个操作
const batchSize = 10;
for (let i = 0; i < operations.length; i += batchSize) {
const batch = operations.slice(i, i + batchSize);
await this.syncBatch(batch);
}
console.log('同步完成');
} catch (error) {
console.error('同步失败:', error);
throw error;
} finally {
this.isSyncing = false;
}
}
private async syncBatch(operations: SyncOperation[]): Promise<void> {
try {
// 发送到服务器
const response = await this.apiClient.syncNotes(operations);
// 处理服务器响应
const tx = this.db.transaction(['notes', 'syncLogs'], 'readwrite');
const notesStore = tx.objectStore('notes');
const syncLogsStore = tx.objectStore('syncLogs');
for (const result of response.results) {
if (result.success) {
// 同步成功,更新本地状态
const note = await notesStore.get(result.noteId);
if (note) {
note.syncStatus = 'synced';
note.serverVersion = result.serverVersion;
note.updatedAt = new Date();
await notesStore.put(note);
}
// 从待同步队列中移除
this.pendingOperations.delete(result.noteId);
// 标记同步日志为已处理
if (result.operationId) {
await syncLogsStore.delete(result.operationId);
}
} else if (result.conflict) {
// 检测到冲突
await this.handleConflict(result.noteId, result.serverData);
}
}
await tx.done;
} catch (error) {
console.error('批量同步失败:', error);
throw error;
}
}
}
4.2 冲突检测与解决策略
当多个客户端同时修改同一份数据时,冲突不可避免。我们需要一个健壮的冲突解决策略。
// sync/conflict-resolver.ts
export interface ConflictResolution {
noteId: number;
strategy: 'client-wins' | 'server-wins' | 'merge' | 'manual';
resolvedData: Note;
resolutionTime: Date;
}
export class ConflictResolver {
constructor(private db: IDBPDatabase<NotesDBSchema>) {}
// 检测冲突
async detectConflict(
noteId: number,
serverData: Note
): Promise<boolean> {
const tx = this.db.transaction('notes', 'readonly');
const store = tx.objectStore('notes');
const localNote = await store.get(noteId);
if (!localNote) {
return false;
}
// 简单的版本号冲突检测
if (serverData.serverVersion && localNote.serverVersion) {
return serverData.serverVersion > localNote.serverVersion &&
localNote.syncStatus === 'pending';
}
// 基于时间戳的冲突检测
const serverTime = new Date(serverData.updatedAt).getTime();
const localTime = new Date(localNote.updatedAt).getTime();
// 如果服务器版本更新,且本地有未同步的修改,则存在冲突
return serverTime > localTime && localNote.syncStatus === 'pending';
}
// 自动解决冲突(基于策略)
async resolveConflict(
noteId: number,
serverData: Note,
strategy: ConflictResolution['strategy'] = 'merge'
): Promise<ConflictResolution> {
const tx = this.db.transaction('notes', 'readwrite');
const store = tx.objectStore('notes');
const localNote = await store.get(noteId);
if (!localNote) {
throw new Error(`笔记 ${noteId} 不存在`);
}
let resolvedData: Note;
switch (strategy) {
case 'client-wins':
// 客户端优先:保留本地修改,但更新服务器版本号
resolvedData = {
...localNote,
serverVersion: serverData.serverVersion,
syncStatus: 'synced' as const,
updatedAt: new Date()
};
break;
case 'server-wins':
// 服务器优先:使用服务器数据
resolvedData = {
...serverData,
id: noteId,
syncStatus: 'synced' as const
};
break;
case 'merge':
// 合并策略:智能合并双方修改
resolvedData = this.mergeNotes(localNote, serverData);
break;
case 'manual':
// 手动解决:标记为冲突,等待用户处理
resolvedData = {
...localNote,
syncStatus: 'conflict' as const,
conflictWith: serverData
};
break;
default:
throw new Error(`未知的冲突解决策略: ${strategy}`);
}
await store.put(resolvedData);
await tx.done;
return {
noteId,
strategy,
resolvedData,
resolutionTime: new Date()
};
}
// 智能合并算法
private mergeNotes(localNote: Note, serverNote: Note): Note {
// 简单的合并策略:以最新修改为准
const localTime = new Date(localNote.updatedAt).getTime();
const serverTime = new Date(serverNote.updatedAt).getTime();
const baseNote = serverTime > localTime ? serverNote : localNote;
const otherNote = serverTime > localTime ? localNote : serverNote;
// 合并标题:如果双方都修改了标题,添加冲突标记
let mergedTitle = baseNote.title;
if (baseNote.title !== otherNote.title &&
baseNote.updatedAt !== otherNote.updatedAt) {
mergedTitle = `${baseNote.title} (合并自: ${otherNote.title})`;
}
// 合并内容:使用操作变换(OT)或简单的追加
let mergedContent = baseNote.content;
if (baseNote.content !== otherNote.content) {
mergedContent = this.mergeContent(baseNote.content, otherNote.content);
}
// 合并标签:取并集
const mergedTags = Array.from(
new Set([...baseNote.tags, ...otherNote.tags])
);
return {
...baseNote,
id: localNote.id, // 保持原有ID
title: mergedTitle,
content: mergedContent,
tags: mergedTags,
syncStatus: 'synced' as const,
updatedAt: new Date(),
serverVersion: Math.max(
localNote.serverVersion || 0,
serverNote.serverVersion || 0
) + 1
};
}
private mergeContent(baseContent: string, otherContent: string): string {
// 简单的合并:检测是否只是追加
if (baseContent.includes(otherContent)) {
return baseContent;
}
if (otherContent.includes(baseContent)) {
return otherContent;
}
// 复杂合并:使用分隔符合并
return `${baseContent}\n\n--- 合并内容 ---\n\n${otherContent}`;
}
// 获取所有冲突笔记
async getConflictingNotes(): Promise<Note[]> {
const tx = this.db.transaction('notes', 'readonly');
const store = tx.objectStore('notes');
const index = store.index('by-sync-status');
return index.getAll(IDBKeyRange.only('conflict'));
}
// 手动解决冲突
async manualResolveConflict(
noteId: number,
resolution: 'keep-local' | 'use-server' | 'custom',
customData?: Partial<Note>
): Promise<void> {
const tx = this.db.transaction('notes', 'readwrite');
const store = tx.objectStore('notes');
const note = await store.get(noteId);
if (!note || note.syncStatus !== 'conflict') {
throw new Error(`笔记 ${noteId} 没有冲突需要解决`);
}
let resolvedNote: Note;
switch (resolution) {
case 'keep-local':
resolvedNote = {
...note,
syncStatus: 'pending' as const,
conflictWith: undefined
};
break;
case 'use-server':
if (!note.conflictWith) {
throw new Error('服务器数据不存在');
}
resolvedNote = {
...note.conflictWith,
id: noteId,
syncStatus: 'pending' as const,
conflictWith: undefined
};
break;
case 'custom':
if (!customData) {
throw new Error('自定义数据不能为空');
}
resolvedNote = {
...note,
...customData,
syncStatus: 'pending' as const,
conflictWith: undefined,
updatedAt: new Date()
};
break;
}
await store.put(resolvedNote);
await tx.done;
}
}
4.3 离线优先的UI/UX设计
良好的用户体验需要UI能够反映数据的同步状态,并在离线时提供清晰的反馈。
// ui/sync-status.tsx
import React, { useState, useEffect } from 'react';
import { SyncManager } from '../sync/sync-manager';
interface SyncStatusProps {
syncManager: SyncManager;
}
export const SyncStatus: React.FC<SyncStatusProps> = ({ syncManager }) => {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [pendingCount, setPendingCount] = useState(0);
const [lastSync, setLastSync] = useState<Date | null>(null);
const [isSyncing, setIsSyncing] = useState(false);
useEffect(() => {
// 监听网络状态
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// 定期检查待同步数量
const checkPending = () => {
// 这里需要从syncManager获取实际数量
// 简化示例
setPendingCount(Math.floor(Math.random() * 10)); // 模拟数据
};
const interval = setInterval(checkPending, 5000);
checkPending();
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
clearInterval(interval);
};
}, [syncManager]);
const handleManualSync = async () => {
if (!isOnline || isSyncing) return;
setIsSyncing(true);
try {
await syncManager.sync();
setLastSync(new Date());
} catch (error) {
console.error('手动同步失败:', error);
} finally {
setIsSyncing(false);
}
};
return (
<div className="sync-status">
<div className="status-indicator">
<span className={`status-dot ${isOnline ? 'online' : 'offline'}`} />
<span className="status-text">
{isOnline ? '在线' : '离线'}
</span>
</div>
{pendingCount > 0 && (
<div className="pending-count">
<span className="count-badge">{pendingCount}</span>
<span className="count-text">待同步</span>
</div>
)}
{lastSync && (
<div className="last-sync">
最后同步: {lastSync.toLocaleTimeString()}
</div>
)}
<button
className="sync-button"
onClick={handleManualSync}
disabled={!isOnline || isSyncing || pendingCount === 0}
>
{isSyncing ? '同步中...' : '立即同步'}
</button>
{!isOnline && (
<div className="offline-warning">
<span className="warning-icon">⚠️</span>
<span>当前处于离线状态,更改将在恢复网络后同步</span>
</div>
)}
</div>
);
};
// 样式示例
const styles = `
.sync-status {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: #f5f5f5;
border-radius: 6px;
font-size: 14px;
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.status-dot.online {
background-color: #4caf50;
animation: pulse 2s infinite;
}
.status-dot.offline {
background-color: #f44336;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.pending-count {
display: flex;
align-items: center;
gap: 4px;
}
.count-badge {
background: #ff9800;
color: white;
border-radius: 10px;
padding: 2px 8px;
font-size: 12px;
font-weight: bold;
}
.sync-button {
background: #2196f3;
color: white;
border: none;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
font-size: 14px;
}
.sync-button:disabled {
background: #ccc;
cursor: not-allowed;
}
.offline-warning {
display: flex;
align-items: center;
gap: 6px;
color: #f44336;
font-size: 12px;
}
.warning-icon {
font-size: 16px;
}
`;
5. 实战:构建完整的离线笔记应用
现在让我们将所有组件组合起来,构建一个完整的离线笔记应用。这个应用将展示如何在实际项目中使用IndexedDB处理复杂的数据操作。
5.1 应用架构与状态管理
// app/note-app.ts
import { initDatabase } from '../db/init';
import { NoteRepository } from '../db/operations';
import { NoteQueryService } from '../db/query';
import { SyncManager } from '../sync/sync-manager';
import { ConflictResolver } from '../sync/conflict-resolver';
import { NoteApiClient } from '../api/client';
export class NoteApp {
private db: IDBPDatabase<NotesDBSchema> | null = null;
private noteRepo: NoteRepository | null = null;
private queryService: NoteQueryService | null = null;
private syncManager: SyncManager | null = null;
private conflictResolver: ConflictResolver | null = null;
private isInitialized = false;
private initializationPromise: Promise<void> | null = null;
// 单例模式
private static instance: NoteApp;
static getInstance(): NoteApp {
if (!NoteApp.instance) {
NoteApp.instance = new NoteApp();
}
return NoteApp.instance;
}
private constructor() {}
// 初始化应用
async initialize(): Promise<void> {
if (this.isInitialized) return;
if (this.initializationPromise) {
return this.initializationPromise;
}
this.initializationPromise = this._initialize();
return this.initializationPromise;
}
private async _initialize(): Promise<void> {
try {
// 1. 初始化数据库
this.db = await initDatabase();
// 2. 初始化各个服务
this.noteRepo = new NoteRepository(this.db);
this.queryService = new NoteQueryService(this.db);
this.conflictResolver = new ConflictResolver(this.db);
// 3. 初始化API客户端和同步管理器
const apiClient = new NoteApiClient({
baseURL: process.env.API_BASE_URL,
timeout: 10000
});
this.syncManager = new SyncManager(this.db, apiClient);
// 4. 启动自动同步
this.syncManager.startAutoSync();
// 5. 检查并解决现有冲突
await this.checkAndResolveConflicts();
this.isInitialized = true;
console.log('笔记应用初始化完成');
} catch (error) {
console.error('应用初始化失败:', error);
throw error;
}
}
// 笔记CRUD操作
async createNote(title: string, content: string, tags: string[] = []): Promise<number> {
await this.ensureInitialized();
const noteData = {
title,
content,
tags,
createdAt: new Date(),
updatedAt: new Date(),
isArchived: false,
syncStatus: 'pending' as const
};
const noteId = await this.noteRepo!.createNote(noteData);
// 记录同步操作
await this.syncManager!.recordChange(noteId, 'create', noteData);
return noteId;
}
async updateNote(
noteId: number,
updates: Partial<Omit<Note, 'id'>>
): Promise<void> {
await this.ensureInitialized();
const tx = this.db!.transaction('notes', 'readwrite');
const store = tx.objectStore('notes');
const note = await store.get(noteId);
if (!note) {
throw new Error(`笔记 ${noteId} 不存在`);
}
const updatedNote = {
...note,
...updates,
updatedAt: new Date(),
syncStatus: 'pending' as const
};
await store.put(updatedNote);
await tx.done;
// 记录同步操作
await this.syncManager!.recordChange(noteId, 'update', updates);
}
async deleteNote(noteId: number): Promise<void> {
await this.ensureInitialized();
const tx = this.db!.transaction(['notes', 'attachments'], 'readwrite');
const notesStore = tx.objectStore('notes');
const attachmentsStore = tx.objectStore('attachments');
// 删除笔记
await notesStore.delete(noteId);
// 删除关联的附件
const attachmentsIndex = attachmentsStore.index('by-note-id');
const attachments = await attachmentsIndex.getAll(IDBKeyRange.only(noteId));
for (const attachment of attachments) {
await attachmentsStore.delete(attachment.id!);
}
await tx.done;
// 记录同步操作
await this.syncManager!.recordChange(noteId, 'delete');
}
// 查询方法
async searchNotes(query: string, options: {
tags?: string[];
dateRange?: { start?: Date; end?: Date };
page?: number;
pageSize?: number;
} = {}): Promise<{notes: Note[], total: number}> {
await this.ensureInitialized();
const { tags, dateRange, page = 1, pageSize = 20 } = options;
// 如果有搜索词,使用全文搜索
if (query.trim()) {
const searchService = new NoteSearchService(this.db!);
const searchResults = await searchService.searchWithWorker(query);
// 进一步筛选
let filtered = searchResults;
if (tags && tags.length > 0) {
filtered = filtered.filter(note =>
tags.every(tag => note.tags.includes(tag))
);
}
if (dateRange) {
filtered = filtered.filter(note => {
const noteTime = new Date(note.updatedAt).getTime();
const startTime = dateRange.start?.getTime() || 0;
const endTime = dateRange.end?.getTime() || Date.now();
return noteTime >= startTime && noteTime <= endTime;
});
}
// 分页
const start = (page - 1) * pageSize;
const end = start + pageSize;
const paginated = filtered.slice(start, end);
return {
notes: paginated,
total: filtered.length
};
}
// 如果没有搜索词,使用常规查询
if (tags && tags.length > 0) {
if (tags.length === 1) {
// 单个标签,使用索引查询
return {
notes: await this.queryService!.getNotesByTag(tags[0]),
total: 0 // 简化处理
};
} else {
// 多个标签,使用复合查询
return {
notes: await this.queryService!.getNotesByTagsAndDate(
tags,
dateRange?.start,
dateRange?.end
),
total: 0
};
}
}
// 分页查询
return this.queryService!.getNotesPaginated(page, pageSize);
}
// 冲突处理
private async checkAndResolveConflicts(): Promise<void> {
const conflicts = await this.conflictResolver!.getConflictingNotes();
if (conflicts.length > 0) {
console.warn(`发现 ${conflicts.length} 个冲突需要处理`);
// 自动解决简单冲突
for (const conflict of conflicts) {
if (conflict.conflictWith) {
const shouldAutoResolve = this.shouldAutoResolveConflict(
conflict,
conflict.conflictWith
);
if (shouldAutoResolve) {
await this.conflictResolver!.resolveConflict(
conflict.id!,
conflict.conflictWith,
'merge'
);
console.log(`自动解决笔记 ${conflict.id} 的冲突`);
}
}
}
}
}
private shouldAutoResolveConflict(local: Note, server: Note): boolean {
// 简单的自动解决策略:如果只有一方修改了内容,自动解决
const localTime = new Date(local.updatedAt).getTime();
const serverTime = new Date(server.updatedAt).getTime();
// 如果时间差小于5分钟,且内容差异不大,自动合并
const timeDiff = Math.abs(localTime - serverTime);
const contentDiff = this.calculateContentDiff(local.content, server.content);
return timeDiff < 5 * 60 * 1000 && contentDiff < 0.1; // 10%以内的差异
}
private calculateContentDiff(content1: string, content2: string): number {
// 简单的文本差异计算
const words1 = content1.split(/\s+/);
const words2 = content2.split(/\s+/);
const set1 = new Set(words1);
const set2 = new Set(words2);
const intersection = new Set([...set1].filter(x => set2.has(x)));
const union = new Set([...set1, ...set2]);
return 1 - (intersection.size / union.size);
}
private async ensureInitialized(): Promise<void> {
if (!this.isInitialized) {
await this.initialize();
}
}
// 清理资源
async cleanup(): Promise<void> {
if (this.syncManager) {
this.syncManager.stopAutoSync();
}
if (this.db) {
this.db.close();
}
this.isInitialized = false;
this.initializationPromise = null;
}
}
5.2 错误处理与数据恢复
在生产环境中,我们需要考虑各种异常情况,并提供数据恢复机制。
// app/error-handler.ts
export class NoteAppErrorHandler {
private errorCount = new Map<string, number>();
private readonly MAX_RETRIES = 3;
async handleDatabaseError(error: Error, operation: string): Promise<void> {
console.error(`数据库操作失败 [${operation}]:`, error);
const errorKey = `${operation}:${error.name}`;
const count = this.errorCount.get(errorKey) || 0;
if (count >= this.MAX_RETRIES) {
throw new Error(`操作失败,已重试${this.MAX_RETRIES}次: ${error.message}`);
}
this.errorCount.set(errorKey, count + 1);
// 根据错误类型采取不同策略
if (error.name.includes('QuotaExceededError')) {
await this.handleStorageQuotaExceeded();
} else if (error.name.includes('VersionError')) {
await this.handleVersionError();
} else if (error.name.includes('ConstraintError')) {
await this.handleConstraintError(error, operation);
} else {
// 通用重试
await this.retryWithBackoff(operation, count + 1);
}
}
private async handleStorageQuotaExceeded(): Promise<void> {
console.warn('存储空间不足,尝试清理缓存');
// 1. 清理过期的同步日志
const db = await initDatabase();
const tx = db.transaction('syncLogs', 'readwrite');
const store = tx.objectStore('syncLogs');
const index = store.index('by-timestamp');
// 删除30天前的日志
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const range = IDBKeyRange.upperBound(thirtyDaysAgo);
const oldLogs = await index.getAll(range);
for (const log of oldLogs) {
await store.delete(log.id!);
}
await tx.done;
db.close();
// 2. 清理已删除笔记的附件
await this.cleanupOrphanedAttachments();
// 3. 如果还是空间不足,提示用户
if (!await this.checkStorageQuota()) {
throw new Error('存储空间不足,请手动清理数据');
}
}
private async handleVersionError(): Promise<void> {
console.warn('数据库版本错误,尝试重新初始化');
// 备份现有数据
const backup = await this.createBackup();
try {
// 删除并重建数据库
await this.recreateDatabase();
// 恢复数据
await this.restoreFromBackup(backup);
} catch (recoveryError) {
console.error('数据库恢复失败:', recoveryError);
throw new Error('数据库损坏,已重置为初始状态');
}
}
private async handleConstraintError(error: Error, operation: string): Promise<void> {
console.warn(`约束错误 [${operation}]:`, error);
// 对于唯一约束错误,尝试生成新的ID
if (error.message.includes('unique')) {
await new Promise(resolve => setTimeout(resolve, 100));
// 重试操作时会生成新的ID
}
}
private async retryWithBackoff(operation: string, attempt: number): Promise<void> {
const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
console.log(`第${attempt}次重试 [${operation}],等待${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
}
private async createBackup(): Promise<BackupData> {
const db = await initDatabase();
const tx = db.transaction(
['notes', 'tags', 'attachments', 'syncLogs'],
'readonly'
);
const backup: BackupData = {
timestamp: new Date(),
notes: await tx.objectStore('notes').getAll(),
tags: await tx.objectStore('tags').getAll(),
attachments: await tx.objectStore('attachments').getAll(),
syncLogs: await tx.objectStore('syncLogs').getAll()
};
await tx.done;
db.close();
// 压缩备份数据
const compressed = this.compressBackup(backup);
// 存储到localStorage(作为最后的手段)
try {
localStorage.setItem('notes-app-backup', JSON.stringify(compressed));
localStorage.setItem('notes-app-backup-time', new Date().toISOString());
} catch (e) {
console.warn('无法存储备份到localStorage:', e);
}
return backup;
}
private async recreateDatabase(): Promise<void> {
// 关闭现有连接
if (NoteApp.getInstance()) {
await NoteApp.getInstance().cleanup();
}
// 删除数据库
const dbName = 'notes-app';
const req = indexedDB.deleteDatabase(dbName);
await new Promise<void>((resolve, reject) => {
req.onsuccess = () => resolve();
req.onerror = () => reject(new Error('删除数据库失败'));
req.onblocked = () => {
console.warn('数据库被其他标签页占用,请关闭其他标签页后重试');
reject(new Error('数据库被占用'));
};
});
// 重新初始化
await NoteApp.getInstance().initialize();
}
private compressBackup(backup: BackupData): CompressedBackup {
// 简单的压缩:移除元数据,只保留必要数据
return {
timestamp: backup.timestamp,
notes: backup.notes.map(note => ({
title: note.title,
content: note.content,
tags: note.tags,
createdAt: note.createdAt,
updatedAt: note.updatedAt
})),
tags: backup.tags
};
}
private async checkStorageQuota(): Promise<boolean> {
if ('storage' in navigator && 'estimate' in navigator.storage) {
try {
const estimate = await navigator.storage.estimate();
const usageRatio = (estimate.usage || 0) / (estimate.quota || 1);
return usageRatio < 0.9; // 使用率低于90%
} catch (e) {
console.warn('无法获取存储配额:', e);
}
}
return true; // 如果不支持配额API,假设空间足够
}
}
interface BackupData {
timestamp: Date;
notes: Note[];
tags: Tag[];
attachments: Attachment[];
syncLogs: SyncLog[];
}
interface CompressedBackup {
timestamp: Date;
notes: Array<Pick<Note, 'title' | 'content' | 'tags' | 'createdAt' | 'updatedAt'>>;
tags: Tag[];
}
5.3 性能优化与最佳实践总结
经过前面的实战,我们总结出一些IndexedDB的最佳实践:
- 合理设计数据库结构:根据查询模式创建索引,避免全表扫描
- 使用事务批处理:将多个操作放在一个事务中,减少事务开销
- 实现优雅的错误处理:区分可重试错误和不可恢复错误
- 监控性能指标:跟踪查询时间、事务成功率等关键指标
- 提供数据恢复机制:定期备份,支持从错误中恢复
- 优化存储空间:清理过期数据,监控存储配额
- 实现智能同步:冲突检测与自动解决策略
- 考虑离线体验:UI反映同步状态,提供离线提示
在实际项目中,我遇到过这样一个案例:一个团队协作的笔记应用,用户经常在移动网络不稳定的环境下使用。最初版本没有完善的冲突解决机制,导致用户经常丢失数据。后来我们实现了基于操作变换(OT)的冲突解决算法,并添加了详细的同步日志,问题才得到彻底解决。关键是要理解,离线同步不是简单的“最后写入获胜”,而是需要根据业务逻辑设计合适的合并策略。
另一个重要的经验是:一定要在生产环境中监控IndexedDB的性能。我们曾经发现某个查询在数据量达到5000条时变得异常缓慢,通过分析发现是因为没有正确使用索引。添加合适的索引后,查询时间从几秒降低到几十毫秒。
最后,记住IndexedDB虽然强大,但也不是万能的。对于特别复杂的关系型数据操作,或者需要跨浏览器标签页实时同步的场景,可能需要结合其他技术(如WebSocket、Service Workers等)。选择合适的技术组合,才能构建出既强大又用户友好的Web应用。
1581


&spm=1001.2101.3001.11974&articleId=153503038&d=1&t=3&u=999968883a7f47fbb833e84f498feec3)

被折叠的 条评论
为什么被折叠?



