目录

第七部分:游标(Cursor)与范围查询

7.1 游标概述

游标(Cursor)是 IndexedDB 中遍历数据的机制。与 getAll() 一次性加载所有数据不同,游标可以逐条处理记录,适合大数据集的遍历和复杂查询场景。

7.1.1 游标 vs getAll

特性 游标 getAll
内存占用 低(逐条加载) 高(全部加载)
适用数据量 大数据集 小数据集
能否中途停止 不能
能否边遍历边修改 不能
使用复杂度 较高 简单
性能 适合大量数据 适合少量数据

7.2 基本游标操作

7.2.1 打开游标

// 基本语法
const request = store.openCursor();
const request = store.openCursor(query);
const request = store.openCursor(query, direction);
 
// 参数说明
// query: 主键值或 IDBKeyRange
// direction: 'next', 'nextunique', 'prev', 'prevunique'

7.2.2 正向遍历

const transaction = db.transaction(['users'], 'readonly');
const store = transaction.objectStore('users');
 
const request = store.openCursor();
 
request.onsuccess = function(event) {
  const cursor = event.target.result;
 
  if (cursor) {
    console.log('主键:', cursor.key);
    console.log('数据:', cursor.value);
 
    // 继续下一条
    cursor.continue();
  } else {
    console.log('遍历完成');
  }
};

7.2.3 反向遍历

// 从最后一条开始遍历
const request = store.openCursor(null, 'prev');
 
request.onsuccess = function(event) {
  const cursor = event.target.result;
 
  if (cursor) {
    console.log('倒序 - 主键:', cursor.key);
    cursor.continue();
  }
};

7.3 范围查询

7.3.1 IDBKeyRange 详解

// 等于某个值
IDBKeyRange.only(value);
 
// 大于等于 lower
IDBKeyRange.lowerBound(lower, open);
// open = true: 大于(不包含 lower)
// open = false: 大于等于(包含 lower)
 
// 小于等于 upper
IDBKeyRange.upperBound(upper, open);
 
// 在 lower 和 upper 之间
IDBKeyRange.bound(lower, upper, lowerOpen, upperOpen);
 
// 示例
IDBKeyRange.only(25);                    // = 25
IDBKeyRange.lowerBound(20);              // >= 20
IDBKeyRange.lowerBound(20, true);        // > 20
IDBKeyRange.upperBound(30);              // <= 30
IDBKeyRange.upperBound(30, true);        // < 30
IDBKeyRange.bound(20, 30);               // 20 <= x <= 30
IDBKeyRange.bound(20, 30, true, true);   // 20 < x < 30
IDBKeyRange.bound(20, 30, false, true);  // 20 <= x < 30

7.3.2 范围查询示例

// 查询年龄在 20-30 之间的用户
const ageIndex = store.index('ageIndex');
const range = IDBKeyRange.bound(20, 30);
const request = ageIndex.openCursor(range);
 
// 查询名字以 "张" 开头的用户
const nameIndex = store.index('nameIndex');
const nameRange = IDBKeyRange.bound('张', '张\uffff');
const request2 = nameIndex.openCursor(nameRange);
 
// 查询创建时间在最近 7 天的记录
const dateIndex = store.index('createdAtIndex');
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000);
const dateRange = IDBKeyRange.lowerBound(sevenDaysAgo);
const request3 = dateIndex.openCursor(dateRange);

7.4 索引游标

7.4.1 通过索引遍历

const transaction = db.transaction(['users'], 'readonly');
const store = transaction.objectStore('users');
const ageIndex = store.index('ageIndex');
 
// 按年龄排序遍历
const request = ageIndex.openCursor();
 
request.onsuccess = function(event) {
  const cursor = event.target.result;
 
  if (cursor) {
    // cursor.key 是索引值(年龄)
    // cursor.primaryKey 是主键
    // cursor.value 是完整对象
    console.log(`年龄: ${cursor.key}, 用户ID: ${cursor.primaryKey}`);
    cursor.continue();
  }
};

7.4.2 键游标

键游标只遍历键,不加载完整对象,性能更好:

// 只获取键
const request = store.openKeyCursor();
 
request.onsuccess = function(event) {
  const cursor = event.target.result;
 
  if (cursor) {
    console.log('键:', cursor.key);
    // cursor.value 为 undefined
    cursor.continue();
  }
};
 
// 索引键游标
const index = store.index('ageIndex');
const request2 = index.openKeyCursor();
 
request2.onsuccess = function(event) {
  const cursor = event.target.result;
 
  if (cursor) {
    console.log('年龄:', cursor.key);
    console.log('主键:', cursor.primaryKey);
    cursor.continue();
  }
};

7.5 高级游标操作

7.5.1 更新游标当前记录

const transaction = db.transaction(['users'], 'readwrite');
const store = transaction.objectStore('users');
 
const request = store.openCursor();
 
request.onsuccess = function(event) {
  const cursor = event.target.result;
 
  if (cursor) {
    const user = cursor.value;
 
    // 修改数据
    if (user.status === 'inactive') {
      user.lastLogin = new Date();
      user.status = 'active';
 
      // 更新当前记录
      const updateRequest = cursor.update(user);
      updateRequest.onsuccess = () => {
        console.log('更新成功:', user.id);
      };
    }
 
    cursor.continue();
  }
};

7.5.2 删除游标当前记录

const transaction = db.transaction(['users'], 'readwrite');
const store = transaction.objectStore('users');
 
const request = store.openCursor();
 
request.onsuccess = function(event) {
  const cursor = event.target.result;
 
  if (cursor) {
    const user = cursor.value;
 
    // 删除符合条件的记录
    if (user.createdAt < Date.now() - 365 * 24 * 3600 * 1000) {
      const deleteRequest = cursor.delete();
      deleteRequest.onsuccess = () => {
        console.log('删除成功:', user.id);
      };
    }
 
    cursor.continue();
  }
};

7.5.3 提前终止遍历

const transaction = db.transaction(['users'], 'readonly');
const store = transaction.objectStore('users');
 
let count = 0;
const maxResults = 10;
 
const request = store.openCursor();
 
request.onsuccess = function(event) {
  const cursor = event.target.result;
 
  if (cursor && count < maxResults) {
    console.log(cursor.value);
    count++;
    cursor.continue();
  } else {
    console.log(`已获取 ${count} 条记录,遍历结束`);
    // 不调用 cursor.continue(),遍历自动停止
  }
};

7.6 分页实现

7.6.1 偏移量分页

function getPage(storeName, page = 1, pageSize = 20, options = {}) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction([storeName], 'readonly');
    const store = transaction.objectStore(storeName);
 
    let index = store;
    if (options.sortBy) {
      try {
        index = store.index(`${options.sortBy}Index`);
      } catch (e) {
        console.warn(`索引 ${options.sortBy}Index 不存在,使用主键排序`);
      }
    }
 
    const direction = options.sortOrder === 'desc' ? 'prev' : 'next';
    const skip = (page - 1) * pageSize;
 
    const results = [];
    let skipped = 0;
 
    const request = index.openCursor(null, direction);
 
    request.onsuccess = function(event) {
      const cursor = event.target.result;
 
      if (!cursor) {
        resolve(results);
        return;
      }
 
      if (skipped < skip) {
        skipped++;
        cursor.continue();
        return;
      }
 
      if (results.length < pageSize) {
        results.push(cursor.value);
        cursor.continue();
      } else {
        resolve(results);
      }
    };
 
    request.onerror = () => reject(request.error);
  });
}
 
// 使用
const page1 = await getPage('users', 1, 20, { sortBy: 'createdAt', sortOrder: 'desc' });
const page2 = await getPage('users', 2, 20, { sortBy: 'createdAt', sortOrder: 'desc' });

7.6.2 游标分页

更高效的分页方式,使用上次的位置继续:

async function* paginate(storeName, pageSize = 20) {
  const transaction = db.transaction([storeName], 'readonly');
  const store = transaction.objectStore(storeName);
 
  let hasMore = true;
  let lastKey = null;
 
  while (hasMore) {
    const results = [];
    let count = 0;
 
    await new Promise((resolve, reject) => {
      let request;
      if (lastKey) {
        request = store.openCursor(IDBKeyRange.lowerBound(lastKey, true));
      } else {
        request = store.openCursor();
      }
 
      request.onsuccess = (event) => {
        const cursor = event.target.result;
 
        if (cursor && count < pageSize) {
          results.push(cursor.value);
          lastKey = cursor.key;
          count++;
          cursor.continue();
        } else {
          hasMore = !!cursor;
          resolve();
        }
      };
 
      request.onerror = () => reject(request.error);
    });
 
    yield results;
  }
}
 
// 使用
const pager = paginate('users', 50);
for await (const page of pager) {
  console.log('获取到', page.length, '条记录');
  // 处理数据...
}

7.7 本章小结

本章介绍了游标的使用方式:

继续阅读 第八部分:数据库版本升级