欢迎大家加入开源鸿蒙跨平台开发者社区,一起共建开源鸿蒙跨平台生态。

本文对应模块:Web 层的数据持久化实现,包括 IndexedDB 数据库的初始化、CRUD 操作、高级查询、事务处理,以及与原生层数据存储的同步机制。

📌 概述

IndexedDB 是浏览器提供的一个本地数据库 API,用于在客户端存储大量数据。在 HarmonyOS Cordova 应用中,IndexedDB 是实现数据持久化的最佳选择。待办事项应用需要存储任务、分类、标签、提醒、笔记、习惯、习惯记录和目标等多种数据。数据库采用 IndexedDB 实现,包含 8 个对象存储(表)。tasks 表存储任务信息,包括标题、描述、状态、优先级、分类、截止日期等字段。categories 表存储分类信息,包括名称、图标和颜色。tags 表存储标签,reminders 表存储提醒时间,notes 表存储笔记内容,habits 表存储习惯信息,habitRecords 表存储习惯打卡记录,goals 表存储目标信息。

🔗 数据库初始化流程

当应用启动时,Web 层会首先初始化 IndexedDB 数据库。初始化过程包括打开数据库连接、创建必要的对象存储(表)和索引。如果数据库版本号增加,会触发 onupgradeneeded 事件,在这个事件中创建新的表和索引。初始化完成后,应用就可以进行数据的增删改查操作。整个初始化过程是异步的,使用 Promise 来处理成功和失败的情况。

💾 数据库初始化

完整的数据库初始化代码

/**
 * 数据库模块 - IndexedDB 实现
 * 负责所有数据的持久化存储和查询
 */

class DatabaseModule {
    constructor() {
        this.dbName = 'TodoAppDB';
        this.version = 2;
        this.db = null;
        this.isInitialized = false;
    }

    /**
     * 初始化数据库
     * 创建所有必要的表和索引
     */
    async init() {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(this.dbName, this.version);

            request.onerror = () => {
                console.error('[DB] 数据库打开失败:', request.error);
                reject(request.error);
            };

            request.onsuccess = () => {
                this.db = request.result;
                this.isInitialized = true;
                console.log('[DB] 数据库初始化成功');
                resolve(this.db);
            };

            request.onupgradeneeded = (event) => {
                const db = event.target.result;
                console.log('[DB] 创建数据库表...');

                // 创建任务表
                if (!db.objectStoreNames.contains('tasks')) {
                    const taskStore = db.createObjectStore('tasks', { 
                        keyPath: 'id', 
                        autoIncrement: true 
                    });
                    // 创建索引以加快查询速度
                    taskStore.createIndex('status', 'status', { unique: false });
                    taskStore.createIndex('category', 'category', { unique: false });
                    taskStore.createIndex('priority', 'priority', { unique: false });
                    taskStore.createIndex('dueDate', 'dueDate', { unique: false });
                    taskStore.createIndex('createdAt', 'createdAt', { unique: false });
                    taskStore.createIndex('icon', 'icon', { unique: false });
                    taskStore.createIndex('parentId', 'parentId', { unique: false });
                    console.log('[DB] 任务表创建成功');
                }

                // 创建分类表
                if (!db.objectStoreNames.contains('categories')) {
                    const categoryStore = db.createObjectStore('categories', { 
                        keyPath: 'id', 
                        autoIncrement: true 
                    });
                    categoryStore.createIndex('name', 'name', { unique: true });
                    console.log('[DB] 分类表创建成功');
                }

                // 创建标签表
                if (!db.objectStoreNames.contains('tags')) {
                    const tagStore = db.createObjectStore('tags', { 
                        keyPath: 'id', 
                        autoIncrement: true 
                    });
                    tagStore.createIndex('name', 'name', { unique: true });
                    console.log('[DB] 标签表创建成功');
                }

                // 创建提醒表
                if (!db.objectStoreNames.contains('reminders')) {
                    const reminderStore = db.createObjectStore('reminders', { 
                        keyPath: 'id', 
                        autoIncrement: true 
                    });
                    reminderStore.createIndex('taskId', 'taskId', { unique: false });
                    reminderStore.createIndex('reminderTime', 'reminderTime', { unique: false });
                    console.log('[DB] 提醒表创建成功');
                }

                // 创建笔记表
                if (!db.objectStoreNames.contains('notes')) {
                    const noteStore = db.createObjectStore('notes', { 
                        keyPath: 'id', 
                        autoIncrement: true 
                    });
                    noteStore.createIndex('taskId', 'taskId', { unique: false });
                    noteStore.createIndex('createdAt', 'createdAt', { unique: false });
                    console.log('[DB] 笔记表创建成功');
                }

                // 创建习惯表
                if (!db.objectStoreNames.contains('habits')) {
                    const habitStore = db.createObjectStore('habits', { 
                        keyPath: 'id', 
                        autoIncrement: true 
                    });
                    habitStore.createIndex('createdAt', 'createdAt', { unique: false });
                    console.log('[DB] 习惯表创建成功');
                }

                // 创建习惯记录表
                if (!db.objectStoreNames.contains('habitRecords')) {
                    const recordStore = db.createObjectStore('habitRecords', { 
                        keyPath: 'id', 
                        autoIncrement: true 
                    });
                    recordStore.createIndex('habitId', 'habitId', { unique: false });
                    recordStore.createIndex('date', 'date', { unique: false });
                    console.log('[DB] 习惯记录表创建成功');
                }

                // 创建目标表
                if (!db.objectStoreNames.contains('goals')) {
                    const goalStore = db.createObjectStore('goals', { 
                        keyPath: 'id', 
                        autoIncrement: true 
                    });
                    goalStore.createIndex('status', 'status', { unique: false });
                    goalStore.createIndex('deadline', 'deadline', { unique: false });
                    console.log('[DB] 目标表创建成功');
                }
            };
        });
    }

    /**
     * 获取对象存储
     * @param {string} storeName - 表名
     * @param {string} mode - 访问模式 ('readonly' 或 'readwrite')
     */
    getObjectStore(storeName, mode = 'readonly') {
        const transaction = this.db.transaction(storeName, mode);
        return transaction.objectStore(storeName);
    }
}

// 创建全局数据库实例
const db = new DatabaseModule();

代码解释:

DatabaseModule 类的 init() 方法使用 indexedDB.open() 打开或创建数据库,第二个参数是版本号。当数据库版本升级时,会触发 onupgradeneeded 事件,在这个事件中创建所有必要的表和索引。createObjectStore() 方法创建表,keyPath 指定主键字段,autoIncrement 表示主键自动递增。createIndex() 方法为表创建索引,加快查询速度,unique 参数表示索引值是否唯一。getObjectStore() 方法获取表的引用,用于执行 CRUD 操作,mode 参数指定访问模式(readonly 或 readwrite)。整个初始化过程使用 Promise 来处理异步操作,成功时返回数据库实例,失败时返回错误信息。

🔍 CRUD 操作实现

添加数据

/**
 * 添加任务
 * @param {Object} taskData - 任务数据
 */
async addTask(taskData) {
    return new Promise((resolve, reject) => {
        const store = this.getObjectStore('tasks', 'readwrite');
        
        // 准备任务数据
        const task = {
            title: taskData.title,
            description: taskData.description || '',
            status: 'pending',
            priority: taskData.priority || 'medium',
            category: taskData.category || 'default',
            dueDate: taskData.dueDate || null,
            createdAt: new Date().toISOString(),
            completedAt: null,
            icon: taskData.icon || '📋',
            parentId: taskData.parentId || null,
            tags: taskData.tags || [],
            notes: taskData.notes || []
        };

        const request = store.add(task);

        request.onsuccess = () => {
            console.log('[DB] 任务添加成功, ID:', request.result);
            resolve(request.result);
        };

        request.onerror = () => {
            console.error('[DB] 任务添加失败:', request.error);
            reject(request.error);
        };
    });
}

代码解释:

addTask() 方法首先调用 getObjectStore(‘tasks’, ‘readwrite’) 获取任务表的写入权限。然后准备任务数据,包括标题、描述、状态、优先级等字段,并为缺失的字段设置默认值。createdAt 字段自动设置为当前时间,completedAt 初始为 null。然后调用 store.add() 方法添加新记录到数据库。如果主键已存在,add() 会报错,此时应该使用 put() 方法进行更新。request.onsuccess 回调在添加成功时触发,返回新记录的自动生成 ID。request.onerror 回调在添加失败时触发,返回错误信息。整个操作被包装在 Promise 中,便于使用 async/await 语法。

查询数据

/**
 * 获取所有任务
 */
async getAllTasks() {
    return new Promise((resolve, reject) => {
        const store = this.getObjectStore('tasks', 'readonly');
        const request = store.getAll();

        request.onsuccess = () => {
            const tasks = request.result;
            console.log('[DB] 获取所有任务:', tasks.length, '条');
            resolve(tasks);
        };

        request.onerror = () => {
            console.error('[DB] 获取任务失败:', request.error);
            reject(request.error);
        };
    });
}

/**
 * 按状态查询任务
 * @param {string} status - 任务状态 ('pending' 或 'completed')
 */
async getTasksByStatus(status) {
    return new Promise((resolve, reject) => {
        const store = this.getObjectStore('tasks', 'readonly');
        const index = store.index('status');
        const request = index.getAll(status);

        request.onsuccess = () => {
            const tasks = request.result;
            console.log('[DB] 按状态查询任务:', status, tasks.length, '条');
            resolve(tasks);
        };

        request.onerror = () => {
            console.error('[DB] 查询失败:', request.error);
            reject(request.error);
        };
    });
}

/**
 * 按分类查询任务
 * @param {string} category - 分类名称
 */
async getTasksByCategory(category) {
    return new Promise((resolve, reject) => {
        const store = this.getObjectStore('tasks', 'readonly');
        const index = store.index('category');
        const request = index.getAll(category);

        request.onsuccess = () => {
            resolve(request.result);
        };

        request.onerror = () => {
            reject(request.error);
        };
    });
}

/**
 * 按 ID 获取单个任务
 * @param {number} id - 任务 ID
 */
async getTaskById(id) {
    return new Promise((resolve, reject) => {
        const store = this.getObjectStore('tasks', 'readonly');
        const request = store.get(id);

        request.onsuccess = () => {
            resolve(request.result);
        };

        request.onerror = () => {
            reject(request.error);
        };
    });
}

代码解释:

getAllTasks() 方法使用 store.getAll() 获取任务表中的所有记录,返回一个数组。getTasksByStatus() 方法首先获取 status 索引的引用,然后使用 index.getAll(status) 查询所有状态匹配的任务。这种方法比全表扫描更高效,因为索引已经按状态排序。getTasksByCategory() 方法类似,使用 category 索引查询特定分类的任务。getTaskById() 方法使用 store.get(id) 按主键获取单个任务,这是最快的查询方式,因为主键是唯一的。所有查询方法都返回 Promise,便于使用 async/await 语法。

更新数据

/**
 * 更新任务
 * @param {number} id - 任务 ID
 * @param {Object} updates - 要更新的字段
 */
async updateTask(id, updates) {
    return new Promise((resolve, reject) => {
        const store = this.getObjectStore('tasks', 'readwrite');
        const getRequest = store.get(id);

        getRequest.onsuccess = () => {
            const task = getRequest.result;
            if (!task) {
                reject(new Error('任务不存在'));
                return;
            }

            // 合并更新数据
            const updatedTask = { ...task, ...updates };
            const updateRequest = store.put(updatedTask);

            updateRequest.onsuccess = () => {
                console.log('[DB] 任务更新成功, ID:', id);
                resolve(updatedTask);
            };

            updateRequest.onerror = () => {
                console.error('[DB] 任务更新失败:', updateRequest.error);
                reject(updateRequest.error);
            };
        };

        getRequest.onerror = () => {
            reject(getRequest.error);
        };
    });
}

/**
 * 完成任务
 * @param {number} id - 任务 ID
 */
async completeTask(id) {
    return this.updateTask(id, {
        status: 'completed',
        completedAt: new Date().toISOString()
    });
}

代码解释:

updateTask() 方法首先调用 store.get(id) 获取现有的任务记录。如果任务不存在,会抛出错误。如果任务存在,使用对象展开运算符(…)将更新的字段合并到现有任务对象中。然后调用 store.put() 方法更新记录。put() 方法与 add() 不同,如果主键已存在会更新,如果不存在会插入新记录。这种先查询再更新的模式确保了数据的完整性,避免了覆盖其他字段。completeTask() 方法是 updateTask() 的便利方法,用于标记任务为已完成,自动设置 status 为 ‘completed’ 和 completedAt 为当前时间。

删除数据

/**
 * 删除任务
 * @param {number} id - 任务 ID
 */
async deleteTask(id) {
    return new Promise((resolve, reject) => {
        const store = this.getObjectStore('tasks', 'readwrite');
        const request = store.delete(id);

        request.onsuccess = () => {
            console.log('[DB] 任务删除成功, ID:', id);
            resolve();
        };

        request.onerror = () => {
            console.error('[DB] 任务删除失败:', request.error);
            reject(request.error);
        };
    });
}

/**
 * 清空所有任务
 */
async clearAllTasks() {
    return new Promise((resolve, reject) => {
        const store = this.getObjectStore('tasks', 'readwrite');
        const request = store.clear();

        request.onsuccess = () => {
            console.log('[DB] 所有任务已清空');
            resolve();
        };

        request.onerror = () => {
            reject(request.error);
        };
    });
}

代码解释:

deleteTask() 方法使用 store.delete(id) 删除指定 ID 的任务记录。delete() 方法是异步的,成功时触发 onsuccess 回调。clearAllTasks() 方法使用 store.clear() 清空任务表中的所有记录,这是一个危险操作,应该谨慎使用。clear() 方法会删除表中的所有数据,但不会删除表本身或其索引。这两个方法都返回 Promise,便于在异步流程中使用。

📊 高级查询操作

范围查询

/**
 * 查询指定日期范围内的任务
 * @param {string} startDate - 开始日期 (ISO 格式)
 * @param {string} endDate - 结束日期 (ISO 格式)
 */
async getTasksByDateRange(startDate, endDate) {
    return new Promise((resolve, reject) => {
        const store = this.getObjectStore('tasks', 'readonly');
        const index = store.index('dueDate');
        
        // 创建范围查询
        const range = IDBKeyRange.bound(startDate, endDate);
        const request = index.getAll(range);

        request.onsuccess = () => {
            resolve(request.result);
        };

        request.onerror = () => {
            reject(request.error);
        };
    });
}

排序和分页

/**
 * 获取分页任务列表
 * @param {number} pageSize - 每页数量
 * @param {number} pageNumber - 页码 (从 1 开始)
 * @param {string} sortBy - 排序字段
 * @param {string} order - 排序顺序 ('asc' 或 'desc')
 */
async getTasksPaginated(pageSize = 10, pageNumber = 1, sortBy = 'createdAt', order = 'desc') {
    return new Promise((resolve, reject) => {
        const store = this.getObjectStore('tasks', 'readonly');
        const index = store.index(sortBy);
        
        // 创建游标请求
        const direction = order === 'desc' ? 'prev' : 'next';
        const request = index.openCursor(null, direction);

        const tasks = [];
        let count = 0;
        const skip = (pageNumber - 1) * pageSize;

        request.onsuccess = (event) => {
            const cursor = event.target.result;
            if (cursor) {
                if (count >= skip && count < skip + pageSize) {
                    tasks.push(cursor.value);
                }
                count++;
                if (count < skip + pageSize) {
                    cursor.continue();
                }
            } else {
                resolve({
                    data: tasks,
                    pageNumber: pageNumber,
                    pageSize: pageSize,
                    total: count
                });
            }
        };

        request.onerror = () => {
            reject(request.error);
        };
    });
}

代码解释:

getTasksByDateRange() 方法使用 IDBKeyRange.bound() 创建范围查询条件,指定开始日期和结束日期。然后使用 index.getAll(range) 获取日期范围内的所有任务。getTasksPaginated() 方法实现了分页功能,首先获取指定排序字段的索引,然后使用 index.openCursor() 打开游标。游标允许逐条遍历记录,direction 参数控制遍历顺序(‘next’ 表示升序,‘prev’ 表示降序)。在遍历过程中,使用 skip 和 pageSize 计算要跳过的记录数和要取的记录数。当遍历到指定范围内的记录时,将其添加到 tasks 数组中。cursor.continue() 方法移动到下一条记录。当游标遍历完所有记录时,返回分页结果,包括数据、页码、每页数量和总记录数。

🔄 事务处理

/**
 * 批量添加任务(事务处理)
 * @param {Array} tasks - 任务数组
 */
async addTasksBatch(tasks) {
    return new Promise((resolve, reject) => {
        const transaction = this.db.transaction('tasks', 'readwrite');
        const store = transaction.objectStore('tasks');

        const results = [];

        tasks.forEach((task) => {
            const request = store.add(task);
            request.onsuccess = () => {
                results.push(request.result);
            };
        });

        transaction.oncomplete = () => {
            console.log('[DB] 批量添加任务成功:', results.length, '条');
            resolve(results);
        };

        transaction.onerror = () => {
            console.error('[DB] 批量添加失败:', transaction.error);
            reject(transaction.error);
        };
    });
}

代码解释:

db.transaction() 方法创建一个事务,指定要操作的对象存储和访问模式(readwrite)。事务确保了多个操作的原子性,即事务中的所有操作要么全部成功,要么全部失败。在事务中,遍历任务数组,对每个任务调用 store.add() 方法添加到数据库。每个操作的成功结果会被收集到 results 数组中。transaction.oncomplete 事件在所有操作完成后触发,此时可以返回成功结果。transaction.onerror 事件在任何操作失败时触发,此时会拒绝 Promise 并返回错误信息。

🔌 原生层的数据存储与同步

HarmonyOS 原生层也需要与 Web 层的 IndexedDB 进行数据同步。原生层可以使用 HarmonyOS 的关系型数据库(RDB)来存储数据,并通过 Cordova 插件与 Web 层进行通信。

// ArkTS 代码示例 - 原生数据库初始化
import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { CordovaPlugin, CallbackContext } from '@magongshou/harmony-cordova/Index';
import { PluginResult, MessageStatus } from '@magongshou/harmony-cordova/Index';

export class DatabaseSyncPlugin extends CordovaPlugin {
    private rdbStore: relationalStore.RdbStore | null = null;

    // 初始化原生数据库
    async initNativeDatabase(callbackContext: CallbackContext, args: string[]): Promise<void> {
        try {
            const context = getContext() as common.UIAbilityContext;
            const storeConfig: relationalStore.StoreConfig = {
                name: 'TodoAppDB.db',
                securityLevel: relationalStore.SecurityLevel.S1
            };

            this.rdbStore = await relationalStore.getRdbStore(context, storeConfig);
            
            // 创建任务表
            const createTaskTableSQL = `
                CREATE TABLE IF NOT EXISTS tasks (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    title TEXT NOT NULL,
                    description TEXT,
                    status TEXT,
                    priority TEXT,
                    category TEXT,
                    dueDate TEXT,
                    createdAt TEXT,
                    completedAt TEXT,
                    icon TEXT
                )
            `;
            
            await this.rdbStore.executeSql(createTaskTableSQL);
            
            const result = PluginResult.createByString(MessageStatus.OK, '原生数据库初始化成功');
            callbackContext.sendPluginResult(result);
        } catch (error) {
            const result = PluginResult.createByString(
                MessageStatus.ERROR, 
                (error as Error).message
            );
            callbackContext.sendPluginResult(result);
        }
    }

    // 从 Web 层同步数据到原生层
    async syncTaskToNative(callbackContext: CallbackContext, args: string[]): Promise<void> {
        try {
            const taskData = JSON.parse(args[0]);
            
            if (!this.rdbStore) {
                throw new Error('原生数据库未初始化');
            }

            const valueBucket: relationalStore.ValuesBucket = {
                title: taskData.title,
                description: taskData.description,
                status: taskData.status,
                priority: taskData.priority,
                category: taskData.category,
                dueDate: taskData.dueDate,
                createdAt: taskData.createdAt,
                icon: taskData.icon
            };

            const rowId = await this.rdbStore.insert('tasks', valueBucket);
            
            const result = PluginResult.createByString(
                MessageStatus.OK, 
                JSON.stringify({ id: rowId })
            );
            callbackContext.sendPluginResult(result);
        } catch (error) {
            const result = PluginResult.createByString(
                MessageStatus.ERROR, 
                (error as Error).message
            );
            callbackContext.sendPluginResult(result);
        }
    }
}

原生代码解释:

DatabaseSyncPlugin 是一个 Cordova 插件,负责原生层的数据库管理和与 Web 层的数据同步。initNativeDatabase 方法初始化原生数据库,使用 HarmonyOS 的 relationalStore 模块创建关系型数据库。StoreConfig 配置了数据库的名称和安全级别。然后通过 executeSql 方法执行 SQL 语句创建 tasks 表,表结构与 IndexedDB 中的任务表相同。syncTaskToNative 方法接收来自 Web 层的任务数据,将其插入到原生数据库中。首先将 JSON 字符串解析为对象,然后创建一个 ValuesBucket 对象,包含所有任务字段。最后调用 rdbStore.insert() 方法将数据插入到数据库,返回新插入记录的行 ID。

Web 层调用原生数据同步

Web 层在保存数据到 IndexedDB 后,可以调用原生插件将数据同步到原生数据库:

// JavaScript 代码 - 同步数据到原生层
async function syncTaskToNative(taskData) {
    return new Promise((resolve, reject) => {
        cordova.exec(
            function(result) {
                console.log('任务已同步到原生层:', result);
                resolve(result);
            },
            function(error) {
                console.error('同步失败:', error);
                reject(error);
            },
            'DatabaseSyncPlugin',
            'syncTaskToNative',
            [JSON.stringify(taskData)]
        );
    });
}

// 在添加任务时调用
async function addTask(taskData) {
    try {
        // 先保存到 IndexedDB
        const id = await db.addTask(taskData);
        
        // 再同步到原生层
        await syncTaskToNative({ ...taskData, id });
        
        console.log('任务已保存并同步');
    } catch (error) {
        console.error('保存任务失败:', error);
    }
}

Web 层代码解释:

syncTaskToNative 函数使用 cordova.exec() 调用原生插件的 syncTaskToNative 方法。任务数据被序列化为 JSON 字符串作为参数传递给原生层。成功回调接收原生层返回的结果,包括新插入记录的行 ID。错误回调处理同步失败的情况。addTask 函数展示了完整的流程:首先调用 db.addTask() 将任务保存到 IndexedDB,获得任务 ID。然后调用 syncTaskToNative() 将任务同步到原生数据库。这样可以确保 Web 层和原生层的数据保持同步。

📝 总结

IndexedDB 是 HarmonyOS Cordova 应用中实现 Web 层数据持久化的最佳选择。通过合理的数据库设计、高效的查询操作和事务处理,可以构建高效、可靠的数据存储系统。同时,通过 Cordova 插件与原生层的数据库进行同步,可以实现完整的跨层数据管理。在后续的文章中,我们将基于这个数据库系统实现具体的业务功能模块。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐