突破 IndexedDB 的界限

在本文中,我想与大家分享如何执行一些很酷的 IndexedDB 查询,这些查询在没有添加一些“技巧”的情况下是无法“实现”的。

除了“全文搜索”算法外,我将展示的算法都是我在编写开源 JavaScript 库 Dexie.js 时发明的。其中一些算法并非我独创,因为其他人也发现了它们,但我相信至少不区分大小写的搜索算法是我 Dexie.js 库特有的。

本文中的所有代码片段都是概念性的。如果你需要查看可靠的代码,我建议你深入研究 Dexie.js 的源代码,其中所有这些算法都已实现并经过了全面单元测试。

四种 IndexedDB 查询

IndexedDB API 只能执行四种类型的查询

  1. IDBKeyRange.only(精确匹配)
  2. IDBKeyRange.upperBound() - 查找属性 X 小于某个值的项目
  3. IDBKeyRange.lowerBound() - 查找属性 X 大于某个值的项目
  4. IDBKeyRange.bound() - 查找属性 X 在某个值范围内的项目。

我将展示什么

我将向您展示如何扩展 IndexedDB 以支持以下功能

anyOf()
查找属性 X 等于给定一组可能值中的任何值的项目
equalsIgnoreCase()
不区分大小写的搜索
startsWith()
查找以某个前缀开头的字符串
startsWithIgnoreCase()
查找以不区分大小写的前缀开头的字符串
or()
组合两个查询,并获取这两个查询的并集。
全文搜索
通过搜索包含在大量文本中的单词来查找项目

这个列表可以比这长得多,但我们现在先从这些开始。

除了全文搜索之外,所有这些扩展类型的查询都不需要你存储任何元数据。它们可以使用任何已在使用的 IndexedDB 数据库。例如,你不需要存储要进行不区分大小写匹配的字符串的小写版本。

所以让我们开始展示打破 IndexedDB 界限的“技巧”。

anyOf()

在 SQL 中,这等效于 IN 关键字

SELECT * FROM table WHERE X IN (a,b,c)

我从这个开始的原因是,它非常直接简单,易于理解。理解了这一点,将更容易理解用于执行不区分大小写搜索的算法。

一些背景知识

让我们从深入了解一些 IndexedDB 基础知识开始
要迭代表中的所有记录,你需要在 IDBObjectStore 或 IDBIndex 实例上调用 indexOrStore.openCursor()。对于每个记录,你将通过你的onsuccess回调被回调

// Start a transaction to operate on your 'friends' table
var trans = db.transaction(["friends"], "readonly");

// Get the 'name' index from your 'friends' table.
var index = trans.objectStore("friends").index("name");

// Start iteration by opening a cursor
var cursorReq = index.openCursor();

// When any record is found, you get notified in onsuccess
cursorReq.onsuccess = function (e) {

    var cursor = e.target.result;
    if (cursor) {
        // We have a record in cursor.value
        console.log(JSON.stringify(cursor.value));
        cursor.continue();
    } else {
        // Iteration complete
    }
};

IDBCursor.continue() 的重载版本

在上面的示例中,我们在不带任何参数的情况下调用了 cursor.continue()。这将使游标前进到下一个键。但如果我们在 cursor.continue(nextKey) 中提供一个参数,我们就会告诉游标快进到给定的键。如果我们正在迭代“name”索引并写入

cursor.continue("David");

…下一个onsuccess将拥有一个定位在第一个 name 等于“David”的记录上的游标(如果该键存在)。规范要求游标必须定位在与指定键相等或大于指定键的第一个记录上,其排序顺序与索引相同。

算法

假设我们有一个包含大量朋友和朋友的朋友的数据库,他们有各种不同的姓名。现在我们要找到所有具有以下姓名之一的朋友:“David”、“Daniel”或“Zlatan”。

我们将像上面所示的那样,在“name”索引上执行基于游标的迭代,并使用 cursor.continue(nextName) 快进到我们正在查找的名称。当在索引上打开游标时,找到的项目的顺序将按照该索引的排序顺序排列。因此,如果我们在“name”索引上执行操作,我们将获得所有按“name”排序的朋友。

我们首先要做的是对我们正在查找的名称数组进行排序(),以便获得以下 JavaScript 数组

["Daniel", "David", "Zlatan"]

(因为 n 在 v 之前)。然后我们执行以下操作

  1. 调用cursor.continue("Daniel")(排序集中第一个项目)
  2. onsuccess:检查cursor.key == "Daniel"。如果是,则将cursor.value包含在结果中,并调用cursor.continue()(不带参数)以检查是否有更多 Daniel。
  3. 当没有更多 Daniel 找到时,调用cursor.continue("David")(下一个项目…)
  4. onsuccess:检查cursor.key == "David"。如果是,则将 cursor.value 包含在结果中,并调用cursor.continue()(不带参数)以检查是否有更多 David。
  5. 当没有更多 David 找到时,调用cursor.continue("Zlatan")
  6. onsuccess:检查cursor.key == "Zlatan"。如果是,则将cursor.value包含在结果中,并调用cursor.continue()(不带参数)以检查是否有更多 Zlatan。否则,我们可以通过不再调用cursor.continue()来停止迭代,因为我们知道不会再找到任何结果(该集合已排序!)。

算法示例实现

function comparer (a,b) {
    return a < b ? -1 : a > b ? 1 : 0;
}

function equalsAnyOf(index, keysToFind, onfound, onfinish) {

    var set = keysToFind.sort(comparer);
    var i = 0;
    var cursorReq = index.openCursor();

    cursorReq.onsuccess = function (event) {
        var cursor = event.target.result;

        if (!cursor) { onfinish(); return; }

        var key = cursor.key;

        while (key > set[i]) {

            // The cursor has passed beyond this key. Check next.
            ++i;

            if (i === set.length) {
                // There is no next. Stop searching.
                onfinish();
                return;
            }
        }

        if (key === set[i]) {
            // The current cursor value should be included and we should continue
            // a single step in case next item has the same key or possibly our
            // next key in set.
            onfound(cursor.value);
            cursor.continue();
        } else {
            // cursor.key not yet at set[i]. Forward cursor to the next key to hunt for.
            cursor.continue(set[i]);
        }
    };
}

不区分大小写的搜索

Dexie.js 使用与上面的anyOf()算法类似的 IDBCursor.continue(key) 方法实现不区分大小写的搜索,但算法需要稍微复杂一些。

假设我们需要在“friends”表中查找“david”,无论其大小写。无论“David”还是“david”都应该被找到。显然,我们可以创建一个包含“david”所有可能的大小写组合的数组,然后使用上面的anyOf()算法。但是,随着我们正在查找的键的长度增加,组合的数量将呈指数级增长。但我们可以使用一个技巧;由于 cursor.continue() 将定位在排序顺序中的下一条记录上,这将揭示当我们定位在不匹配的键上时,我们可以跳过哪些组合。

我们所做的是开始搜索“David”的最小可能值,即“DAVID”,因为大写 Unicode 字符的值小于其相应的小写版本。如果数据库中不存在“DAVID”,我们将定位在最小的可能键上,但该键大于“DAVID”。现在,我们不是测试“david”的下一个组合(它将是“DAVId”),而是首先检查我们定位到的键,并从该键中推断出下一个要搜索的“david”变体。请查看下面代码片段中的nextCasing()函数,了解我们如何基于 IDBCursor 定位到的键推断出键的下一个可能版本。我不会在这里逐行介绍它,而是建议你参考下面**函数 nextCasing(key, lowerKey) {…}**中的代码注释。

function findIgnoreCaseAlgorithm(index, needle, onfound, onfinish) {

    // index: An instance of IDBIndex
    // needle: The string to search for
    // onfound: A function to call for each found item
    // onfinish: A function to call when we're finshed searching.

    var upperNeedle = needle.toUpperCase();
    var lowerNeedle = needle.toLowerCase();
    var cursorReq = index.openCursor();

    cursorReq.onsuccess = function (event) {
        var cursor = event.target.result;
        if (!cursor) {
            // No more data to iterate over - call onfinish()
            onfinish();
            return;
        }

        var key = cursor.key;
        if (typeof key !== 'string') {
            // Just in case we stumble on data that isnt what we expect -
            // toLowerCase() wont work on this object. Check next.
            cursor.continue();
            return;
        }

        var lowerKey = key.toLowerCase();
        if (lowerKey === lowerNeedle) {
            onfound(cursor.value); // Notify caller we found somethin
            cursor.continue(); // Check next record, it might match too!
        } else {
            // Derive least possible casing to appear after key in sort order
            var nextNeedle = nextCasing(key, lowerKey, upperNeedle, lowerNeedle);
            if (nextNeedle) {
                cursor.continue(nextNeedle);
            } else {
                // No more possible case combinations to look for.
                // Call onfinish() and dont call cursor.continue() anymore.
                onfinish();
            }
        }
    };

    function nextCasing(key, lowerKey) {
        var length = Math.min(key.length, lowerNeedle.length); // lowerNeedle is from outer scope
        var llp = -1; // "llp = least lowerable position"

        // Iterate through the most common first chars for cursor.key and needle.
        for (var i = 0; i < length; ++i) {
            var lwrKeyChar = lowerKey[i];

            if (lwrKeyChar !== lowerNeedle[i]) {
                // The char at position i differs between the found key and needle being
                // looked for when just doing case insensitive match.
                // Now check how they differ and how to trace next casing from this:
                if (key[i] < upperNeedle[i]) {
                    // We could just append the UPPER version of the key we're looking for
                    // since found key is less than that.
                    return key.substr(0, i) + upperNeedle[i] + upperNeedle.substr(i + 1);
                }

                if (key[i] < lowerNeedle[i]) {
                    // Found key is between lower and upper version. Lets first append a
                    // lowercase char and the rest as uppercase.
                    return key.substr(0, i) + lowerNeedle[i] + upperNeedle.substr(i + 1);
                }

                if (llp >= 0) {
                    // Found key is beyond this key. Need to rewind to last lowerable
                    // position and return key + 1 lowercase char + uppercase rest.
                    return key.substr(0, llp) + lowerKey[llp] + upperNeedle.substr(llp + 1)
                }

                // There are no lowerable positions - all chars are already lowercase
                // (or non-lowerable chars such as space, periods etc)

                return null;
            }

            if (key[i] < lwrKeyChar) {
                // Making lowercase of this char would make it appear after key.
                // Therefore set llp = i.
                llp = i;
        }

        // All first common chars of found key and the key we're looking for are equal
        // when ignoring case.
        if (length < lowerNeedle.length) {
            // key was shorter than needle, meaning that we may look for key + UPPERCASE
            // version of the rest of needle.
            return key + upperNeedle.substr(key.length);
        }

        // Found key was longer than the key we're looking for
        if (llp < 0) {
            // ...and there is no way to make key we're looking for appear after found key.
            return null;
        } else {
            // There is a position of a char, that if we make that char lowercase,
            // needle will become greater than found key.
            return key.substr(0, llp) + lowerNeedle[llp] + upperNeedle.substr(llp + 1);
        }
    }
}

性能

在一个性能测试中,我创建了 10,000 个具有随机字符串的对象,并让 equalsIgnoreCase() 算法尝试在其中查找项目。对于 Opera,在 10,000 个项目中找到 6 个匹配项大约需要 4 到 5 毫秒。对于 Mozilla 最新版本,则需要 7 毫秒。如果改为遍历所有 10,000 个项目并手动比较,则对于 Mozilla 需要 1514 毫秒,而对于 Opera 需要 346 毫秒。

startsWith(str)

匹配字符串键的前缀是使用 IndexedDB 可以做的最直接的技巧。它并非 Dexie.js 独有,因为其他库也支持它。但是,为了完整起见,以下是如何执行此操作:只需执行 IDBKeyRange.bound(),其中 lowerBound 是前缀,而 upperBound 是一个字符串,该字符串将包含所有可能的前缀延续。最简单的方法是只需附加一个具有最高可能值的字符;'\uffff'。

IDBKeyRange.bound(prefix, prefix + 'uffff', false, false)

startsWithIgnoreCase(str)

当匹配的字符串应该不区分大小写进行比较时,我们需要进行游标搜索,就像 equalsIgnoreCase() 一样。我们实际上可以使用相同的算法,但改变我们比较每个条目的方式。我们不是使用“==”运算符进行比较,而是使用 String.indexOf()。如果字符串以给定值开头,这将为我们提供值 0。因此,只需复制上面 equalsIgnoreCase() 中的代码示例,并将onsuccess部分更改为以下内容

cursorReq.onsuccess = function (event) {
    var cursor = event.target.result;
    if (!cursor) {
        // No more data to iterate over - call onfinish()
        onfinish();
        return;
    }

    var key = cursor.key;
    if (typeof key !== 'string') {
        // Just in case we stumble on data that isnt what we expect -
        // toLowerCase() wont work on this object. Check next.
        cursor.continue();
        return;
    }

    var lowerKey = key.toLowerCase();
    if (lowerKey.indexOf(lowerNeedle) === 0) {
        onfound(cursor.value); // Notify caller we found somethin
        cursor.continue(); // Check next record, it might match too!
    } else {
        // Derive least possible casing to appear after key in sort order
        var nextNeedle = nextCasing(key, lowerKey, upperNeedle, lowerNeedle);
        if (nextNeedle) {
            cursor.continue(nextNeedle);

        } else {
            // No more possible case combinations to look for.
            // Call onfinish() and dont call cursor.continue() anymore.
            onfinish();
        }
    }
};

逻辑或

IndexedDB 不支持逻辑或。你一次只能指定一个键范围。但是,它支持的是并行运行多个查询 - 即使在使用同一个事务时(只要查询是只读查询)。即使查询没有并行运行,它仍然可以工作,但性能会稍微下降。Dexie.js 中的 OR 操作已使用 Chrome、IE、Firefox 和 Opera 进行了单元测试。

除了并行执行这两个查询之外,我们唯一需要做的事情是删除任何重复项。为此,我们可以使用一组找到的主键。每当在任何并行查询中找到新的匹配记录时,它都会首先检查该集合是否已经包含该记录。如果没有,它会调用该条目的 onfound,并设置 set[primKey] = true,这样如果在另一个查询中找到相同的条目,它将静默地忽略对 onfound() 的调用。

以下是操作方法。以下代码片段是一个简化版本,仅支持对两个标准 IDBKeyRange 查询进行逻辑或运算。使用 Dexie,您可以对任意数量的标准或扩展操作(例如 equalsIgnoreCase())进行或运算。

function logical_or(index1, keyRange1, index2, keyRange2, onfound, onfinish) {
    var openCursorRequest1 = index1.openCursor(keyRange1);
    var openCursorRequest2 = index2.openCursor(keyRange2);
    assert(index1.objectStore === index2.objectStore); // OR can only be done on same store
    var primKey = index1.objectStore.keyPath;
    var set = {};
    var resolved = 0;

    function complete() {
        if (++resolved === 2) onfinish();
    }

    function union(item) {
        var key = JSON.stringify(item[primKey]);
        if (!set.hasOwnProperty(key)) {
            set[key] = true;
            onfound(item);
        }
    }

    openCursorRequest1.onsuccess = function (event) {
        var cursor = event.target.result;
        if (cursor) {
            union(cursor.value);
        } else {
            complete();
        }
    }

    openCursorRequest2.onsuccess = function (event) {
        var cursor = event.target.result;
        if (cursor) {
            union(cursor.value);
        } else {
            complete();
        }
    }
}

要使用 Dexie.js 访问此算法,您可以键入类似以下内容

db.friends.where('name').equalsIgnoreCase('david').or('shoeSize').above(40).toArray(fn)

... 这将使给定的回调函数 (fn) 使用结果数组进行调用。

在使用并行或执行时,排序顺序将无效。部分原因是两个不同的查询在不同的索引上执行,具有不同的排序顺序,但主要是由于两个查询由浏览器的后台线程并行运行,我们无法知道哪个 onsuccess 先于另一个调用。但是,这可以通过实现 onfound 将项目推送到数组,以及实现 onfinish 使用任何请求的排序顺序对其进行排序来解决

var index1 = transaction.objectStore("friends").index("name");
var index2 = transaction.objectStire("friends").index("shoeSize");
var keyRange1 = IDBKeyRange.bound ("Da", "Dauffff");
var keyRange2 = IDBKeyRange.lowerBound (40, true);
//
// SELECT * FROM friends WHERE name LIKE 'Da%' OR shoeSize > 40 ORDERBY shoeSize;
//

var result = [];
logical_or (index1, keyRange1, index2, keyRange2, function (friend) {
    result.push(friend);
}, function () {
    result.sort (function (a,b) { return a.shoeSize - b.shoeSize; });

});

全文搜索

在大型字符串(文本字段)中搜索特定单词是另一个常见用例,IndexedDB 不提供开箱即用的支持。但是,可以通过将文本拆分为单词并将生成的集合存储在“multiEntry”索引中来轻松实现。在我的本文中的所有其他算法中,不需要元数据来使它们工作,但在全文搜索中,必须通过添加特定的 multiEntry 索引来进行准备,并且每次文本发生更改时,必须相应地更新 multiEntry 索引,以包含文本中生成的单词集。

  1. 在定义架构时,创建一个索引“words”,并将 multiEntry 设置为 true。
    • 在裸机 IndexedDB 中:store.createIndex("words", "words", { multiEntry: true });
    • 在 Dexie.js 中:以星号 (*) 为前缀:db.version(1).stores({blogs: '++id,title,text,*words'});
  2. 每当更新文本时,将文本拆分为单词,并将所有唯一单词存储在对象的“words”属性中。

    var blogEntry = new BlogEntry(
    
        "Blogging about IndexedDB",
    
        "Much to say about IndexedDB there is... bla bla bla - hope I'm not boring you...");
    
    db.put(blogEntry);
    
    blogEntry.setText("...changing my blog post to another IndexedDB text...");
    
    db.put(blogEntry);
    
    function BlogEntry(title, words) {
    
        ...
    
        this.setText = function (value) {
            this.text = value;
            this.words = getAllWords(value);
        }
    
        function getAllWords(text) {
            var allWordsIncludingDups = text.split(' ');
            var wordSet = allWordsIncludingDups.reduce(function (prev, current) {
                prev[current] = true;
                return prev;
            }, {});
            return Object.keys(wordSet);
        }
    }
    
  3. 要通过搜索单词查找项目,请使用“words”索引启动查询。
    • 裸机 IndexedDB:index.openCursor(IDBKeyRange.only('IndexedDB'));
    • Dexie.js:db.blogs.where('words').equalsIgnoreCase('indexeddb')

有关如何在 Dexie 中执行此操作的示例,请参见 Dexie 中的 FullTextSearch.js 文件

实时示例

如果您想在浏览器或手机中玩这些示例,请访问 Dexie.js wiki 上的示例,您可以在其中找到这些示例以及更多可供您尝试的示例。

最后的话

无论您只是对 IndexedDB API 感兴趣,还是正在实现自己的 IndexedDB 库,我希望本文中介绍的技术在您需要最大限度地利用 IndexedDB 的可能性时对您有所帮助。

我还想鼓励您查看 Dexie.js,以了解它如何帮助您实现目标。为了使这个库得到良好的文档记录和测试,我们付出了很多努力。这个库还很年轻,但少数发现它的用户在 Dexie.js 论坛 中表达了极大的热情。

关于 David Fahlander

前 Commodore 64 黑客 (triad),现任 Javascript 爱好者。Awarica AB 的开发人员。 Dexie.js 的作者——一个用于 IndexedDB 的开源包装库。

David Fahlander 的更多文章…

关于 Robert Nyman [名誉编辑]

Mozilla Hacks 的技术布道者和编辑。发表关于 HTML5、JavaScript 和开放网络的演讲和博客。Robert 是 HTML5 和开放网络的坚定支持者,自 1999 年以来一直在从事 Web 前端开发工作——在瑞典和纽约市。他还在 http://robertnyman.com 上定期撰写博客文章,热爱旅行和结识新朋友。

Robert Nyman [名誉编辑] 的更多文章…


3 条评论

  1. Doug Reeder

    感谢您记录了如何使用 IndexedDB 进行全文搜索!它的功能比人们意识到的还要强大。

    我独立完成了我的应用程序 Serene Notes 的工作:http://hominidsoftware.com/searchablenotes/index.html,您可以在 http://searchablenotes.hominidsoftware.com/ 上在线尝试。

    2014 年 6 月 19 日 上午 10:55

  2. Joe

    哇,很高兴我们有了这个很棒的东西,所以不用 WebSQL。

    2014 年 6 月 22 日 下午 13:00

  3. chris

    是的,正如 Joe 所说,这比 webSQL 效率高得多。我可能不是软件工程师,但我绝对能发现赢家!

    2014 年 6 月 24 日 上午 11:52

本文的评论已关闭。