1、是什么

IndexedDB 是浏览器内置的 NoSQL 数据库,用于在客户端存储大量结构化数据(如 JSON 格式数据),支持离线访问、事务操作和索引查询,是前端实现本地数据持久化的核心方案之一(适用于 PWA、离线应用、大型表单缓存等场景)

2、核心概念

IndexedDB 概念类比传统数据库说明
数据库(Database)数据库实例一个浏览器中可创建多个数据库,每个数据库有唯一名称,且版本号递增(版本更新触发升级事件)。
对象仓库(Object Store)数据表(Table)数据库中存储数据的 “表”,每个对象仓库存储一类结构化数据(如 “用户表”“商品表”),需指定主键(Primary Key)。
事务(Transaction)事务操作数据的最小单元,支持 readwrite(读写)和 readonly(只读)模式,所有数据操作必须在事务中执行,且具备原子性(要么全成功,要么全失败)。
索引(Index)索引为对象仓库的非主键字段创建索引,加速查询(如为 “用户表” 的 “手机号” 字段建索引,避免全表扫描)。
游标(Cursor)游标遍历对象仓库中数据的工具,支持按条件筛选、排序,适用于批量查询或分页加载。

3、使用

1、封装工具类

// src/utils/indexedDBUtil.js
class IndexedDBUtil {
    constructor(dbName, version, storeConfigs) {
        this.dbName = dbName; // 数据库名称
        this.version = version; // 数据库版本(必须为正整数)
        this.storeConfigs = storeConfigs; // 对象仓库配置(数组,支持多表)
        this.db = null; // 数据库实例(连接成功后赋值)
    }

    /**
     * 1. 打开数据库(核心方法:创建/升级数据库、创建对象仓库和索引)
     */
    openDB() {
        return new Promise((resolve, reject) => {
            // 1. 调用浏览器 indexedDB API 打开数据库
            const request = window.indexedDB.open(this.dbName, this.version);

            // 2. 数据库首次创建或版本升级时触发(仅执行一次)
            request.onupgradeneeded = (event) => {
                this.db = event.target.result;
                console.log(`数据库 ${this.dbName} 版本升级至 ${this.version}`);

                // 3. 遍历创建对象仓库(按配置)
                this.storeConfigs.forEach(({ storeName, keyPath, indexes }) => {
                    // 若对象仓库已存在,先删除(避免版本升级时冲突,实际项目可按需调整)
                    if (this.db.objectStoreNames.contains(storeName)) {
                        this.db.deleteObjectStore(storeName);
                    }

                    // 创建对象仓库:keyPath 为主键(如用户ID),autoIncrement 可选(自增主键)
                    const objectStore = this.db.createObjectStore(storeName, {
                        keyPath, // 主键字段(如 "id")
                        // autoIncrement: true, // 若主键自增,可添加此配置
                    });

                    // 4. 为对象仓库创建索引(若有)
                    if (indexes && indexes.length) {
                        indexes.forEach(({ indexName, indexPath, unique }) => {
                            // indexName:索引名;indexPath:索引字段;unique:是否唯一(如手机号唯一)
                            objectStore.createIndex(indexName, indexPath, { unique });
                        });
                    }
                });
            };

            // 5. 数据库打开成功
            request.onsuccess = (event) => {
                this.db = event.target.result;
                console.log(`数据库 ${this.dbName} 打开成功`);
                resolve(this.db);
            };

            // 6. 数据库打开失败(如版本号降级、浏览器不支持)
            request.onerror = (event) => {
                reject(new Error(`数据库打开失败:${event.target.error.message}`));
            };
        });
    }

    /**
     * 2. 获取事务和对象仓库(所有数据操作依赖此方法)
     * @param {string} storeName - 对象仓库名称
     * @param {string} mode - 事务模式:'readwrite'(读写)/ 'readonly'(只读)
     */
    getStore(storeName, mode = 'readonly') {
        if (!this.db) throw new Error('数据库未连接,请先调用 openDB()');
        // 1. 创建事务(指定操作的对象仓库和模式)
        const transaction = this.db.transaction(storeName, mode);
        // 2. 获取对象仓库实例
        const store = transaction.objectStore(storeName);
        // 3. 监听事务失败事件
        transaction.onerror = (event) => {
            throw new Error(`事务失败:${event.target.error.message}`);
        };
        return store;
    }

    /**
     * 3. 新增数据(Add:主键重复时会报错,适合新增)
     * @param {string} storeName - 对象仓库名称
     * @param {object} data - 新增数据(需包含主键,除非配置 autoIncrement)
     */
    addData(storeName, data) {
        const store = this.getStore(storeName, 'readwrite');
        return new Promise((resolve, reject) => {
            const request = store.add(data);
            request.onsuccess = () => resolve('数据新增成功');
            request.onerror = (event) => reject(new Error(`新增失败:${event.target.error.message}`));
        });
    }

    /**
     * 4. 更新数据(Put:主键存在则更新,不存在则新增)
     * @param {string} storeName - 对象仓库名称
     * @param {object} data - 更新数据(必须包含主键)
     */
    putData(storeName, data) {
        const store = this.getStore(storeName, 'readwrite');
        return new Promise((resolve, reject) => {
            const request = store.put(data);
            request.onsuccess = () => resolve('数据更新成功');
            request.onerror = (event) => reject(new Error(`更新失败:${event.target.error.message}`));
        });
    }

    /**
     * 5. 根据主键查询数据(Get:快速查询单个数据)
     * @param {string} storeName - 对象仓库名称
     * @param {*} key - 主键值(如用户ID:1001)
     */
    getDataByKey(storeName, key) {
        const store = this.getStore(storeName);
        return new Promise((resolve, reject) => {
            const request = store.get(key);
            request.onsuccess = (event) => resolve(event.target.result); // 无数据时返回 null
            request.onerror = (event) => reject(new Error(`查询失败:${event.target.error.message}`));
        });
    }

    /**
     * 6. 根据索引查询数据(Index:按非主键字段查询,需先创建索引)
     * @param {string} storeName - 对象仓库名称
     * @param {string} indexName - 索引名称
     * @param {*} value - 索引字段值(如手机号:13800138000)
     */
    getDataByIndex(storeName, indexName, value) {
        const store = this.getStore(storeName);
        return new Promise((resolve, reject) => {
            // 1. 获取索引实例
            const index = store.index(indexName);
            // 2. 根据索引值查询(get() 查单个,getAll() 查所有匹配)
            const request = index.get(value);
            request.onsuccess = (event) => resolve(event.target.result);
            request.onerror = (event) => reject(new Error(`索引查询失败:${event.target.error.message}`));
        });
    }

    /**
     * 7. 删除数据(Delete:根据主键删除)
     * @param {string} storeName - 对象仓库名称
     * @param {*} key - 主键值
     */
    deleteData(storeName, key) {
        const store = this.getStore(storeName, 'readwrite');
        return new Promise((resolve, reject) => {
            const request = store.delete(key);
            request.onsuccess = () => resolve('数据删除成功');
            request.onerror = (event) => reject(new Error(`删除失败:${event.target.error.message}`));
        });
    }

    /**
     * 8. 关闭数据库
     */
    closeDB() {
        if (this.db) {
            this.db.close();
            this.db = null;
            console.log(`数据库 ${this.dbName} 已关闭`);
        }
    }

    /**
     * 9. 删除数据库(谨慎使用!会删除所有对象仓库和数据)
     */
    deleteDB() {
        return new Promise((resolve, reject) => {
            const request = window.indexedDB.deleteDatabase(this.dbName);
            request.onsuccess = () => {
                this.db = null;
                resolve(`数据库 ${this.dbName} 已删除`);
            };
            request.onerror = (event) => reject(new Error(`删除数据库失败:${event.target.error.message}`));
        });
    }

    /**
     * 模糊查询数据(核心新增方法)
     * @param {string} storeName - 对象仓库名称
     * @param {string} field - 要查询的字段(如 'name'、'title')
     * @param {string} keyword - 模糊查询关键词
     * @param {RegExp} [regExp] - 可选:自定义正则表达式(优先级高于 keyword)
     * @returns {Promise<Array>} 匹配的结果数组
     */
    fuzzySearch(storeName, field, keyword, regExp) {
        const store = this.getStore(storeName);
        return new Promise((resolve, reject) => {
            const result = [];
            // 关键词去空格 + 转义特殊字符(增强鲁棒性)
            const trimmedKeyword = (keyword + '').trim();
            const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
            const regex = regExp || new RegExp(escapeRegExp(trimmedKeyword), 'i');

            const request = store.openCursor();
            request.onsuccess = (event) => {
                const cursor = event.target.result;
                if (cursor) {
                    const data = cursor.value;
                    // 允许数字/字符串类型的字段,其他类型(如 null/undefined)跳过
                    if (data[field] !== undefined && data[field] !== null) {
                        // 将字段值转为字符串(无论原类型是数字还是字符串)
                        const fieldValue = String(data[field]);
                        if (regex.test(fieldValue)) {
                            result.push(data);
                        }
                    } else {
                        console.log(`字段 ${field} 的值为 null/undefined,跳过`);
                    }
                    cursor.continue();
                } else {
                    resolve(result);
                }
            };
            request.onerror = (event) => {
                reject(new Error(`模糊查询失败:${event.target.error.message}`));
            };
        });
    }

    /**
     * 批量获取所有数据(辅助方法,用于演示)
     * @param {string} storeName - 对象仓库名称
     */
    getAllData(storeName) {
        const store = this.getStore(storeName);
        return new Promise((resolve, reject) => {
            const request = store.getAll(); // 获取所有数据(IndexedDB 2.0+ 支持)
            request.onsuccess = (event) => resolve(event.target.result);
            request.onerror = (event) => reject(new Error(`获取所有数据失败:${event.target.error.message}`));
        });
    }

    /**
     * 分页查询(按主键范围,适合主键自增的场景)
     * @param {string} storeName - 对象仓库名称
     * @param {number} pageNum - 页码(从 1 开始)
     * @param {number} pageSize - 每页条数
     * @param {IDBKeyRange} [keyRange] - 可选:主键范围(如只查询 ID 100~200 的数据)
     * @returns {Promise<{data: Array, total: number}>} 分页结果(数据+总条数)
     */
    async paginationQuery(storeName, pageNum = 1, pageSize = 10, keyRange) {
        const store = this.getStore(storeName);
        const result = { data: [], total: 0, pageNum, pageSize, totalPage: 0 };

        try {
            // 1. 先查询总条数(用于计算总页数)
            result.total = await new Promise((resolve) => {
                const countRequest = keyRange
                    ? store.count(keyRange) // 按范围统计
                    : store.count(); // 统计所有数据
                countRequest.onsuccess = () => resolve(countRequest.result);
            });

            // 2. 计算分页起始位置(跳过前 (pageNum-1)*pageSize 条数据)
            const skip = (pageNum - 1) * pageSize;
            if (skip >= result.total) {
                // 若起始位置超出总条数,直接返回空数据
                return result;
            }

            // 3. 打开游标,跳过前 skip 条,读取 pageSize 条数据
            await new Promise((resolve) => {
                let current = 0; // 当前已读取的条数
                // 打开游标(可选:按主键范围过滤)
                const request = store.openCursor(keyRange);

                request.onsuccess = (event) => {
                    const cursor = event.target.result;
                    if (cursor) {
                        if (current < skip) {
                            // 跳过前 skip 条数据
                            current++;
                            cursor.continue();
                        } else if (current < skip + pageSize) {
                            // 读取当前页数据(未超过 pageSize)
                            result.data.push(cursor.value);
                            current++;
                            cursor.continue();
                        } else {
                            // 已读取足够的条数,结束查询
                            resolve();
                        }
                    } else {
                        // 游标遍历结束(数据不足一页)
                        resolve();
                    }
                };
            });
            // 总页数
            result.totalPage = result.total > 0 ? Math.ceil(result.total / result.pageSize) : 0;

            return result;
        } catch (err) {
            throw new Error(`分页查询失败:${err.message}`);
        }
    }

    /**
     * 按索引分页查询(适合按非主键字段排序分页)
     * @param {string} storeName - 对象仓库名称
     * @param {string} indexName - 索引名称
     * @param {number} pageNum - 页码(从 1 开始)
     * @param {number} pageSize - 每页条数
     * @param {IDBKeyRange} [keyRange] - 可选:索引范围
     * @param {boolean} [ascending=true] - 是否升序(false 为降序)
     * @returns {Promise<{data: Array, total: number}>} 分页结果
     */
    async paginationByIndex(storeName, indexName, pageNum, pageSize, keyRange, ascending = true) {
        const store = this.getStore(storeName);
        const result = { data: [], total: 0 };

        try {
            // 1. 获取索引
            const index = store.index(indexName);

            // 2. 统计符合条件的总条数
            result.total = await new Promise((resolve) => {
                const countRequest = keyRange
                    ? index.count(keyRange)
                    : index.count();
                countRequest.onsuccess = () => resolve(countRequest.result);
            });

            // 3. 计算分页起始位置
            const skip = (pageNum - 1) * pageSize;
            if (skip >= result.total) {
                return result;
            }

            // 4. 按索引打开游标,支持排序和分页
            await new Promise((resolve) => {
                let current = 0;
                // 打开索引游标(direction 控制排序:next 升序,prev 降序)
                const request = index.openCursor(
                    keyRange,
                    ascending ? 'next' : 'prev'
                );

                request.onsuccess = (event) => {
                    const cursor = event.target.result;
                    if (cursor) {
                        if (current < skip) {
                            // 跳过前 skip 条
                            current++;
                            cursor.continue();
                        } else if (current < skip + pageSize) {
                            // 读取当前页数据
                            result.data.push(cursor.value);
                            current++;
                            cursor.continue();
                        } else {
                            // 读取完毕
                            resolve();
                        }
                    } else {
                        // 数据不足一页
                        resolve();
                    }
                };
            });

            return result;
        } catch (err) {
            throw new Error(`索引分页查询失败:${err.message}`);
        }
    }
}

export default IndexedDBUtil;

2、Vue3 使用

<template>
  <div class="indexed-db-demo">
    <h3>IndexedDB 用户管理示例</h3>

    <!-- 新增/更新表单 -->
    <div class="form">
      <input
          v-model="form.id"
          placeholder="用户ID(主键,如 1001)"
          type="number"
      />
      <input
          v-model="form.name"
          placeholder="用户名"
      />
      <input
          v-model="form.phone"
          placeholder="手机号(索引字段)"
      />
      <button @click="handleSave">保存(新增/更新)</button>
      <button @click="handleDelete" :disabled="!form.id">删除</button>
    </div>

    <!-- 查询区域 -->
    <div class="query">
      <input
          v-model="queryKey"
          placeholder="输入用户ID查询"
          type="number"
      />
      <button @click="handleQueryByKey">按ID查询</button>

      <input
          v-model="queryPhone"
          placeholder="输入手机号查询"
      />
      <button @click="handleQueryByPhone">按手机号查询</button>
    </div>

    <!-- 结果展示 -->
    <div class="result">
      <h4>查询结果:</h4>
      <pre>{{ JSON.stringify(currentUser, null, 2) }}</pre>
    </div>

    <!-- 状态提示 -->
    <div class="status" :class="{ success: statusType === 'success', error: statusType === 'error' }">
      {{ statusMsg }}
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import IndexedDBUtil from '@/utils/indexedDBUtil.js';

// 1. 初始化 IndexedDB 实例
// 配置:数据库名=userDB,版本=1,对象仓库=users(主键=id,索引=phone(唯一))
const dbUtil = new IndexedDBUtil('userDB', 1, [
  {
    storeName: 'users', // 对象仓库名称(表名)
    keyPath: 'id', // 主键字段(用户ID)
    indexes: [
      { indexName: 'idx_phone', indexPath: 'phone', unique: true } // 索引:手机号唯一
    ]
  }
]);

// 2. 组件状态
const form = ref({ id: '', name: '', phone: '' }); // 新增/更新表单
const queryKey = ref(''); // 按ID查询的关键词
const queryPhone = ref(''); // 按手机号查询的关键词
const currentUser = ref(null); // 当前查询结果
const statusMsg = ref(''); // 操作状态提示
const statusType = ref(''); // 状态类型:success/error

/**
 * 3. 组件挂载时打开数据库
 */
onMounted(async () => {
  try {
    await dbUtil.openDB();

    setStatus('success', '数据库连接成功');
  } catch (err) {
    setStatus('error', err.message);
  }
});

/**
 * 4. 组件卸载时关闭数据库
 */
onUnmounted(() => {
  dbUtil.closeDB();
});

/**
 * 5. 设置状态提示
 */
const setStatus = (type, msg) => {
  statusType.value = type;
  statusMsg.value = msg;
  // 3秒后清除提示
  setTimeout(() => {
    statusMsg.value = '';
    statusType.value = '';
  }, 3000);
};

/**
 * 6. 保存数据(新增/更新)
 */
const handleSave = async () => {
  const { id, name, phone } = form.value;
  if (!id || !name || !phone) {
    setStatus('error', '请填写完整表单');
    return;
  }
  try {
    // 用 putData:主键存在则更新,不存在则新增
    await dbUtil.putData('users', { id: Number(id), name, phone });
    setStatus('success', '数据保存成功');
    // 保存后清空表单
    form.value = { id: '', name: '', phone: '' };
  } catch (err) {
    setStatus('error', err.message);
  }
};

/**
 * 7. 按主键(ID)查询
 */
const handleQueryByKey = async () => {
  // let list = await dbUtil.fuzzySearch('users', "id", queryKey.value);
  // console.log("数据", list)
  const key = queryKey.value;
  if (!key) {
    setStatus('error', '请输入用户ID');
    return;
  }
  try {
    // const user = await dbUtil.getDataByKey('users', Number(key));
    // const user = await dbUtil.fuzzySearch('users', "id", key);
    const user = await dbUtil.paginationQuery('users', 1, 1);
    currentUser.value = user;
    setStatus('success', user ? '查询成功' : '未找到该用户');
  } catch (err) {
    setStatus('error', err.message);
  }
};

/**
 * 8. 按索引(手机号)查询
 */
const handleQueryByPhone = async () => {
  const phone = queryPhone.value;
  if (!phone) {
    setStatus('error', '请输入手机号');
    return;
  }
  try {
    const user = await dbUtil.getDataByIndex('users', 'idx_phone', phone);
    currentUser.value = user;
    setStatus('success', user ? '查询成功' : '未找到该用户');
  } catch (err) {
    setStatus('error', err.message);
  }
};

/**
 * 9. 删除数据(按主键)
 */
const handleDelete = async () => {
  const id = form.value.id;
  if (!confirm(`确定要删除 ID 为 ${id} 的用户吗?`)) return;
  try {
    await dbUtil.deleteData('users', Number(id));
    setStatus('success', '数据删除成功');
    currentUser.value = null;
    form.value = { id: '', name: '', phone: '' };
  } catch (err) {
    setStatus('error', err.message);
  }
};
</script>

<style scoped>
.indexed-db-demo {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}
.form, .query {
  display: flex;
  gap: 10px;
  margin: 15px 0;
  align-items: center;
  flex-wrap: wrap;
}
input {
  padding: 8px;
  flex: 1;
  min-width: 150px;
}
button {
  padding: 8px 16px;
  cursor: pointer;
  background: #409eff;
  color: #fff;
  border: none;
  border-radius: 4px;
}
button:disabled {
  background: #c0c4cc;
  cursor: not-allowed;
}
.result {
  margin: 15px 0;
  padding: 10px;
  background: #f5f5f5;
  border-radius: 4px;
}
.status {
  margin: 10px 0;
  padding: 8px;
  border-radius: 4px;
}
.success {
  background: #f0f9eb;
  color: #67c23a;
}
.error {
  background: #fef0f0;
  color: #f56c6c;
}
</style>

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐