通过 underscore.js 中的另一个数组过滤 json 数据

Filter a json data by another array in underscore.js

我有一个搜索字段,我想使用 underscore.js 添加一些复杂的功能。

有时用户会搜索整个“句子”,例如“Samsung galaxy A20s ultra”。我想使用搜索字符串中的任何词过滤 JSON 数据,并按包含更多词的结果排序。

示例数据:

var phones = [
{name: "Samsung A10s", id: 845},
{name: "Samsung galaxy", id: 839},
{name: "Nokia 7", id: 814},
{name: "Samsung S20s ultra", id: 514},
{name: "Apple iphone ultra", id: 159},
{name: "LG S20", id: 854}];

使用下划线的最佳方法是什么?

在这个答案中,我将构建一个带有两个参数的函数 searchByRelevance

  1. 一个 JSON 的 phone 数组,具有 nameid 属性,以及
  2. 一个搜索字符串,

and which returns 是一个新的 JSON 数组,只有 phones 其中 name 至少有一个词与搜索字符串相同,排序后 phone 最常用的单词排在最前面。

让我们首先确定所有子任务以及如何使用 Underscore 实现它们。完成后,我们可以将它们组合到 searchByRelevance 函数中。最后,我也会花一些话来说明我们如何确定什么是“最好的”。

子任务

将字符串拆分为单词

你不需要下划线。字符串有 builtin split method:

"Samsung galaxy A20s ultra".split(' ')
// [ 'Samsung', 'galaxy', 'A20s', 'ultra' ]

但是,如果你有一个完整的字符串数组并且你想将它们全部拆分,所以你得到一个数组数组,你可以使用 _.invoke:

_.invoke([
    'Samsung A10s',
    'Samsung galaxy',
    'Nokia 7',
    'Samsung S20s ultra',
    'Apple iphone ultra',
    'LG S20'
], 'split', ' ')
// [ [ 'Samsung', 'A10s' ],
//   [ 'Samsung', 'galaxy' ],
//   [ 'Nokia', '7' ],
//   [ 'Samsung', 'S20s', 'ultra' ],
//   [ 'Apple', 'iphone', 'ultra' ],
//   [ 'LG', 'S20' ] ]

找出两个数组共有的词

如果你有两个单词数组,

var words1 = [ 'Samsung', 'galaxy', 'A20s', 'ultra' ],
    words2 = [ 'Apple', 'iphone', 'ultra' ];

然后你可以使用 _.intersection:

得到一个新数组,其中只包含他们共有的单词
_.intersection(words1, words2) // [ 'ultra' ]

计算数组中的单词数

这又是你不需要下划线的东西:

[ 'Samsung', 'A10s' ].length // 2

但是如果您有多个单词数组,您可以使用 _.map:

获取所有单词的单词计数
_.map([
    [ 'Samsung', 'A10s' ],
    [ 'Samsung', 'galaxy' ],
    [ 'Nokia', '7' ],
    [ 'Samsung', 'S20s', 'ultra' ],
    [ 'Apple', 'iphone', 'ultra' ],
    [ 'LG', 'S20' ]
], 'length')
// [ 2, 2, 2, 3, 3, 2 ]

按某种标准对数组进行排序

_.sortBy 这样做。例如id为phones的数据:

_.sortBy(phones, 'id')
// [ { name: 'Apple iphone ultra', id: 159 },
//   { name: 'Samsung S20s ultra', id: 514 },
//   { name: 'Nokia 7', id: 814 },
//   { name: 'Samsung galaxy', id: 839 },
//   { name: 'Samsung A10s', id: 845 },
//   { name: 'LG S20', id: 854 } ]

要按降序而不是升序排序,您可以先按升序排序,然后使用 builtin reverse method:

反转结果
_.sortBy(phones, 'id').reverse()
// [ { name: 'LG S20', id: 854 },
//   { name: 'Samsung A10s', id: 845 },
//   { name: 'Samsung galaxy', id: 839 },
//   { name: 'Nokia 7', id: 814 },
//   { name: 'Samsung S20s ultra', id: 514 },
//   { name: 'Apple iphone ultra', id: 159 } ]

你也可以传递一个标准函数。该函数接收当前项目,它可以做任何事情,只要它 return 是一个字符串或数字用作当前项目的排名。例如,这按名称的最后一个字母对 phone 进行排序(使用 _.last):

_.sortBy(phones, function(phone) { return _.last(phone.name); })
// [ { name: 'LG S20', id: 854 },
//   { name: 'Nokia 7', id: 814 },
//   { name: 'Samsung S20s ultra', id: 514 },
//   { name: 'Apple iphone ultra', id: 159 },
//   { name: 'Samsung A10s', id: 845 },
//   { name: 'Samsung galaxy', id: 839 } ]

按某种标准对数组元素进行分组

不是直接排序,我们也可以首先按标准 分组 项目。这是按名称的第一个字母对 phones 进行分组,使用 _.groupBy and _.first:

_.groupBy(phones, function(phone) { return _.first(phone.name); })
// { S: [ { name: 'Samsung A10s', id: 845 },
//        { name: 'Samsung galaxy', id: 839 },
//        { name: 'Samsung S20s ultra', id: 514 } ],
//   N: [ { name: 'Nokia 7', id: 814 } ],
//   A: [ { name: 'Apple iphone ultra', id: 159 } ],
//   L: [ { name: 'LG S20', id: 854 } ] }

我们已经看到我们可以将键传递给排序或分组依据,或者传递一个 return 用作标准的函数。我们可以在这里使用第三个选项来代替上面的函数:

_.groupBy(phones, ['name', 0])
// { S: [ { name: 'Samsung A10s', id: 845 },
//        { name: 'Samsung galaxy', id: 839 },
//        { name: 'Samsung S20s ultra', id: 514 } ],
//   N: [ { name: 'Nokia 7', id: 814 } ],
//   A: [ { name: 'Apple iphone ultra', id: 159 } ],
//   L: [ { name: 'LG S20', id: 854 } ] }

获取对象的键

这就是 _.keys 的用途:

_.keys({name: "Samsung A10s", id: 845}) // [ 'name', 'id' ]

您也可以使用标准 Object.keys 执行此操作。 _.keys 适用于 Object.keys 不适用的旧环境。否则,它们可以互换。

把一个数组的东西变成其他的东西

我们之前已经看到使用_.map来获取多个单词数组的长度。通常,它需要一个数组或对象以及您希望对该数组或对象的每个元素完成的操作,它将 return 一个包含结果的数组:

_.map(phones, 'id')
// [ 845, 839, 814, 514, 159, 854 ]
_.map(phones, ['name', 0])
// [ 'S', 'S', 'N', 'S', 'A', 'L' ]
_.map(phones, function(phone) { return _.last(phone.name); })
// [ 's', 'y', '7', 'a', 'a', '0' ]

请注意与 _.sortBy_.groupBy 的相似之处。这是 Underscore 中的一个通用模式:你有一些东西的集合,你想对每个元素做一些事情,以获得某种结果。您想要对每个元素执行的操作称为“iteratee”。 Underscore 有一个函数确保你可以在所有使用迭代器的函数中使用相同的迭代器简写:_.iteratee.

有时您可能想对集合的每个元素做一些事情,并以不同于 _.map_.sortBy 和其他 Underscore 函数已经做的方式组合结果。在这种情况下,您可以使用 _.reduce,这是它们中最通用的函数。例如,下面是我们如何创建 phone 名称的混合,方法是取第一个 phone 名称的第一个字母,第二个 [=] 名称的第二个字母257=],依此类推:

_.reduce(phones, function(memo, phone, index) {
    return memo + phone.name[index];
}, '')
// 'Sakse0'

我们传递给 _.reduce 的函数为每个 phone 调用。 memo 是我们到目前为止构建的结果。该函数的结果用作我们处理的下一个 phone 的新 memo。通过这种方式,我们一次构建一个 phone 字符串。 _.reduce 的最后一个参数,在本例中为 '',设置 memo 的初始值,因此我们有一些东西可以开始。

将多个数组连接成一个数组

为此我们有 _.flatten:

_.flatten([
    [ 'Samsung', 'A10s' ],
    [ 'Samsung', 'galaxy' ],
    [ 'Nokia', '7' ],
    [ 'Samsung', 'S20s', 'ultra' ],
    [ 'Apple', 'iphone', 'ultra' ],
    [ 'LG', 'S20' ]
])
// [ 'Samsung', 'A10s', 'Samsung', 'galaxy', 'Nokia', '7',
//   'Samsung', 'S20s', 'ultra', 'Apple', 'iphone', 'ultra',
//   'LG', 'S20' ]

综合起来

我们有一个 phone 数组和一个搜索字符串,我们想以某种方式将每个 phone 与搜索字符串进行比较,最后我们想合并结果所以我们通过相关性得到 phones。让我们从中间部分开始。

“这些 phone 中的每一个”都会响铃吗?我们正在创建一个迭代器!我们希望它以 phone 作为参数,并且我们希望它 return 它的 name 与搜索字符串共有的单词数。此函数将执行此操作:

function relevance(phone) {
    return _.intersection(phone.name.split(' '), searchTerms).length;
}

这假设在 relevance 函数之外定义了一个 searchTerms 变量。它必须是一个数组,其中包含搜索字符串中的单词。我们稍后会处理这个问题;让我们先解决如何合并我们的结果。

虽然有很多可能的方法,但我认为下面的方法很优雅。我首先按相关性对 phone 进行分组,

_.groupBy(phones, relevance)

但我想 omit phone 组与搜索字符串共有零个单词:

var groups = _.omit(_.groupBy(phones, relevance), '0');

请注意,我省略了 string'0',而不是 number0,因为 _.groupBy 的结果是一个对象,而对象的键总是字符串。

现在我们需要根据匹配词的数量对剩余的 groups 进行排序。通过获取我们的 groups,

的键,我们知道每组匹配词的数量
_.keys(groups)

我们可以先对这些进行升序排序,但我们必须注意将它们转换回数字,这样我们将在 10(数值比较)之前对 2 进行排序,而不是 [=83] =] 在 '2' 之前(字典顺序比较):

_.sortBy(_.keys(groups), Number)

然后我们可以将其反转以得出我们组的最终顺序。

var tiers = _.sortBy(_.keys(groups), Number).reverse();

现在我们只需要将这个排序后的键数组转换为包含实际 phone 组的数组。为此,我们可以使用 _.map_.propertyOf:

_.map(tiers, _.propertyOf(groups))

最后,我们只需要将其展平成一个大数组,以便根据相关性获得我们的搜索结果。

_.flatten(_.map(tiers, _.propertyOf(groups)))

让我们将所有这些打包到我们的 searchByRelevance 函数中。请记住,我们仍然需要在 relevance 迭代对象之外定义 searchTerms

function searchByRelevance(phones, searchString) {
    var searchTerms = searchString.split(' ');
    function relevance(phone) {
        return _.intersection(phone.name.split(' '), searchTerms).length;
    }
    var groups = _.omit(_.groupBy(phones, relevance), '0');
    var tiers = _.sortBy(_.keys(groups), Number).reverse();
    return _.flatten(_.map(tiers, _.propertyOf(groups)));
}

现在进行测试!

searchByRelevance(phones, 'Samsung galaxy A20s ultra')
// [ { name: 'Samsung galaxy', id: 839 },
//   { name: 'Samsung S20s ultra', id: 514 },
//   { name: 'Samsung A10s', id: 845 },
//   { name: 'Apple iphone ultra', id: 159 } ]

什么是“最佳”?

如果你用代码行数来衡量“好”,那么代码越少越好。我们只用了八行代码就实现了上面的searchByRelevance,所以看起来很不错。

然而,它有点密集。行数增加了,但是 可读性 提高了一点,如果我们使用 chaining:

function searchByRelevance(phones, searchString) {
    var searchTerms = searchString.split(' ');
    function relevance(phone) {
        return _.intersection(phone.name.split(' '), searchTerms).length;
    }
    var groups = _.chain(phones)
        .groupBy(relevance)
        .omit('0');
    return groups.keys()
        .sortBy(Number)
        .reverse()
        .map(_.propertyOf(groups.value()))
        .flatten()
        .value();
}

“好”的另一个维度是性能searchByRelevance 可以更快吗?为了理解这一点,我们通常采用最小和最频繁的操作,然后计算给定大小的输入执行该操作的频率。

我们在 searchByRelevance 中要做的主要事情是比较单词。这不是最小的操作,因为比较单词包括比较字母,但是因为英语中的单词往往很短,所以我们现在可以假装比较两个单词是我们最小的也是执行次数最多的操作。这使得计算更容易一些。

对于每个 phone,我们将其名称中的每个单词与搜索字符串中的每个单词进行比较。如果我们有 100 个 phone,平均 phone 名称有 3 个词,搜索字符串有 5 个词,那么我们将进行 100 * 3 * 5 = 1500 个词比较。

计算机速度很快,1500 算不了什么。通常,如果您执行最小步骤的次数保持在 100000 (100k) 以下,您可能甚至不会注意到延迟,除非最小步骤非常昂贵。

但是,随着输入的增加,单词比较的数量将呈爆炸式增长。如果我们有 20000 (20k) phones,平均名称中的 5 个词和 10 个词的搜索字符串,我们已经在进行一百万个词的比较。这可能意味着在结果出现之前盯着你的屏幕几秒钟。

我们可以写一个 searchByRelevance 的变体,可以在眨眼间搜索 20k 个长名字的 phone 吗?是的,事实上我们也可以做一百万甚至更多!我不会逐行详细介绍,但我们可以通过使用适当的查找结构来获得更好的速度:

// lookup table by word in the name
function createIndex(phones) {
    return _.reduce(phones, function(lookup, phone) {
        _.each(phone.name.split(' '), function(word) {
            var matchingPhones = (lookup[word] || []);
            matchingPhones.push(phone.id);
            lookup[word] = matchingPhones;
        });
        return lookup;
    }, {});
}

// search using lookup tables
function searchByRelevance(phonesById, idsByWord, searchString) {
    var groups = _.chain(searchString.split(' '))
        .map(_.propertyOf(idsByWord))
        .compact()
        .flatten()
        .countBy()
        .pairs()
        .groupBy('1');
    return groups.keys()
        .sortBy(Number)
        .reverse()
        .map(_.propertyOf(groups.value()))
        .flatten(true) // only one level of flattening
        .map('0')
        .map(_.propertyOf(phonesById))
        .value();
}

要使用它,我们创建查找 table 一次,然后在每次搜索中重复使用它们。仅当 phone 的 JSON 数据发生变化时,我们才需要重新创建查找 table。

var phonesById = _.indexBy(phones);
var idsByWord = createIndex(phones);

searchByRelevance(phonesById, idsByWord, 'Samsung galaxy A20s ultra')
// [ { name: 'Samsung galaxy', id: 839 },
//   { name: 'Samsung S20s ultra', id: 514 },
//   { name: 'Samsung A10s', id: 845 },
//   { name: 'Apple iphone ultra', id: 159 } ]
searchByRelevance(phonesById, idsByWord, 'Apple')
// [ { name: 'Apple iphone ultra', id: 159 } ]

要了解这有多快,让我们再次计算最小的操作。在createIndex中,最小最频繁的操作是存储单词与phone的id之间的关联。我们对每个 phone 名称中的每个单词执行一次。在 searchByRelevance 中,最小最频繁的操作是在 countBy 步骤中增加给定 phone 的相关性。我们对搜索字符串中的每个单词执行一次,对于每个匹配该单词的 phone。

如果我们做出一些合理的假设,我们可以估计给定搜索字符串的匹配数 phone。 phone 名称中出现频率最高的词可能是品牌,例如“三星”和“苹果”。由于至少有十个品牌,我们可以假设与给定搜索词匹配的 phone 的数量通常少于 phone 总数的 10%。所以执行一次搜索所需的时间是搜索字符串中的单词数乘以 phone 的数量,再乘以 10%(即除以 10)。

所以如果我们有 100 个 phone 名称中平均包含 3 个单词,那么索引需要 100 * 3 = 300 次在 idsByWord 查找 table 中存储关联.使用搜索字符串中的 5 个词执行搜索仅需要 5 * 100 * 10% = 50 个相关增量。这已经比我们无需查找 tables 所需的 1500 个单词比较快得多,尽管计算机背后的人不会注意到这种情况下的差异。

查找table方法的速度优势随着输入的增加而进一步增加:

┌───────────────────┬───────┬────────┬───────┐
│ Problem size      │ Small │ Medium │ Large │
├───────────────────┼───────┼────────┼───────┤
│ phones            │   100 │    20k │    1M │
│ words per name    │     3 │      5 │     8 │
│ search terms      │     5 │     10 │    15 │
├───────────────────┼───────┼────────┼───────┤
│ w/o lookup tables │       │        │       │
│ word comparisons  │  1500 │     1M │  120M │
├───────────────────┼───────┼────────┼───────┤
│ w/ lookup tables  │       │        │       │
│ associations      │   300 │   100k │    8M │
│ increments        │    50 │    20k │  1.5M │
└───────────────────┴───────┴────────┴───────┘

事实上,这仍然低估了速度优势,因为 phone 与给定搜索词匹配的百分比可能会随着 phone 数量的增加而下降。

查找 tables 使搜索更快。但它更好吗?正如我之前所说,对于小问题,速度差异不会很明显。查找 tables 的一个缺点是这需要更多的代码,这使得它更难理解,并且需要更多的努力来维护。它还需要与关联数一样大的查找 table,这意味着我们将使用比以前更多的额外内存。

总而言之,什么是“最好的”总是取决于不同约束之间的权衡,例如代码大小、速度和内存使用。您可以自行决定如何权衡这些约束之间的关系。