Lodash-精要-全-

Lodash 精要(全)

原文:zh.annas-archive.org/md5/a40920859cbe8dee73f8d7287a17feb5

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

有时,JavaScript 可能是一个令人沮丧的编程语言来工作。当你认为你已经完全弄懂了它的时候,性能或跨浏览器问题以最糟糕的方式暴露出来。Lo-Dash 只是许多旨在帮助 JavaScript 程序员编写优雅、高效、可移植代码的库之一。Underscore.js 引入了更好的 JavaScript 函数式编程方法。Lo-Dash 是这一努力的延续,在这本书中,我们将看到 Lo-Dash 与其他库(包括 Underscore)的不同之处。

在本质上,JavaScript 是一种函数式语言,但并不一定是纯函数式语言——你可以修改变量并引入副作用。然而,函数是一等公民,你可以用它们做很多事情。Lo-Dash 拥抱了这个想法,并为程序员提供了他们编写可读性和可维护性函数式代码所需的所有工具。你可以在其他语言中使用高阶函数所做的所有酷炫事情,使用 Lo-Dash 也是可能的。

虽然 Lo-Dash 提供的不仅仅是低级实用工具和性能提升,这些虽然总是受欢迎的;它还增强了编程模型,以及从诸如函数式和适用性编程等语言中借鉴的思想。这些思想以一个连贯的 API 形式传递给 JavaScript 应用。Underscore 开启了这一进程,而 Lo-Dash 进一步增强了这些思想,尽管使用了不同的设计和实现策略。

本书涵盖的内容

第一章, 处理数组和集合,解释了集合类似于数组,但比数组更通用。本章探讨了这两个构造之间的差异以及可用的操作它们的 Lo-Dash 函数。

第二章, 处理对象,教你如何 Lo-Dash 将对象视为集合。本章探讨了与对象一起工作的可用函数。

第三章, 处理函数,专注于 Lo-Dash 提供的函数式工具。

第四章, 使用 Map/Reduce 进行转换,解释了 map/reduce 编程模型在 Lo-Dash 代码中经常被使用。本章提供了许多使用 Lo-Dash map/reduce 工具的多种形式的示例。

第五章, 组装链,解释了 Lo-Dash 如何将操作值的函数链接在一起。本章探讨了链是什么以及它们是如何工作的。

第六章, 应用构建块,探讨了帮助组织 Lo-Dash 代码的高级应用组件。

第七章, 使用 Lo-Dash 与其他库,解释了 Lo-Dash 并不做所有事情。这一章通过示例说明了其他库如何帮助 Lo-Dash,反之亦然。

第八章, 内部设计和性能,探讨了 Lo-Dash 的一些内部设计决策,并提供了一些关于如何提高 Lo-Dash 代码性能的建议。

你需要这本书什么

运行本书中示例所需的唯一软件是一个现代网络浏览器。所有 JavaScript 库都包含在代码示例中。唯一的例外是第七章, 使用 Lo-Dash 与其他库,它涉及与 Node.js (nodejs.org/) 和一些相应的 npm 包一起工作。

这本书面向的对象

这本书是为那些对 Lo-Dash 既新又熟悉或已经使用了一段时间的 JavaScript 中级程序员而写的。无论你是否从未编写过一行 Lo-Dash 代码,或者你的 Lo-Dash 代码正在生产环境中运行,这本书都有适合你的内容。理想情况下,你已经编写了一些中级/高级 JavaScript 代码,并使用过像 jQuery 或 Backbone 这样的库。

约定

在这本书中,你会找到许多不同风格的文本,以区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"union()函数连接集合,并移除重复值。"

代码块设置如下:

var collection = [ 
    { name: 'Jonathan' },
    { first: 'Janet' },
    { name: 'Kevin' },
    { name: 'Ruby' }
];

if (!_.every(collection, 'name')) {
    return 'Missing name property';
}
// → "Missing name property"

任何命令行输入或输出都如下所示:

npm install -g grunt-cli grunt-contrib-connect

注意

警告或重要提示会出现在这样的框中。

读者反馈

我们始终欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢什么或可能不喜欢什么。读者反馈对我们开发你真正能从中获得最大价值的标题非常重要。

要向我们发送一般反馈,只需发送一封电子邮件到 <feedback@packtpub.com>,并在邮件主题中提及书名。

如果你在某个主题上有专业知识,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在你已经是 Packt 图书的骄傲拥有者,我们有一些东西可以帮助你从购买中获得最大价值。

下载示例代码

您可以从www.packtpub.com下载您购买的所有 Packt Publishing 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

错误清单

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中找到错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误清单,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误清单提交表单链接,并输入您的错误详细信息来报告它们。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站或添加到该标题的错误清单部分。

要查看之前提交的错误清单,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将在错误清单部分显示。

侵权

互联网上对版权材料的侵权是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的非法复制我们的作品,请立即提供位置地址或网站名称,以便我们可以追究补救措施。

请通过<copyright@packtpub.com>与我们联系,并提供疑似侵权材料的链接。

我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。

问答

如果您在这本书的任何方面遇到问题,您可以通过<questions@packtpub.com>联系我们,我们将尽力解决问题。

第一章. 使用数组和集合

Lo-Dash 提供了各种在数组和集合上操作的功能。这通常涉及以某种形式遍历集合。Lo-Dash 帮助使迭代行为易于实现,包括搜索数据以及构建新的数据结构。

集合是应用编程的核心。这是我们将会连续对集合中的每个元素应用函数的地方。本章介绍了集合的概念,这是 Lo-Dash 代码广泛使用的。

在本章中,我们将涵盖以下主题:

  • 遍历集合

  • 排序数据

  • 搜索数据

  • 将集合切割成更小的部分

  • 转换集合

数组和集合之间的区别

对于 Lo-Dash 新手来说,最初感到困惑的来源之一是数组和集合之间的区别。Lo-Dash API 为数组提供了一套函数,并为集合提供了一套函数。但为什么?这些函数似乎对任何集合都是可互换的。好吧,根据 Lo-Dash,对集合实际上是什么的更好定义可能会澄清一些事情。

集合是一个抽象概念。也就是说,我们可以使用 Lo-Dash 中找到的集合函数来迭代我们想要的任何 JavaScript 对象。例如,forEach() 函数会愉快地遍历数组、字符串或对象。这些类型之间的微妙差异以及它们在迭代中的含义对开发者来说是隐藏的。

Lo-Dash 提供的数组函数更少抽象,实际上它们确实期望一个数组。从某种意义上说,即使是这些函数也是抽象的,因为它们并没有明确检查 Array 类型。它们要求对象支持数值索引,并且具有数值的 length 属性。

吸取的教训是,在作为 Lo-Dash 程序员的大部分日子里,数组和集合之间的区别并不重要。主要是因为主要的集合类型将始终是数组。在极少数情况下,当区别很重要时,只需记住数组函数对它们认为可接受的数据有稍微严格一些的标准。

遍历集合

Lo-Dash 在集合上做了很多迭代,无论是开发者明确执行,还是通过高级 Lo-Dash 函数隐式执行。让我们看看基本的 forEach() 函数:

var collection = [
    'Lois',
    'Kathryn',
    'Craig',
    'Ryan'
];

_.forEach(collection, function(name) {
    console.log(name);
});
// → 
// Lois
// Kathryn
// Craig
// Ryan

这段代码并没有做什么,除了记录集合中每个项目的值。然而,它确实给你一个关于 Lo-Dash 中迭代函数外观的一般感觉。正如其名所示,对于数组中的每个元素,应用函数回调。传递给回调函数的不仅仅是当前元素。我们还可以得到当前索引。

小贴士

下载示例代码

你可以从你购买的所有 Packt Publishing 书籍的账户中下载示例代码文件,网址为www.packtpub.com。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。

看看下面的代码:

var collection = [
    'Timothy',
    'Kelly',
    'Julia',
    'Leon'
];

_.forEach(collection, function(name, index) {
    if (name === 'Kelly') {
        console.log('Kelly Index: ' + index);
        return false;
    }
});
// → Kelly Index: 1

返回false告诉forEach()这个迭代将是最后一个。索引,在每次迭代中,都会递增并作为第二个参数传递给回调函数。

forEach()函数以典型的从左到右的方式遍历集合。如果我们想要相反的行为,我们可以使用其堂兄弟函数,forEachRight()

var collection = [
    'Carl',
    'Lisa',
    'Raymond',
    'Rita'
];

var result = [];

_.forEachRight(collection, function(name) {
    result.push(name);
});
// →
// [
//   "Rita",
//   "Raymond",
//   "Lisa",
//   "Carl"
// ]

这种行为在处理已排序的集合时很有用,正如前面代码所示。但是,假设我们想要以降序在 DOM 中渲染这个数组数据。前面的代码显示我们可以渲染给定迭代中的每个项目。在这种情况下使用forEachRight()函数的优势在于不需要对数组进行反转排序。

然而,很多时候这个快捷方式是不够的,你必须对你的集合进行排序。接下来我们将查看 Lo-Dash 中帮助排序的函数。

排序数据

在纯 JavaScript 中,排序的方法涉及数组和两种方法。sort()方法按升序对数组进行排序,使用原始的比较操作。你可以通过传递sort(),一个比较器函数来自定义这种行为。例如,你可以使用这个回调函数按降序排序一个数组。另一种方法,reverse(),只是简单地反转数组的顺序。它是当前顺序的相反,无论是什么。

原生的数组sort()方法在原地排序数组,尽管你可能不希望这样。不可变操作减少了副作用,因为它们不会改变原始集合。具体来说,你可能已经按特定顺序请求了 API 数据。UI 的一个区域想要以不同的排序顺序渲染这个数组。好吧,你不想改变请求的顺序。在这种情况下,有一个返回包含原始项目但按预期排序顺序的新数组的函数会更好。

使用sortBy()

sortBy()函数是 Lo-Dash 对原生Array.sort()方法的回应。由于它是一个抽象的集合函数,它不仅限于数组。看看下面的代码:

_.sortBy('cba').join('');

当函数与字符串作为输入正常工作时,输出是一个排序后的字符数组;因此,需要调用它们将它们重新组合在一起。这是因为sortBy()函数始终返回一个数组作为结果。

sortBy() 函数与原生的 Array.sort() 方法类似,默认情况下按升序对集合项进行排序。同样,与原生方法类似,我们可以向 sortBy() 传递一个回调函数来定制排序行为,如下所示:

var collection = [
    { name: 'Moe' },
    { name: 'Seymour' },
    { name: 'Harold' }, 
    { name: 'Willie' }
];

_.sortBy(collection, function(item) {
    return item.name;
});

传递给 sortBy() 的前面的回调函数返回对象属性的值。通过这样做,排序行为将比较属性值——在这种情况下,name——而不是对象本身。实际上,有一种更短的方法可以达到相同的结果:

_.sortBy(collection, 'name');

这在 Lo-Dash 术语中被称为 pluck 风格 简写。我们传入我们想要按其排序集合的属性名称。然后从集合中的每个对象中提取该属性的值。实际上,我们将在稍后更深入地查看 pluck() 函数。

sortBy() 有一个最后的技巧,它将 pluck 简写提升到了下一个层次,并允许按多个属性名进行排序,如下面的代码所示:

var collection = [
    { name: 'Clancy', age: 43 },
    { name: 'Edna', age: 32 },
    { name: 'Lisa', age: 10 },
    { name: 'Philip', age: 10 }
];

_.sortBy(collection, [ 'age', 'name' ]);
// →
// [
//   { name: "Lisa", age: 10 },
//   { name: "Philip", age: 10 },
//   { name: "Edna", age: 32 },
//   { name: "Clancy", age: 43 }
// ]

这里的主要排序决定因素是 age 属性。如果我们指定第二个属性名,则用于确定具有相同主要排序顺序的元素顺序。它充当决定性因素。这里有两个对象的 age 等于 10。由于 name 属性是次要排序顺序,这就是这两个对象被排序的方式。在 Web 应用程序中,多个排序属性是一个典型用例,如果没有这个 Lo-Dash 工具,我们将需要编写大量 JavaScript 代码来实现。

维护排序顺序

使用 sortBy() 函数是改变现有集合排序顺序的绝佳工具,尤其是如果我们不希望永久更改该集合的默认排序顺序时。然而,在其他时候,你可能希望永久保持集合排序。有时,这实际上是由发送给我们以 JSON 数据形式集合的后端 API 完成的。

在这些情况下,排序很简单,因为你实际上不需要对任何东西进行排序。它已经完成了。挑战在于维护排序顺序。因为,有时元素会实时添加到集合中。维护排序顺序的直观方法是将新元素简单地添加到集合中然后重新排序。Lo-Dash 的替代方案是找出将保持当前集合排序顺序的插入点。这在上面的代码中显示:

var collection = [ 
    'Carl',
    'Gary',
    'Luigi',
    'Otto'
];

var name = 'Luke';

collection.splice(_.sortedIndex(collection, name), 0, name);
// → 
// [
//   "Carl",
//   "Gary",
//   "Luigi",
//   "Luke",
//   "Otto"
// ]

新的 name 变量被插入到倒数第二个位置。这实际上是维护排序集合顺序所需的功能。相同的 splice() 数组方法用于从集合中删除项目,这不会破坏顺序。添加新项目是一个挑战,因为需要搜索以确定插入索引。sortedIndex() 函数在集合上执行二分搜索以确定新项目适合的位置。

搜索数据

应用程序不使用整个集合。相反,它们遍历集合的子集,或者它们在集合中查找特定的项目。Lo-Dash 提供了一些功能工具,帮助程序员找到他们需要的数据。

筛选集合

使用 Lo-Dash 在集合上执行筛选操作的最简单方法是使用where()函数。该函数接受一个对象参数,并将匹配其属性与集合中的每个项目,如下面的代码所示:

var collection = [ 
    { name: 'Moe', age: 47, gender: 'm' },
    { name: 'Sarah', age: 32, gender: 'f' },
    { name: 'Melissa', age: 32, gender: 'f' },
    { name: 'Dave', age: 32, gender: 'm' }
];

_.where(collection, { age: 32, gender: 'f' });
// →
// [
//   { name: "Sarah", age: 32, gender: "f" },
//   { name: "Melissa", age: 32, gender: "f" }
// ]

之前的代码在agegender属性上对集合进行了筛选。查询结果对应的是 32 岁的女性。Moe对象与这两个属性都不匹配,而Dave对象与age属性匹配,但与gender属性不匹配。关于where()筛选的一个好的思考方式是,你传递给筛选器的每个对象属性都将进行逻辑并且连接。例如,匹配agegender属性。

where()函数因其简洁的语法和对集合的直观应用而非常出色。这种简单性带来了一些限制。首先,我们比较的属性值必须与集合中的每个项目完全匹配。有时,我们需要比严格相等更复杂的比较。其次,where()连接查询条件的方式的逻辑and并不总是令人满意。逻辑or条件同样常见。

对于这些高级筛选功能,你应该转向filter()函数。这是一个基本的筛选操作,比where()查询还要简单:

var collection = [ 
    { name: 'Sean', enabled: false },
    { name: 'Joel', enabled: true },
    { name: 'Sue', enabled: false },
    { name: 'Jackie', enabled: true }
];

_.filter(collection, 'enabled');
// →
// [
//   { name: "Joel", enabled: true },
//   { name: "Jackie", enabled: true }
// ]

由于这个集合中enabled属性对两个对象有真值,它们被返回到一个新的数组中。

注意

Lo-Dash 在所有地方都使用真值的概念。这仅仅意味着,如果在一个if语句或三元运算符中使用,值将测试为正。值不需要是布尔类型和true才能是真值。一个对象、一个数组、一个字符串、一个数字——这些都是真值。而 null、undefined 和 0——都是假值。

如前所述,filter()函数填补了where()函数的空白。与where()不同,filter()接受一个回调函数,该函数应用于集合中的每个项目,如下面的代码所示:

var collection = [ 
    { type: 'shirt', size: 'L' },
    { type: 'pants', size: 'S' },
    { type: 'shirt', size: 'XL' },
    { type: 'pants', size: 'M' }  
];

_.filter(collection, function(item) {
    return item.size === 'L' || item.size === 'M';
});
// →
// [
//   { type: "shirt", size: "L" },
//   { type: "pants", size: "M" }
// ]

回调函数使用or条件来满足这里的size约束——mediumlarge。这用where函数是无法实现的。

注意

filter()函数也接受一个对象参数。在 Lo-Dash 术语中,这被称为where 风格回调。许多函数,不仅仅是filter(),接受指定为对象的筛选标准,并像where()一样表现。

当我们知道我们要找什么时,使用 filter() 函数过滤集合是好的。回调函数给程序员足够的灵活性来组合复杂的标准。但有时,我们不知道从集合中需要什么。相反,我们只知道我们不需要什么,如下面的代码所示:

var collection = [
    { name: 'Ryan', enabled: true },
    { name: 'Megan', enabled: false },
    { name: 'Trevor', enabled: false },
    { name: 'Patricia', enabled: true }
];

_.reject(collection, { enabled: false });
// →
// [
//   { name: "Ryan", enabled: true },
//   { name: "Patricia", enabled: true }
// ]

你可以在这里看到,只有 enabled 项目被返回,这相当于执行 _.filter(collection, {enabled: true}),这是对 filter() 的简单反转。你使用哪个函数取决于个人偏好以及它们被使用的上下文。选择在代码中读起来更清晰的那个。

注意

reject() 实际上在内部使用了 filter() 函数。它使用 negate() 函数来反转传递给 filter() 的回调函数的结果。

在集合中查找项目

有时,我们需要特定的集合项目。过滤集合只是生成一个包含较少项目的新的集合。相反,在集合中查找项目意味着找到特定的项目。

用于在集合中查找项目的函数被恰当地命名为 find()。此函数接受与 filter() 函数相同的参数。你可以传递属性名称作为字符串,传递一个包含属性名称和值的对象以执行类似 where 风格的搜索,或者传递一个简单的回调函数以匹配你想要的内容。以下是一个示例:

var collection = [ 
    { name: 'Derek', age: 37 },
    { name: 'Caroline', age: 35 },
    { name: 'Malcolm', age: 37 },
    { name: 'Hazel', age: 62 } 
];

_.find(collection, { age:37 });
// → { name: "Derek", age: 37 }

实际上,有两个项目符合集合中的样式标准——DerekMalcolm。如果我们运行这段代码,我们会看到只有 Derek 被返回。这是因为 find() 函数一旦找到匹配项就会立即返回。在集合中搜索项目时,集合的顺序很重要。它不考虑重复的值。

让我们换个方向看看我们会找到什么。使用相同的集合和相同的搜索标准,你可以向相反方向搜索:

_.findLast(collection, { age:37 });
// → { name: "Malcolm", age: 37 }

虽然 find() 函数在集合中搜索匹配项的第一个出现,但 findLast() 函数则搜索最后一个出现。这在处理已排序的集合时非常有用——你可以更好地优化你的线性搜索。

注意

虽然 Lo-Dash 对迭代集合时使用的 while 循环进行了大量优化,但使用 find() 等函数执行搜索是线性的。重要的是要记住,使用 Lo-Dash 的程序员需要考虑他们独特应用数据带来的性能影响。Lo-Dash 函数针对通用常见情况进行优化,它们不会因为使用它们而神奇地使你的代码更快。它们是帮助程序员制作高性能代码的工具。

将集合切割成更小的部分

到目前为止,我们已经看到了如何过滤集合,创建一个新的较小的集合。Lo-Dash 为你提供了一些函数,这些函数接受现有的数组并生成一个或多个较小的数组。例如,你可能想要任何数组的开头部分或结尾部分的一部分。数组可以被分成较小的数组块,这对于批量处理非常有用。你还可以使用 Lo-Dash 数组工具来删除重复项,从而确保数组的唯一性。

首尾集合部分

使用原生的 JavaScript 数组,你可以使用 slice() 数组方法来截取数组的前一部分。Lo-Dash 在原生的数组 slice() 方法之上提供了抽象,这使得开发者编写直观的代码变得稍微容易一些——这并不总是原生数组方法的情况。此外,Lo-Dash 的 take() 函数作用于集合,因此它可以与数组和字符串一起使用,如下面的代码所示:

var array = [ 
    'Steve',
    'Michelle',
    'Rebecca',
    'Alan'
];

_.take(array, 2);
// → [ "Steve", "Michelle" ]

_.take('lodash', 2).join('');
// → "lo"

使用 take() 对数组进行操作和字符串操作时的输出有所不同。当应用于数组时,它生成一个新的数组,这是原始数组的一个子集。然而,当应用于字符串时,它返回一个包含单个字符的新数组。前面的代码将返回 [ 'l', 'o' ]。这很可能不是我们想要的,所以我们将使用空字符串将这些字符重新组合在一起。

我们可以使用 takeRight() 函数来截取集合和字符串的最后部分。使用相同的数组和字符串,你可以运行以下代码来获取集合的最后部分:

_.takeRight(array, 2);
_.takeRight(string, 4).join('');

结果数组看起来像 [ 'Rebecca', 'Alan']。结果字符串看起来像 'dash'

take() 应用于不带任何参数的集合将截取第一个项目。同样,将 takeRight() 应用于不带任何参数将截取最后一个项目。在这两种情况下,返回的值是一个包含一个项目的数组,而不是项目本身。如果你只是想要第一个或最后一个集合项目,请分别使用 first()last() Lo-Dash 函数。

将集合分成块

有时候,我们会遇到大型集合。真的是非常大的集合。特别是当使用 API 数据时,前端并不总是能控制返回的数据集的大小。当 API 返回大量数据时,我们的代码处理这些数据可能会导致 UI 冻结。我们无法确切地说给我们更少的数据来工作,这样 UI 就不会冻结。UI 冻结也是不可接受的。

Lo-Dash 非常高效地遍历集合。然而,它并没有控制你代码中可能执行的成本高昂的操作。这就是导致 UI 冻结的原因——不是集合本身的大小,也不是执行一次成本高昂的操作——而是这两个因素结合在一起,对 UI 的响应性构成了致命的威胁。

chunk() 函数是一种将处理非常大的集合分解为几个较小任务的方法。这给了 UI 更新的机会——渲染挂起的 DOM 更新并处理挂起的事件。这个函数的用法可以在以下代码中看到:

function process(chunks, index) {
    var chunk = chunks[index];
    if (_.isUndefined(chunk)) {
        return;
    };  
    console.log('doing expensive work ' + _.last(chunk));
    _.defer(_.partial(process, chunks, ++index));
}

var collection = _.range(10000),
    chunks = _.chunk(collection, 50);

process(chunks, 0);
// → 
// doing expensive work 49
// doing expensive work 99
// doing expensive work 149

如果前面的代码看起来有点让人望而却步,别担心。这里介绍了一些可能让你感到困惑的新概念。让我们先从高层次上解释一下代码实际上在做什么。创建了一个大集合,并将其分割成更小的集合块。process() 函数对每个块进行一些工作,然后再次调用自身来处理下一个块,直到没有块为止。

集合本身是通过range()函数生成的,其中包含10000个整数。重要的是集合的大小,而不是内容。chunk()函数用于将大集合分割成更小的集合。我们指定了每个分割集合的大小,在这种情况下,我们得到了 20 个包含 50 个项目的较小集合。处理工作是通过调用process(chunks, 0)开始的。第二个参数是要开始的第一个块。

process() 函数本身根据index参数获取下一个要处理的块。如果块未定义,这意味着已经到达了末尾,没有更多的块可以处理。否则,我们可以开始对块进行昂贵的处理,如示例中的console.log()调用所示。最后,defer()函数将开始处理下一个块。我们使用defer()的原因是给调用栈一个清空的机会,也给 DOM 操作一个运行的机会。如果我们不这样做,使用chunk()来分割处理就没有意义了。defer()函数期望一个回调,我们使用partial()来创建一个新函数,该函数已经提供了参数。

注意

defer()partial()函数在第三章使用函数中有更深入的介绍。

我们如何知道我们的数组块的大小?在之前的代码中,我们选择了50作为块大小。但这是一个任意决定,还是基于应用程序中使用的典型数据集?简短的答案是,我们必须稍微调整并针对常见情况进行优化。这可能意味着根据整体集合大小的百分比来确定块大小,如下面的代码所示:

var collection = _.range(10),
    size = Math.ceil(0.25 * collection.length);
_.chunk(collection, size);
// → 
// [
//   [ 0, 1, 2 ],
//   [ 3, 4, 5 ],
//   [ 6, 7, 8 ],
//   [ 9 ]
// ]

这里的块大小最终是3。实际大小是 2.5,但由于没有 2.5 个集合元素,所以取其上限。此外,你感兴趣的不是块大小的精确度,而是接近 25%的程度。

注意

你可能已经注意到3不能整除10chunk()函数足够智能,不会遗漏任何项。任何未填满块大小的剩余项仍然被包括在内。

构建唯一数组

集合中有时会有不想要的重复项。这可能是 API 数据本身包含重复项的结果,或者是在前端执行的其他计算中的副作用。无论原因如何,Lo-Dash 提供了必要的工具,可以快速生成唯一的集合。

uniq()函数接受一个集合作为输入,并生成一个新的集合作为输出,其中任何重复项都被移除:

var collection = [ 
    'Walter',
    'Brenda',
    'Arthur',
    'Walter'
];

_.uniq(collection);
// → [ "Walter", "Brenda", "Arthur" ]

默认情况下,潜在的重复项使用严格相等运算符进行比较。在先前的集合中,由于'Walter' === 'Walter',找到了重复项并将其移除。您可以更详细地指定您希望uniq()如何比较值。例如,如果我们有一个对象集合,我们只想基于name属性的唯一对象,我们可以编写_.uniq(collection, 'name')。该函数还接受一个回调,用于在比较之前计算值。这在对象的唯一性不是那么直接的情况下很有用,如下面的代码所示:

var collection = [ 
    { first: 'Julie', last: 'Sanders' },
    { first: 'Craig', last: 'Scott' },
    { first: 'Catherine', last: 'Stewart' },
    { first: 'Julie', last: 'Sanders' },
    { first: 'Craig', last: 'Scott' },
    { first: 'Janet', last: 'Jenkins' }
];

_.uniq(collection, function(item) {
    return item.first + item.last;
});
// →
// [
//   { first: "Julie", last: "Sanders" },
//   { first: "Craig", last: "Scott" },
//   { first: "Catherine", last: "Stewart" },
//   { first: "Janet", last: "Jenkins" }
// ]

此代码确保集合中每个对象的唯一性基于全名。可能没有全名属性,也许在应用程序的其他地方并不需要。因此,uniq()函数可以即时构建一个,这仅用于验证此约束的唯一目的。

转换集合

Lo-Dash 提供了一系列工具,可以将集合转换为新的数据结构。此外,还有一些工具可以将两个或多个集合组合成一个单一的集合。这些函数专注于前端开发者面临的最常见且最繁重的编程任务。您不必专注于样板集合转换,可以回到制作出色的应用程序——用户对您那样酷炫的紧凑代码的关注程度不如您。

分组集合项

集合中的项有时会隐式分组。例如,假设有一个给定类对象的size属性,其允许的值是'S''M''L'。你的前端应用程序中的代码可能需要将包含这些各种组的项四舍五入以供显示。我们不会编写自己的代码,而是让groupBy()函数处理构建此类分组的复杂性:

var collection = [ 
    { name: 'Lori', size: 'S' },
    { name: 'Johnny', size: 'M' },
    { name: 'Theresa', size: 'S' },
    { name: 'Christine', size: 'S' }
];

_.groupBy(collection, 'size');
// →
// {
//   S: [
//     { name: "Lori", size: "S" },
//     { name: "Theresa", size: "S" },
//     { name: "Christine", size: "S" }
//   ],
//   M: [
//     { name: "Johnny", size: "M" }
//   ]
// }

groupBy() 函数,正如你可能已经注意到的,它并不返回一个集合——它接受一个集合作为输入,但将其转换为一个对象。groupBy() 返回的对象包含输入集合的原始项,只是组织方式不同。该对象的属性是你要按其分组的值。前述代码中的大多数集合项将驻留在 S 属性中。

注意

你还会看到,像 groupBy() 这样的转换函数实际上并没有修改项本身——只是它们所在的集合。这就是为什么在前述代码的结果对象中,每个项仍然有它的 size 属性,尽管实际上并不需要。

当你以字符串形式传递属性名时,groupBy() 将使用 pluck 风格的回调函数从集合中的每个项中获取该属性的值。唯一的属性值形成分组对象的键。正如通常情况那样,对象属性并不明确,需要在运行时计算。在分组项的上下文中,函数回调可以用于在分组不是简单比较的情况下对集合项进行分组,如下述代码所示:

var collection = [ 
    { name: 'Andrea', age: 20 },    
    { name: 'Larry', age: 50 },  
    { name: 'Beverly', age: 67 },
    { name: 'Diana', age: 39 }
];

_.groupBy(collection, function(item) {
    return item.age > 65 ? 'retired' : 'working';
});
// →
// {
//   working: [
//     { name: "Andrea", age: 20 },
//     { name: "Larry", age: 50 },
//     { name: "Diana", age: 39 }
//   ],
//   retired: [
//     { name: "Beverly", age: 67 }
//   ]
// }

与测试相等性不同,此回调函数测试近似值。也就是说,age 属性中大于 65 的任何内容都被认为是退休的。我们将其作为分组标签返回。请记住,如果这些回调函数返回原始类型作为键,那就最好了。对于任何其他值,返回字符串 working。这些回调函数的优点是,它们可以用来快速生成关于你正在处理的 API 数据的报告。前述示例通过传递给 groupBy() 的单行回调函数说明了这一点。

注意

虽然 groupBy() 函数可以接受一个 where 风格的对象作为第二个参数,但这可能不是你想要的。例如,如果集合中的项通过了测试,它最终会进入 true 组。否则,它就是 false 组的一部分。在深入使用 pluck 或 where 风格的回调函数之前要小心——它们可能不会按预期工作。尝试一下,快速得到结果以验证你的方法。

计算集合项数量

Lo-Dash 帮助我们找到集合的最小值和最大值。如果我们处理的是只包含数字的大量数组,我们可能不需要任何帮助。如果是这种情况,Math.min() 就是我们的朋友。在几乎所有其他情况下,min()max() 函数都是最佳选择,至少因为它们支持回调。让我们看看以下示例:

var collection = [ 
    { name: 'Douglas', age: 52, experience: 5 }, 
    { name: 'Karen', age: 36, experience: 22 },
    { name: 'Mark', age: 28, experience: 6 }, 
    { name: 'Richard', : age: 30, experience: 16 }
];

_.min(collection, 'age'),
// → { name: "Mark", age: 28, experience: 6 }

_.max(collection, function(item) {
    return item.age + item.experience;
});
// → { name: "Karen", age: 36, experience: 22 }

第一个调用是min(),它接收一个字符串参数——我们想要在集合中获取最小值的属性的名称。这使用了 pluck 风格的回调简写,并产生了简洁的代码,其中你知道你正在处理的属性。前面代码中的第二个调用是max()。这个函数支持与min()相同的回调简写,但在这里,没有预先存在的属性值供你使用。由于你想要的是age属性加上experience属性,提供给max()的回调函数为我们计算这个值并找出最大值。

注意,min()max()函数返回的是实际的集合项,而不是最小或最大值。这很有道理,因为我们可能想要对项本身进行操作,而不仅仅是获取最小/最大值。

除了找到集合的最小和最大值之外,还需要找到集合的实际大小。如果你正在处理数组,这很容易,因为它们已经具有内置的length属性。字符串也是如此。然而,对象并不总是具有length属性。Lo-Dash 的size()函数会告诉你对象有多少键,这是从对象中期望的直观行为,但默认情况下并不存在。看看下面的代码:

var collection = [ 
    { name: 'Gloria' },
    { name: 'Janice' },
    { name: 'Kathryn' },
    { name: 'Roger' }
];  

var first = _.first(collection);
_.size(collection); // → 4
_.size(first); // → 1
_.size(first.name); // → 6

size()函数的第一个调用返回集合的长度。它会寻找length属性,如果集合有一个,则返回这个值。由于它是一个数组,length属性存在,并且值为4。这就是返回的值。first变量是一个对象,因此它没有length属性。它会计算对象中的键的数量并返回这个值——在这个例子中,1。最后,size()被调用在一个字符串上。这个字符串的长度值为6

size()函数的三种用法中我们可以看出,其中几乎没有猜测的成分。在默认的 JavaScript 行为不一致且不直观的情况下,Lo-Dash 提供了一个单一的功能来处理常见的用例。

展平和压缩

数组可以嵌套到任意深度,有时会包含没有实际用途的假值。Lo-Dash 有函数来处理这两种情况。例如,我们的 UI 组件可能被传递为一个包含嵌套数组的数组。但我们的组件并不使用这种结构,实际上,它更多的是一种阻碍而不是帮助。我们可以展平数组以提取并丢弃组件不需要的不必要结构,如下面的代码所示:

var collection = [ 
    { employer: 'Lodash', employees: [
        { name: 'Barbara' },
        { name: 'Patrick' },
        { name: 'Eugene' }
    ]}, 
    { employer: 'Backbone', employees: [
        { name: 'Patricia' },
        { name: 'Lillian' },
        { name: 'Jeremy' }
    ]}, 
    { employer: 'Underscore', employees: [
        { name: 'Timothy' },
        { name: 'Bruce' },
        { name: 'Fred' }
    ]}  
];  
var employees = _.flatten(_.pluck(collection, 'employees'));

_.filter(employees, function(employee) {
    return (/^[bp]/i).test(employee.name);
});
// → 
// [
//   { name: "Barbara" },
//   { name: "Patrick" },
//   { name: "Patricia" },
//   { name: "Bruce" }
// ]

当然,我们实际上并没有改变原始集合的结构,而是在实时构建一个新的集合,使其更适合当前的环境。在先前的例子中,该集合由employer对象组成。然而,我们的组件更关注的是employee对象。因此,第一步是使用pluck()方法将这些对象从其原始对象中提取出来。这会得到一个数组的数组。因为我们实际上提取的是每个employer数组中的employee数组。

下一步是将这个employee数组扁平化为一个employee对象的数组,flatten()函数可以轻松处理这一点。做所有这些事情的目的,虽然实际上并不多,现在我们有一个易于过滤的结构。特别是,这段代码使用扁平化的集合结构来过滤掉以bp开头的员工姓名。

注意

另有一个名为flattenDeep()的扁平化函数,它可以访问任意嵌套数组的深度以创建一个扁平化的结构。当你需要超出flatten()函数查看的一级嵌套时,这很有用。然而,由于性能影响,不建议扁平化未知大小和深度的数组。大型数组结构有很大可能锁定用户的 UI。

flatten()函数的近亲是compact()函数,通常一起使用。我们将使用compact()来从扁平化数组中移除假值,或者仅用于已经存在的普通数组,或者在其过滤之前移除假值。这将在以下代码中展示:

var collection = [ 
    { name: 'Sandra' },
    0,  
    { name: 'Brandon' },
    null,
    { name: 'Denise' },
    undefined,
    { name: 'Jack' }
    ];
var letters = [ 's', 'd' ],
    compact = _.compact(collection),
    result = [];

_.each(letters, function(letter) {
    result = result.concat(
        _.filter(compact, function(item) {
            return _.startsWith(item.name.toLowerCase(), letter);
        })
    );  
});
// → 
// [
//   { name: "Sandra" },
//   { name: "Denise" }
// ]

我们可以看到这个集合中有些值是我们显然不想处理的。但,希望不是那么令人悲伤的现实是,在动态类型语言中进行前端开发,并且使用后端数据意味着你无法控制很多合理性检查。前面代码使用compact()函数所做的只是从集合中移除任何假值。这些值包括0nullundefined。实际上,如果没有压缩集合,这段代码甚至无法运行,因为它对集合中每个对象的name属性被定义有隐含的假设。

compact()不仅可以用于安全目的——移除违反约定的项——还可以用于性能目的。你会看到前面的代码在循环内部搜索集合。因此,在进入外部循环之前从集合中移除的任何项,性能提升就越大。

回到前面的代码,有一个问题可能会让 Lo-Dash 程序员感到意外。假设我们不想有任何没有name属性的东西。嗯,我们只是在移除假值——没有name属性的对象仍然是有效的,compact()函数允许它们通过。例如,{}没有name属性,2也没有,但它们在上一个方法中都被允许通过。一个更安全的方法可能是先提取再压缩,如下面的代码所示:

var collection = [ 
    { name: 'Sandra' },
    {}, 
    { name: 'Brandon' },
    true,
    { name: 'Denise' },
    1,  
    { name: 'Jack' }
    ];
var letters = [ 's', 'd' ],
    names = _.compact(_.pluck(collection, 'name')),
    result = [];

_.each(letters, function(letter) {
    result = result.concat(
        _.filter(names, function(name) {
            return _.startsWith(name.toLowerCase(),
                                letter);
        })  
    );  
});

在这里,我们面临一个类似的过滤任务,但集合略有不同。它包含一些会导致我们的代码失败的对象,因为这些对象没有具有字符串值的name键。快速而简单的解决方案是在执行compact()调用之前从集合中的所有项目提取name属性。这将导致没有name属性的对象返回undefined值。但这正是我们想要的,因为compact()可以轻松排除这些值。此外,我们的代码现在实际上更简单了。需要注意的是,有时简单的方法不起作用。有时,你需要完整的对象,而不仅仅是名称。只有在可以逃避的情况下才作弊。

验证某些或所有项目

有时,我们的代码的某些部分取决于所有或某些集合项目的有效性。Lo-Dash 为您提供了两个互补的工具来完成这项工作。every()函数如果回调对集合中的每个项目都返回true,则返回truesome()函数是every()的懒惰兄弟——它一旦回调对某个项目返回true,就提供并返回true,如下面的代码所示:

var collection = [ 
    { name: 'Jonathan' },
    { first: 'Janet' },
    { name: 'Kevin' },
    { name: 'Ruby' }
];

if (!_.every(collection, 'name')) {
    return 'Missing name property';
}
// → "Missing name property"

此代码在对其进行任何操作之前检查集合中的每个项目是否有name属性。由于其中一个项目使用了错误的属性名称,代码将提前返回。在if语句下面的代码可以假设每个项目都有一个name属性。

另一方面,我们可能只想知道是否有任何项目具有必要的值。您可以使用这项技术来显著提高性能。例如,假设您有一个循环,它对每个集合项目执行昂贵的操作。您可以进行一个相对便宜的预检,以确定是否值得运行这个昂贵的循环。以下是一个示例:

var collection = [ 
    { name: 'Sean' },
    { name: 'Aaron' },
    { name: 'Jason' },
    { name: 'Lisa' }
];
if (_.some(collection, 'name')) {
    // Perform expensive processing...
}

如果some()调用遍历整个集合而没有任何true回调返回值,这意味着我们可以跳过更昂贵的处理。例如,如果我们有一个可能很大的集合,并且我们需要使用一些非平凡的比较运算符来过滤它,也许还需要一些函数调用,那么开销真的开始增加。如果不需要,使用some()是一种避免这种重处理的好方法。

并集、交集和差集

本章的最后部分探讨了 Lo-Dash 函数,这些函数可以比较两个或多个数组并生成一个结果数组。从某种意义上说,我们正在将多个集合合并成一个集合。union() 函数连接集合,同时移除重复值。intersection() 函数构建一个包含所有提供集合共有值的集合。最后,xor() 函数构建一个包含所有提供集合差异的集合。这有点像是 intersection() 的逆操作。

当有多个重叠的集合包含相似的项目——可能是相同的项目时,可以使用 union() 函数。与其逐个遍历每个集合,不如将集合合并,同时移除重复项,如下面的代码所示:

var css = [ 
    'Philip',
    'Donald',
    'Mark'
];  
var sass = [ 
    'Gary',
    'Michelle',
    'Philip'
];  
var less = [ 
    'Wayne',
    'Ruth',
    'Michelle'
];

_.union(css, sass, less);
// →
// [
//   "Philip",
//   "Donald",
//   "Mark",
//   "Gary",
//   "Michelle",
//   "Wayne",
//   "Ruth"
// ]

这段代码将三个数组转换成一个单一的数组。你可以看到在结果数组中没有重叠。也就是说,存在于输入数组中的任何项目在结果数组中只包含一次。让我们看看使用 intersection() 如何查看重叠的情况:

var css = [ 
    'Rachel',
    'Denise',
    'Ernest'
];  

var sass = [ 
    'Lisa',
    'Ernest',
    'Rachel'
];  

var less = [ 
    'Ernest',
    'Rachel',
    'William'
];

_.intersection(css, sass, less);
// → [ "Rachel", "Ernest" ]

在这里,交集是 ErnestRachel,因为这些字符串存在于传递给 intersection() 的所有三个集合中。现在,让我们看看如何使用 xor() 比较两个集合之间的差异:

var sass = [ 
    'Lisa',
    'Ernest',
    'Rachel'
];
var less = [ 
    'Ernest',
    'Rachel',
    'William'
];

return _.xor(sass, less);
// → [ "Lisa", "William" ]

将这两个数组传递给 xor() 将会生成一个新的数组,该数组包含两个数组之间的差异。在这种情况下,差异是 LisaWilliam。其余的都是交集。

注意

xor() 函数可以接受任意数量的集合进行比较。然而,在比较超过两个集合时,请谨慎行事。最常见的情况是比较两个集合以找出它们之间的差异。超出这个范围就是进入集合论领域,你可能会得到你预期的结果。

摘要

本章向您介绍了集合的概念以及它们如何与数组进行比较。Lo-Dash 将集合视为一个抽象概念——所有 JavaScript 数组都是集合,但并非所有集合都是数组。我们通过 Lo-Dash 提供的工具了解了遍历集合的概念——这是应用编程中的一个基本概念,本书中将会经常提及。

集合可以被过滤,并且可以从集合中检索项目。Lo-Dash 还为你提供了将集合转换为在实现前端 UI 组件时所需的其它结构的工具。

我们已经尝到了 Lo-Dash 编程中的一些常见主题——比如回调函数几乎在所有事情中都处于核心地位的想法,以及可以节省编码努力的多种简写,例如 pluck 和 where 回调。现在,让我们看看 Lo-Dash 如何与对象一起工作,以及我们可用的各种函数,这些内容将在下一章中介绍。

第二章 使用对象

在 JavaScript 中,一切都是对象。这包括函数、数组和字符串。还有一个普通对象的观念——键值对的字典。后者结构在需要通过键查找值时很有用。换句话说,这是程序员可能想要读取的东西——而不是数组中找到的数值索引。

许多 API 返回 JSON 数据——你经常会发现普通对象。虽然你可以仅使用 JavaScript 对象实现很多功能,但 Lo-Dash 使使用对象进行常见操作变得更加容易。这些函数使日常任务变得不那么无聊,正如你很快就会发现的,你通常可以找到一种更简洁的方法来处理对象。

除了平面对象的访问和操作之外,Lo-Dash 还提供了一些可以应用于代码中任何对象的实用函数。这些函数主要关注验证你正在处理的对象的类型,这是一个使用纯 JavaScript 实现的重复性任务。

在本章中,我们将涵盖以下主题:

  • 确定对象类型

  • 属性的赋值和访问

  • 遍历对象

  • 调用方法

  • 对象的转换

  • 创建和克隆对象

确定对象类型

在本节中,我们将探讨在 JavaScript 中通常如何处理类型验证以及 Lo-Dash 中的类型检查函数如何改进这种情况。

类型强制转换

当一个对象与另一个对象进行比较时,JavaScript 中会发生类型强制转换。也就是说,我们有一个操作数对象,一个操作符,以及第二个操作数对象。根据要执行的操作,第二个对象可能会被强制转换为与第一个操作数兼容的表示形式。以下是一个示例操作:

true == 1

这些显然是代表不同原始类型的不同对象。在松散类型编程的精神下,这个表达式触发了类型强制转换。第一个操作数是一个布尔值,第二个操作数是一个数字。== 等于操作符会将 1 的布尔表示与 true 进行比较。这就是为什么这个表达式总是评估为 true。这些值大致上是相等的。

你可以使用严格等于操作符来避免类型强制转换。以下表达式将评估为 false,因为两个操作数是以其原始形式进行比较的:

true === 1

那么为什么关闭类型强制转换呢?这似乎是一个有用的工具。好吧,当你不在乎某些事情时,它是有用的。例如,上一章向您介绍了真值和假值的概念——那些大致上是真或大致上是假的事物。在这里,类型强制转换是你的朋友,因为它捕捉了一组可能值,而不是必须严格检查许多可能值的相等性。简而言之,类型强制转换的存在是为了通过编写更少的代码来使你的生活更轻松。

然而,有时类型强制转换根本帮不上忙,反而可能引入难以追踪的隐蔽错误。例如,让我们用以下表达式对一些值取反:

!false;
!undefined;
!0;

这些都评估为true,这个事实可能会对我们的代码造成问题。特别是,一个对象可以缺少属性,这会评估为与具有false0值的定义属性相同。再次强调,在这些情况下,显式操作和关闭类型强制转换使用严格相等/不等运算符是有帮助的。

那么,所有这些的目的是什么,它与 Lo-Dash 有什么关系呢?前面的表达式只是大量边缘情况和问题的一个小样本,这些问题可能在我们应用程序中不同类型的对象交互时出现。Lo-Dash 通过提供一致的行为来减少这些复杂性。内部,Lo-Dash 必须执行各种丑陋的类型比较和检查,这样我们就不必执行它们。作为额外的奖励,这些实用函数在 Lo-Dash API 中公开。

管理函数参数

并非总是清楚我们的函数将以何种类型的参数被调用。我们的函数声明中指定的参数数量也不一定与调用者提供的数量相匹配——这些被称为可变参数函数。Lo-Dash 提供的类型检查功能可以更好地准备你的函数来处理可能抛给它们的任何内容。

例如,你可以显式检查每个函数参数,以确定传递给函数的内容。在可选参数的情况下,你可以使用这些函数显式检查是否传递了任何参数,如下面的代码所示:

function hello(name) {
    if (_.isString(name)) {
        return 'hello, ' + name;
    }   
}   

hello('world');
// → "hello, world"

hello();
// → undefined

如果name参数不是一个字符串,它就不是其他任何东西,换句话说,函数什么都不做。这就是前面代码中在第二次调用hello()时的情形。我们与其什么都不做,不如在我们的函数中内置一些其他补救措施,但这取决于我们的函数具体做什么。关键是我们要意识到可能会传递给我们的内容。

在函数调用中,关于是否存在参数的一个变体是参数传递的顺序。你可以忽略最后一个参数,因为它的值是未定义的,而且它本身也是可选的。然而,如果我们的函数接受三个参数,并且第二个是可选的,那会怎样呢?我们不得不在我们的函数内部调整参数及其值。许多库都这样做,Lo-Dash 通过其类型检查函数简化了这些问题,如下面的代码所示:

function hello(greeting, person) {
    if (_.isPlainObject(greeting)) {
        person = greeting;
        greeting = 'hi, ';
    }   
    return greeting + person.name;
}

hello('hello, ', { name: 'Dillan' });
// → "hello, Dillan"

hello({ name: 'Danielle' });
// → "hi, Danielle"

在这里,hello() 函数期望一个 greeting 字符串和一个 person 对象。结果证明,greeting 参数实际上是可选的,但它是最先的参数。因此,函数检查 greeting 是否实际上是一个普通对象,这表明省略了 greeting 字符串。然后,你只需确保 person 被分配了 greeting 的值。

注意

所有这些类型检查操作实际上都可以使用 Vanilla JavaScript 完成。另一方面,由于 JavaScript 的神秘类型系统,这样做有一些细微差别。Lo-Dash 只提取那些你不需要自己检查的常见操作,并将它们作为易于理解的函数名暴露出来。

算术

如果你曾经在 JavaScript 应用程序中做过算术运算,你知道使用错误的类型作为操作数可能会导致一些真正令人困惑的结果。例如,以下表达式可能或可能不熟悉:

1/0; // Infinity
1+'one'; // NaN

问题的关键在于,当这些家伙抬头时,通常不是一个好兆头。这可能是由于我们自己的功能代码(显然是在开发中且未投入生产)导致的,或者仅仅是因为我们的功能被调用时使用了错误的数据。在任一情况下,我们都需要准备好去排查发生的问题。我们通过为我们的算术操作提供一个安全网来实现这一点,如下所示:

var operand1 = 1/0,
    operand2 = NaN,
    results = [];

_.forEach([ operand1, operand2 ], function(op) {
    if (_.isFinite(op)) {
        results.push('operand 1 is finite');
    } else {
        if (!_.isNumber(op) || _.isNaN(op)) {
            results.push(op.toString() + ' is not a number');
        } else {
            results.push('Infinity cannot be used here');
        }
    }
});

return results;
// → 
// [
//   "Infinity cannot be used here",
//   "NaN is not a number"
// ]

这段代码遍历操作数,并使用 Lo-Dash 的 isFinite() 函数检查每个操作数是否是有限的。这个函数可以被视为一个万用工具;如果这个测试通过,那么你通常可以使用操作数进行算术运算。如果 isFinite() 失败,则执行 else 代码,这是尝试找出失败原因的一种尝试。如果它不是一个数字,那么显然它不是有限的。这包括像 trueStringnull 这样的值。另一方面,如果它是一个非有限的数字,我们知道我们正在处理无穷大。

注意

NaN 实际上是一个数字——这是 JavaScript 类型系统的最佳表现。这就是为什么在前面代码中的 if 语句有一个对 !_.isNumber()_.isNaN() 的检查。

可调用对象

如果你曾经尝试调用一个不是函数的东西,你可能已经看到了类似 TypeError: undefined is not a function 的错误消息。在这种情况下,属性或变量根本不存在。然而,如果我们尝试调用一个存在但不可调用的对象,我们也会得到类似的错误消息。

有时这种错误是期望的,因为我们的代码正在尝试调用一个不是函数的东西。解决方案:我们去修复它。记住,JavaScript 是动态类型的,根据我们的应用程序是如何设计的,可能存在需要在我们尝试调用它之前显式检查某物是否是函数的情况,如下面的示例所示:

var object = {
    a: function() { return 'ret'; },
    b: []
};

_.isFunction(object.a) && object.a();
// → "ret" 

_.isFunction(object.b) && object.b();
// → false

第一个属性 a 是一个函数,因此调用 isFunction() 发出的检查通过,函数被调用。另一方面,b 属性是一个数组,不可调用。所以在那里没有发生任何事情。

分配和访问属性

使用值创建新的 JavaScript 对象是一个简单的任务。繁琐的部分在于当我们需要将一个对象的内容合并到另一个对象中,或者当我们需要确保新对象有默认值时。在对象中定位值并验证键是否存在实际上需要大量的代码,如果不是 Lo-Dash 工具帮助我们完成这些活动的话。

扩展对象

在 JavaScript 库中,一个常见的模式是通过其他对象扩展对象以分配属性值。这可以通过逐个语句将一个属性分配给对象来实现。这种方法的麻烦在于你需要提前确切地知道哪些属性将被分配到目标对象中。考虑当新值来自函数参数时。提前知道所有可能的属性值是不切实际的。直接使用传递的源并扩展目标更简单。这就是为什么你会在 Lo-Dash 中找到这个模式,包括 Lo-Dash。这些工具被公开,以便你在你的应用程序中遵循相同的模式。以下是一个示例:

var object = { 
    name: 'Jeremy',
    age: 42
};

_.assign(object, {
    occupation: 'Programmer'
});

// →
// {
//   name: "Jeremy",
//   age: 42,
//   occupation: "Programmer"
// }

在这里,目标对象是 object 变量,它被分配了 occupation 属性。实际上,使用 assign(),我们必须小心,因为它不关注现有属性。任何传入的源都将覆盖它们,如下面的代码所示:

var object1 = { 
        name: 'Jenny',
        age: 27
    },  
    object2 = { 
        age: 31
    },  
    object3 = { 
        occupation: 'Neurologist'
    };

_.assign(object1, object2, object3);
// →
// {
//   name: "Jenny",
//   age": 31,
//   occupation: "Neurologist"
// }

两个对象被分配到目标 object1assign() 函数接受你需要的任何参数——它们按从左到右的顺序依次连接,并覆盖前面的属性。例如,在前面提到的代码中,没有新的对象被分配以覆盖 name 属性。然而,第二个对象覆盖了 age 属性。最终对象有一个全新的属性,并简单地添加到目标中。请注意,最终对象是 object1,它被就地修改。

注意

Lo-Dash 为其一些函数使用了别名。例如,extend() 只是 assign() 的别名;它做的是完全相同的事情。这是一个个人喜好问题,哪个被使用。你更喜欢将一个对象视为被 分配 到另一个对象,还是将一个对象视为 扩展 另一个对象?

到目前为止,我们处理了相互覆盖的简单属性,但更复杂的属性,如对象和数组,怎么办?我们是否希望这些值合并在一起而不是完全被覆盖?以下是一个展示这一点的示例:

var object1 = { 
        states: { running: 'poweroff' },
        names: [ 'CentOS', 'REHL' ]
    },  
    object2 = { 
        states: { off: 'poweron' },
        names: [ 'Ubuntu', 'Debian' ]
    };

_.merge(object1, object2, function(dest, src) {
    if (_.isArray(dest) && _.isArray(src)) {
        return dest.concat(src);
    }   
});
// →
// {
//   states: {
//     running: "poweroff",
//     off: "poweron"
//   },
//   names: [
//     "CentOS",
//     "REHL",
//     "Ubuntu",
//     "Debian"
//   ]
// }

merge() 函数在覆盖属性之前会递归地检查对象的属性,这与 assign() 函数不同。在其他方面,这两个函数是相似的,我们正在将一个或多个对象的属性复制到单个目标对象中。注意 states 属性——它没有被覆盖。相反,merge() 将检查两个对象并将它们合并在一起。其他在目标中已经存在的、具有相同属性名的类型将被简单地覆盖。这包括数组。

注意,我们能够将你自己的回调函数传递给 merge()。这个函数决定了如何合并属性。代码检查目标属性和源属性是否为数组。如果是,将源数组合并到目标中,否则它将被覆盖。回调将忽略任何不是数组的值。

新对象的默认值

在 JavaScript 编程中,一个常见的做法是通过参数自定义属性。也就是说,当我们创建一个新的对象实例时,我们可能提供一个在对象被使用的上下文中独特的参数。然而,为了有效地使用此模式,我们必须在调用者没有提供任何值时提供一些默认值。有几种方法可以做到这一点,但 Lo-Dash 提供了一个函数可以处理绝大多数情况,如下面的代码所示:

var object = { 
    name: 'George'
};

_.defaults(object, {
    name: '', 
    age: 0,
    occupation: ''
});
// →
// {
//   name: "George",
//   age": 0,
//   occupation": ""
// }

如我们所见,name 属性没有被默认值覆盖。其他两个默认值,ageoccupation,被分配给对象,因为它们是未定义的。如果属性存在任何其他值,defaults() 将使用该值,而不是默认值。

注意

defaults() 函数实际上使用了 assign() 函数。它只是传递 assign(),一个自定义默认值分配方式的回调函数。具体来说,是通过查找未定义的值。

查找键和值

在纯 JavaScript 中,对象访问属性值使用与数组相同的语法。也就是说,使用方括号表示法,但通常使用可读性强的字符串,而不是数值索引。然而,与数值索引和数组存在相同问题的还有对象和键。仅仅因为键是字符串,并不意味着我们知道哪些键是可用的。有时,我们必须搜索对象以找到我们正在寻找的键。

我们使用 findKey() 函数来定位第一个对象属性,该属性返回的回调函数为真值:

var object = { 
    name: 'Gene',
    age: 43, 
    occupation: 'System Administrator'
};

_.findKey(object, function(value) {
    return value === 'Gene';
});
// → "name"

这里,结果是 name;因为它是我们 findKey() 回调返回 true 的第一个属性值。奇怪的是,pluck 风格的简写方式并不像你想象的那样工作。调用 _.findKey(object, 'Gene') 什么也没找到。那是因为它将每个属性值都视为嵌套对象。以下是一个如何使用此函数的 where 风格简写方式的示例:

var object = { 
    programmers: {
        Keith: 'C',
        Marilyn: 'JavaScript'
    },  
    designers: {
        Lori: 'CSS',
        Marilyn: 'HTML'
    }   
};

_.findKey(object, { Marilyn: 'JavaScript' });
// → "programmers"

正如我们所见,它将每个属性值视为另一个对象;这些是 where 条件所测试的值。我们还可以使用以下代码找到具有数组值的对象属性的键:

var object = { 
    Maria: [
        'Python',
        'Lisp',
        'Go'
    ],  
    Douglas: [
        'CSS',
        'Clojure',
        'Haskell'
    ]   
}; 

var lang = 'Lisp';

_.findKey(object, function(value) {
    if (_.isArray(value)) {
        return _.contains(value, lang);
    } else {
        return value === lang;
    }   
});
// → "Maria"

传递给 findKey() 的回调函数检查属性值是否为数组。如果是,它会检查值是否存在于其中。否则,它将只执行严格的值比较。由于 search 项存在于第一个属性值中,所以结果键将是 Maria

注意

findKey() 函数有一个互补函数叫做 findLastKey()。这个函数简单地朝相反方向搜索。它有点像集合中的 find()findLast()。区别在于数组中顺序是保留的。在对象中,你正在处理一个无序的键值对集合。由于顺序从未得到保证,findLastKey() 的实用性有限。

我们可能会发现自己正在处理一个对象,但我们不一定需要使用键。记住,对象也是集合,所以你仍然可以使用 find()where(),就像在数组上使用一样,如下面的示例所示:

var object = {
    8490: {
        first: 'Arthur',
        last: 'Evans',
        enabled: false
    },  
    7035: {
        first: 'Shirley',
        last: 'Rivera',
        enabled: false
    },  
    4818: {
        first: 'William',
        last: 'Howard',
        enabled: true
    }   
};

_.find(object, 'enabled');
// → 
// {
//   first: "William",
//   last: "Howard",
//   enabled: true
// }

_.where(object, { last: 'Rivera' });
// → 
// [
//   {
//     first: "Shirley",
//     last: "Rivera",
//     enabled: false
//   }
// ]

这些函数将每个 object 属性值视为数组的一个元素,忽略键。接下来,我们将探讨在简单集合简写不足以满足需求的情况下如何遍历对象。

遍历对象

当我们需要遍历对象的属性以实现组件的行为时,Lo-Dash 有几个函数非常有用。我们将从探索一些基本迭代开始。然后,我们将探讨如何遍历继承的对象属性,接着查看键和值以及遍历它们的简单方法。

基本遍历

正如我们在上一章中看到的,对象可以像数组一样迭代——它们都是集合。虽然这样做的方式略有不同,但 Lo-Dash 通过统一的函数 API 抽象掉了这些差异,如下面的代码所示:

var object = { 
    name: 'Vince',
    age: 42, 
    occupation: 'Architect'
}, result = [];

_.forOwn(object, function(value, key) {
    result.push(key + ': ' + value);
});
// → 
// [
//   "name: Vince",
//   "age: 42",
//   "occupation: Architect"
// ]

上述代码看起来有些熟悉。它就像 forEach() 函数一样。传递给回调函数的第二个参数不是索引,而是属性键。

注意

当应用于对象时,_.forOwn()_.forEach() 函数的行为相同。这两个函数共享同一个基础函数,用于遍历集合。Lo-Dash 有几个足够通用的基础函数,可以服务于许多目的。虽然这些函数不是作为公共 API 的一部分公开的,但它们使得公开的函数更小、更易于理解。

包含继承属性

对象迭代只包括 自有 属性。也就是说,直接在对象上定义的属性,而不是在 原型链 更高的地方定义的属性。我们可以使用 hasOwnProperty() 方法来测试所讨论的属性是否是自有属性。我们传递这个方法要查找的属性名称,如果该属性定义在这个属性上而不是原型链的更高处,它将返回 true

注意

如果“原型链”这个术语听起来很陌生,你可能想了解一下它们是什么以及它们是如何工作的。JavaScript 对象是原型的,所以如果你是 JavaScript 程序员,理解这个概念很重要。这个话题远远超出了本书的范围,但网上有数百个关于原型的优秀资源。对于本书,你不需要完全理解这个主题,只需要这一节的内容。

Lo-Dash 有另一个对象迭代函数,称为 forIn()。这个函数能够遍历自有属性,以及通过原型链继承的属性。以下是一个示例:

function Person() {
    this.full = function() {
        return this.first + ' ' + this.last;
    };  
}   

function Employee(first, last, occupation) {
    this.first = first;
    this.last = last;
    this.occupation = occupation;
}   

Employee.prototype = new Person();

var employee = new Employee('Theo', 'Cruz', 'Programmer'),
    resultOwn = [], 
    resultIn = [];

_.forOwn(employee, function(value, key) {
    resultOwn.push(key);
});
// → [ "first", "last", "occupation" ]

_.forIn(employee, function(value, key) {
    resultIn.push(key);)
});
// → [ "first", "last", "occupation", "full" ]

这段代码同时使用了对象迭代的形式,forOwn() 后跟 forIn()。两者之间的区别在于 full 键,它只出现在 forIn() 生成的结果中。这是因为它在 Person 对象中定义,而 Person 对象是 Employee 的原型。

键和值

之前,我们一直在使用直接遍历对象键和值的 Lo-Dash 函数。这是直接的方法。然而,如果我们已经编写了一些期望键或值数组的代码,会怎样呢?有一种间接的方法可以遍历对象,这涉及到将对象的键或值作为数组获取。然后你遍历这些数组。

例如,以下是一些遍历对象键的代码:

var object = { 
    occupation: 'Optometrist',
    last: 'Lynch',
    first: 'Shari'
};

_.sortBy(_.keys(object));
;
// → [ "first", "last", "occupation" ]

上述结果是 keys() 函数构建的字符串数组。我们使用 sortBy() 函数作为一种快速而简单的手段来排序数组。然后按照顺序将每个属性键推入 result 数组。让我们构建这个示例,并使用它作为获取对象属性值的有序访问手段:

var object = { 
    occupation: 'Optometrist',
    last: 'Lynch',
    first: 'Shari'
};

return _.at(object, _.sortBy(_.keys(object)));
// → [ "Shari", "Lynch", "Optometrist" ]

这段代码采取了一些捷径。然而,这不就是编写好代码的全部意义吗?我们不是使用 forEach() 函数在排序后迭代键,而是直接将它们传递给 at() 函数。这个函数接受一个键或索引数组,并将按顺序为我们查找值。上述结果是按键排序的属性值数组。

注意

keys() 函数在用于遍历对象的 forOwn() 函数中起着至关重要的作用。这个函数用于获取对象键,然后迭代键,查找对象值。再次强调,一些外部 Lo-Dash 函数在内部起着至关重要的作用。

当你真的不需要键名时,values()keys()互补。例如,为了构建一个包含对象值的数组,你可以使用以下代码:

var object = { 
    first: 'Hue',
    last: 'Burton',
    occupation: 'Horticulturalist'
};

_.values(object);
// → [ "Hue", "Burton", "Horticulturalist" ]

从这个点开始,我们有一个值数组可以工作。键被完全忽略。例如,如果我们想根据值的具体属性而不是键来对object属性值进行排序,就像我们之前看到的那样,可以使用以下代码:

var object = { 
    Angular: { name: 'Patrick' },
    Ember: { name: 'Jane' },
    Backbone: { name: 'George' }
};

_.sortBy(_.values(object), 'name');
// → 
// [
//   { name: "George" },
//   { name: "Jane" },
//   { name: "Patrick" }
// ]

这就像我们只是通过截断键将object转换成一个数组。实际上,用toArray()替换values()会产生完全相同的结果。在底层,如果传递给toArray()的是一个对象,它实际上会调用values()

调用方法

对象不仅仅包含静态属性值——其中一些值是可调用的函数。作为对象属性的函数通常被称为方法,因为它们通常与它们所属的对象的封装状态进行交互。有时,对象只是方便地在代码中分配和传递函数的一种方式。最终,它们只是被分配给属性值的函数,而 Lo-Dash 有一些函数可以帮助找到并调用它们。

获取结果

当我们不确定给定的属性名是函数还是其他类型时,我们可以使用result()函数。这可以极大地简化我们的代码,因为我们不需要编写检查属性是否应该像常规静态属性值那样访问或是否需要调用的代码。result()函数的使用在以下代码中显示:

var object1 = { 
        name: 'Brian'
    },  
    object2 = { 
        name: function() {
            return 'Brian';
        }   
    },  
    object3 = {};

_.result(object1, 'name', 'Brian');
// → "Brian" 

_.result(object2, 'name', 'Brian');
// → "Brian" 

_.result(object3, 'name', 'Brian');
// → "Brian" 

我们可以看到结果始终是Brian,并且result()在所有三个对象上的调用都是相同的。然而,结果是Brian有三个不同的原因。第一个对象有一个字符串值为Brianname属性。第二个对象有一个返回字符串Brian的函数值的name属性。第三个对象没有name属性,因此使用了默认的Brian值。通过你非常少的努力,使用result()函数的对象促进了对象属性访问的一致性。

适度地使用result(),否则我们会在代码中对其频繁使用而感到困惑。在直接属性访问或直接方法调用产生更简洁代码的情况下,走这条路。在属性访问产生一致结果呈现问题时,这些情况应该很少,result()是我们的朋友。

查找方法

在我们调用方法之前,如果方法不存在,我们可能想要执行比简单默认值更复杂的逻辑。例如,我们可能知道某些对象上有name()方法,但其他对象上没有。我们确定知道的是,没有带有简单值的name属性,所以result()函数在这里没有帮助。

functions()函数会遍历一个对象,并返回一个数组,其中包含值是函数的键,如下面的代码所示:

function Person(first, last) {
    this.first = first;
    this.last = last;
}

Person.prototype.name = function() {
    return this.first + ' ' + this.last;
};

_.functions(new Person('Teresa', 'Collins'));
// → [ "name" ]

注意,name()方法被定义为人的原型的一部分,而不是直接在Person实例上。如果我们这样考虑,这是有道理的。如果这个方法在原型链的更高处存在,它仍然是可以调用的,使用当前实例作为其上下文。因此,我们希望那些方法名出现在结果数组中,这正是这里发生的情况。

转换对象

有时候,我们在实现一个功能,而我们正在处理的对象根本不适合这个用途——你需要将其转换成一个更适合我们需求的结构。Lo-Dash 附带了一些函数可以帮助我们做到这一点。我们可以从对象中创建一个数组的数组,我们可以选择我们想要与之工作的对象属性,我们还可以通过反转键和值来翻转对象。

使用成对

pairs()函数接受一个对象参数,并生成一个数组,其中每个元素本身也是一个数组,包含键和值。在某些情况下,这种结构可能会更加方便。以下是一个例子:

function format(label, value) {
    return label + ': ' + value;    
}   
var object = { 
    first: 'Katherine',
    last: 'Bailey',
    age: 33
}, result = '';

_.forEach(_.pairs(object), function(pair) {
    result += format.apply(null, pair) + '\n';
});
// → "first: Katherine\nlast: Bailey\nage: 33\n" 

这段代码遍历object,但在这样做之前,它调用了pairs()函数。这把对象转换成了一个数组,因此forEach()的回调函数接收这个数组的一个元素。pair参数是一个数组,第一个元素是键,第二个是值。使用这个键值对,我们可以对format()函数调用apply()

这意味着如果我们有一个通用的回调函数,它使用类似format()这样的函数,我们不需要传递特定的参数。正如这里所示,当传递pair数组时,你可以调用apply()

选择和省略属性

有时候,并不是每个对象属性都是必要的。在扩展对象时,实际上这可能是一项有害的练习——添加不必要的属性。相反,你可以使用pick()函数来选择你需要的属性:

var object1 = { 
        name: 'Kevin Moore',
        occupation: 'Programmer'
    },  
    object2 = { 
        specialty: 'Python',
        employer: 'Acme'
    };

_.assign(object1, _.pick(object2, 'specialty'));
// →
// {
//   name: "Kevin Moore",
//   occupation: "Programmer",
//   specialty: "Python"
// }

在这个例子中,第二个对象只有一个我们感兴趣的属性,即specialty。碰巧的是,只有一个属性被删除,即employer。然而,我们在这里所做的,就是只选择我们需要用来扩展现有对象的内容,从而排除了任何不想要的属性在后续造成问题的可能性。

另一方面,我们可能确切地知道我们不想从一个对象中得到什么。pick()的补充是omit(),它从对象中排除指定的属性,以下是一个例子:

var object1 = { 
        name: 'Kevin Moore',
        occupation: 'Programmer'
    },  
    object2 = { 
        specialty: 'Python',
        employer: 'Acme'
    };

_.assign(object1, _.omit(object2, 'employer'));
// →
// {
//   name: "Kevin Moore",
//   occupation: "Programmer",
//   specialty: "Python"
// }

这段代码是 pick() 示例的反面。我们不是使用 pick() 来指定要包含的内容,而是使用 omit() 来指定要从赋值中排除的内容。我们使用哪一个取决于我们对对象和哪些属性有价值所拥有的必要知识。

除了提供我们想要包含或排除的属性名称之外,我们还可以提供自定义逻辑,以回调函数的形式做出决策,如下面的代码所示:

var object = { 
    name: 'Lois Long',
    age: 0,
    occupation: null
};

_.omit(object, function(value) {
    return !(!_.isBoolean(value) && value);
});
// → { name: "Lois Long" }

这段代码与 compact() 在集合上的工作方式相同。我们的回调函数应用于每个 object 属性值,如果它返回 true,则该值将从结果对象中省略。在这里,我们省略了非真值,除了布尔类型。

反转键和值

我们的应用可能定义了一个使用 keys()values() 来处理对象的函数。然而,我们可能会遇到想要该函数反向工作的情形。也就是说,如果函数使用 keys(),我们希望它使用 values()。如果它使用 values(),我们希望它使用 keys()

而不是改变我们到处使用且已知稳定的函数,我们可以在传递之前简单地使用 Lo-Dash 的 invert() 函数来反转对象:

function sortValues(object) {
    return _.values(object).sort();
}   

var object1 = { 
        first: 'Mathew',
        last: 'Johnson'
    },  
    object2 = { 
        first: 'Melissa',
        last: 'Willians'
    };

sortValues(object1);
// → [ "Johnson", "Mathew" ]

sortValues(_.invert(object2));
// → [ "first", "last" ]

sortValues() 函数相当简单。它接受一个 object 参数,使用 values() 函数构建属性值数组,然后返回排序后的数组。如果我们想出于某种原因在对象键上重用 sortValues(),我们只需在传递之前对对象使用 invert()。这使得对象的键成为值。因此,当 sortValues() 调用 values() 函数时,它实际上获取的是键。

创建和克隆对象

本章的最后一个主题是创建和克隆 JavaScript 对象。在日常工作中,我们通常不需要过多思考就完成了对象的创建或克隆。new 关键字或对象字面量记法已经足够满足我们的需求。很少有必要去克隆对象。然而,当需要时,Lo-Dash 提供了处理这两种情况所需的工具。

创建对象

create() 函数帮助我们缩小了函数式和面向对象范式之间的差距。它允许我们在对象中利用一些关键的函数式 JavaScript 组件。特别是,当涉及到创建新对象时指定原型。

这可能听起来不是什么大事,但它可以带来一些有趣、复杂的黑客技巧。假设我们使用字面量记法定义了一个对象集合。这些对象具有简单的字符串属性值。再假设我们有一个通过方法定义了一些行为的类。使用 create() 函数,我们可以直接将属性值传递给该类的新实例,以便利用其行为,如下面的代码所示:

function Person() {}
Person.prototype.name = function() {
    return this.first + ' ' + this.last;
};  

var collection = [ 
        { first: 'Jean', last: 'Flores' },
        { first: 'Edward', last: 'Baker' },
        { first: 'Jennifer', last: 'Walker' }
    ],  
    people = []; 

_.forEach(collection, function(item) {
    people.push(_.create(Person.prototype, item));
}); 

_.invoke(people, 'name');
// → [ "Jean Flores", "Edward Baker", "Jennifer Walker" ]

在前面的例子中,将Person对象视为一个合约或接口是有帮助的。然后我们使用create()函数将这个合约绑定到集合中的每个对象。现在,集合中的每个对象都有一个name()方法,我们通过使用invoke()函数生成一个名称数组来证明这一点,该函数将调用集合中每个项目的给定方法名称。

克隆对象

重复创建相同的对象实例可能导致代码重复。特别是,如果我们使用对象字面量语法。一个替代方案是扩展一个新对象以包含新属性,但如果我们要复制的东西不是一个普通对象,这可能会出现问题。Lo-Dash 有一个clone()函数,足够灵活,可以复制几乎所有东西,包括嵌套对象。这种灵活性是以性能成本为代价的,所以请明智地使用它。以下是如何使用clone()函数的示例:

function Person(first, last) {
    this.first = first;
    this.last = last;
}

var object1 = { 
        first: 'Laura',
        last: 'Gray'
    },  
    object2 = new Person('Bruce', 'Price'),
    clone1 = _.clone(object1),
    clone2 = _.clone(object2);

clone1.first === 'Laura';
// → true

clone2.first === 'Bruce' && clone2 instanceof Person;
// → false

object1变量包含一个普通对象,而object2变量包含Person类的一个实例,但它们本质上是一样的。它们都有firstlast属性。clone1clone2变量包含它们各自的克隆。有趣的是,我们接下来执行的断言。第一个断言通过,因为我们只是验证原始name属性中的字符串仍然存在于克隆属性中。第二个断言失败,并不是因为克隆的first属性不等于Bruce。它失败是因为clone2不是Person的实例。相反,它是一个Object的实例,因为clone()函数没有采取设置适当构造函数属性等必要步骤。

除了克隆的对象不是Person类的实例之外,它几乎与原始对象和属性访问等相同。它应该仍然像在普通对象上那样工作。clone()的重点实际上是复制一个普通对象,以便断开与原始对象的引用。然后它可以被操作而不会触及源。

摘要

本章向我们介绍了如何使用 Lo-Dash 函数来执行复杂的对象交互。我们从那些让对 JavaScript 类型系统进行推理变得不那么痛苦的工具开始。接下来,我们看到了在各个上下文中如何访问和分配对象属性。遍历对象属性是下一个话题,Lo-Dash 提供了大量的工具供你使用。特别是,如果我们只想遍历对象的键或值,而不需要编写大量的样板代码,那么 Lo-Dash 函数就帮我们处理了这一点。当我们向 Lo-Dash 函数传递一个对象时,它会产生一个新的结构,这就是所谓的转换。比如当我们寻找一组键值对时。选择或省略属性也是一个非常直接的活动。我们通过查看对象创建和克隆功能来结束本章。这些功能在我们需要稍微调整规则以满足应用程序需求时非常有帮助。

通过前两章,你可能已经注意到定义和使用了大量函数。这不是偶然的——JavaScript 将函数视为一等公民,Lo-Dash 也是如此。函数将是下一章的重点。

第三章:与函数一起工作

你会在足够大的 JavaScript 代码块中到处找到函数。这是因为它们被当作任何其他原始类型一样对待。在 JavaScript 中,一切都是对象,包括函数。函数有一个上下文和原型,并且可以被分配到新的上下文和变量中。

Lo-Dash 帮助最佳利用函数。在缺少部分的情况下,Lo-Dash 提供的实用工具让我们能够编写真正优雅的函数式代码。本章深入探讨这些实用工具。无论我们是改变this的含义还是装饰现有的函数,我们都会通过示例说明如何开始。

在本章中,我们将涵盖以下主题:

  • 绑定函数上下文

  • 装饰函数

  • 函数约束

  • 定时执行

  • 组合和柯里化函数

绑定函数上下文

每个 JavaScript 函数都有一个上下文。如果你来自面向对象的语言,函数上下文与一个方法所属的对象非常相似。当然,区别在于 JavaScript 并不在面向对象的概念意义上对对象进行分类。相反,函数绑定到一个默认上下文,并且这个上下文可以很容易地在运行时改变。甚至有内置的语言机制来实现这一点。

Lo-Dash 使改变函数上下文变得容易。在用 Lo-Dash 编程时,我们经常需要处理函数上下文。现在我们将探讨几种处理和改变函数上下文的方法。

改变this关键字

在函数内部,执行上下文通过this关键字来引用。这是一个我们不需要声明的特殊绑定。它始终在给定的函数作用域内可用。重要的是要记住,如果调用者决定覆盖this的含义,那么这完全取决于调用者。

bind()函数是一种强大的方式,可以构建一个永久绑定到指定上下文的新函数。以下是如何bind()工作的初步了解:

function sayWhat() {
    return 'Say, ' + this.what;
}   

var sayHello = _.bind(sayWhat, {
    what: 'hello'
});

var sayGoodbye = _.bind(sayWhat, {
    what: 'goodbye'
}); 

sayHello();
// → "Say, hello"

sayGoodbye();
// → "Say, goodbye"

之前的代码定义了一个通用的sayWhat()函数,它根据函数的上下文格式化字符串消息。特别是,它寻找上下文的what属性。接下来我们使用bind()根据sayWhat()定义两个新的函数。sayHello()函数绑定到一个新的上下文,而sayGoodbye()函数绑定到另一个上下文。bind()的第二个参数是函数绑定时成为this的对象。我们可以看到,每个上下文都定义了一个独特的what属性值,并且这在调用这两个函数的输出中得到了反映。

注意

没有 Lo-Dash,我们将依赖于函数的call()apply()bind()方法来改变其上下文。Lo-Dash bind()实现的优点是它性能更好,因为它能够比原生方法进行更好的优化。

sayWhat() 函数没有使用任何参数。但仅仅因为我们正在玩弄上下文并不意味着我们绑定的函数不能接受参数。许多函数同时使用调用者传递的参数和上下文对象。具有自定义上下文的函数确实可以接受参数。它们也可以在绑定到新的上下文后使用额外的参数调用,如下面的代码所示:

function sayWhat(what) {
    if (_.isUndefined(what)) {
        what = this.what;
    }   
    return 'Say, ' + what;
}   

var sayHello = _.bind(sayWhat, {
    what: 'hello'
});

var sayGoodbye = _.bind(sayWhat, {}, 'goodbye'),
    saySomething = _.bind(sayWhat, {});

sayHello();
// → "Say, hello"

sayGoodbye();
// → "Say, goodbye"

saySomething('what?');
// → "Say, what?"

sayWhat() 函数接受一个 what 参数,用于构建字符串消息。如果未提供此参数,则回退到上下文的 what 属性。现在我们定义了三个新的函数,它们都具有独特的上下文和参数约束。sayHello() 函数与前面的例子没有区别;what 值在上下文中。sayGoodbye() 函数定义传递了 bind() 的第三个参数。在上下文对象之后,bind() 将接受任何数量的参数,这些参数也被绑定到函数上,但以不同的方式。这被称为部分应用,我们将在本章后面讨论这一点。函数始终绑定到上下文,同时也绑定到参数值。最后,saySomething() 函数绑定到一个缺少 what 属性的上下文。此外,它也没有绑定到任何 what 参数。然而,当函数被调用时,仍然可以提供 what 参数,就像这里的情况一样。

绑定方法

JavaScript 中本身没有方法——只有函数和上下文。然而,这并不妨碍程序员遵循更传统的面向对象模型。

如果我们将函数分配给对象属性,那么该对象就成为函数的上下文。这只是默认行为,正如前节所示,上下文可以改变。然而,函数所属的对象,作为默认上下文,很好地映射到方法和封装。bindAll() 函数可以帮助强制这种映射:

function bindName(name) {
    return _.bind(name, {
        first: 'Becky',
        last: 'Rice'
    }); 
}   

var object = { 
    first: 'Ralph',
    last: 'Crawford',
    name: function() {
        return this.first + ' ' + this.last;
    }   
};

var name = bindName(object.name);

object.name();
// → "Ralph Crawford"

name();
// → "Becky Rice"

_.bindAll(object);

name = bindName(object.name)

name();
// → "Ralph Crawford"

让我们逐步分析这个实验。目标是说明一旦将 bindAll() 函数应用于对象,该对象的所有方法都将绑定到它,并且上下文不能改变。首先,bindName() 函数只是接受另一个函数并将其绑定到 Becky 上下文。我们将在稍后用它来证明一个观点。

object 变量包含一个具有一些简单属性和简单方法的普通对象。name 变量是使用 bindName() 函数定义的函数。请注意,我们正在将 object.name() 方法赋予一个新的上下文。我们放入 result 对象中的值证实了这一点。接下来是调用 object 上的 bindAll()。从这一点开始,name() 方法的上下文不能再改变——它被固定在 object 上。然后我们通过再次尝试将其绑定到新的上下文来证明这一事实,但 bindAll() 强制了上下文。

注意

当使用 bindAll() 时,你可能会无意中破坏应用程序中的其他功能。改变函数上下文的能力是一种优势,而不是弱点。只有在你绝对确定方法上下文永远不会改变时才使用 bindAll()。如果方法上下文不应该改变时改变的可能性很小,那么没有必要使用 bindAll()

名称 bindAll() 表明这是一个全有或全无的操作,但实际上并非如此。我们不必强制每个附加到对象上的方法的上下文。我们实际上可以指定方法名称作为第二个参数,并且只有这些方法被绑定到对象上下文中。以下示例说明了这一点:

function getName() {
    return this.name;
}   

var object = { 
    name: 'My Bound Object',
    method1: getName,
    method2: getName,
    method3: getName
};

_.bindAll(object, [ 'method1', 'method2' ]); 

var method3 = _.bind(object.method3, {
    name: 'New Context'
}); 

object.method1();
// → "My Bound Object"

object.method2();
// → "My Bound Object"

method3();
// → "New Context"

我们可以看到,bindAll() 的调用指定了只有 method1method2 被绑定到 object 上。稍后,我们实际上尝试将 method3 绑定到一个全新的上下文,并且它按预期工作。如果我们没有将 bindAll() 调用限制为特定方法,我们就无法改变 method3 的上下文。

动态方法

方法也可以懒绑定到对象上。我们可以使用 bindKey() 函数构建一个新的函数,该函数将在给定的对象上调用给定的方法名称。该方法实际上在调用 bindKey() 之前不必存在。这就是懒绑定部分。如果你需要将方法作为回调分配,但不确定该方法是否已经存在,这会很有用。以下是一个示例:

function workLeft() {
    return 65 - this.age + ' years';
}   

var object = { 
    age: 38
};  

var work = _.bindKey(object, 'work');

object.work = workLeft;

work();
// → "27 years"

这里有一个具有 age 属性的对象。我们还有一个 workLeft() 函数,它根据上下文的 age 属性计算一个数字。我们可以直接将这个函数分配给 work 属性,但我们已经使用 bindKey() 函数构建了一个新的函数,当调用时将引用 work() 方法。需要注意的是,我们能够在 objectwork() 方法存在之前构建这个回调函数。它稍后会被添加。它也可以被替换为不同的实现,并且仍然会调用适当的方法。

注意

bindKey() 创建的函数最终被调用时,绑定的键必须存在。否则,你会得到一个 TypeError

就像使用 bind() 函数将函数绑定到上下文一样,我们在管理参数方面仍然拥有自由度。也就是说,当调用绑定的函数时,我们可以绑定参数值或提供参数值,如下面的代码所示:

function workLeft(retirement, period) {
    return retirement - this.age + ' ' + period;
}   

var collection = [ 
    { age: 34, retirement: 60 },
    { age: 47 },
    { age: 28, retirement: 55 },
    { age: 41 }
];

var functions = [], 
    result = []; 

 _.forEach(collection, function(item) {
    functions.push(_.bindKey(item, 'work', item.retirement ?
        item.retirement : 65));
}); 

_.forEach(collection, function(item) {
    _.extend(item, { work: workLeft }); 
}); 

_.forEach(functions, function(item) {
    result.push(item('years'));
});
// → 
// [
//   "26 years",
//   "18 years",
//   "27 years",
//   "24 years"
// ]

workLeft() 函数依赖于几个参数和上下文的 age 属性。接下来,我们定义了一组对象和几个空数组来执行我们的实验。现在我们有三个 forEach() 迭代,展示了参数如何与 bindKey() 一起工作。

第一次迭代是遍历集合,并在其中应用 bindKey() 函数以生成 work() 方法。我们可以看到,集合中的不是每个对象都有 retirement 属性值。如果没有,我们将其绑定为 65 作为参数值。此时,我们有一个函数数组,每个函数都绑定到其对象的 work() 方法。第二次迭代填充集合中每个对象的 work 属性,因此现在 work() 成为一个可调用的函数。

最后一次迭代使用另一个参数调用每个这些绑定方法函数。

装饰函数

装饰器的作用正如其名,它为函数添加额外的功能。它就像是对功能的一种装饰。例如,假设我们已经实现了一个在某种结构中查找数据的函数。这个函数已经在我们的应用程序中得到了使用,但现在我们正在实现一个新的组件,它需要这个相同的功能以及一些额外的功能。我们可以使用 Lo-Dash 提供的函数装饰工具来扩展现有的函数。

Lo-Dash 函数装饰有两种类型:部分函数,它构建具有原始函数部分参数的新函数,以及包装器,它构建一个新函数,该函数将原始函数包裹在一个全新的函数中。

部分函数

要使用 Lo-Dash 创建部分函数,您使用 partial() 函数。生成的函数将预先提供一些参数——在调用时我们不需要再次提供。这个概念在我们需要动态向函数提供参数,尤其是在将其传递到那些参数不可用的新上下文之前时非常有用。以下示例也展示了回调函数的情况:

function sayWhat(what) {
    return 'Say, ' + what;
}

var hello = _.partial(sayWhat, 'hello'),
    goodbye = _.partial(sayWhat, 'goodbye');

hello();
// → "Say, hello"

goodbye();
// → "Say, goodbye"

sayWhat() 函数根据提供的字符串参数构建一个简单的字符串。接下来的两个 partial() 调用提供了这个参数。当 hello()goodbye() 函数被调用时,它们将使用各自的局部参数调用 sayWhat()

如本章中迄今为止所看到的,许多处理函数的 Lo-Dash 函数返回新的函数。它们也支持调用者传递的参数。这很有价值,因为向我们的函数添加新参数不需要更改我们的函数绑定,如下所示:

function greet(greeting, name) {
    return greeting + ', ' + name;
}   

var hello = _.partial(greet, 'hello'),
    goodbye = _.partial(greet, 'goodbye');

hello('Fran');
// → "hello, Fran"

goodbye('Jacob');
// → "goodbye, Jacob"

上一段代码中的 greet() 函数接受两个参数,greetingnamehello()goodbye() 函数被构建为部分函数,它们使用已提供的第一个参数调用 greet()。稍后,当这些函数被调用时,我们可以提供更具体的参数——name

如果上下文特定的参数是第一个函数参数,我们还能否让部分函数的调用者提供名称?为了回答这个问题,我们转向 partialRight() 函数:

function greet(name, greeting) {
    return greeting + ', ' + name;
}   

var hello = _.partialRight(greet, 'hello'),
    goodbye = _.partialRight(greet, 'goodbye');

hello('Brent');
// → "hello, Brent"

goodbye('Alison');
// → "goodbye, Alison"

这段代码看起来与上一个示例相似,但有一个重要的区别。greet() 函数期望 name 参数作为第一个参数。我们希望调用者能够指定这个值,但我们还想指定 greeting 作为部分参数。partialRight() 函数与 partial() 函数的工作方式相同,除了它以不同的顺序传递参数给函数。

部分函数不仅限于我们自己的函数。我们可以利用这种简写来对抗 Lo-Dash 功能本身。如果你需要在回调中运行 Lo-Dash 函数,例如,你可以构造一个新的部分函数,该函数重新定义了 Lo-Dash 函数,并预先提供了参数。这在上面的代码中有所展示:

var collection = [ 
    'Sheila',
    'Kurt',
    'Wade',
    'Kyle'
];

var random = _.partial(_.random, 1, collection.length),
    sample = _.partial(_.sample, collection);

random();
// → 4

sample();
// → "Wade"

在这里,我们有一个简单的集合和两个操作该集合的部分函数。首先,我们利用 random() Lo-Dash 函数,提供部分参数作为范围。然后我们利用 sample() 函数,提供要采样的集合作为部分参数。

函数装饰器

我们可以利用 wrap() 函数来装饰值或另一个函数,使其具有特定的行为。与所有其他 Lo-Dash 函数辅助函数一样,使用 wrap() 的一个优点是,生成函数的调用者可以通过参数提供更多数据,如下面的代码所示:

function strong(value) {
    return '<strong>' + value + '</strong>';
}   

function regex(exp, val) {
    exp = _.isRegExp(exp) ?
        exp : new RegExp(exp);
    return _.isUndefined(val) ?
        exp : exp.exec(val);
}   

var boldName = _.wrap('Marianne', strong),
    getNumber = _.wrap('(\\d+)', regex);

boldName();
// → "<strong>Marianne</strong>"

getNumber('abc123')[1];
// → "123"

第一个函数 strong() 将值包裹在 <strong/> 标签中。第二个函数 regex() 稍微复杂一些。它将值包裹在一个 RegExp 实例中。但它足够智能,只有在值是字符串的情况下才这样做——如果它已经是一个正则表达式,就没有必要创建另一个。此外,如果提供了第二个参数,它将对该值执行正则表达式,并返回结果。

调用 boldName() 的结果不言自明。值 'Marianne' 被包裹在 strong() 函数中。getNumber() 函数是包裹了一个看起来像数字的正则表达式字符串的结果。然而,对 getNumber() 的调用提供了一个额外的参数,即调用提供了要对其执行正则表达式的字符串。这就是为什么我们使用调用后的数字索引来访问结果。

让我们把注意力转向使用 wrap() 装饰现有函数以添加新功能:

var user = _.sample([
    'Scott',
    'Breanne'
]);

var allowed = [
    'Scott',
    'Estelle'
];  

function permission(func) {
    if (_.contains(allowed, user)) {
        return func.apply(null, _.slice(arguments, 1));
    }   
    throw new Error('DENIED');
}   

function echo(value) {
    return value;
}   

var welcome = _.wrap(echo, permission);

welcome('Yo there!');

这里的基本思想是使用权限检查能力来装饰 echo() 函数。如果 user 变量存在于 allowed 数组中,permission() 函数将只调用传递给它的函数。如果不满足这种情况,将引发异常。反复运行此代码将随机生成拒绝错误。这完全取决于 'Breanne'(她不在 allowed 数组中)是否被采样为当前用户。

函数约束

类似于用新行为装饰函数的是对函数施加的约束。这影响了函数何时以及多久可以调用。函数约束还控制了通过调用函数返回的值的缓存方式。Lo-Dash 有处理这些场景的函数。

限制调用次数

有两个 Lo-Dash 函数处理给定函数被调用的次数计数。after() 函数将在组合函数被调用指定次数后执行回调。once() 函数将给定的函数限制为只调用一次。让我们看看 after() 并了解它是如何工作的:

function work(value) {
    progress();
}   

function reportProgress() {
    console.log(++complete + '%');
    progress = complete < 100 ?
        _.after(0.01 * collection.length, reportProgress) :
        _.noop;
}   

var complete = 0,
    collection = _.range(9999999),
    progress = _.noop;

reportProgress();

_.forEach(collection, work);
// →
// 1%
// 2%
// 3%
// …

work() 函数是一个虚构的函数,实际上它除了调用 progress() 以外什么都不做,这通知世界进度已经有所进展。一个真正做了工作的函数会在完成工作后调用 progress()。接下来,我们有一个 reportProgress() 函数。它负责记录进度。它还使用 after() 创建了 progress() 函数。直到 complete 变量达到 100%,它将再次调用 reportProgress(),这会重新定义 progress() 函数。after() 函数将在 progress() 被调用 x 次之后调用它所提供的回调函数。在这种情况下,x 是集合长度的 1%。

总结来说,reportProgress() 定义了 progress() 函数。这个函数被需要通知世界其进度的工作函数调用。在 progress() 被调用多次之后,会调用 reportProgress()。这是记录进度和重新定义 progress() 的地方。

所有这些操作都是通过创建一个相当大的集合并遍历它来实现的,在遍历过程中调用 work()。但在迭代开始之前,我们通过调用 reportProgress() 来启动进度跟踪器。这个代码的一个优点是,在跟踪进度和执行工作之间有一个关注点的分离。工作函数只需要担心调用 progress()reportProgress() 只关心定期记录进度,而不关心实际的工作内容。

异步操作也可以使用 after()。前面的例子明确调用了由 after() 创建的函数。但是,如果我们想在几个异步回调函数触发后同步发生的事情,该怎么办?让我们使用以下代码来找出答案:

function process(coll, callback) {
    var sync = _.after(coll.length, callback);
    _.forEach(coll, function() {
        setTimeout(sync, _.random(2000));
    }); 
    console.log('timeouts all set');
}   

process(_.range(5), function() {
    console.log('callbacks completed');
});
// →
// timeouts all set
// callbacks completed  

首先,我们有一个 process() 函数,它的目的是象征一个长时间运行的后台异步过程——换句话说,就是运行在后台的过程。这个函数接收两个参数:一个集合和一个回调函数。callback 是一个在集合上的每个异步操作完成后被调用的函数。我们通过使用 after() 创建一个新的 sync() 函数来实现这一点。集合长度被传递给 after()。这意味着在 sync() 被调用五次之后,即我们的集合长度,回调函数将被调用。

接下来,我们随机选择一个超时时间并调用 sync()——这是异步部分。在所有超时都设置完毕后,我们记录 sync() 的调用已被安排。当这些操作完成后,执行的回调记录一个基本消息。

有时候,只调用一次函数是有用的。除此之外,它只是无用的重复——无害,但不是必要的。因此,一个函数的一个有用的约束可能是只允许它被调用一次。但我们如何强制执行这样的规则呢?这可以通过以下代码来完成:

function getLeader(coll) {
    return _.first(_.sortBy(coll, 'score').reverse());
}   

var collection = [ 
    { name: 'Dana', score: 84.4 },
    { name: 'Elsa', score: 44.3 },
    { name: 'Terrance', score: 55.9 },
    { name: 'Derrick', score: 86.1 }
];

var leader = _.once(getLeader);

leader(collection);
// → { name: "Derrick", score: 86.1 }

在此代码中,getLeader() 函数接收一个集合并返回领导者的名字,根据的是 score 属性。我们使用这个函数来构建 leader() 函数。使用 once(),我们告诉 leader() 函数只调用一次 getLeader(),并且只调用一次。你无法阻止调用者对这些函数进行 50 次调用。once() 函数的职责是封装传递给它的函数,存储第一次调用的返回值。如果这个值被设置,它将被缓存以供后续调用。因此,前面的代码假设集合是不变的,领导者将始终相同。

缓存值

之前的例子展示了使用 Lo-Dash 缓存值的第一个例子。如果函数被限制只能调用一次,那么存储第一次调用的值可能就足够了。这几乎是一种副作用式的缓存——有一个更明确的办法,使用 memoize() 函数。显式缓存对于数学函数特别有用,因为给定相同的输入,总是产生相同的输出。这也被称为 引用透明性。以下是一个例子:

function toCelsius(degrees) {
    return (degrees - 32) * 5 / 9;
}   

function toFahrenheit(degrees) {
    return degrees * 9 / 5 + 32; 
}   

var celsius = _.memoize(toCelsius),
    fahrenheit = _.memoize(toFahrenheit);

toCelsius(89).toFixed(2) + ' C';
// → "31.67 C"

celsius(89).toFixed(2) + ' C'; 
// → "31.67 C"

toFahrenheit(23).toFixed(2) + ' F'; 
// → "73.40 F"

fahrenheit(23).toFixed(2) + ' F';
// → "73.40 F"

在这里,我们有两个简单的数学函数,它们是 memoization 的良好候选。toCelsius() 函数接收给定的华氏度数并返回相应的摄氏度值。toFahrenheit() 函数是它的逆函数——它接收摄氏度参数并返回华氏值。然后我们用 memoize() 包装这两个函数,得到两个新的函数,celsius()fahrenheit()

之后,我们连续对同一个函数进行两次调用。第一次调用计算值并存储它。第二次调用返回缓存的值而不进行计算,但这个缓存查找是如何工作的?缓存的函数是如何知道使用缓存中的值而不是计算一个值的?让我们通过以下代码来找出答案:

function toCelsius(degrees) {
    return (degrees - 32) * 5 / 9;
}   

function toFahrenheit(degrees) {
    return degrees * 9 / 5 + 32; 
}   

function convertTemp(degrees, system) {
    return system.toUpperCase() === 'C' ?
        toFahrenheit(degrees).toFixed(2) + ' F' :
        toCelsius(degrees).toFixed(2) + ' C'; 
}   

var convert = _.memoize(convertTemp, function(degrees, system) {
     return degrees + system;
}); 

convert(89, 'F');
convert(89, 'F');
convert(23, 'C');
convert(23, 'C');

默认情况下,由 memoize() 生成的函数将使用提供的第一个参数作为缓存键。缓存是一个简单的对象,通过属性键来查找值。在先前的例子中,缓存函数只接受一个参数。这是可以的,但在接受多个参数的更复杂函数中,你需要一种方法来解析查找键,正如前面例子所示。

这基本上是前面例子的重写,因为它生成了相同的结果。我们仍然有 toCelsius()toFahrenheit() 函数,但我们引入了一个新的 convertTemp() 函数。这个函数接受两个参数:degrees 和代表这些度数的温度 system。基于这些参数值,我们可以适当地调用 toCelsius()toFahrenheit()

我们接着构建 convert() 函数,它是 convertTemp() 的缓存版本。你会注意到传递给 memoize() 的第二个函数构建并返回一个缓存键。如果没有它,缓存键仍然只会根据第一个参数的值进行咨询,这会导致返回错误的数据。请小心。

注意

你可能已经注意到,我们可以继续使用之前缓存的函数,celsius()fahrenheit()。这意味着多层缓存,这听起来实际上相当酷。抵制这种做法的诱惑。如果你执行得足够糟糕,以至于需要多层缓存,那么是时候在更高层次上重新考虑设计。

定时执行

从本质上讲,JavaScript 代码是同步执行的,也就是说,你没有多个控制线程,每个线程运行你代码的一部分,并争夺 CPU 的注意力。现代浏览器中有 Web Workers,但这些还远未普及,并且与你在其他语言中找到的线程 API 相似度不高。这一切的好处是,作为程序员,你不需要担心同步原语和与多线程编程相关的所有其他讨厌的细节。

相反,你面临的是不同类型的困难,因为你必须处理事件、DOM 和其他形式的回调;这就是同步代码的局限性。有时,这实际上是期望的。例如,你需要等待预定的时间后才能发生某些事情。或者,也许你想要更新 DOM,然后从上次离开的地方继续。Lo-Dash 提供了一些工具,帮助你处理在调用定时函数和应对副作用时的棘手细节。

延迟函数调用

delay() 函数用于在给定数量的毫秒数过去后执行一个指定的回调函数。这实际上与内置的 setTimeout() 函数的工作方式相同。以下代码展示了这一点:

function poll() {
    if (++cnt < max) {
        console.log('polling round ' + (cnt + 1));
        timer = _.delay(poll, interval);
    } else {
        clearTimeout(timer);
    }   
}   

var cnt = -1, 
    max = 5,
    interval = 3000,
    timer;

poll();
// →
// polling round 1
// polling round 2
// polling round 3
// polling round 4
// polling round 5 

这段代码定义了一个 poll() 函数,用于定期记录我们正在进行的轮询哪一轮。轮询是前端中常用的模式,用于同步来自 API 的数据与用户正在查看的内容。我们已将控制轮询迭代次数的 max 变量设置为 5interval 变量设置为 3000 毫秒。它控制轮询调用的频率。你可以看到,poll() 函数首先检查我们是否已经达到最大迭代次数。如果没有,通过调用 delay()timer 变量获得一个超时值——只是一个整数。delay() 回调是 poll()。如果我们已经达到阈值,超时将被清除,并且不再进行进一步的轮询调度。

如果你仔细观察,你会发现使用 delay() 和内置的 setTimeout() 函数之间没有区别。两者都接受回调函数和持续时间作为参数,并且两者都返回一个可以清除的超时编号 clearTimeout()。与 setTimeout() 相比,delay() 的有趣之处在于它们如何处理参数。让我们看看参数是如何处理的:

function sayHi(name, delay) {
    function sayHiImp(name) {
        console.log('Hi, ' + name);
    }   
    if (_.isUndefined(delay)) {
        _.delay(sayHiImp, 1, name);
    } else {
        _.delay(sayHiImp, delay, name);
    }   
}   

sayHi('Jan');
sayHi('Jim', 3000);
// →
// Hi, Jan
// Hi, Jim

在这里,我们创建了一个 sayHi() 函数。这个函数在调用的 sayHiImp() 函数内部有一个嵌套函数,这是实际的实现。sayHi() 函数只是 sayHiImp() 的包装器。它记录给定的 name 参数并检查是否提供了 delay 参数;如果没有,它提供一个默认的 delay 值。重要的是我们的函数要么始终同步运行,要么异步运行,但绝不能两者兼而有之。然而,如果有 delay 值,我们使用 delay() 函数来推迟对 sayHiImp() 的调用。注意,我们还把 name 参数传递给 delay() 调用。我们不需要构建自己的部分函数,而是让 delay() 为我们构建一个。

延迟函数调用

当 JavaScript 代码在浏览器中运行时,它会启动一个被称为 调用栈 的过程。大多数编程语言都共享调用栈的概念。它可以被看作是一个可追踪的函数调用图,从根调用开始。有趣的是,JavaScript 调用栈和 DOM 是两个完全独立的实体,它们共享相同的控制线程。这意味着在存在活动的 JavaScript 调用栈时,DOM 不会运行。这就是为什么长时间运行的 JavaScript 代码会锁定 UI 交互性的原因。

使用 defer() 函数是解决那些可能需要一段时间(在这里,“一段时间”是一个相对术语——2 秒就是一段时间)的函数的场景的解决方案。你可以将对该函数的调用推迟到调用栈清除之后,以下代码展示了这一点:

function expensive() {
    _.forEach(_.range(Math.pow(2, 25)), _.noop);
    console.log('done');
}   

_.defer(expensive);
console.log('computing...');
// →
// computing...
// done  

expensive()函数什么都不做,只是占用 CPU 一段时间,防止console.log()调用执行。因此,我们使用defer()来调用expensive(),它等待当前调用栈完成。'computing...'字符串作为调用栈中的最后一个语句被记录。不久之后,'done'字符串出现在控制台日志中。这个技巧是我们给 DOM 一个更新机会,在执行昂贵的代码之前。

另一种在调用栈清除后每次想要调用某个函数时调用defer()的方法是创建一个包装函数。然后,你可以像调用任何其他函数一样调用这个包装函数,它会为你处理延迟。这是通过以下代码实现的:

function deferred(func) {
    return _.defer.apply(_, ([ func ])
        .concat(_.slice(arguments, 1)));
}   

function setTitle(title) {
    console.log('Title: "' + title + '"');
}   

function setState(app) {
    console.log('State: "' + app.state + '"');
}   

var title = _.wrap(setTitle, deferred),
    state = _.wrap(setState, deferred),
    app = { state: 'stopped' };

title('Home');
state(app);
app.state = 'started';
// →
// Title: "Home"
// State: "started"

这里有两个函数,setTitle()setState(),我们希望它们都是可延迟的。第一个函数接受一个title参数并记录它。第二个函数接受一个app对象并记录该对象的state属性。deferred()函数是一个包装器。我们将使用它和wrap()一起使任何函数可延迟。deferred()所做的只是将defer()应用于传递给它的函数和一些参数。

接下来,你可以看到title()函数是setTitle()的延迟版本,而state()函数是setState()的延迟版本。我们还有一个初始状态为'stopped'app对象。调用title()state()将始终在调用栈清除后延迟执行。这一点在前面的代码中通过在调用state()之后将状态设置为started进一步说明。你可以猜测哪个字符串会被记录。

节流函数调用

通常,DOM 中的事件触发频率可能会比你能够处理的频率要高得多。简单地移动鼠标指针就有可能在每秒生成数百个事件。如果每个事件都有一个处理程序,并且该处理程序执行任何有意义的操作,UI 将会滞后。无论处理器有多快,都无法跟上。唯一跟上节奏的方法是忽略大多数这些事件,并且只在一定频率下做出响应。这个想法在以下代码中得到了说明:

var el = document.querySelector('#container'),
    onMouseMove = _.throttle(function(e) {
        console.log('X: ' + e.clientX + ' Y: ' + e.clientY);
    }, 750);

el.addEventListener('mousemove', onMouseMove);
window.addEventListener('hashchange', function cleanup() {
    el.removeEventListener('mousemove', onMouseMove);
    window.removeEventListener('mousemove', cleanup);
});

el变量是我们想要监听mousemove事件的 DOM 元素。onMouseMove函数是通过将一个函数传递给throttle()创建的。这个回调函数简单地记录鼠标坐标。我们还向throttle()传递750,因为这是这个回调函数允许运行的最大频率。接下来,我们绑定事件并设置清理操作,在完成时移除监听器。如果我们没有节流onMouseMove(),你会在console.log()的详细程度中看到明显的差异。

防抖函数调用

去抖动函数与节流函数类似。区别在于等待时间结束后会发生什么。使用throttle(),函数总是会被调用。例如,如果将节流函数的wait值设置为10毫秒,并且在这 10 毫秒内调用了函数,它将在下一次等待之前被调用。使用debounce(),在 10 毫秒的等待期间,如果调用了函数,它将额外等待 10 毫秒。让我们看看一些去抖动代码:

function log(msg, item) {
    console.log(msg + ' ' + item);
}

var debounced = _.debounce(_.partial(log, 'debounced'), 1),
    throttled = _.throttle(_.partial(log, 'throttled'), 1),
    size = 1500;

_.forEach(_.range(size), debounced);
_.forEach(_.range(size), throttled);

我们有一个简单的log()函数,它记录一条消息和一个项目编号。然后我们继续构建debounced()throttled()函数的版本。然后我们通过相同大小的循环运行这两个版本。有什么区别?输出看起来像这样:

throttled 0 
throttled 1
throttled 744 
debounced 1499
throttled 1499

这里发生了什么?我们将debounced()throttled()wait时间都设置为1毫秒。在处理1500个项目所需的时间内,throttled()函数的等待期过去了两次。一旦发生这种情况,就调用了log()函数,因此产生了输出。注意,debounce()的输出只有在处理完成后才发生。这是因为debounce()在 1 毫秒的等待期间被多次调用,并在下一次等待期间再次调用。

注意

throttle()函数实际上在底层使用了debounce()。所有的复杂性都在debounce()中,它接受几个配置选项。其中之一是执行的前导尾随边缘。这意味着什么?你会在前面的输出中注意到,throttled()函数是在debounce()之后被调用的。这是等待期的尾随边缘。等待期的前导边缘是在等待期开始之前。这两个边缘对于throttle()默认都是true。这意味着你在一个紧张的循环中,你的节流函数正在被猛烈地打击,函数立即被调用,在等待下一次调用之前。然后,如果循环突然结束,当等待期结束时,函数再次被调用。

函数组合和柯里化

本章的最后部分是关于组装函数,这些函数通过较小的函数实现更大的行为。组装此类函数有两种方法。第一种是使用名为compose()的适当命名的函数,它执行提供的函数的嵌套调用,或者在顺序很重要的情况下,我们可以使用flow()函数一起返回值。柯里化允许你根据不同的上下文连续调用函数。这些 Lo-Dash 工具中的每一个都让你能够以有趣的方式在你的应用程序中构建现有的功能。

函数组合

compose() 函数从提供的函数中构建一个新的函数。当我们调用这个新函数时,会开始一个嵌套的函数调用,即最后提供的函数会使用任何额外的参数被调用。然后返回的值会被传递给下一个函数,依此类推,最终为调用者产生一个值。以下示例更好地解释了这一点:

function dough(pizza) {
    if (_.isUndefined(pizza)) {
        pizza = {};
    }   
    return _.extend({
        dough: true
    }, pizza);
}   

function sauce(pizza) {
    if (!pizza.dough) {
        throw new Error('Dough not ready');
    }   
    return _.extend({
        sauce: true
    }, pizza);
}   

function cheese(pizza) {
    if (!pizza.sauce) {
        throw new Error('Sauce not ready');
    }   
    return _.extend({
        cheese: true
    }, pizza);
}   

var pizza = _.compose(cheese, sauce, dough);

pizza();
// → { cheese: true, sauce: true, dough: true }

有三个函数负责组装披萨——dough()sauce()cheese()。每个函数的职责是在提供的 pizza 对象上设置相应的属性为 truepizza() 函数是通过这些函数组合而成的,这些函数又使用 compose() 函数。因此调用 pizza() 将会调用 cheese(sauce(dough()))。注意这些函数中发生的一些检查。例如,dough() 可以接受一个对象或构造一个新的对象。然而,如果没有 dough 属性,sauce() 函数将无法工作。同样,如果没有 saucecheese() 函数会抱怨。

注意

虽然能够组合函数很方便,但进行先决条件检查是个好主意。这样它们会快速失败,因此其他尝试从你的函数中组合出东西的开发者会有一个明显的指示,如果某件事不可能的话。

如果函数调用的反向顺序让你感到困惑,不要担心。我们可以使用 flow() 函数来反转顺序。使用相同的 pizza 函数,我们可以对 pizza() 组合函数进行轻微的修改:

var pizza = _.flow(dough, sauce, cheese);

return pizza();

注意

compose() 函数实际上是 flowRight() 函数的别名。flow()flowRight() 函数较新。在 Lo-Dash 的早期版本中,compose() 函数是独立的。

柯里化函数

你是否曾经发现自己不得不创建一大堆变量,这些变量除了最终传递给函数之外没有任何作用?与变量创建不同,柯里化技术允许你部分应用函数。也就是说,你只需调用函数,提供你当时拥有的数据即可。柯里化函数会持续返回函数,直到它拥有所有必要的参数。这个技术将通过以下示例进行解释:

function makePizza(dough, sauce, cheese) {
    return {
        dough: dough,
        sauce: sauce,
        cheese: cheese
    };  
}   

function dough(pizza) {
    return pizza(true);
}   

function sauceAndCheese(pizza) {
    return pizza(true, true);
}   

var pizza = _.curry(makePizza);

sauceAndCheese(dough(pizza));
// → { cheese: true, sauce: true, dough: true }

makePizza() 函数具有任意数量的三个参数——函数期望的参数数量。这意味着通过在 makePizza() 上调用 curry() 创建的 pizza() 函数会持续返回函数,直到它被三个参数调用。我们可以以任何我们想要的方式传递这些参数。这可以是所有三个参数同时传递,也可以是一个接一个地传递。这意味着不同的上下文可以将数据传递给函数,而无需在其他地方存储它们。

摘要

希望在阅读本章之后,你对 JavaScript 中函数的欣赏有所提升。Lo-Dash 让前端中的函数式编程变得更好。JavaScript 中的函数默认是灵活的,例如改变执行上下文。本章向你展示了 Lo-Dash 的一些函数如何通过移除否则可能需要的许多样板代码,使处理函数上下文变得更加容易。部分函数是函数式编程的基础,但在 JavaScript 中,这是一项相当不容易的任务。Lo-Dash 使得创建部分函数和通过包装额外的逻辑来装饰函数变得简单。

我们研究了帮助限制函数何时应该运行的函数。例如,函数是否应该只允许运行一次?返回值是否应该被缓存?函数执行的计时是一个复杂的话题,尤其是当你考虑到 DOM 以及它是如何与 JavaScript 调用栈集成的时候。Lo-Dash 有一系列处理函数定时执行的函数。我们详细地研究了这些函数。

本章以如何将较小的函数组合成较大的功能模块结束。柯里化函数允许你定义足够灵活的函数,可以在多个上下文中调用,从而减少了在传递之前临时存储参数的需求。在此基础上,我们介绍了 Lo-Dash 的基本概念。你到目前为止学到的关于集合、对象和函数的概念,在整个书籍的剩余部分都是适用的。我们现在可以继续学习映射和归约值,这是一种强大的技术,你将在使用 Lo-Dash 编程时反复使用。

第四章。使用 Map/Reduce 进行转换

前三章都提到了 Lo-Dash 的转换可能性。无论你是在处理集合、对象还是函数,Lo-Dash 函数的一个常见模式是通过生成一个新的(尽管略有不同)版本来转换输入。转换值的思想是应用编程的核心,其中你编写的大部分代码都是一系列的转换。从本章开始,我们将转换方向,更详细地研究转换。

尤其是我们将探讨所有我们可以使用 Lo-Dash 和 map/reduce 编程模型完成的有趣事情。我们将从基础知识开始,通过一些基本的映射和基本的减少来湿脚。随着我们通过本章的进展,我们将开始介绍更多高级技术,这些技术可以从 map/reduce 的角度来考虑。

一旦你读完本章,目标是牢固地理解可帮助映射和减少集合的 Lo-Dash 函数。此外,你将开始注意到不同的 Lo-Dash 函数如何在 map/reduce 领域中协同工作。准备好了吗?

在本章中,我们将涵盖以下主题:

  • 提取值

  • 映射集合

  • 映射对象

  • 减少集合

  • 减少对象

  • 绑定上下文

  • Map/reduce 模式

提取值

我们已经在第一章 处理数组和集合 中看到了如何使用 pluck() 函数从集合中提取值。考虑这是你对映射的非正式介绍,因为这正是它在做的事情。它接受一个输入集合,将其映射到一个新的集合,只提取我们感兴趣的属性。以下是一个示例:

var collection = [ 
    { name: 'Virginia', age: 45 },
    { name: 'Debra', age: 34 },
    { name: 'Jerry', age: 55 },
    { name: 'Earl', age: 29 }
];  

_.pluck(collection, 'age');
// → [ 45, 34, 55, 29 ]

这是一种非常简单的映射操作。实际上,你可以用 map() 做同样的事情:

var collection = [ 
    { name: 'Michele', age: 58 },
    { name: 'Lynda', age: 23 },
    { name: 'William', age: 35 },
    { name: 'Thomas', age: 41 }
];

_.map(collection, 'name');
// → 
// [
//   "Michele",
//   "Lynda",
//   "William",
//   "Thomas"
// ] 

如你所料,这里的输出与使用 pluck() 的输出完全相同。事实上,pluck() 实际上是在底层使用 map() 函数。传递给 map() 的回调是通过 property() 构造的,它只返回指定的属性值。当传递一个字符串而不是一个函数给 map() 函数时,map() 函数会回退到这种提取行为。

在对映射的本质进行简要介绍之后,让我们进一步深入探讨映射集合中可能实现的内容。

映射集合

在本节中,我们将探讨映射集合。将一个集合映射到另一个集合的范围从简单的组合——正如我们在上一节中看到的——到复杂的回调。映射集合中每个项目的回调可以包括或排除属性,并可以计算新值。我们还将讨论过滤集合的问题以及如何与映射结合进行。

包括和排除属性

当应用于一个对象时,pick() 函数会生成一个新的对象,其中只包含指定的属性。与之相反的函数 omit() 会生成一个对象,除了指定的属性外,包含所有属性。由于这些函数对单个对象实例工作良好,为什么不使用它们与集合一起使用呢?你可以使用这两个函数通过映射到新的属性来从集合中去除属性,如下面的代码所示:

var collection = [ 
    { first: 'Ryan', last: 'Coleman', age: 23 },
    { first: 'Ann', last: 'Sutton', age: 31 },
    { first: 'Van', last: 'Holloway', age: 44 },
    { first: 'Francis', last: 'Higgins', age: 38 }
];  

_.map(collection, function(item) {
    return _.pick(item, [ 'first', 'last' ]); 
});
// → 
// [
//   { first: "Ryan", last: "Coleman" },
//   { first: "Ann", last: "Sutton" },
//   { first: "Van", last: "Holloway" },
//   { first: "Francis", last: "Higgins" }
// ]

在这里,我们正在使用 map() 函数创建一个新的集合。提供给 map() 的回调函数应用于集合中的每个项目。item 参数是集合中的原始项目。回调函数预期返回该项目的映射版本,而这个版本可以是任何东西,包括原始项目本身。

注意

map() 回调中操作原始项目时要小心。如果项目是一个对象并且它在应用程序的其他地方被引用,它可能会产生意外的后果。

在前面的代码中,我们返回一个新的对象作为映射的项目。这是使用 pick() 函数完成的。我们只关心 firstlast 属性。我们新映射的集合看起来与原始集合相同,只是没有任何项目有 age 属性。这个新映射的集合在以下代码中可以看到:

var collection = [ 
    { first: 'Clinton', last: 'Park', age: 19 },
    { first: 'Dana', last: 'Hines', age: 36 },
    { first: 'Pete', last: 'Ross', age: 31 },
    { first: 'Annie', last: 'Cross', age: 48 }
];  

_.map(collection, function(item) {
    return _.omit(item, 'first');
});
// → 
// [
//   { last: "Park", age: 19 },
//   { last: "Hines", age: 36 },
//   { last: "Ross", age: 31 },
//   { last: "Cross", age: 48 }
// ]

这段代码与之前的 pick() 代码采用完全相同的方法。唯一的区别是我们排除了新创建的集合中的 first 属性。你也会注意到我们传递了一个包含单个属性名的字符串,而不是属性名数组。

除了将字符串或数组作为 pick()omit() 的参数传递之外,我们还可以传递一个函数回调。这在不清楚集合中哪些对象应该具有哪些属性时很合适。在 map() 回调中使用这样的回调允许我们对具有非常少的代码的集合执行详细的比较和转换:

function invalidAge(value, key) {
    return key === 'age' && value < 40; 
}   

var collection = [ 
    { first: 'Kim', last: 'Lawson', age: 40 },
    { first: 'Marcia', last: 'Butler', age: 31 },
    { first: 'Shawna', last: 'Hamilton', age: 39 },
    { first: 'Leon', last: 'Johnston', age: 67 }
];

_.map(collection, function(item) {
    return _.omit(item, invalidAge);
});
// → 
// [
//   { first: "Kim", last: "Lawson", age: 40 },
//   { first: "Marcia", last: "Butler" },
//   { first: "Shawna", last: "Hamilton" },
//   { first: "Leon", last: "Johnston", age: 67 }
// ]

由这段代码生成的新集合排除了 age 属性,对于 age 值小于 40 的项目。提供给 omit() 的回调函数应用于对象中的每个键值对。这段代码是 Lo-Dash 实现简洁性的良好示例。这里运行着大量的迭代代码,而且没有看到任何 forwhile 语句。

执行计算

现在是时候将我们的注意力转向在 map() 回调中执行计算了。这包括查看项目并根据其当前状态计算一个新值,该值最终将被映射到新集合中。这可能意味着扩展原始项目的属性或用新计算出的值替换一个。无论哪种情况,映射这些计算都比编写应用于集合中每个项目的逻辑要容易得多。以下示例解释了这一点:

var collection = [ 
    { name: 'Valerie', jqueryYears: 4, cssYears: 3 },
    { name: 'Alonzo', jqueryYears: 1, cssYears: 5 },
    { name: 'Claire', jqueryYears: 3, cssYears: 1 },
    { name: 'Duane', jqueryYears: 2, cssYears: 0 } 
];  

_.map(collection, function(item) {
    return _.extend({
        experience: item.jqueryYears + item.cssYears,
        specialty: item.jqueryYears >= item.cssYears ?
            'jQuery' : 'CSS'
    }, item);
});
// → 
// [
//   {
//     experience": 7,
//     specialty": "jQuery",
//     name": "Valerie",
//     jqueryYears": 4,
//     cssYears: 3
//   },
//   {
//     experience: 6,
//     specialty: "CSS",
//     name: "Alonzo",
//     jqueryYears: 1,
//     cssYears: 5
//   },
//   {
//     experience: 4,
//     specialty: "jQuery",
//     name: "Claire",
//     jqueryYears: 3,
//     cssYears: 1
//   },
//   {
//     experience: 2,
//     specialty: "jQuery",
//     name: "Duane",
//     jqueryYears: 2,
//     cssYears: 0
//   }
// ]

在这里,我们将原始集合中的每个项目映射到其扩展版本。特别是,我们为每个项目计算两个新值——经验专长经验属性仅仅是jqueryYearscssYears属性的简单总和。专长属性是基于jqueryYearscssYears属性中较大值的计算结果。

之前,我提到了在map()回调中修改项目时需要小心。一般来说,这是一个坏主意。记住map()是用来生成新集合的,而不是修改现有集合,这会有所帮助。以下是不小心操作带来的可怕后果的说明:

var app = {}, 
    collection = [ 
        { name: 'Cameron', supervisor: false },
        { name: 'Lindsey', supervisor: true },
        { name: 'Kenneth', supervisor: false },
        { name: 'Caroline', supervisor: true }
    ];  

app.supervisor = _.find(collection, { supervisor: true }); 

_.map(collection, function(item) {
    return _.extend(item, { supervisor: false }); 
}); 

console.log(app.supervisor);
// → { name: "Lindsey", supervisor: false }

这个回调的破坏性性质并不明显,对程序员来说几乎不可能追踪和诊断。它实际上是为每个项目重置supervisor属性。如果这些项目在应用程序的其他地方被使用,每当这个映射任务执行时,supervisor属性值就会被覆盖。如果你需要重置这样的值,确保更改被映射到新值,而不是对原始值进行更改。

映射也可以使用原始值作为项目。通常,我们会有一组原始值数组,我们希望将其转换成另一种表示形式。例如,假设你有一个表示字节的尺寸数组。你可以使用以下代码将这些数组映射到一个新集合中,其中尺寸以可读的值表示:

function bytes(b) {
    var units = [ 'B', 'K', 'M', 'G', 'T', 'P' ],
        target = 0;
    while (b >= 1024) { 
        b = b / 1024;
        target++;
    }   
    return (b % 1 === 0 ? b : b.toFixed(1)) +
        units[target] + (target === 0 ? '' : 'B');
}   

var collection = [ 
    1024,
    1048576,
    345198,
    120120120
];  

_.map(collection, bytes);
// → [ "1KB", "1MB", "337.1KB", "114.6MB" ]

bytes()函数接受一个数值参数,即要格式化的字节数。这是起始单位。我们只是不断递增target单位,直到我们得到一个小于1024的值。例如,我们集合中的最后一个项目映射到'114.6MB'bytes()函数可以直接传递给map(),因为它期望我们的集合中的值是它们当前的值。

调用函数

我们不总是需要为map()编写自己的回调函数。在合理的地方,我们可以自由地利用 Lo-Dash 函数来映射我们的集合项目。例如,假设我们有一个集合,我们想知道每个项目的大小。我们可以使用size() Lo-Dash 函数作为我们的map()回调,如下所示:

var collection = [ 
    [ 1, 2 ],
    [ 1, 2, 3 ],
    { first: 1, second: 2 },
    { first: 1, second: 2, third: 3 } 
];  

_.map(collection, _.size);
// → [ 2, 3, 2, 3 ]

这段代码的额外好处是size()函数返回一致的结果,无论传递给它的参数是什么。实际上,任何接受单个参数并根据该参数返回新值的函数都是map()回调的有效候选者。例如,我们也可以映射每个项目的最小值和最大值:

var source = _.range(1000),
    collection = [ 
        _.sample(source, 50),
        _.sample(source, 100),
        _.sample(source, 150)
    ];  

_.map(collection, _.min);
// → [ 20, 21, 1 ]

_.map(collection, _.max);
// → [ 931, 985, 991 ]

如果我们想要将集合中的每个项目映射到一个排序版本呢?由于我们不排序集合本身,所以我们不关心项目在集合中的位置,而是关心项目本身,例如,如果它们是数组。让我们看看以下代码会发生什么:

var collection = [ 
    [ 'Evan', 'Veronica', 'Dana' ],
    [ 'Lila', 'Ronald', 'Dwayne' ],
    [ 'Ivan', 'Alfred', 'Doug' ],
    [ 'Penny', 'Lynne', 'Andy' ]
];  

_.map(collection, _.compose(_.first, function(item) {
    return _.sortBy(item);
}));

// → [ "Dana", "Dwayne", "Alfred", "Andy" ]

这段代码使用 compose() 函数构建一个 map() 回调。第一个函数通过传递给 sortBy() 来返回项目的排序版本。然后,这个排序列表的第一个项目作为映射项目返回。最终结果是包含我们集合中每个数组按字母顺序排列的第一个项目的新集合,仅用三行代码。不错。

过滤和映射

过滤和映射是两个紧密相关的集合操作。过滤只提取那些特别感兴趣的集合项目。映射将集合转换以产生新的集合。但如果我们只想映射集合的某个子集呢?那么将过滤和映射操作链在一起是有意义的,对吧?以下是一个可能看起来像这样的例子:

var collection = [ 
    { name: 'Karl', enabled: true },
    { name: 'Sophie', enabled: true },
    { name: 'Jerald', enabled: false },
    { name: 'Angie', enabled: false }
];  

_.compose(
    _.partialRight(_.map, 'name'),
    _.partialRight(_.filter, 'enabled')
)(collection);
// → [ "Karl", "Sophie" ]

这个映射是通过使用 compose() 来构建一个立即调用的函数来执行的,我们的 collection 作为参数。这个函数由两个部分组成。我们在两个参数上都使用了 partialRight(),因为我们希望在这两种情况下提供的集合作为最左边的参数。第一个部分函数是 filter()。我们部分应用了 enabled 参数。因此,这个函数将在传递给 map() 之前过滤我们的集合。过滤集合的结果传递给 map(),它部分应用了 name 参数。最终结果是包含启用 name 字符串的集合。

注意

关于前面代码的重要一点是,过滤操作发生在 map() 函数运行之前。我们本可以将过滤后的集合存储在一个中间变量中,而不是通过 compose() 流程化。无论哪种风格,确保你映射集合中的项目与源集合中的项目相对应是很重要的。通过不返回任何内容来过滤 map() 回调中的项目是可行的,但这并不明智,因为这在字面意义上和比喻意义上都不太合适。

映射对象

前一节主要关注集合以及如何映射它们。但是等等,对象也是集合,对吧?这确实是正确的,但区分数组和普通对象是值得的。主要原因在于在执行映射/归约操作时,顺序和键有影响。最终,数组和对象在映射/归约方面有不同的用途,本章试图承认这些差异。

现在我们将开始探讨 Lo-Dash 程序员在处理对象并将它们映射到集合时所采用的一些技术。有许多因素需要考虑,例如对象内的键,以及在对象上调用方法。我们将研究键值对之间的关系以及它们如何在映射上下文中使用。

处理键

我们可以用有趣的方式来使用给定对象的键,将对象映射到一个新的集合中。例如,我们可以使用keys()函数来提取对象的键,并将它们映射到除了属性值之外的其他值,如下面的示例所示:

var object = { 
    first: 'Ronald',
    last: 'Walters',
    employer: 'Packt'
};  

_.map(_.sortBy(_.keys(object)), function(item) {
    return object[item];
});
// → [ "Packt", "Ronald", "Walters" ]

上述代码从object中构建了一个属性值数组。它是通过使用map()来实现的,实际上是将objectkeys()数组进行映射。这些键是通过sortBy()进行排序的。因此,Packt是结果数组的第一个元素,因为在object键中,employer是按字母顺序排在第一位的。

有时,执行其他对象的查找并将这些值映射到目标对象是有用的。例如,并非所有 API 都返回给定页面所需的所有内容,打包在一个整洁的小对象中。你必须进行连接并构建所需的数据。以下代码展示了这一点:

var users = {}, 
    preferences = {}; 

_.each(_.range(100), function() {
    var id = _.uniqueId('user-');
    users[id] = { type: 'user' };
    preferences[id] = { emailme: !!(_.random()) };
}); 

_.map(users, function(value, key) {
    return _.extend({ id: key }, preferences[key]);
});
// →
// [
//   { id: "user-1", emailme: true },
//   { id: "user-2", emailme: false },
//   ...
// ]

此示例构建了两个对象,userspreferences。在每个对象的情况下,键是我们使用uniqueId()生成的用户标识符。user对象中只有一些虚拟属性,而preferences对象有一个emailme属性,设置为随机的布尔值。

现在假设我们需要快速访问users对象中所有用户的这个偏好。如您所见,使用map()users对象上实现这一点非常直接。回调函数返回一个包含用户 ID 的新对象。我们通过key查找来扩展这个对象,以获取特定用户的偏好。

调用方法

对象属性不仅限于存储原始字符串和数字。属性可以存储作为其值的函数,或者称为方法。然而,根据你使用对象的环境,方法并不总是可调用的,特别是如果你对对象使用的环境几乎没有或没有控制权。在这些情况下,一种有用的技术是将调用这些方法的结果进行映射,并在相关环境中使用这个结果。让我们看看以下代码是如何做到这一点的:

var object = { 
    first: 'Roxanne',
    last: 'Elliot',
    name: function() {
        return this.first + ' ' + this.last;
    },  
    age: 38, 
    retirement: 65, 
    working: function() {
        return this.retirement - this.age;
    }   
};  

_.map(object, function(value, key) {
    var item = {}; 
    item[key] = _.isFunction(value) ? object[key]() : value
    return item;
});
// →
// [
//   { first: "Roxanne" },
//   { last: "Elliot" },
//   { name: "Roxanne Elliot" },
//   { age: 38 },
//   { retirement: 65 },
//   { working: 27 }
// ]

_.map(object, function(value, key) {
    var item = {}; 
    item[key] = _.result(object, key);
    return item;
});
// →
// [
//   { first: "Roxanne" },
//   { last: "Elliot" },
//   { name: "Roxanne Elliot" },
//   { age: 38 },
//   { retirement: 65 },
//   { working: 27 }
// ]

在这里,我们有一个包含原始属性值和这些属性值使用的方法的对象。现在我们希望映射调用这些方法的结果,我们将尝试两种不同的方法。第一种方法使用isFunction()函数来确定属性值是否可调用。如果是,我们调用它并返回该值。第二种方法更容易实现,并达到相同的效果。result()函数被应用于对象和当前键。这测试了我们是否正在处理一个函数,因此我们的代码不必这样做。

注意

在映射方法调用的第一种方法中,你可能已经注意到我们使用object[key]()而不是value()来调用方法。前者保留了对象变量的上下文,但后者失去了上下文,因为它作为一个没有任何对象的普通函数被调用。所以当你编写调用方法的映射回调但没有得到预期结果时,确保方法的上下文是完整的。

也许你有一个对象,但你不确定哪些属性是方法。你可以使用functions()来找出这一点,然后映射调用每个方法的结果到一个数组,如下面的代码所示:

var object = { 
    firstName: 'Fredrick',
    lastName: 'Townsend',
    first: function() {
        return this.firstName;
    },  
    last: function() {
        return this.lastName;
    }   
};  

var methods = _.map(_.functions(object), function(item) {
    return [ _.bindKey(object, item) ];
}); 

_.invoke(methods, 0);
// → [ "Fredrick", "Townsend" ]

object变量有两个方法,first()last()。假设我们不知道这些方法,我们可以使用functions()来找到它们。在这里,我们使用map()构建一个methods数组。输入是一个包含给定对象所有方法名称的数组。我们返回的值很有趣。它是一个单值数组;你很快就会明白原因。这个数组的值是通过将对象和方法名称传递给bindKey()构建的函数。这个函数在调用时,将始终使用object作为其上下文。

最后,我们使用invoke()来调用methods数组中的每个方法,构建一个新的结果数组。回想一下,我们的map()回调返回了一个数组。这是一个简单的技巧,让invoke()工作,因为它是一种方便调用方法的方式。它通常期望一个键作为第二个参数,但一个数字索引同样有效,因为它们都是通过相同的方式查找的。

映射键值对

仅因为你正在处理一个对象,并不意味着它是理想的,甚至可能是必要的。这正是map()的作用——将你拥有的映射到你需要的。例如,属性值有时对你所做的事情来说可能就是全部,你可以完全不用键。为此,我们有values()函数,并将值传递给map()

var object = { 
    first: 'Lindsay',
    last: 'Castillo',
    age: 51
};  

_.map(_.filter(_.values(object), _.isString), function(item) {
    return '<strong>' + item + '</strong>';
});
// → [ "<strong>Lindsay</strong>", "<strong>Castillo</strong>" ]

我们在这里从object变量中想要的只是一个属性值的列表,这些值都是字符串,这样我们就可以对它们进行格式化。换句话说,键是firstlastage的事实是不相关的。因此,首先我们调用values()来构建一个值数组。接下来,我们将该数组传递给filter(),移除任何不是字符串的内容。然后我们将这个输出传递给map(),在那里我们可以使用<strong/>标签映射字符串。

反过来也可能成立——没有键,值完全没有意义。如果是这样,可能将键值对映射到一个新的集合中是合适的,如下面的示例所示:

function capitalize(s) {
    return s.charAt(0).toUpperCase() + s.slice(1);
}   

function format(label, value) {
    return '<label>' + capitalize(label) + ':</label>' +
        '<strong>' + value + '</strong>';
}   

var object = { 
    first: 'Julian',
    last: 'Ramos',
    age: 43
};  

_.map(_.pairs(object), function(pair) {
    return format.apply(undefined, pair);
});
// →
// [
//   "<label>First:</label><strong>Julian</strong>",
//   "<label>Last:</label><strong>Ramos</strong>",
//   "<label>Age:</label><strong>43</strong>"
// ]

我们将运行对象通过 pairs() 函数的结果传递给 map()。传递给我们的 map 回调函数的参数是一个数组,第一个元素是键,第二个是值。碰巧的是,format() 函数期望一个键和一个值来格式化给定的字符串,因此我们可以使用 format.apply() 来调用该函数,传递给它 pair 数组。这种方法纯粹是个人喜好问题。没有必要在 map() 之前调用 pairs()。我们同样可以直接调用 format。但有时,这种方法更受欢迎,原因很多,其中之一就是程序员的风格,这些原因广泛而多样。

简化集合

现在是时候看看如何简化集合了。Lo-Dash 在这里也提供了很多帮助,提供了帮助我们简化数组、对象以及任何向我们抛来的东西的函数。除了原始数据类型之外,所有数据结构都可以简化为更简单的东西。

我们将从查看常见的简化情况开始,例如求和值等。接下来,我们将讨论过滤集合的主题以及它与简化的关系。然后,我们将探讨一些更高级的计算技术。

求和值

与其他编程语言不同,JavaScript 没有内置机制来对值数组进行求和。我们所能达到的求和程度是原生的 Array.reduce() 方法,它实际上是通用的,并不专门用于求和值。Lo-Dash 版本的 reduce 更加通用,以下是一个如何在集合中求和值的示例:

var collection = [ 
    { ram: 1024, storage: 2048 },
    { ram: 2048, storage: 4096 },
    { ram: 1024, storage: 2048 },
    { ram: 2048, storage: 4096 }
];  

_.reduce(collection, function(result, item) {
    return result + item.ram;
}, 0);
// → 6144

_.reduce(collection, function(result, item) {
    return result + item.storage;
}, 0);
// → 12288

在这里,我们有一个简单的集合,我们将其简化为两个值。第一次调用 reduce() 有一个回调函数,它将累加器和当前项的 ram 属性相加。第二次 reduce() 调用执行相同的事情,但它作用于 storage 属性。我们实际上是将集合简化为一个数字,因此得名。你还会注意到,在回调函数之后,我们向 reduce() 传递了一个 0 值。这就是累加器。正如其名所示,它的任务是随着每个项目通过回调函数而累积数据。这也被称为结果,并且总是作为第一个参数传递给 reduce 回调。现在让我们看看不同类型的累加器:

var collection = [ 
    { hits: 2, misses: 4 },
    { hits: 5, misses: 1 },
    { hits: 3, misses: 8 },
    { hits: 7, misses: 3 } 
];  

_.reduce(collection, function(result, item) {
    return {
        hits: result.hits + item.hits,
        misses: result.misses + item.misses
    }; 
}, { hits: 0, misses: 0 });
// → { hits: 17, misses: 16 }

这个累加器是一个对象,它初始化两个属性为 0。回调函数只是不断返回一个新的累加器对象,其中包含计算出的 hitsmisses 的总和。这种方法的副作用之一是,我们只需要一个 reduce() 调用而不是两个。然而,累加器并不是必需的。在简单的求和项的情况下,实际上使用它们并没有意义。以下代码展示了这一点:

function add(a, b) {
    return a + b;
}   

var collection = [ 
    { wins: 34, loses: 21 },
    { wins: 58, loses: 12 },
    { wins: 34, loses: 23 },
    { wins: 40, loses: 15 }
];  

_.reduce(_.range(1, 6), add);
// → 15

_.reduce(_.pluck(collection, 'wins'), add);
// → 166

_.reduce(_.pluck(collection, 'loses'), add);
// → 71

此示例使用一个通用的 reduce 回调函数,该函数返回其两个参数的总和。然后我们有一个基本的对象集合,每个对象有两个属性。reduce() 的第一次调用将一个数字数组传递给 add() 回调。接下来的两次调用首先使用 pluck() 构建一个数字数组,使用它们各自的键名字符串。这些调用使用相同的回调。要注意的是,此代码中没有在 reduce() 调用中显式指定累加器。当调用者未指定时,默认值是集合的第一个元素。对于具有这些原始值的数组,这是可以的,实际上可以简化回调函数。

过滤和缩减

您并不总是需要或想要将整个集合缩减到单个值。相反,需要一个过滤后的子集。有时,您的代码接收到的集合是应用过滤后的较大集合的结果。或者,您需要应用过滤本身。考虑以下代码:

var collection = [ 
    { name: 'Gina', age: 34, enabled: true },
    { name: 'Trevor', age: 45, enabled: false },
    { name: 'Judy', age: 71, enabled: true },
    { name: 'Preston', age: 19, enabled: false }
];  

_.reduce(_.filter(collection, 'enabled'), function(result, item) {
    result.names.push(item.name);
    result.years += item.age;
    return result;
}, { names: [], years: 0 });
// →
// {
//   names: [
//     "Gina",
//     "Judy"
//   ],
//   years: 105
// }

filter() 函数仅用于将启用的对象传递给 reduce() 调用。这被称为过滤然后缩减。然而,这里可以应用另一种方法。如下面的代码所示:

var collection = [ 
    { name: 'Melissa', age: 28, enabled: true },
    { name: 'Kristy', age: 22, enabled: true },
    { name: 'Kerry', age: 31, enabled: false },
    { name: 'Damon', age: 36, enabled: false }
];  

_.reduce(collection, function(result, item) {
    if (item.enabled) {
        result.names.push(item.name);
        result.years += item.age;
    }   
    return result;
}, { names: [], years: 0 });
// →
// {
//   names: [
//     "Melissa",
//     "Kristy"
//   ],
//   years: 50
// }

此方法在回调函数内部执行必要的过滤。这被称为过滤和缩减。如果项目未启用,我们只需返回最后一个结果。如果已启用,我们执行常规的缩减工作。所以这就像我们只是在跳过那些无论如何都会被过滤的项目。这个优点是,对于大型集合,您不需要两次通过集合进行线性操作,而只需一次。缺点是 reduce 回调函数中增加了复杂性。但是,无论在哪里可以最小化这种复杂性,例如在前面的例子中,将过滤工作转移到 reduce 回调函数以优化您的代码。

最小值、最大值和平均值操作

Lo-Dash 提供了帮助进行更复杂操作的功能,同时让您编写干净、简洁的代码。例如,min()max() 函数接受回调函数,使它们能够在各种情况下使用,如下面的示例所示:

function score(item) {
    return _.reduce(item.scores, function(result, score) {
        return result + score;
    }); 
}   

var collection = [ 
    { name: 'Madeline', scores: [ 88, 45, 83 ] },
    { name: 'Susan', scores: [ 79, 82, 78 ] },
    { name: 'Hugo', scores: [ 90, 84, 85 ] },
    { name: 'Thomas', scores: [ 74, 69, 78 ] } 
];  

_.min(collection, score);
// →
// {
//   name: "Madeline",
//   scores: [
//     88,
//     45,
//     83
//   ]
// }

_.max(collection, score);
// →
// {
//   name: "Hugo",
//   scores: [
//     90,
//     84,
//     85
//   ]
// }

在此代码中定义的 score() 函数将传入的项目缩减为其 scores 属性的总和,假设它是一个数组。这旨在用作 min()max() 函数的回调。想法是 score() 被应用于我们集合中的每个对象,并返回最小值或最大值。所以实际上我们正在进行两个缩减工作,一个用于 scores 属性,另一个用于集合。

将集合缩减到平均值稍微复杂一些,因为没有名为 avg() 的 Lo-Dash 函数可以将集合缩减到平均值。让我们看看我们是否可以实施一些不需要比前面示例更多代码的方法:

function average(items) {
    return _.reduce(items, function(result, item) {
        return result + item;
    }) / items.length;
}   

var collection = [ 
    { name: 'Anthony', scores: [ 89, 59, 78 ] },
    { name: 'Wendy', scores: [ 84, 80, 81 ] },
    { name: 'Marie', scores: [ 58, 67, 63 ] },
    { name: 'Joshua', scores: [ 76, 68, 74 ] } 
];  

_.reduce(collection, function(result, item, index, coll) {
    var ave = average(item.scores);
    result.push(ave);
    if (index === (coll.length - 1)) {
        return average(result);
    }   
    return result;
}, []).toFixed(2);
// → "73.08"

就像本例之前的scores()回调函数一样,我们还有一个average()函数。这个函数将传入的项目减少到它们的平均值。我们的集合由对象组成,每个对象都有一个scores数组。我们感兴趣的是找到整个集合的平均值。因此,我们将对集合调用reduce()。回调函数使用average()函数计算每个项目的平均分数。然后将这个结果添加到reduce()累加器中。如果我们已经到达最后一个项目,通过检查集合长度来完成平均。然后是计算最终平均值的时刻——平均的平均值。由于累加器是一个数字数组,我们可以简单地通过将其传递给average()函数来返回生成的值。

减少对象

在本节中,我们将关注减少对象和与对象累加器一起工作。减少对象与减少数组非常相似,区别在于你有键而不是索引。哦,对了,还有顺序,这也很重要——数组是有序的,对象则不是。

在本章前面,我们瞥见了累加器是什么。在这里,我们将更深入地研究对象累加器,包括一些利用这个概念的内置 Lo-Dash 函数。

减少键

你可以根据对象的键将其减少为不同的内容。例如,如果你只需要某些属性,你可以使用以下代码将对象减少到只包含这些属性:

var object = { 
        first: 'Kerry',
        last: 'Singleton',
        age: 41
    },  
    allowed = [ 'first', 'last' ];

_.reduce(object, function(result, value, key) {
    if (_.contains(allowed, key)) {
        result[key] = value;
    }   
    return result;
}, {});
// → { first: "Kerry", last: "Singleton" }

_.pick(object, allowed);
// → { first: "Kerry", last: "Singleton" }

allowed数组包含允许的属性键名,我们使用reduce()函数来检查给定的键是否允许。如果是允许的,它将被添加到对象累加器中。否则,它将被跳过。你会注意到,我们可以通过将allowed数组传递给pick()函数来实现相同的效果。所以,在编写自己的回调函数之前,先检查 Lo-Dash 默认做了什么。另一方面,你自己的代码更容易改变。

对象累加器

作为reduce()函数的替代方案,transform()函数用于将源对象转换为目标对象。主要区别在于使用transform时,存在一个隐含的累加器。这个累加器对象是在第一次调用transform()函数时创建的。然后,它作为引用传递给每个属性的callback函数,如下例所示:

var object = { 
    first: '&lt;strong&gt;Nicole&lt;/strong&gt;',
    last: '&lt;strong&gt;Russel&lt;/strong&gt;',
    age: 26
};   

_.transform(object, function(result, value, key) {
    if (_.isString(value)) {
        result[key] = _.unescape(value);
    }   
});
// →
// {
//   first: "<strong>Nicole</strong>",
//   last: "<strong>Russel</strong>"
// }

这里有一个具有两个字符串属性的对象。我们传递给transform()callback函数寻找字符串属性,并使用unescape()替换任何 HTML 字符代码。result参数在这里,就像在reduce()回调中一样,但我们不需要返回它。我们也不需要提供累加器对象,因为它为我们创建了。让我们更仔细地看看累加器是如何创建的。

注意

使用 transform() 的缺点是它看起来像是在转换并返回传入的对象,但实际上并非如此。transform() 函数不会修改源对象。

假设我们正在转换一个类的实例,而不仅仅是普通对象。这可以通过以下代码完成:

function Person(first, last) {
    this.first = first;
    this.last = last;
}   

Person.prototype.name = function name() {
    return this.first + ' ' + this.last;
};  

var object = new Person('Alex', 'Rivera');

_.transform(object, function(result, value, key) {
    if (_.isString(value)) {
        result[key] = value.toUpperCase();
    }   
}).name();
// → "ALEX RIVERA"

object 变量持有 Person 的一个实例。我们的 transform() 回调函数简单地查找字符串并将它们转换为它们的大写等效形式。当我们对转换后的对象调用 name() 函数时,我们得到预期的结果。请注意,name() 方法是在 Person 原型上。transform() 函数使用适当的构造函数正确地构建了转换后的实例。这确保了原型方法和属性位于它们应该的位置。

Lo-Dash 有其他一些函数在对象累加器方面与上述函数类似,区别在于源是一个集合而不是一个对象。例如,你可以对集合进行分组或索引项,如下面的代码所示:

var collection = [ 
    { id: _.uniqueId('id-'), position: 'absolute', top: 12 },
    { id: _.uniqueId('id-'), position: 'relative', top: 20 },
    { id: _.uniqueId('id-'), position: 'absolute', top: 12 },
    { id: _.uniqueId('id-'), position: 'relative', top: 20 }
];  

_.groupBy(collection, 'position');
// →
// {
//   absolute: [
//     { id: "id-1", position: "absolute", top: 12 },
//     { id: "id-3", position: "absolute", top: 12 }
//   ],
//   relative: [
//     { id: "id-2", position: "relative", top: 20 },
//     { id: "id-4", position: "relative", top: 20 }
//   ]
// }

_.indexBy(collection, 'id');
// →
// {
//   "id-1": {
//     id: "id-1",
//     position: "absolute",
//     top: 12
//   },
//   "id-2": {
//     id: "id-2",
//     position: "relative",
//     top: 20
//   },
//   "id-3": {
//     id: "id-3",
//     position: "absolute",
//     top: 12
//   },
//   "id-4": {
//     id: "id-4",
//     position: "relative",
//     top: 20
//   }
// }

groupBy() 函数根据指定属性的值对集合中的项进行分组。也就是说,如果两个相同的项具有相同的 position 属性值,它们将一起被分组在同一个对象键下。另一方面,indexBy() 将仅将一个项放入给定的键中。因此,这个函数更适合唯一属性,如标识符。如果我们愿意,我们可以传递一个函数来生成值,而不是传递一个 property 字符串。indexBy() 调用的结果是具有唯一键的对象,我们可以使用这些键来查找项。

绑定上下文

你可能并不总是想使用匿名函数或 Lo-Dash 函数作为你的 map() 回调。reduce() 也是如此。幸运的是,你可以轻松地将回调函数的上下文绑定在这两种情况下。例如,假设你有一个不是全局的应用程序对象。你仍然可以将其作为回调函数的上下文,如下面的代码所示:

var app = { 
    states: [
        'running',
        'off',
        'paused'
    ],  
    machines: [
        { id: _.uniqueId(), state: 1 },
        { id: _.uniqueId(), state: 0 },
        { id: _.uniqueId(), state: 0 },
        { id: _.uniqueId(), state: 2 } 
    ]   
};  

var mapStates = _.partialRight(_.map, function(item) {
    return _.extend({
        state: this.states[item.state]
    }, _.pick(item, 'id'));
}, app);

mapStates(app.machines);
// →
// [
//   { state: "off", id: "1" },
//   { state: "running", id: " " },
//   { state: "running", id: " " },
//   { state: "paused", id: " " }
// ]

上述示例使用 partialRight() 函数来组合回调函数。我们正在部分应用 map() 函数的参数。第一个是回调函数,第二个是函数的上下文,在这个例子中是 app 实例。这基本上使得回调函数能够将 this 关键字作为应用程序来引用,尽管它不在全局作用域中。

同样的上下文绑定原则也可以应用于 reduce() 函数:

var collection = [ 12, 34, 53, 43 ],
    settings = { tax: 1.15 },
    applyTax = _.partialRight(_.reduce, function(result, item) {
        return result + item * this.tax;
    }, 0, settings);

applyTax(collection).toFixed(2);
// → "163.30"

在这里,reduce 回调是一个部分函数,其上下文是 settings 对象。该对象有一个 tax 属性,用于通过将每个集合中的项的值乘以累加器来减少集合。然后将此结果添加到累加器中。

Map/reduce 模式

我们将用一些基本的 map/reduce 模式来结束这一章,这些模式适用于你到目前为止在本章中学到的所有内容。首先,我们将看看通用回调函数是什么样的,以及为什么它们是有用的。然后我们将介绍 map/reduce 链的概念。

通用回调函数

随着你前端应用程序的发展,你将开始注意到你的所有 map/reduce 回调函数之间有一些共同之处。换句话说,你可能会将回调的通用方面提取到一个单一的通用回调中。正如你在本章和上一章中看到的,使用 Lo-Dash 部分应用和组合新函数很容易。当你有一系列通用函数想要用作回调时,这特别有帮助。

例如,让我们创建一些通用的 map() 回调函数,看看它们如何被使用:

function add(item) {
    var result = _.clone(item);
    result[this.prop] += this.value;
    return result;
}   

function upper(item) {
    var result = _.clone(item);
    result[this.prop] = result[this.prop].toUpperCase();
    return result;
}   

var collection = [ 
    { name: 'Gerard', balance: 100 },
    { name: 'Jean', balance: 150 },
    { name: 'Suzanne', balance: 200 },
    { name: 'Darrell', balance: 250 }
];  

var mapAdd = _.partial(_.map, collection, add),
    mapUpper = _.partial(_.map, collection, upper);

mapAdd({ prop: 'balance', value: 50 }); 
// →
// [
//   { name: "Gerard", balance: 150 },
//   { name: "Jean", balance: 200 },
//   { name: "Suzanne", balance: 250 },
//   { name: "Darrell", balance: 300 }
// ]

mapAdd({ prop: 'balance', value: 100 });
// →
// [
//   { name: "Gerard", balance: 200 },
//   { name: "Jean", balance: 250 },
//   { name: "Suzanne", balance: 300 },
//   { name: "Darrell", balance: 350 }
// ]

mapUpper({ prop: 'name'});
// →
// [
//   { name: "GERARD", balance: 100 },
//   { name: "JEAN", balance: 150 },
//   { name: "SUZANNE", balance: 200 },
//   { name: "DARRELL", balance: 250 }
// ]

在这里,我们有两个通用函数,add()upper()。它们都遵循相似的模式。例如,它们都引用 this.prop 属性。因此,它们都是上下文相关的。然而,这是一个优点,而不是缺点。add() 回调使用 this.prop 来确定要操作哪个属性。this.value 属性确定要添加的值。正如我们所见,为这些函数提供上下文很容易,这就是我们如何将这些特定信息传递给回调的方式。upper() 回调做同样的事情,但它将现有的属性转换为大写。

mapAdd()mapUpper() 函数被创建为部分函数,预先提供了集合和通用的回调函数。唯一缺少的是上下文,这将在函数被调用时提供。这意味着这些函数在整个应用程序中都有可能是有用的,当被调用时可以获取新的上下文。

注意

就像任何其他编程任务一样,尝试提前创建通用的 map/reduce 回调函数很诱人,也就是说,试图预见你需要类似但略有不同的功能的地方。但事实是,事后诸葛亮是一个强大的工具。在你开始重复自己之后,你会发现通用函数在哪里变得有用要容易得多。另一方面,先见之明往往会导致概念上有用的回调函数,但实际上并不需要。

适用于 map() 回调函数的通用函数的所有想法也适用于 reduce() 回调函数。以下是一个例子:

function sum(a, b) {
    return a + b[this.prop];
}   

var collection = [ 
    { low: 40, high: 70 },
    { low: 43, high: 83 },
    { low: 39, high: 79 },
    { low: 45, high: 74 }
];  

var reduceSum = _.partial(_.reduce, collection, sum, 0); 

reduceSum({ prop: 'low' });
// → 167

reduceSum({ prop: 'high' });
// → 306

通用 sum() 函数返回两个参数的和。然而,它使用 this.prop 来确定应该添加哪个属性。然后我们使用 partial() 创建 reduceSum() 函数。现在我们可以用我们想要的任何上下文调用 reduceSum()

Map/reduce 链

本章我们将探讨的最后一个模式是 map/reduce 链的概念。这与 Google 介绍的 map/reduce 编程模型密切相关。想法是,对于大型数据集,当您可以将问题分解为一系列映射操作时,解决计算密集型问题更容易。然后这些操作被馈送到一系列减少操作。从这个角度来看,跨节点分布计算要容易得多。

然而,我们感兴趣的是在映射作业和减少作业之间发生的手续转移。映射作业负责将源数据映射为减少作业可以消费的东西。这种模式实际上可能重复多次。例如,数据集被映射然后减少。减少的结果随后进一步映射,再进一步减少,依此类推。让我们看看在 Lo-Dash 中这样的操作看起来是什么样子:

var collection = [ 
    { name: 'Wade', balance: 100 },
    { name: 'Donna', balance: 125 },
    { name: 'Glenn', balance: 90 },
    { name: 'Floyd', balance: 110 }
], bonus = 25; 

var mapped = _.map(collection, function(item) {
    return _.extend({
        bonus: item.balance + bonus
    }, item);
}); 

_.reduce(mapped, function(result, item, index, coll) {
    result += (item.bonus - item.balance) / item.bonus;
    if (index === (coll.length - 1)) {
        result = result / coll.length * 100;
    }   
    return result;
}, 0).toFixed(2) + '%';
// → "19.23%"

在这里,我们通过为每个项目添加一个 bonus 属性到 balance 项来计算金额,从而映射集合。这个新映射的集合存储在 mapped 变量中。然后我们将集合减少到平均增长率。请注意,减少回调期望一个映射集合,因为它使用了 bonus 属性,而这个属性不在原始集合中。

摘要

本章向您介绍了 map/reduce 编程模型以及 Lo-Dash 工具如何帮助您在应用程序中实现它。首先,我们检查了映射集合,包括如何选择包含哪些属性以及如何执行计算。然后我们转向映射对象。键在对象映射到新对象和集合的方式中起着重要作用。在映射时还需要考虑方法和函数。

本章的第二部分涵盖了减少的内容,包括如何求和项,如何转换对象,以及如何制定通用的回调函数,这些函数可以在各种环境中使用。章节以简要介绍将 map/reduce 操作链式连接起来的样子结束。

Map/reduce 是一个重要的话题,因为 Lo-Dash 支持许多编程模型的变体。现在是时候扩展链式概念了,结果发现,不仅仅是 map/reduce 函数可以被粘合在一起。

第五章。链式组装

到目前为止,本书中我们讨论的例子都是独立使用 Lo-Dash 函数的。这并不意味着它们不能一起工作;只是它们可以更简洁,或者更紧凑。我们会调用一个函数来计算一个值,存储这个值,然后调用另一个函数使用存储的值作为参数来计算一个新的值,并重复这个过程。这很累人,但可以很容易地解决。

理念是将这个功能流线化为一系列调用。这种方法遵循应用编程的概念,即我们有一个起始集合,在链的每个阶段,这个集合都会被转换。它就像一条装配线,最终产品是在特定上下文中需要的值。

Lo-Dash 通过包装器概念实现了这种编程模式——一个构造函数,用于包装原始值,从而实现链式函数调用。在本章中,我们将看到如何使用这种方法简化复杂的代码,甚至产生可重用的组件。

在本章中,我们将涵盖以下主题:

  • 创建 Lo-Dash 包装器

  • 构建过滤器

  • 测试真值条件

  • 计数项目

  • 转换

  • 中间结果

  • 键和值

  • 返回链

创建 Lo-Dash 包装器

在本节中,我们将介绍包装值的理念。然后我们将使用包装器来链式调用函数。我们还将探讨如何终止调用链。

链式调用

将函数调用链在一起是应用编程的一种模式,其中集合被转换成不同的东西。这个新转换的集合随后被传递到链中的下一个调用,依此类推。这就是“应用”这个术语的来源;你正在将函数应用于集合中的每个项目。由于这个过程反复进行,很容易将链式调用打包成一个可重用的组件。这是一个在每一步添加、删除或修改值的管道,最终产生一个结果。

另一种可能的方式是对链的更实际的观点,它只是调用函数的一种更简单的方式。jQuery 使这一概念流行起来。当阅读 jQuery 代码时,你会发现有很多链式调用,但代码仍然是可读的。通常,链可以作为一个单独的语句来构建,如下面的代码所示:

$('body')
    .children()
    .first()
    .is('h1');
// → true

这个 jQuery 链由四个调用组成,表达为一个单独的语句。第一个调用是 jQuery 构造函数,它包装了指定的 DOM 元素。接下来,我们调用 children() 来获取子元素。first() 函数返回第一个子元素。链通过调用 is() 结束,它返回一个简单的布尔值,而不是 jQuery 对象。

注意

注意这里的代码格式。如果你要组合功能链,保持代码的可读性非常重要。我推荐遵循的主要约定是,在下一行缩进链式调用。这样,你不会有不必要的跨多列的语句,并且可以一眼看出这段代码是一个函数调用链。

包装值

在 Lo-Dash 中包装值的工作方式基本上与 jQuery 相同。有一个包装器调用构建 jQuery/Lo-Dash 对象。每个链式调用返回一个包装器对象。有一个终止调用返回一个原始类型。也有一些明显的区别,关于 jQuery 和 Lo-Dash 如何包装值。例如,你不能传递一个 CSS 选择器字符串给 Lo-Dash 包装器函数,并期望它包装 DOM 元素。你也不希望这样做——Lo-Dash 是一个低级实用程序库,而 jQuery 在根本上是更高层次的抽象。

这并不是包装值和应用函数调用链的全部故事。在每一个角落都有细微的差别和边缘情况,我们将在本章的整个过程中解决这些问题。但就目前而言,让我们进入代码:

_(['a', 'b', 'c'])
    .at([1, 2])
    .value();
// → [ "b", "c" ]

这里是我们的 hello-world 链。我们一直在书中使用_对象来访问 Lo-Dash API,它也是一个构造函数。它接受一个 JavaScript 原始值作为参数。这就是被包装并传递给链中第一个函数调用的值。在这里,我们调用at()函数,表示我们想要索引为12的项。调用value()函数得到我们想要的结果。

显然,前面的代码不需要使用包装器——只有一个函数调用。然而,重点不是简洁性,而是调用链的基本结构。随着我们在本章中遇到更复杂的例子,我们将看到链如何显著减少代码量。这里还有两个 Lo-Dash 包装器构造函数:

_({a: 'b', c: 'd'})
    .contains('b');
// → true

_('abcd')
    .contains('b');
// → true

第一个包装器使用一个普通对象作为其原始值。第二个包装器使用一个字符串。在两种情况下,链都会立即终止,因为我们调用了contains(),它本身返回一个原始布尔值。再次强调,我们不需要使用包装器和调用链来编写前面的代码。如果你只调用一个函数,最好不要这样做,否则你只会让其他阅读你代码的人感到困惑。前面代码的目的在于说明我们可以包装普通对象和字符串,并将它们视为集合。

显式和隐式链式调用

一旦我们有了 Lo-Dash 包装器实例,我们就可以开始进行链式函数调用了。然而,并非所有这些函数都是可链式的。不可链式的函数返回原始值,例如布尔值或数字。这被称为隐式链式。它是隐式的,因为那些本应返回集合的函数实际上返回了一个 Lo-Dash 包装器实例。其他函数没有集合作为返回值。对这些函数的调用将终止链式调用。

另一方面,存在显式链式调用——这将保持链式调用直到通过调用 value() 显式终止。例如,如果你的链式调用是显式的,调用 contains() 将返回一个包装器,而不是通常的布尔值。以下是一些隐式和显式链式调用的示例:

_([3,2,1])
    .sort()
    .first();
// → 1

_.chain([3,2,1])
    .sort()
    .first()
    .isNumber()
    .value();
// → true

第一个链使用默认的 Lo-Dash 链式配置。first() 函数获取数组中的第一个元素并返回它。由于这个元素可以是任何东西(在这个例子中是一个数字),first() 函数是不可链式的。我们不需要显式调用 value(),因为不可链式的函数返回未解包的值。然而,第二个链使用显式链式调用。这是通过使用 chain() 函数构造 Lo-Dash 包装器实例来实现的。结果包装器在所有方面都是相同的,除了我们需要显式调用 value() 来解包值。在显式链式中,每个函数都是可链式的。例如,first() 的调用现在返回一个包装器实例而不是一个数字。这也是通过 isNumber() 实现的。

你想要使用这种显式链式调用的主要原因是为了避免在链式调用完成后使用临时变量和额外的语句。例如,在前面代码的显式链式中,我们只需要知道排序集合中的第一个元素是否是数字。如果我们能从链式中直接得到我们想要的,就没有必要将第一个元素存储在一个新变量中。

构建过滤器

链式函数调用的强大用途是构建过滤器,依次从较大的集合中过滤掉不需要的项。假设你已经有了一段使用 filter() 函数对集合进行操作的代码。但现在你需要修改这个过滤操作,可能需要添加额外的约束。与其去修改你已知可以正常工作的现有 filter() 代码,你不如构建一个过滤器链。

多次调用 filter()

组装过滤器链的最简单方法是将多个 filter() 函数调用连接起来。以下是一个这样的例子:

var collection = [
    { name: 'Ellen', age: 20, enabled: true },
    { name: 'Heidi', age: 24, enabled: false },
    { name: 'Roy', age: 21, enabled: true },
    { name: 'Garry', age: 23, enabled: false }
];

_(collection)
    .filter('enabled')
    .filter(function(item) {
        return item.age >= 21;
    })
   .value();
// → [ { name: "Roy", age: 21, enabled: true } ]

filter() 的第一次调用使用 enabled 属性的 pluck 风格简写,过滤掉该属性为假值的项。下一次 filter() 调用使用一个回调函数,过滤掉 age 属性值小于 21 的项。我们最终只剩下一个项,通过调用 value() 来解包。

注意

当我们可以直接修改回调函数时,为什么还要调用两个或更多的 filter() 呢?这不会意味着更少的代码和更快的执行吗?真正的优势在于阅读和修改此代码。我们想看到当我们移除启用的过滤器时会发生什么吗?只需注释掉该行即可。可读性和可维护性几乎总是应该优于从复杂的回调函数中挤压性能的尝试。当然,这里有一些例外,但不要为了性能而发明性能问题。

将 filter() 与 where() 结合使用

where() 函数是一种表达性很强的过滤集合的方法,使用逻辑 and 条件。与其试图在一个 filter() 回调函数中表达所有的查询约束,为什么不利用 where() 语法在合适的地方呢?让我们看看它是如何工作的:

var collection = [
    { name: 'Janice', age: 38, gender: 'f' },
    { name: 'Joey', age: 20, gender: 'm' },
    { name: 'Lauren', gender: 'f' },
    { name: 'Drew', gender: 'm' }
];

_(collection)
    .where({ gender: 'f' })
    .filter(_.flow(_.property('age'), _.isFinite))
    .value();
// → [ { name: "Janice", age: 38, gender: "f" } ]

此过滤器将包括所有女性项目,并且是 where() 函数的良好候选者。接下来,我们想确保所有项目都有一个 age 属性,其值是一个有限数字。我们通过将一个回调函数传递给 filter() 来做到这一点。在这里,我们使用了一些快捷方式,而不是定义自己的内联回调函数。flow() 函数将为我们构造一个函数,让结果流向我们给出的每个函数参数。我们使用 property() 函数构建一个函数,获取每个项目的 age 属性,并将其传递给 isFinite() 函数。在我们的集合中,有几项没有 age 属性。这些未定义的值未通过测试,并被过滤掉。

注意

连锁过滤函数的顺序可能很重要。例如,首先进行广泛过滤是明智的。这样,当集合通过管道流动时,它会更快地缩小规模,这意味着其他函数的工作量更少。在哪里这很重要并不立即明显,但随着你的代码成熟,你将开始注意到排序调整。代码中连锁结构的优点是,顺序更改是微不足道的。

删除和获取集合项目

Lo-Dash 有工具可以让我们从集合的开始或结束处过滤集合。这些工具在函数调用链的上下文中特别有用,因为使用它们通常取决于集合的先前转换。例如,考虑以下代码中的排序顺序:

var collection = [
    { first: 'Dewey', last: 'Mills' },
    { first: 'Charlene', last: 'Larson' },
    { first: 'Myra', last: 'Gray' },
    { first: 'Tasha', last: 'Malone' }
];

_(collection)
    .sortBy('first')
    .dropWhile(function(item) {
        return _.first(item.first) < 'F';
    })
    .value();
// → 
// [
//   { first: "Myra", last: "Gray" },
//   { first: "Tasha", last: "Malone" }
// ]

在这个链中的第一个调用使用 sortBy() 函数根据 first 属性对集合进行排序。现在集合已经排序,我们可以调用 dropWhile()。从左侧开始,这个函数会从集合中删除项目,直到回调返回 true。我们的特定回调获取名字字符串的第一个字符,如果它小于 F,我们就删除它。这使我们只剩下一个只包含以 F 开头及以上的名字的集合。

除了从集合的左侧删除项目外,我们还可以从右侧删除项目。或者,我们可以使用链结合两种方法,如下面的代码所示:

var name = '  Donnie Woods   ',
    emptyString = _.partial(_.isEqual, ' ');

_(name)
    .toArray()
    .dropWhile(emptyString)
    .dropRightWhile(emptyString)
    .join('');
// → "Donnie Woods"

在这里,我们包裹的是一个字符串值而不是一个数组,模拟了String.trim()的功能。因此,我们链的第一个任务就是使用toArray()将字符串拆分为单个字符。drop函数期望一个数组。接下来,我们使用dropWhile()函数并传递它我们的emptyString()回调函数。这将从字符串中删除字符,直到找到实际字符。然后使用dropRightWhile()从数组的另一端执行相同的任务,但方向相反。最后,我们将数组重新组合成一个字符串,去掉两端被删除的空字符。

注意

是的,你可以使用正则表达式和压缩代码得到相同的结果。正则表达式很棒,但它们不是为每个人而设计的,并且它们只适用于字符串。在采取任何方向之前权衡你的选项。

我们可以执行从数组的两端删除项目的逆操作。我们可以取项目,从而删除其他所有内容。例如:

var collection = [
    { name: 'Jeannie', grade: 'B+' },
    { name: 'Jeffrey', grade: 'C' },
    { name: 'Carrie', grade: 'A-' },
    { name: 'James', grade: 'A' }
];

_(collection)
    .sortBy('grade')
    .takeWhile(function(item) {
        return _.first(item.grade) === 'A';
    })
    .value();
// → 
// [
//   { name: "James", grade: "A" },
//   { name: "Carrie", grade: "A-" }
// ]

我们只对具有A等级的项目感兴趣。我们与takeWhile()一起使用的回调函数对具有A的项目返回true。当然,这仅因为链中的第一步是按grade属性对数组进行排序。如果我们没有先做这一步,我们就会错过我们正在寻找的项目。

项目也可以从集合中以相反的方向取出。也就是说,我们不是从左到右移动,而是从右到左移动。这在排序很重要且你不想执行额外的步骤来从集合中取出所需项目时很理想。这种排序在下面的代码中显示:

var collection = _.sample(_.range(1, 21), 10),
    total = 5,
    min = 10;

_(collection)
    .sortBy()
    .takeRightWhile(function(item, index, array) {
        return item >= min &&
            array.length - index <= total;
    })
    .value();
// → [ 13, 14, 15, 17, 20 ]

这里使用的集合是 10 个整数的随机抽样。我们链中的第一次调用是sortBy(),它只是简单地按无参数提供的数组进行排序。这是升序的,我们想要前五个项目。我们本可以反转排序顺序,但相反,我们跳过了这一步,直接进入takeRightWhile()函数。这里使用的回调函数将返回大于min的数字,并且只要我们没有超过总数。

拒绝项目

拒绝操作与过滤操作非常相似。在过滤的情况下,你知道你想要什么。在拒绝的情况下,你知道你不需要什么。这些拒绝操作可以链接在一起来构建复杂的查询,如下面的代码所示:

var object = {
    first: 'Conrad',
    last: 'Casey',
    age: 37,
    enabled: true
};

_(object)
    .reject(_.isBoolean)
    .reject(_.isString)
    .first()
    .toFixed(2);
// → "37.00"

在这里,我们将两个reject()调用链在一起。包装的值是一个对象,我们只关心那些不是布尔值或字符串的属性值。这些函数——isBoolean()isString()——已经作为 Lo-Dash API 的一部分存在,我们可以直接将它们传递给reject()。在这里没有必要编写我们自己的回调函数。

我们可以使用result()函数帮助我们链式拒绝集合项。result()函数无论指定的属性值是函数还是不可调用值,其工作方式都是相同的。以下是使用result()或仅使用属性名调用reject()时的差异说明:

function User(name, disabled) {
    this.name = name;
    this.disabled = disabled;
}

User.prototype.enabled = function() {
    return !this.disabled;
};

var collection = [
        new User('Phil', true),
        new User('Wilson', false),
        new User('Kathey', true),
        new User('Nina', false)
    ],
    enabled = _.flow(_.identity,
        _.partialRight(_.result, 'enabled'));

_(collection)
    .reject('disabled')
    .value();
// →
// [
//   { name: "Wilson", disabled: false },
//   { name: "Nina", disabled: false }
// ]

_(collection)
    .reject(_.negate(enabled))
    .value();
// →
// [
//   { name: "Wilson", disabled: false },
//   { name: "Nina", disabled: false }
// ]

User实例有一个disabled属性,如果disabledfalse,则enabled()方法返回truecollection变量包含这些User实例的数组。enabled()函数是我们自己构建的。我们将将其用作reject()的回调。这个函数使用result()从集合中的每个项获取enabled()值。在这里,identity()函数被用作一个技巧,以便让partialRight()作为reject()的回调工作。

使用 initial()和 rest()

initial()函数获取除了最后一个元素之外的所有元素——这可以通过链式操作以有趣的方式结合使用。例如,假设我们有一个需要清理的简单字符串:

var string = 'abc\n';

_(string)
    .slice()
    .initial()
    .join('');
// → "abc"

如果我们知道字符串总是以我们不关心的内容结束,这是一个很容易将其删除的方法。相同的代码也适用于数组;我们并没有将范围限制在字符串上。例如,slice()函数是链式操作的一部分,它使链式操作能够与字符串一起工作。如果我们传递一个数组,slice()将没有任何影响——相同的代码仍然有效。然而,我们可能希望在稍后删除它,以及join()调用。鉴于我们的链式代码的格式,这很简单。

initial()的逆操作是rest()——它获取数组中的所有元素,除了第一个元素。就像我们不在乎最后一个元素的情况一样,也可能有不关心第一个元素的情况。以下是如何使用rest()的示例:

var collection = [
    { name: 'init', task: _.noop },
    { name: 'sort', task: _.random },
    { name: 'search', task: _.random }
];

_(collection)
    .rest()
    .invoke('task')
    .value();
// → [ 1, 1 ]

这个集合包含具有task()方法的对象。集合是有序的,所以第一个任务总是init任务,我们对此不感兴趣,因为它是一个noop()函数。我们通过将invoke()方法链接到rest()函数来测试这一点,如果一切顺利,我们应该得到一个随机数字数组,并且没有未定义的值。

测试真值条件

除了简单地过滤集合外,你通常还需要测试集合的条件。这可能包括过滤一个集合,然后对结果进行简单的肯定/否定回答。在需要检查集合的真值条件的情况下,通常在链式操作的最后进行测试会更简单。通常没有必要在过程中编写多个语句和分配多个变量。

测试集合是否包含项目

可能,最直接的测试就是检查一个集合是否包含我们正在寻找的项目。在这种情况下,contains()函数很有用,因为它可以轻松地附加到执行其他操作之前的链的末尾。以下是一个contains()函数用法的示例:

var string = 'abc123',
    array = [ 'a', 'b', 'c', 1, 2, 3 ];

_(string)
    .filter(_.isString)
    .contains('c');
// → true

_(array)
    .filter(_.isString)
    .contains('c');
// → true

代码中有两个集合——一个字符串和一个数组。接下来的两个链在除了包装不同的值之外都是相同的。然而,在这种情况下,两者都返回true,因为字符串中有c,数组中也有。

如果你只关心测试项目是否存在,那么使用contains()等函数总是一个好习惯。如果找到值,这些函数会提前停止循环,或者短路,从而节省宝贵的 CPU 周期。

通常,你并不拥有确切的值。相反,你只有查询约束,但你仍然只对它们是否满足感兴趣,而不是数据本身。这可以通过使用find()filter()方法来实现:

var string = 'Dana Porter',
    array = [
        { name: 'Luis', gender: 'm' },
        { name: 'Rhonda', gender: 'f' },
        { name: 'Kirk', gender: 'm' },
        { name: 'Emily', gender: 'f' }
    ];

_(string)
    .chain()
    .filter(function(item) {
        return item.toUpperCase() === 'A';
    })
    .size()
    .isEqual(2)
    .value();
// → true

!!(_(array)
    .find(function(item) {
        return _.first(item.name).toUpperCase() === 'R' &&
            item.gender === 'f';
    }));
// → true

代码中的第一个链是针对字符串值的。注意我们在这里使用了chain()来启用显式链式调用——这意味着我们最终必须显式调用value()来展开结果。这里的filter()调用返回匹配A的项。我们这样做是为了计算链中这些项的数量。在这种情况下,字符串通过了测试,因为有两个A字符。缺点是我们寻找的是一个确切的数字——2filter()函数会在找到两个项目之后继续过滤。

第二个链使用了一个包装数组。在这里,我们将调用find()的结果转换为一个布尔值。在这里,我们可以使用更复杂的查询条件。

任何事物或任何东西都是真实的

本章最后关于检查真值条件的探讨,包括验证至少一个项目或整个集合的有效性。也就是说,如果一个或多个项目通过了我们所设定的测试,那么这个集合可能被认为是有效的。或者,也许要求更为严格,集合中的每个项目都必须通过测试才能被认为是有效的。让我们看看这些测试如何在链中使用:

var collection = [
    1414728000000,
    1383192000000,
    1351656000000,
    1320033600000
];

_(collection)
    .map(function(item) {
        return new Date(item);
    })
    .every(function(item) {
        return item.getMonth() === 9 && item.getDate() === 31;
     });
// → true

这个集合包含时间戳数字,因此链中的第一个调用是map(),将每个集合项转换为Date实例。现在每个项都是日期,我们可以使用every()来验证在这个集合中,每一天都是万圣节。

现在,让我们看看如何使用some()函数来终止链。这将验证至少有一个项目通过了测试,并且一旦找到就会停止循环:

var collection = [
    { name: 'Danielle', age: 34, skill: 'Backbone' },
    { name: 'Sammy', age: 19, skill: 'Ember' },
    { name: 'Donna', age: 41, skill: 'Angular' },
    { name: 'George', age: 17, skill: 'Marionette' }
];

_(collection)
    .reject({ skill: 'Ember' })
    .reject({ skill: 'Angular' })
    .some(function(item) {
        return item.age >= 25;
    });
// → true

你可以看到,在拒绝EmberAngular爱好者之后,我们确保至少有一位25岁以上的BackboneMarionette程序员。

计数项目

之前主题的一个变体——测试真值条件——是在它们的值通过处理链移动后,在集合中计数项。例如,我们可能需要知道集合中有多少项满足给定的条件。我们可以使用调用链来获取那个数字。

使用长度和大小()

size()函数很方便,因为我们可以直接在 Lo-Dash 包装器上调用它。这是我们链式操作运行后,在我们的集合中计数结果项的首选方式:

var object = { first: 'Charlotte', last: 'Hall' },
    array = _.range(10);

_(object)
    .omit('first')
    .size();
// → 1

_(array)
    .drop(5)
    .size();
// → 5

在这里,我们有数组对象。第一个链使用size()函数来计算在省略了first属性之后属性的数量。第二个链包裹了数组,并在删除5个项目之后计算剩余的项目数量。

注意

我们可以使用length属性,但我们必须先调用value()。使用size()只是一个快捷方式。

使用 countBy()进行分组

我们也可以计数多个项。也就是说,给定一个集合,我们可以将其分成组,然后计算每个组中的项目数量。使用链,我们可以编写一些相当复杂的代码:

var collection = [
    { name: 'Pamela', gender: 'f' },
    { name: 'Vanessa', gender: 'f' },
    { name: 'Gina', gender: 'f' },
    { name: 'Dennis', gender: 'm' }
];

_(collection)
    .countBy('gender')
    .pairs()
    .sortBy(1)
    .reverse()
    .pluck(0)
    .value();
// → [ "f", "m" ]

这个链通过按gender属性对集合进行分组开始。接下来,我们使用pairs()函数获取一个数组的数组。我们这样做是为了能够按组中的项目数量对组进行排序。在集合排序后,我们可以提取我们感兴趣的价值。在这种情况下,f性别是第一个,因为那个组有更高的计数。

注意

之前的代码使用了两个有趣的技巧。首先,我们向sortBy()函数传递一个数值索引。由于键的访问方式与索引相同,所以这按预期工作。其次,我们向pluck()函数传递一个数值索引,这与sortBy()函数的原因相同。

减少集合

我们在链式操作中计数项的最终方法是减少集合。当你想要将整个集合减少为一个使用更复杂的函数计算得出的总和时,这很有用,这些函数应用于每个项目。可以使用以下代码减少集合:

var collection = [
    { name: 'Chad', skills: [ 'backbone', 'lodash' ] },
    { name: 'Simon', skills: [ 'html', 'css', 'less' ] },
    { name: 'Katie', skills: [ 'grunt', 'underscore' ] },
    { name: 'Jennifer', skills: [ 'css', 'grunt', 'less' ] }
];

_(collection)
    .pluck('skills')
    .reduce(function(result, item) {
        return _.size(item) > 2 &&
            _.contains(item, 'grunt') &&
            result + 1;
    }, 0);
// → 1

在这里,我们正在从集合中的每个项目中提取skills属性。我们对skills值感兴趣的两个问题是:它是否包含字符串grunt?并且它是否有超过2个项?如果这些条件得到满足,那么我们就增加由reduce()调用返回的减少的总值。

转换

现在我们来看看数据通过我们构建的处理管道时发生的转换。Lo-Dash 和它在链中转换数据有趣的地方在于原始集合没有被修改——而是构建了一个新的集合。这减少了副作用,并且是其他函数式编程语言中不可变概念的基础。

构建组、并集和唯一值

在 Lo-Dash 中找到的一些最强大的转换工具可以无需太多努力即可直接使用。这包括根据它们包含的特定值对集合项进行分组、在保留唯一值的同时将数组连接起来,以及从数组中移除任何重复项。例如:

var collection = [
    { name: 'Rudolph', age: 24 },
    { name: 'Charles', age: 43 },
    { name: 'Rodney', age: 37 },
    { name: 'Marie', age: 28 }
];

_(collection)
    .map(function(item) {
        var experience = 'seasoned veteran';
        if (item.age < 30) {
            experience = 'noob';
        } else if (item.age < 40) {
            experience = 'geek cred';
        }
        return _.extend({
            experience: experience
        }, item);
    })
    .groupBy('experience')
    .map(function(item, key) {
        return key +
            ' (' + _.pluck(item, 'name').join(', ') + ')';
    })
    .value();
// →
// [
//   "noob (Rudolph, Marie)",
//   "seasoned veteran (Charles)",
//   "geek cred (Rodney)"
// ]

这个链式操作封装了一个普通对象的集合,链式操作中的第一次调用将item对象映射为其扩展版本。我们正在计算它们的experience属性的字符串版本,并将其分配给一个新的属性。接下来,我们使用groupBy()函数根据这个新的experience属性对集合进行分组。链式操作的最后一个步骤是再次使用map()来生成各种经验组的字符串表示。

使用union()函数连接数组可能很有用,如果你已经有一个封装的数组,并且需要确保它具有某些值,同时确保这些值不重复。union()函数的应用在以下示例中显示:

var collection = _.sample(_.range(1, 101), 10);

_(collection)
    .union([ 25, 50, 75])
    .sortBy()
    .value();
// → [ 1, 3, 21, 25, 27, 37, 40, 50, 57, 73, 75, 94 ]

你可以看到我们的封装数组,10 个随机数的样本,使用union()函数与另一个数组连接。然后我们返回排序后的结果。如果你检查输出,你会注意到它总是会包含255075。你也会注意到这些数字永远不会重复。

最后,如果你有一个值集合,并且只需要移除重复项,uniq()函数允许你在链式处理中执行此操作:

function name(item) {
    return item.first + ' ' + item.last;
}

var collection = [
    { first: 'Renee', last: 'Morris' },
    { first: 'Casey', last: 'Wise' },
    { first: 'Virginia', last: 'Grant' },
    { first: 'Toni', last: 'Morris' }
];

_(collection)
    .uniq('last')
    .sortBy('last')
    .value();
// →
// [
//   { first: "Virginia", last: "Grant" },
//   { first: "Renee", last: "Morris" },
//   { first: "Casey", last: "Wise" }
// ]

_(collection)
    .uniq(name)
    .sortBy(name)
    .value();
// →
// [
//   { first: "Casey", last: "Wise" },
//   { first: "Renee", last: "Morris" },
//   { first: "Toni", last: "Morris" },
//   { first: "Virginia", last: "Grant" }
// ]

_(collection)
    .map(name)
    .uniq()
    .sortBy()
    .value();
// →
// [
//   "Casey Wise",
//   "Renee Morris",
//   "Toni Morris",
//   "Virginia Grant"
// ]

我们看到了从封装集合中提取唯一值的三种不同方法。第一种使用提取风格的简写来过滤重复项。由于我们传递了字符串last,它将在这个属性中查找唯一值。第二种方法传递了一个回调函数,该函数将firstlast名称属性连接起来。请注意,这个相同的函数在同一个链式操作中的sortBy()调用中也被使用。最后一种方法没有向uniq()传递任何参数,因为链式操作的第一步将我们的对象数组映射为字符串数组。

提取值

通常,在你的功能链中,你会意识到你不需要你的集合中每个对象的全部内容。这可以使链式操作中后续的操作变得更加简单。为了提取值,可以使用以下代码:

var collection = [
    { gender: 'f', dob: new Date(1984, 3, 8) },
    { gender: 'm', dob: new Date(1983, 7, 16) },
    { gender: 'f', dob: new Date(1987, 2, 4) },
    { gender: 'm', dob: new Date(1988, 5, 2) }
];

_(collection)
    .where({ gender: 'm' })
    .pluck('dob')
    .map(function(item) {
        return item.toLocaleString();
    })
    .value();
// → [ "8/16/1983, 12:00:00 AM", "6/2/1988, 12:00:00 AM" ]

在这里,我们正在提取dob属性值,这简化了链式操作中后续的map()处理程序。无需查找dob属性,项目本身就是dob属性值。

使用without()函数创建数组

如果我们需要构建一个新的数组,并且我们知道某些值不应作为项目出现,我们可以使用without()函数。这通常是链式操作中的第一个动作,因为它创建了一个新的数组,但并不总是第一个。让我们看看这个例子:

var collection = _.range(1, 11);

return _(collection)
    .without(5, _.first(collection), _.last(collection))
    .reverse()
    .value();
// → [ 9, 8, 7, 6, 4, 3, 2 ]

在此代码中,包装的集合包括从110的数字。我们链中的第一次调用将此数组中的项目复制出来,并将它们放置在一个新的数组中,除了传递给without()函数的参数值。这些值不包括在新数组中。

找到最小和最大值

每个集合都有一个最小值和最大值。使用 Lo-Dash 找到这些值很容易;你只需要使用相应的min()max()函数。但如果你需要调整你正在寻找的最小值的范围呢?让我们使用以下代码来完成这个任务:

var collection = [
    { name: 'Daisy', wins: 10 },
    { name: 'Norman', wins: 12 },
    { name: 'Kim', wins: 8 },
    { name: 'Colin', wins: 4 }
];

_(collection)
    .reject(function(item) {
        return item.wins < 5
    })
    .min('wins');
// → { name: "Kim", wins: 8 }

在这个例子中,我们并不关心具有小于5win计数的项目。因此,我们知道此代码返回的绝对最小值将至少有5次胜利。在拒绝无效的win计数后,我们使用 pluck 风格简写来根据wins属性找到最小值。

max()函数可以以类似的方式用作链式操作:

var collection = [
    { name: 'Kerry', balance: 500, credit: 344 },
    { name: 'Franklin', balance: 0, credit: 554 },
    { name: 'Lillie', balance: 1098, credit: 50 },
    { name: 'Clyde', balance: 473, credit: -900 }
];

_(collection)
    .filter('balance')
    .filter('credit')
    .max(function(item) {
        return item.balance + item.credit;
    });
// → { name: "Lillie", balance: 1098, credit: 50 }

此集合包含具有balancecredit属性的对象。前两个链式操作使用filter()函数删除这些字段任一为假的对象。然后max()函数关闭链式操作。这次,我们使用一个回调函数,允许我们映射我们想要比较的值,以便确定最大值。

找到索引

找到给定元素的索引有其用途,我们可以在调用链中应用index()函数:

function rank(coll, name) {
    return _(coll)
        .sortBy('score')
        .reverse()
        .pluck('name')
        .indexOf(name) + 1;
}

var collection = [
    { name: 'Ruby', score: 43 },
    { name: 'Robert', score: 59 },
    { name: 'Lindsey', score: 38 },
    { name: 'Marty', score: 55 }
];

rank(collection, 'Ruby');
// → 3

rank(collection, 'Marty');
// → 2

此代码中的rank()函数接受一个collection参数和一个name字符串。该函数包装集合,并使用调用链根据score属性确定传入名称的排名。第一步是对集合进行排序,然后将其反转,使其根据score属性值降序排列。接下来,我们使用pluck()函数从集合中提取名称,该函数保持我们刚刚创建的排序顺序。现在我们可以使用indexOf()来确定给定用户的排名。

使用 difference()和 xor()

变换主题的最后一部分是使用difference()xor()函数合并两个数组的内容。这两个函数工作方式相似,但有一些细微的差别值得注意和关注。这些函数在链式操作的开始处非常有用,确保包装的数组只包含必要的值。例如,假设你的数字数组不应该有任何奇数。然后我们可以使用以下代码来满足这个条件:

var collection = _.range(1, 51),
    odds = _.filter(_.range(1, 101), function(item) {
        return item % 2;
    });

_(collection)
    .difference(odds)
    .takeRight(10)
    .reverse()
    .value();
// → [ 32, 34, 36, 38, 40, 42, 44, 46, 48, 50 ]

在此代码中,我们的集合由50个数字组成,而odds数组包含从1100的奇数。我们的链首先通过调用difference()函数开始,将odds数组作为参数传入。接下来,我们从结果数组中取出前 10 个元素并对其进行排序。要注意的是,结果中没有超过50的值。我们已经移除了所有低于50的奇数,因为这是包装数组与作为参数提供的数组之间的差。换句话说,这不是对称差。为此,我们会在链中使用xor()函数:

var collection = _.range(1, 26),
    evens = _.reject(_.range(1, 51), function(item) {
        return item % 2;
    });

_(collection)
    .xor(evens)
    .reverse()
    .value();
// → 
// [ 50, 48, 46, 44, 42, 40, 38, 36, 34, 32, 30, 28, 26,
//   25, 23, 21, 19, 17, 15, 13, 11, 9, 7, 5, 3, 1 ]

这次,我们的集合是一系列从125的数字,而evens数组则包含从250的偶数。我们在链中使用xor()函数将集合与evens数组连接起来。与difference()函数的区别在于,它将包括evens数组中超过25的所有值,因为xor()将计算对称差。

中间结果

有时候,我们不想等到调用链完成才访问在某个步骤计算出的值。考虑一下链中某个函数产生的中间值应该被链中稍后的另一个函数使用的情况。在其他情况下,我们需要完全覆盖链返回的值。

操纵链

我们可以使用tap()函数将我们自己的回调函数注入链中。这与提供给其他 Lo-Dash 函数的回调不同。它不会改变值在函数调用链中流动时的值。相反,将tap()视为拦截值流经链的方式,并可能以某种方式对其做出反应。让我们看看这个函数的例子:

var collection = [
        { name: 'Stuart', age: 41 },
        { name: 'Leah', age: 26 },
        { name: 'Priscilla', age: 37 },
        { name: 'Perry', age: 31 }
    ],
    min,
    max;

_(collection)
    .filter(function(item) {
        return item.age >= 30;
    })
    .tap(function(coll) {
        min = _.min(coll, 'age'),
        max = _.max(coll, 'age')
    })
    .reject(function(item) {
        return item.age === max.age;
    })
    .value();
// min → { name: "Perry", age: 31 }
// max → { name: "Stuart", age: 41 }
// → 
// [
//   { name: "Priscilla", age: 37 },
//   { name: "Perry", age: 31 }
// ]

此代码将我们的集合包装起来,并过滤掉年龄低于 30 岁的项。接下来,我们使用tap()回调来设置我们的minmax变量。注意这些变量的作用域;它们是在链外部定义的,因此可以访问链中任何未来的回调。这正是我们在这里所做的——我们拒绝任何age属性等于找到的最大年龄的项。注意,如果没有在链的第一个过滤器之后计算它,max值可能会有所不同。

注意

这种方法的唯一缺点是,我们的链不再是紧密封装的单元,不能在代码中移动。然而,这种权衡是我们可以优雅地即时计算链所需的值。无论如何,要注意的是,不同的编程风格可能更倾向于一个方向而不是另一个方向。

注入值

操作链在运行时返回的内容的另一种方法是使用thru()函数。它的工作方式与tap()类似,但这个函数返回的任何内容都成为新的值:

var collection = _.range(1, _.random(11)),
    result;

result = _(collection)
     .thru(function(coll) {
         return _.size(coll) > 5 ? coll : [];
     })
     .reverse()
     .value();

_.isEmpty(result) ? 'No Results' : result.join(',');
// → "No Results"

这个链式操作是通过使用thru()函数回调来启动的,以验证集合的最小大小。如果它小于5,我们甚至不需要麻烦——我们只需返回一个空数组。返回一些可以与剩余链式函数一起使用的东西很重要,而空数组非常适合。我们只是使用thru()来声明任何小于5的长度应该与空数组具有相同的意义。这个函数实际上是一个注入这些细微业务规则的理想位置,这些规则通常在代码编写后很久才会显现出来。

键和值

现在是时候将我们的注意力转向对象键和值,以及它们如何在函数调用链中使用。通常,这些涉及将一个普通对象包装在 Lo-Dash 实例中,然后使用keys()values()函数来启动处理。也有时候,你有一组对象,只想处理某些属性值。为此,有pick()omit()函数可以在链中使用。

过滤后的键和值

我们可以在链式操作中的后续步骤使用过滤后的对象键数组的结果。当我们不确定哪些键可用,只有最佳猜测时,这非常有用。让我们尝试通过键和值进行过滤:

var object = {
    firstName: 'Jerald',
    lastName: 'Wolfe',
    age: 49
};

_(object)
    .keys()
    .filter(function(item) {
        return (/name$/i).test(item);
    })
    .thru(function(items) {
        return _.at(object, items);
    })
    .value();
// → [ "Jerald", "Wolfe" ]

我们正在包装的对象有两个以name结尾的属性名。因此,我们将keys()函数作为链式操作的第一步来抓取所有键,然后过滤掉不以name结尾的键。接下来,我们使用thru()函数返回与我们的键过滤结果相对应的对象属性值。对于对象属性值,也可以进行类似操作,尤其是在不需要使用键的情况下。让我们看看一个例子:

var object = {
    first: 'Connie',
    last: 'Vargas',
    dob: new Date(1984, 08, 11)
};

_(object)
    .values()
    .filter(_.isDate)
    .map(function(item) {
        return item.toLocaleString();
    })
    .value();
// → [ "9/11/1984, 12:00:00 AM" ]

这个链式操作会抓取被包装对象的属性值,并过滤掉所有非日期的项。然后,找到的Date对象会被映射到一个字符串数组中。

忽略和选择属性

在链中挑选某些对象属性使用,以及那些要忽略的属性,有其用途,尤其是在包装的值是一个普通对象,并且基于某些标准,有一些属性我们不想使用时。例如,我们可能有一个想要转换成索引对象的集合,但在过程中,我们需要挑选或忽略某些应该或不应该存在的值,如下面的例子所示:

var collection = [
    { first: 'Tracey', last: 'Doyle', age: 40 },
    { first: 'Toby', last: 'Wright', age: 49 },
    { first: 'Leonard', last: 'Hunt', age: 32 },
    { first: 'Brooke', last: 'Briggs', age: 32 }
];

_(collection)
    .indexBy('last')
    .pick(function(value) {
        return value.age >= 35;
    })
    .transform(function(result, item, key) {
        result[key] = _.omit(item, 'last');
    })
    .value();
// → 
// {
//   Doyle: { first: "Tracey", age: 40 },
//   Wright: { first: "Toby", age: 49 }
// }

这段代码通过last属性值对对象数组进行索引。链式操作的下一步是只选择age大于34的项目。最后,由于每个项目都是通过姓氏索引的,我们不再需要last属性,因此transform()函数使用omit()来为每个项目移除它,这是链式操作的最后一个步骤。

返回包装器

包装器和随后的函数调用链并不是随机存在于我们的代码中的。下一章将更深入地探讨这个主题,所以这可以被视为一个预告。到目前为止,我们只看了链在同一个语句中构建和执行的情况。然而,如果我们费尽心思设计一个具有通用目的的调用链,那么不持续组装这个链不是更好吗?让我们用以下代码来设计这个链:

function best(coll, prop, count) {
    return _(coll)
        .sortBy(prop)
        .takeRight(count);
}

var collection = [
    { name: 'Mathew', score: 92 },
    { name: 'Michele', score: 89 },
    { name: 'Joe', score: 74 },
    { name: 'Laurie', score: 83 }
];

var bestScore = best(collection, 'score', 2);

bestScore.value();
// → 
// [
//   { name: "Michele", score": 89 },
//   { name: "Mathew", score: 92 }
// ]

bestScore.reverse().value();
// → 
// [
//   { name: "Michele", score: 89 },
//   { name: "Mathew", score: 92 }
// ]

bestScore.pluck('name').value();
// → [ "Michele", "Mathew" ]

这里定义的 best() 函数返回一个 Lo-Dash 包装器实例。注意,在 best() 内部,我们实际上正在链式调用函数,但它们实际上并没有被调用,这意味着 best() 的返回值是一个包装器。这可以通过 bestScore 变量来说明,它持有包装器实例。这个包装器可以一次又一次地被调用,而无需重新构建函数调用链。尽管如此,如果我们需要稍微调整链,我们可以在其基础上构建。我们通过调用 reverse()pluck() 来对 bestScore 进行这样的操作。

摘要

本章介绍了包装值的概念以及应用于它们的函数调用链。这种多才多艺的编程模型,是 Lo-Dash 的基础,有助于使用紧凑且易于阅读的代码构建复杂的功能块。链式调用并不仅限于 Lo-Dash,它们在许多其他库中也很受欢迎,也许在 jQuery 中最为常见。

应用程序面临着过滤数据的艰巨任务——大量的数据和大量的硬约束。我们不是创建带有许多临时变量的混乱代码,而是提出了几种使用链构建复杂过滤器的方法。之后,我们探讨了使用链测试真值条件。这些就像过滤器一样,除了它们不返回集合结果,而是只返回表示为布尔值的真值陈述。我们还探讨了如何在函数调用链之后计数项目。

我们还学到了另一个基本实践,那就是将集合转换成更适合特定上下文的替代、更易用的表示形式。就像过滤一样,使用链来转换集合通常更好,因为它减少了你需要编写的代码量。

我们以探讨你的函数如何返回不一定立即执行的包装器来结束本章。这是我们在下一章中构建可重用 Lo-Dash 组件的下一步。

第六章:应用构建块

上一章讨论了 Lo-Dash 的关键功能——包装值和执行链式函数调用。这种编程风格的一个好处是能够构建更大的功能单元,这些单元是通用和可移植的。我们在上一章处理包装实例时看到了通用性和可移植性的影子。本章的目标是实现这些想法。编写一个应用程序不仅仅是抛出过滤器和映射,以获得你需要的数据。如果你反复写相同的内容,你的代码很快就会变得混乱。

在本章中,我们将学习如何编写内部使用 Lo-Dash API 的通用函数。我们还将通过探索它们如何像拼图一样组合在一起,以及最终为你的应用程序提供一个坚实的基础,将链式函数调用进一步推进。当你为你的应用程序编写的函数属于 Lo-Dash 基础设施时,就到了这一点。也就是说,你需要将你自己的通用代码与 Lo-Dash API 混合。我们也将处理这一点。

在本章中,我们将涵盖以下主题:

  • 通用函数

  • 通用包装器和链

  • 函数组合

  • 创建混入

通用函数

创建通用函数可以在我们代码的大小和可理解性方面产生重大差异。通用函数在多个上下文中都很有用。它与应用程序松散耦合。这就是高级构建块的全部内容;无论我们是在使用函数式编程模型、更面向对象的方法,还是两者的混合,关键在于通用组件。与编程的许多其他方面一样,Lo-Dash 提供了许多构建通用组件的途径。我们将在本章的整个过程中探讨其中许多途径。让我们从查看不那么通用的函数以及它们与更灵活的表亲如何比较开始。

特定函数

一个函数是否适合单一目的,以及它是否只适合单一目的,并不总是那么明确。根据你的视角,存在不同程度的特定性。考虑以下函数:

var collection = [
    { name: 'Ronnie', age: 43 },
    { name: 'Ben', age: 19 },
    { name: 'Sharon', age: 25 },
    { name: 'Melissa', age: 29 }
];

function collectionNames() {
    return _.map(collection, 'name');
}

function indirectionNames(coll, prop) {
    return _.map(coll, prop);
}

function genericCollNames(coll) {
    return _.map(coll, 'name');
}

function genericPropNames(prop) {
    return _.map(collection, prop);
}

collectionNames();
indirectionNames(collection, 'name');
genericCollNames(collection);

genericPropNames('name');
// → [ "Ronnie", "Ben", "Sharon", "Melissa" ]

这四个函数都产生相同的结果,但它们在我们的应用中的实现方式各有其独特的后果。我们可以评估每个函数的两个通用属性。首先,我们看看正在转换的集合——主要操作数。此外,还有传递的次要参数,它们会影响结果。

collectionNames()函数相当具体,因为它期望在其作用域中有一个collection变量,并且将name参数硬编码为传递给map()的参数。indirectionNames()函数则相反——它是完全通用的,因为它接受集合和属性参数,但它也是完全无意义的,因为它只是一个代理,我们直接调用map()也行。genericCollNames()函数很有趣;我们用这个函数映射的集合是通用的,因为它作为参数传递,而name参数是硬编码的。最后,genericPropNames()函数在硬编码集合时使用了一个通用参数。

注意

在定义函数时,要记住考虑每个极端——从间接到完全硬编码——。这两种极端都几乎不值得追求,而中间地带则是我们应努力达到的目标。至于你硬编码什么,保持什么通用,每个都有独特的权衡,取决于你正在构建的内容。随着应用程序的发展,你经常会发现自己需要调整这些设置。

通用函数参数

JavaScript 允许我们在定义函数时有一定的自由度。并非所有参数都需要在事先静态声明,就像在其他语言中那样。arguments对象可以帮助我们,尤其是在我们试图保持通用性时。例如,某些调用者可能不会传递所有参数。这没关系,我们的函数可以应对这种情况,我们可以利用这种能力来定义更好的函数,这些函数可以更通用地与 Lo-Dash API 交互,如下面的代码所示:

function insert(coll, callback) {
    var toInsert;

    if (_.isFunction(callback)) {
        toInsert = _.slice(arguments, 2);
    } else {
        toInsert = _.slice(arguments, 1);
        callback = _.identity;
    }

    _.each(toInsert, function(item) {
        coll.splice(_.sortedIndex(coll, item, callback), 0, item);
    });

    return coll;

}

var collection = _.range(1, 11);

insert(collection, 8.4);
// → [ 1, 2, 3, 4, 5, 6, 7, 8, 8.4, 9, 10 ]

insert(collection, 1.1, 6.9);
// → [ 1, 1.1, 2, 3, 4, 5, 6, 6.9, 7, 8, 8.4, 9, 10 ]

insert(collection, 4, 100);
// → [ 1, 1.1, 2, 3, 4, 4, 5, 6, 6.9, 7, 8, 8.4, 9, 10, 100 ]

insert()函数接受collcallback参数。集合总是必需的,但回调是可选的。如果没有提供回调,它默认为identity()函数。

在这里还有一些额外的技巧,因为函数中提供的任何其他参数都是要插入到集合中的目标。我们使用slice()函数将这些参数放入toInsert变量中,并且根据是否提供了回调函数,我们以不同的方式切片它们。然后,只需遍历每个要插入的参数值,并将我们的回调传递给sortedIndex()函数。

注意

将回调值设置为identity()在这里并不是严格必要的。这是大多数接受回调的 Lo-Dash 函数的默认行为。明确指定也不会有害,尤其是如果我们不想使用相同的默认函数。

使用部分应用

解决出现的一般参数问题的便捷模式是使用部分应用,即使用partial()函数部分应用函数参数。这让我们可以在运行时构建可以重复使用的函数,而无需始终应用相同的参数。有时甚至无法提供函数参数。以下是一个使用部分应用的示例:

var flattenProp = _.compose(_.flatten, _.prop),
    skills = _.partialRight(flattenProp, 'skills'),
    names = _.partialRight(flattenProp, 'name');

var collection = [
    { name: 'Danielle', skills: [ 'CSS', 'HTML', 'HTTP' ] },
    { name: 'Candice', skills: [ 'Lo-Dash', 'jQuery' ] },
    { name: 'Larry', skills: [ 'KineticJS', 'Jasmine' ] },
    { name: 'Norman', skills: [ 'Grunt', 'Require' ] }
];

_.contains(skills(collection), 'Lo-Dash');
// → true
_.contains(names(collection), 'Candice');
// → true

我们的flattenProp()函数是flatten()prop()的组合。返回的结果是一个扁平化的数组。因此,如果这些属性值本身是数组,它们就会被添加到单个数组中。

在我们的应用中,当使用的数据模型在实体之间共享许多属性时,没有必要总是提供需要扁平化的属性名称。这正是使用部分函数的完美案例。记住,部分函数并非完全静态——它们最终还是会返回函数。我们的代码定义了两个带有预应用prop参数的部分函数。稍后,我们可以使用这个函数与特定的集合一起使用。

注意

从通用函数创建部分函数是一种函数组合的形式,是构建高级应用组件的关键工具。

通用回调

设计一个在代码中手动调用的函数是一回事。然而,回调对于 Lo-Dash 来说至关重要。因此,始终考虑我们的函数可能被用作回调的事实是值得的,如下面的代码所示:

var YEAR_MILLISECONDS = 31560000000;

function validItem(item) {       
    return item.age > 21 &&
        _.isString(item.first) &&
        _.isString(item.last);
}

function computed(item) {
    return _.extend({
        name: _.result(item, 'first', '') + ' ' +
            _.result(item, 'last', ''),
        yob: new Date(new Date() - (YEAR_MILLISECONDS * item.age))
            .getFullYear()
    }, item);
}

var invalidItem = _.negate(validItem);

    { first: 'Roderick', last: 'Campbell', age: 56 },
    { first: 'Monica', last: 'Salazar', age: 38 },
    { first: 'Ross', last: 'Andrews', age: 45 },
    { first: 'Martha', age: 51 }
];

_.every(collection, validItem);
// → false

_.filter(collection, validItem);
// →
// [
//   { first: "Roderick", last: "Campbell", age: 56 },
//   { first: "Monica", last: "Salazar", age: 38 },
//   { first: "Ross", last: "Andrews", age: 45 }
// ]

_.find(collection, invalidItem);
// → { first: "Martha", age: 51 }

_.map(collection, computed);
// →
// [
//   {
//     name: "Roderick Campbell",
//     yob: 1958,
//     first: "Roderick",
//     last: "Campbell",
//     age: 56
//   }, {
//     name: "Monica Salazar",
//     yob: 1976,
//     first: "Monica",
//     last: "Salazar",
//     age: 38
//   }, {
//     name: "Ross Andrews",
//     yob: 1969,
//     first: "Ross",
//     last: "Andrews",
//     age: 45
//   }, {
//     name: "Martha ",
//     yob: 1963,
//     first: "Martha",
//     age: 51 }]

在此代码中定义的第一个回调是validItem(),这是一个极其有用的函数,因为在很多情况下,你可能只对有效的项目感兴趣。这个函数接受一个通用的item参数,如果该参数满足某些条件,则返回true。这是对集合迭代应用回调的理想格式。第二个回调是computed(),它也接受一个通用的item参数。这个回调在映射场景中很有用,因为它返回一个包含计算属性的项目的扩展版本。这里还有一个第三个回调——invalidItem()。这是validItem()函数的逆函数,我们可以使用negate()来创建它。

注意

你可能已经注意到,我们很多回调函数都使用item作为第一个命名参数。这是一个好的实践,因为它给代码的读者一个很好的提示,即给定的函数可能被用作某个地方的回调。

通用包装器和链

在掌握了通用函数之后,我们现在可以将注意力转向 Lo-Dash 包装器实例,并创建通用函数调用链。链在陷入困境且需要快速摆脱复杂编程情况时很有用,但它们在通用意义上也很有用。也就是说,你可以组合足够通用的功能链,以便在多种上下文中应用。

通用过滤器

让我们先看看通用过滤器以及它们如何在我们的函数中发挥作用。过滤器特别适合链式函数调用,因为它们可以通过在先前的过滤器之后应用另一个过滤器来连接起来。在过滤器的末尾通常会有一些排序或其他约束,例如限制返回的结果数量,如下面的代码所示:

function byName(coll, name, take) {
    return _(coll)
        .filter({ name: name })
        .take(_.isUndefined(take) ? 100 : take)
        .value();
}

var collection = [
    { name: 'Theodore', enabled: true },
    { name: 'Leslie', enabled: true },
    { name: 'Justin', enabled: false },
    { name: 'Leslie', enabled: false }
];

byName(collection, 'Leslie');
// →
// [
//   { name: "Leslie", enabled: true },
//   { name: "Leslie", enabled: false }
// ]

byName(_.filter(collection, 'enabled'), 'Leslie');
// →
// [ { name: "Leslie", enabled: true } ]

byName(_(collection).filter('enabled'), 'Leslie');
// →
// [ { name: "Leslie", enabled": true } ]

我们的 byName() 函数包装了传入的集合,并应用了一个 filter() 和一个 take() 操作。它还接受一些参数。name 参数是我们过滤集合的名称。take 参数是可选的,如果提供,则指定要返回的项目数量。如果 take 参数缺失,我们默认为 100

在前面的代码中演示了三种不同的 byName() 调用。第一次调用是最直接的。我们只是传递了名称 Leslie,因为这是我们想要过滤集合的名称。接下来的调用在集合上执行了一个 filter() 操作,然后将结果传递给 byName()。最后的调用与第二个调用得到相同的结果。然而,你会注意到我们已经包装了集合,由于 filter() 函数是可链式的,包装器实例被作为 coll 参数传递。

注意

重新包装 Lo-Dash 包装器是安全的。构造函数能够识别这一点,并知道如何处理它。

这个函数构建了一个相对通用的链。我们可以在运行时传入集合以及我们想要过滤的名称值。我们甚至可以传入我们想要获取的结果数量,而函数并不关心它是否得到一个包装的值。这一点尤其有用,因为它允许我们使用我们开发的执行链式函数调用的其他函数,返回这些链,并使用它们。byName() 函数的限制因素在于它调用 value(),并返回未包装的集合。

返回链

总是让我们的构造包装器的函数返回相同的包装器实例几乎总是一个好主意。在上一节中,我们的函数在调用链完成之后解包了值,并返回了它。这种方法的缺点是调用者可能还有更多操作要应用在链上。为了做到这一点,值需要再次被包装。Lo-Dash 包装器实例应该有自由在你的代码中移动,并从函数传递到函数,就像它是一个普通的数组一样,如下面的示例所示:

function sort(coll, prop, desc) {
    var wrapper = _(coll).sortBy(prop);
    return desc ? wrapper.reverse() : wrapper;
}

var collection = [
    { first: 'Bobby', last: 'Pope' },
    { first: 'Debbie', last: 'Reid' },
    { first: 'Julian', last: 'Garcia' },
    { first: 'Jody', last: 'Greer' }
];

sort(collection, 'first').value(),
// →
// [
//   { first: "Bobby", last: "Pope" },
//   { first: "Debbie", last: "Reid" },
//   { first: "Jody", last: "Greer" },
//   { first: "Julian", last: "Garcia" }
// ]

sort(collection, 'first', true).value(),
// →
// [
//   { first: "Julian", last: "Garcia" },
//   { first: "Jody", last: "Greer" },
//   { first: "Debbie", last: "Reid" },
//   { first: "Bobby", last: "Pope" }
// ]

sort(collection, 'last')
    .takeRight(2)
    .pluck('last')
    .value();
// → [ "Pope", "Reid" ]

sort() 函数相当简单,看起来并没有做太多。表面上,它只是接收一个集合,对其进行排序,然后返回它。是的,这是高层次的目标。首先,你会注意到 coll 参数被包装在 Lo-Dash 构造函数中——参数值可以是包装器实例或未包装的值。该函数还接受一个属性名称或一个用于排序集合的函数回调。desc 参数是可选的,如果为 true,则反转排序顺序。

与我们之前实现的 byName() 函数相比,这个函数的主要区别在于 sort() 总是返回一个包装实例。这意味着如果调用者需要向链中添加更多函数调用,我们不需要重新包装返回的值。你可以在前面的代码中看到这一点,即 sort() 的最后调用。在这里,我们向链中添加了 takeRight()pluck() 调用。以这种方式设计函数使我们能够在整个代码中使用包装器具有很大的灵活性。一般规则是让你的函数对包装器友好,无论是它们接受的参数还是它们返回的内容。

看起来,这种权衡是调用者不仅需要调用你的函数,还需要调用 value() 函数。有时,如果你只想获取实际值以便开始工作,这可能会很麻烦,但请记住,直到调用 value() 函数,链本身并不会执行。这涉及到延迟求值,简单来说就是返回值不会在调用 value() 之前计算。所以这实际上可能是一个期望的特性——能够在不执行它们的情况下构建链。

注意

你应该始终以某种形式记录你的函数确实返回一个 Lo-Dash 包装器,并且调用者需要调用 value()

函数组合

不论我们的函数是手动调用,还是作为另一个函数的回调使用,或者在涉及链的其他上下文中使用,函数组合有助于构建更大的功能块。例如,我们可能有两个较小的函数,它们各自服务于特定的目的。当我们处于可能需要这些函数的场景时,我们可以使用 Lo-Dash 中的函数工具来组合一个新的函数,利用它们,而不是自己从头开始编写。

组合泛型函数

在本章的早期,我们强调了函数需要是通用的,如果它们要在多个上下文中提供帮助。当组合较小的函数的较大组件时,这个想法同样适用。如果我们要用它们来组合任何更大的东西,较小的函数需要是通用的。同样,组合也应该尽可能通用,这样我们就可以将其用作应用程序更大块的一部分的成分。以下代码展示了这一点:

function enabledIndex(obj) {
    return _.transform(obj, function(result, value, key) {
        result[key] = _.result(value, 'enabled', false);
    });
}

var collection = [
    { name: 'Claire', enabled: true },
    { name: 'Patricia', enabled: false },
    { name: 'Mario', enabled: true },
    { name: 'Jerome', enabled: false }
];

var indexByName = _.partialRight(_.indexBy, 'name'),
    enabled = _.partial(_.flow(indexByName, enabledIndex),
    collection);

enabled();
// →
// {
//   Claire: true,
//   Patricia: false,
//   Mario: true,
//   Jerome: false
// }

collection.push({ name: 'Gloria', enabled: true });
enabled();
// →
// {
//   Claire: true,
//   Patricia: false,
//   Mario: true,
//   Jerome: false,
//   Gloria: true
// }

这段代码有两个泛型实用函数。indexByName() 函数接受一个集合,并返回一个对象,其中键是集合中每个项目的 name 属性。enabledIndex() 函数接受一个对象,并根据其 enabled 属性将每个属性值转换为布尔值。也许,这些函数在其他地方的应用中单独使用,但现在,在开发新组件的过程中,我们找到了一个需要将它们一起使用的用例。

而不是必须独立地调用每个函数并将第一个函数的输出传递给第二个函数,我们决定编写一个enabled()函数。这样,每次我们需要将名称映射到enabled布尔值的对象结构时,都可以使用简单的调用。这是通过使用flow()函数部分应用集合参数到我们创建的函数来实现的。flow()函数将第一个参数传递给第一个函数,然后传递下一个,依此类推,并返回结果。

注意

此代码假设每个对象的name属性是唯一的。否则,这种索引方式就没有意义了。

组合回调

我们在前一节中编写的enabled()函数旨在直接由我们的代码在某个地方调用。另一方面,回调通常传递给 Lo-Dash 函数。传递内联匿名函数效果很好,除非你发现自己一次又一次地编写相同的回调函数,或者至少是略有差异的类似函数。没有理由我们的应用程序不能组合通用回调并在整个应用程序中使它们可用,以鼓励重用而不是复制。让我们看看这个例子:

var collection = [
    { first: 'Andrea', last: 'Stewart', age: 28 },
    { first: 'Clarence', last: 'Johnston', age: 31 },
    { first: 'Derek', last: 'Lynch', age: 37 },
    { first: 'Susan', last: 'Rodgers', age: 41 }
];

var minimal = _.flow(_.identity,
    _.partialRight(_.pick, [ 'last', 'age' ]));

_.map(collection, minimal);
// →
// [
//   { last: "Stewart", age: 28 },
//   { last: "Johnston", age: 31 },
//   { last: "Lynch", age: 37 },
//   { last: "Rodgers", age: 41 }
// ]

回调函数面临的一个常见问题是它们无法控制自己的调用方式。一个函数可能使用单个参数调用其每个回调,而下一个则使用三个参数。这阻止了我们做某些我们本来想做的事情,例如使用已经构建并准备就绪的 Lo-Dash 函数组合回调。

在此代码中定义的minimal()函数用于从传入的参数中仅选择必要对象属性。假设我们想将此回调传递给map()。嗯,map()使用三个参数调用其回调,第一个参数是我们真正感兴趣的实际项。这意味着我们几乎不可能使用部分应用参数作为回调的 Lo-Dash 函数。

我们使用minimal()回调采用的解决方案是使用flow()来组合回调。你会注意到第一个函数是identity()。这个函数除了返回传递给它的任何值外,什么都不做。换句话说,它返回第一个参数。在流中下一个是我们的部分函数,它使用pick()。而且你知道吗?它只会收到一个参数,正如我们所需要的,即使在使用带有三个参数的map()回调时。

组合链

现在,我们将探讨如何组合与链一起工作的函数。正如我们在本章中看到的,对于您和任何使用您函数的人来说,使函数接受的参数和返回值灵活是有益的。例如,接受包装实例并返回包装实例的函数意味着它可以传递几乎所有内容,调用者可以自由地扩展调用链。就像我们可以组合普通函数和回调函数一样,我们也可以组合主要关注与链一起工作的函数。请看以下示例:

function sorted(wrapper) {
    return _(wrapper).sortBy();
}

function rejectOdd(wrapper) {
    return _(wrapper).reject(function(item) {
        return item % 2
    });
}

var sortedEvens = _.flow(sorted, rejectOdd),
    evensSorted = _.flow(rejectOdd, sorted,
        _.partialRight(_.result, 'value')),
    collection = _.shuffle(_.range(1, 11));

sortedEvens(collection)
    .reverse()
    .value();
// → [ 10, 8, 6, 4, 2 ]

evensSorted(collection);
// → [ 2, 4, 6, 8, 10 ]

这里 rejectOdd() 函数接受一个集合或包装实例作为第一个参数,并过滤掉奇数。请注意,它返回一个包装实例而不是未展开的值。我们使用这个对包装友好的函数来组合两个新的函数。第一个是 sortedEvens(),它使用我们的 sorted() 函数对集合进行排序。这返回一个包装实例,然后将其传递给 rejectOdd() 函数。evensSorted() 函数执行类似操作,但顺序不同。它在排序之前拒绝奇数,然后使用 partialRight() 通过 result() 函数展开值。

您可以看到,当我们调用 sortedEvens() 函数时,它返回一个包装实例,因为我们通过 reverse() 扩展了函数调用链,然后我们获取值。然而,我们不会用我们的组合 evensSorted() 函数执行这种扩展,因为它为我们展开了值。

方法组合

有时,将函数附加到特定对象作用域或实现方法是有意义的。如果函数需要特定实例的值才能操作,那么将其作为原型的属性实现可能是个好主意,这样它就始终可用于实例。因此,我们可以使用本章中讨论的技术来帮助我们构建对象方法,这些方法是我们应用程序结构中的另一个构建块。请看以下对象方法的示例:

function validThru(next, value) {
    return value && next;
}

function User(first, last, age) {
    this.first = first;
    this.last = last;
    this.age = age;
}

User.prototype.valid = function() {
    return _.chain(this.first)
        .isString()
        .thru(_.partial(validThru, this.last))
        .isString()
        .thru(_.partial(validThru, this.age))
        .isFinite()
        .value();
}

new User('Orlando', 'Olson', 25).valid();
// → true

new User('Timothy', 'Davis').valid();
// → false

new User('Colleen').valid();
// → false

我们的 User 构造函数接受三个参数,并且所有这些都被设置为实例值。我们还实现了一个 valid() 方法。在这里,我们正在使用函数调用链来验证每个实例属性。请注意,我们在这里启用了显式链式调用。这意味着链中的函数通常返回未展开的值,但不会这样做。我们这样做是因为我们需要通过链传递原始值。

first 属性被包装,我们使用 isString() 函数验证它是否为字符串。接下来,我们使用 thru()。在这里,我们使用我们的 validThru() 函数作为 thru() 的回调。基本上,如果 isString() 返回的值(上一个调用)是 true,则返回下一个值。在这种情况下,它部分应用为 last 属性。对 age 属性执行相同的步骤。

这种方法的优点在于,链式调用需要访问几个属性,并且所有这些属性都包含在方法内部。然后我们可以构建一个可读的链式调用,验证所有这些属性,而不需要多个控制流语句,这比链式调用中的两行代码更难维护。

创建 mixins

本章我们将要访问的最后一个主要构建块是 mixin。Lo-Dash 有一个 mixin() 函数,允许我们通过提供自己的函数来扩展 API。你想要这样做有两个原因。第一个原因是通过将你的通用工具集放在 Lo-Dash 对象中,你可以在 _ 符号可访问的地方使用它们。第二个原因是,一旦你混合了自己的函数,它就可以作为函数调用链的一部分使用。

创建一个 average() 函数

一个库如 Lo-Dash 能够实际提供的实用工具是有限的。那些被认为对普通用户最有用的工具是我们默认提供的。这并不意味着你正在开发的应用程序没有高价值的使用案例,你希望 Lo-Dash 实现这个功能。例如,假设你的应用程序到处都在计算平均值。虽然库中没有提供 average 函数,但这并不意味着我们不能将这个函数添加到代码中:

_.mixin({average: function(coll, callback) {
    return _(coll)
        .map(callback)
        .reduce(function(result, item) {
            return result + item;
        }) / _.size(coll);
}});

var collection = [
    { name: 'Frederick', age: 41, enabled: true },
    { name: 'Jasmine', age: 29, enabled: true },
    { name: 'Virgil', age: 47, enabled: true },
    { name: 'Lila', age: 22, enabled: false }
];

_.average(collection, 'age');
// → 34.75

_.average(collection, function(item) {
    return _.size(item.name);
});
// → 6.5

_(collection)
    .filter('enabled')
    .average('age');
// → 39

在我们的 average() mixin 中进行的计算非常简单——将项目除以集合的长度。我们需要考虑的是这些项目的映射。如果你查看 average() mixin 接受的参数,你会注意到它需要一个集合,这是必需的,还有一个回调函数。回调函数是可选的,可以是任何作为 map() 回调接受的函数。然后我们的链式调用在除以集合大小时,将这些项目减少到总和。

你可以看到,average() 函数现在是 Lo-Dash 对象的一部分,并且我们可以传递一个字符串参数或一个函数回调。你还可以看到,该函数是链式可用的,如最后调用所示。

创建一个 distance() 函数

让我们创建一个更复杂的 mixin 函数,称为 distance。它将使用 Levenshtein 距离算法来测量两个字符串之间的编辑距离。我们将创建另一个使用 distance() 的 mixin。这个函数将根据与目标字符串的最短距离对集合进行排序:

_.mixin({distance: function(source, target) {
    var sourceSize = _.size(source),
        targetSize = _.size(target),
        matrix;

    if (sourceSize === 0) {
        return targetSize;
    }
    if (targetSize === 0) {
        return sourceSize;
    }

    matrix = _.map(_.range(targetSize + 1), function(item) {
        return [ item ];
    });

     _.each(_.range(sourceSize + 1), function(item) {
        matrix[0][item] = item;
    });

    _.each(target, function(targetItem, targetIndex) {
        _.each(source, function(sourceItem, sourceIndex) {
            if (targetItem === sourceItem) {
                matrix[targetIndex + 1][sourceIndex + 1] =
                    matrix[targetIndex][sourceIndex];
            } else {
                matrix[targetIndex + 1][sourceIndex + 1] = Math.min(
                    matrix[targetIndex][sourceIndex] + 1,
                    Math.min(matrix[targetIndex + 1][sourceIndex] + 1,
                        matrix[targetIndex][sourceIndex + 1] + 1));
            }
        });
    });

    return matrix[targetSize][sourceSize]

}});

_.mixin({closest: function(coll, value, callback) {
    return _.sortBy(coll, _.flow(_.callback(callback), function(item) {
        return _.distance(value, item);
    }));
}});

var collection = [
    'console',
    'compete',
    'competition',
    'compose',
    'composition'
];

_.distance('good', 'food');
// → 1

_.closest(collection, 'composite');
// →
// [
//   "compose",
//   "compete",
//   "composition",
//   "console",
//   "competition"
// ]

_(collection)
    .closest('consulate')
    .first();
// → "console"

我们不会专注于 Levenshtein 距离算法的细节;关于这一点,网上有大量的资源。我们刚刚实现的 distance() mixin 接受一个 source 字符串和一个 target 字符串,用于进行比较。返回值表示使目标匹配源所需的编辑次数。例如,前面代码中 distance() 的调用结果为 1

closest() 混合函数使用 distance() 作为 sortBy() 回调函数。这是一个有用的函数,因为它通常是我们比较源字符串的目标字符串集合。此外,由于它是一个混合函数,我们能够将其用于链式调用。closest() 的最后调用执行此操作,然后使用 first() 获取最接近的值。

摘要

在本章中,我们学习了一些构建高级应用程序组件的有用方法。函数是 Lo-Dash 编程的基本单元,因此正确利用它们提供的一切非常重要。我们讨论了在设计可重用函数时遇到的一些常见问题。例如,函数的特异性和它接受的参数可以影响函数在 Lo-Dash 代码中的使用位置和方式。

通用包装器和它们实现的链式函数调用是强大的工具,并且它们附带了许多实现选项。我们探讨了链的不同方面的几个示例,以及这些包装器如何与我们的应用程序中的各种函数交互。

函数组合是函数式编程的一个基本组成部分,我们学习了如何利用 Lo-Dash 提供的函数式工具来组合更大的应用程序代码片段。这包括我们手动调用的通用函数和回调函数。本章以查看用于扩展 Lo-Dash API 的混合函数结束。下一章将向您展示如何使用这些应用程序级别的 Lo-Dash 组件,并确保它们与其他库良好地协同工作。

第七章:与其他库一起使用 Lo-Dash

前一章向我们展示了我们的 Lo-Dash 代码在更大的应用中定位后的样子。事物被分解成更通用的、可重用的形式,并且命名和结构保持一致。模式开始显现,随着你的代码开始实现这些模式,它将呈现出一个生产就绪系统的形态。

在将 Lo-Dash 代码部署到生产环境的精神下,本章讨论了当我们的代码被认为稳定时被投入其中的生态系统。Lo-Dash 做了很多事情,但有一些任务这个库并不适合。换句话说,Lo-Dash 很可能不是你应用程序唯一使用的库。

保持我们的代码组织和模块化的最佳方式是使用模块。模块技术在过去的几年中在 JavaScript 社区中受到了很多关注,Lo-Dash 也不例外。我们可以使用应用程序使用的相同模块加载器来实现它。还有适用于 Node.js 的 Lo-Dash 包。

在本章中,我们将涵盖以下主题:

  • 模块

  • jQuery

  • Backbone

  • Node.js

模块

如果你过去几年中做过任何前端开发,你可能已经听说过 AMD 模块,即使你还没有尝试过它们。AMD 发展迅速,全球范围内有大量的生产部署。前端开发中的这种模块化运动源于缺乏组织具有许多依赖的大型项目的合理方式。在 Web 模块出现之前,我们用来组织依赖的唯一工具是 <script> 元素。这仍然是一种被接受的方式来引入 JavaScript 代码——除非有数百个模块。

模块化,尤其是前端网络开发模块化,是一个很大的话题——对于这本书(更不用说本章)来说,太大而不可能充分讨论。所以让我们将这个话题简化为我们 Lo-Dash 程序员相关的内容。将我们的代码分解成只服务于单一目的的模块是良好的编程实践。这提供了良好的关注点分离,并允许我们的组件更容易地独立发展。

在本节中,我们将探讨 RequireJS,这是可用的主要 AMD 模块加载技术之一。Lo-Dash 有构建来帮助我们利用这项技术并构建我们自己的模块。话虽如此,让我们深入了解一些实际细节。

注意

AMD 代表 异步模块定义,这是许多 JavaScript 组件遵循的一个简单模式。虽然它不是一个公认的规范,但在即将到来的 ES6 规范中有所酝酿。还有一个相关的模式称为 UMD,它旨在比 AMD 更通用,并有一些值得怀疑的回退模式。我的建议是坚持使用易于使用的东西,比如 RequireJS,直到有一个被广泛支持的采用标准。

使用模块组织你的代码

让我们看看一个基本模块的样子。想法是定义函数返回模块定义的组件。组件可以是函数、对象、字符串或任何值。如果你正在开发 Lo-Dash 应用程序,你的模块很可能会返回函数。

注意

由于 RequireJS 会发起 XHR 请求,因此使用简单的静态 Web 服务器来提供 JavaScript 模块要容易得多。这本书附带的一些代码有一个Gruntfile,它允许你运行一个简单的 Web 服务器。然而,你需要安装 Node.js。互联网上有许多资源可以帮助你在任何平台上安装 Node。一旦 Node 可用,你可以使用以下命令安装 Grunt:

npm install -g grunt-cli grunt-contrib-connect

这将在你的系统上使grunt命令可用。在根代码目录内,Gruntfile.js文件所在的位置,运行以下命令:

grunt connect

你会看到一些关于服务器无限运行的信息。按下Ctrl + C将停止它。就这样!你可以导航到http://0.0.0.0:8000/chapter7.html来运行本章的示例。

考虑以下代码:

define([], function() {
    return function(coll, filter) {
        return _(coll)
            .filter(filter)
            .reduce(function(result, item) {
                return result + item.age;
            }, 0) / _.size(coll);
    };
});

你可以看到,define()函数接受两个参数。第一个是我们依赖的模块数组,第二个是返回该模块定义的组件的函数。在这种情况下,我们的模块没有外部依赖,它返回一个匿名函数。这个函数接受一个coll和一个filter参数。然后我们使用 Lo-Dash 构造函数来包装集合,并将其缩减到平均值。接下来,让我们看看这个模块是如何被使用的:

var collection = [
    { name: 'Frederick', age: 37 },
    { name: 'Tasha', age: 45 },
    { name: 'Lisa', age: 33 },
    { name: 'Michael', age: 41 }
];

require([ 'modules/average-age' ], function(averageAge) {
    averageAge(collection);
    // → 39
});

你可以在这里看到,我们对require()函数的调用传递了一个模块依赖数组。在这种情况下,我们依赖于average-age模块。当这个模块被加载、评估并准备好使用时,函数回调会被触发。averageAge参数是模块返回的值。在这种情况下,它是我们之前定义的函数,我们展示了如何将其应用于一个集合。

引入 Lo-Dash

我们average-age模块的缺点是它没有定义任何明确的依赖。然而,它显然依赖于 Lo-Dash 可用。那么这段代码是如何工作的呢?_变量在哪里定义的?好吧,前面的例子之所以能运行,仅仅是因为我们使用标准的<script>标签包含了 Lo-Dash。这会将_符号添加到全局命名空间中。

这违反了模块的一个基本原理——不应该需要全局变量。我们最终得到的是隐式依赖,就像前面的代码那样。这意味着使用这个隐式依赖的模块不如它们可能的那样可移植。如果那个<script>标签消失了,我们的模块就停止工作了。幸运的是,我们可以将依赖于 Lo-Dash 的模块定义为具有这个依赖的显式依赖,如下面的代码所示:

define([ 'lodash' ], function(_) {
    return function(coll) {
        return _(coll)
            .sortBy(function(item) {
                return item.first + '' + item.last;
            });
    };
});

在这里,你可以看到,我们不是将空数组作为 define() 的第一个参数传递,而是有一个指向 Lo-Dash 模块的字符串。现在 _ 符号是 define() 函数内的一个参数,而不是全局引用。现在让我们看看这个模块是如何被使用的:

var collection = [
    { first: 'Georgia', last: 'Todd' },
    { first: 'Andrea', last: 'Gretchen' },
    { first: 'Ruben', last: 'Green' },
    { first: 'Johnny', last: 'Tucker' }
];

require([ 'modules/sort-name' ], function(sortName) {
    sortName(collection).value();
    // →
    // [
    //   { first: "Andrea", last: "Gretchen" },
    //   { first: "Georgia", last: "Todd" },
    //   { first: "Johnny", last: "Tucker" },
    //   { first: "Ruben", last: "Green" }
    // ]
});

在这里,我们将 sort-name 模块作为依赖项,sortName() 函数是 require() 回调函数的参数。该函数按名称对输入集合进行排序,并返回一个包装器实例。这在这里通过调用 sortBy() 后跟 value() 来说明。这实际上是一件好事,因为它意味着返回的包装器实例可以在评估和展开之前进行扩展。

你还会注意到,我们在这里间接依赖于 Lo-Dash,因为我们依赖于 sort-name。我们可以调用 value() 函数并扩展返回的包装器,而不需要显式引用 _ 符号。这意味着,如果将来某个时刻 sort-name 模块不再依赖于 Lo-Dash,我们的函数仍然可以工作,尽管我们可能需要移除 value() 调用。

要使 Lo-Dash 与 RequireJS 一起工作,还需要进行另一个步骤。让我们看看 main.js 配置文件,它帮助 RequireJS 确定模块的位置以及它们暴露的内容:

require.config({
    paths: {
        lodash: 'lib/lodash'
    },
    shim: {
        lodash: { exports: '_' }
    }
});

我们在代码中将 lodash 作为依赖项。这个路径配置告诉 RequireJS 在哪里找到该模块。shim 配置部分用于未定义为 AMD 模块的模块。由于 Lo-Dash 就是这样,我们必须添加一个适配器,告诉 RequireJS 当某个模块被要求时实际返回什么。

使用 Lo-Dash AMD 模块

结果表明,使用 Lo-Dash 的更好方式是采用 AMD 模块的形式。Lo-Dash 提供了特定的 AMD 构建版本可供下载,这些版本不需要使用适配器。通过这种方式获取 Lo-Dash 组件的另一个好处是,如果我们只依赖少数几个函数,我们就不必下载整个库。例如,让我们看看我们如何依赖函数类别:

function Person(first, last) {
    this.first = first;
    this.last = last;
}

Person.prototype.name = function() {
    return this.first + '' + this.last;
}

var collection = [
    new Person('Douglas', 'Wright'),
    new Person('Tracy', 'Wilson'),
    new Person('Ken', 'Phelps'),
    new Person('Meredith', 'Simmons')
];

require([ 'lib/lodash-amd/collection' ], function(_) {
    _.invoke(collection, 'name');
    // →
    // [
    //   "Douglas Wright",
    //   "Tracy Wilson",
    //   "Ken Phelps",
    //   "Meredith Simmons"
    // ]
});

在这个例子中,我们使用的是 Lo-Dash 的 AMD 构建。这是通过我们要求的模块路径来体现的。collection 模块被定义为 AMD 模块,并包含所有与集合相关的函数。你可以看到我们使用 _ 符号作为函数参数。这意味着使用集合函数的代码可以像使用任何 Lo-Dash 模块一样编写。例如,如果我们要求完整的 Lo-Dash API 而不是仅集合函数,则不需要更改任何代码。相反,我们只要求 Lo-Dash 的一个子集,从而节省了网络传输成本。

这段代码在集合上使用invoke()函数调用每个项目的name()方法,同时收集结果。然而,这只是一个函数。集合类别中还有很多我们需要的但我们根本不使用的功能。让我们看看我们如何使用更细粒度的 Lo-Dash 函数依赖项:

var collection = [
    { name: 'Susan', age: 57, enabled: false },
    { name: 'Marcus', age: 45, enabled: true },
    { name: 'Ray', age: 25, enabled: false },
    { name: 'Dora', age: 19, enabled: true }
];

require([
    'lib/lodash-amd/collection/filter',
    'lib/lodash-amd/function/partial'
], function(filter, partial) {
    function valid(age, item) {
        return item.enabled && item.age >= age;
    }

    filter(collection, partial(valid, 25));
    // → [ { age: 45, enabled: true, name: "Marcus" } ]

});

正如您在深入类别时可以看到的,对于您想要包含的任何特定功能,都有一个特定的模块。前面的代码使用了两个 Lo-Dash 函数。filter()函数来自集合类别,而partial()函数来自函数类别。这两个函数都直接作为回调参数传递。由于这两个函数模块本身定义为 AMD 模块,它们只需要工作所需的内部依赖项。这意味着我们只要求我们需要的,这可能在某些上下文中只是一件或两件函数,例如我们前面的例子。

这种细粒度级别的一个缺点是,如果您不确定需要什么,您将不得不不断修改您的模型依赖项列表。Lo-Dash 有很多东西可以提供,并且最好尽可能使用 Lo-Dash 函数。例如,如果您正在包装值并将函数调用链接在一起,那么事先知道您将需要哪些函数是很困难的。因此,获取整个 API 可能是一个好主意,这样就没有可能您想要使用的某些东西不在那里。考虑以下代码:

var collection = [
    { name: 'Allan', age: 29, enabled: false },
    { name: 'Edward', age: 43, enabled: false },
    { name: 'Evelyn', age: 39, enabled: true },
    { name: 'Denise', age: 34, enabled: true }
];

require([ 'lib/lodash-amd/main' ], function(_) {
    _(collection)
        .filter('enabled')
        .sortBy('age')
        .reverse()
        .map('name')
        .value();
    // → [ "Evelyn", "Denise" ]
});

这与我们的第一个例子相似,其中我们要求整个 Lo-Dash API。这里的区别在于这是一个 AMD 构建,因此我们要求main Lo-Dash 模块,它包括我们需要的所有内容。另一个区别是,采用这种方法,在主 RequireJS 配置文件中不需要设置路径或 shim。

jQuery

jQuery 无疑是历史上最成功和最广泛使用的技术之一。它的出现是因为浏览器的不一致性;约翰·雷斯(John Resig)决定对此采取行动。与其让应用程序开发者维护自己的代码来处理所有这些平凡的跨浏览器问题,为什么不让他们让 jQuery 来处理这些事情呢?

jQuery 经过多年的发展,得益于成千上万的用户和贡献开发者希望使前端开发不那么令人畏惧。随着时间的推移,它改变了自己的一些方面,并添加了新功能,以跟上其所在环境的变化。

可以肯定地说,jQuery 已经改变了前端开发的方式,并且由于其广泛的采用,将继续这样做。今天存在的几个网络标准都根植于 jQuery。Lo-Dash 在许多方面与 jQuery 相似。虽然它远不如 jQuery 成熟,但它正在迅速被采用。像 jQuery 一样,Lo-Dash 起源于解决 Underscore.js 中存在的跨浏览器问题和性能问题的努力。Lo-Dash 的开发工作已经远远超出了 Underscore.js 的简单替换,并将肯定影响未来的 JavaScript 规范。

因此,jQuery 和 Lo-Dash 在有效性方面相似。它们之间的区别在于它们为程序员解决的具体问题。让我们更仔细地看看这些问题,看看这两个库是否可以相互补充。

Lo-Dash 面临的挑战

Lo-Dash 是一个低级框架,它在语言级别上增强 JavaScript。低级是一个相对术语。并不是说 Lo-Dash 没有任何抽象;它有很多。只是前端开发不仅仅是 JavaScript。Lo-Dash 不擅长那些其他事情,也不是为了这个目的而设计的。

虽然你可以使用 Lo-Dash 编写更好的代码,但这只是战斗的一半。独立的 JavaScript 代码并不能让你走得很远。如果你正在开发一个应用程序,在某个时候,你最终会接触到 DOM。你将进行 API 调用以加载你的应用程序数据并改变服务器端资源的状态。你还将处理这些调用的异步性质和用户事件,同时注意性能并防止泄漏。

前端开发是一项复杂的任务,但 Lo-Dash 在 JavaScript 的所有方面都表现得非常出色。编写简洁、易读且性能良好的代码正是 Lo-Dash 的强项。这通常意味着你的前端代码的核心。对于其他所有事情,还有其他库,例如 jQuery。

jQuery 面临的挑战

jQuery 对程序员如此有吸引力的一个原因是它的入门门槛低。任何构建网站的人都可以立即学习和从 jQuery 中受益,通常只需要一两天。同时,它足够强大,可以从一个基本网站扩展到强大的网络应用程序。DOM 遍历和操作是 jQuery 擅长的,但它也能处理复杂的 Ajax 调用、DOM 事件和异步回调。

这些都是 Lo-Dash 缺乏支持的领域。再次强调,这是故意的。这两个库服务于不同的目的。然而,它们也是互补的,通常在同一个应用程序中并肩作战,执行各自的职责。jQuery 没有提供一组工具来帮助程序员在响应 Ajax 请求、用户事件等所有这些回调函数中。这不是它的用途。你可以自由地使用你喜欢的任何库来增强核心应用程序的业务逻辑,而 Lo-Dash 就是这样的选择之一。

Lo-Dash 和 jQuery 的专注特性使我们能够清晰地分离关注点。jQuery 允许 Lo-Dash 程序员专注于创建高质量的函数式代码。我们已经看到了如何使用 RequireJS 与 Lo-Dash 结合来生成模块化组件。现在让我们看看如何将 Lo-Dash 与 jQuery 结合使用。

将 jQuery 实例用作集合

也许,jQuery 最常见的用例是查询 DOM 元素。结果是 jQuery 对象,它非常类似于数组。我们可以运用 Lo-Dash 的知识将这些实例视为集合。例如,让我们比较 jQuery 的 map() 函数与 Lo-Dash 的 map() 函数:

var i = 1000;
console.time('$');
while (i--) {
    $('li').map(function() {
        return $(this).html();
    });
}
console.timeEnd('$');
i = 1000;
console.time('_');
while (i--) {
    _.map($('li'), function(item) {
        return $(item).html();
    });
}
console.timeEnd('_');
// → 
// $: 64.127ms
// _: 27.434ms

这两种方法的映射输出完全相同。即使是代码差异也是微不足道的。差异仅在于循环性能——由于实现上的差异,Lo-Dash 的 map() 函数将始终优于 jQuery 的 map() 函数。

注意

下一章将更详细地介绍为什么迭代式 Lo-Dash 函数以这种方式执行。

性能提升并不算很大。这里那里多几毫秒又如何呢?前面的例子只找到了几个元素,测试重复了 1,000 次。在生产环境中,你可能会处理更大的查询结果,迭代超过 1,000 次,随着时间的推移,毫秒开始累积。

jQuery 的 map() 函数的性能是否存在根本问题?绝对没有。如果它有效,就使用它。这种变化本身不会让用户感到惊喜。另一方面,如果你是 Lo-Dash 程序员,你会用它来发挥它的长处。Lo-Dash 非常擅长遍历集合。jQuery 非常擅长查询 DOM,并且它仍然承担这个责任。那么实现这个改进的代码成本是多少?基本上为零。

函数绑定

在上一节中,我们看到了 Lo-Dash 和 jQuery 重叠的领域。我们选择 Lo-Dash 方法,因为它在责任角度(遍历集合)和实现成本角度(代码几乎相同)上都是合理的。另一个重叠的领域是函数绑定。jQuery 提供了将函数绑定到特定上下文中的工具,但 Lo-Dash 拥有更好的函数式工具。让我们再次比较这两种方法:

function boundFunction(result, item) {
    return result + this.multiplier * item;
}

var scope = { multiplier: 10 },
    collection = _.range(1, 1000),
    jQueryBound = $.proxy(boundFunction, scope),
    lodashBound = _.bind(boundFunction, scope);

console.time('$');
console.log(_.reduce(collection, jQueryBound));
console.timeEnd('$');

console.time('_');
console.log(_.reduce(collection, lodashBound));
console.timeEnd('_');
// → 
// 4994991
// $: 3.214ms
// 4994991
// _: 0.567ms

这两种方法都将收集过程简化为相同的结果。代码基本上是相同的;唯一的区别是传递给 reduce() 的回调函数的绑定方式。我们将函数绑定到的上下文是一个具有 multiplier 属性的普通对象,当回调函数运行时,会查找这个属性。它是通过引用 this 来查找的,这就是为什么我们必须在将回调函数传递给 reduce() 函数之前绑定上下文的原因。

第一种方法使用 proxy() jQuery 函数,而第二种方法使用 Lo-Dash 的 bind() 函数。与前面 map() 的例子一样,性能优势属于 Lo-Dash,实现它没有成本,而且这是 Lo-Dash 设计得很好的功能之一。所以如果你正在将回调传递给 jQuery 事件函数,bind()proxy() 一样可行,而且是在 Lo-Dash 擅长的范围内。

与 jQuery 延迟实例一起工作

我们已经看到 Lo-Dash 如何在 jQuery 查询 DOM 元素后帮助迭代这些元素。我们也看到了 Lo-Dash 如何在我们的 jQuery 代码中改进函数绑定。让我们反过来看看 jQuery 如何帮助我们编写 Lo-Dash 代码:

function query(coll, filter, sort) {
    var deferred = $.Deferred(),
        _coll = _(coll).filter(filter);

    if (sort) {
        _coll.sortBy(_.isBoolean(sort) ? undefined : sort);
    }

    if (_.size(coll) > 5000) {
        _.defer(function() {
            deferred.resolve(_coll.value());
        });
    } else {
       deferred.resolve(_coll.value());
    }

    return deferred.promise();
}

var collection = _.map(_.range(_.random(10000)), function(item) {
    return {
        id: item,
        enabled: !!_.random()
    };
}), resultSize;

console.log('Collection size: ' + _.size(collection));
query(collection, 'enabled', true).done(function(result) {
    resultSize = _.size(result);
    console.log('Result size: ' + resultSize);
});

if (!resultSize) {
    console.log('Awaiting results...');
}
// → 
// Collection size: 9071
// Awaiting results...
// Result size: 4635

在这里,我们正在使用 Deferred jQuery 对象。这是由执行异步操作的功能返回的。一旦调用者拥有延迟实例,它就充当调用者和函数之间的通道。当函数完成其异步工作后,它会通知调用者并运行回调函数。我们可以对延迟实例执行许多技巧,但在这里我们将保持简单。

我们实现的 query() 函数的职责是将集合包装在 Lo-Dash 包装器中,并使用 filter 参数进行过滤。如果提供了 sort 参数,我们也会对集合进行排序。异步工作发生在我们检查集合大小时。请注意,我们实际上还没有执行任何链式函数调用,因为我们还没有调用 value()。如果集合包含超过 5,000 个项目,我们使用 Lo-Dash 的 defer() 函数在执行 value() 之前清除 JavaScript 调用栈。如果集合包含较少的项目,我们立即执行过滤器。

调用 value() 的结果通过 jQuery 延迟实例传递给调用者,使用 resolve() 函数。这个函数的优点是它总是对调用者异步。即使我们处理的是较小的集合,它仍然被视为异步。输出说明了当我们的随机集合有超过 5,000 个项目时,过滤器是延迟的。当我们看到“等待结果”的消息时,这意味着在查询执行之前,控制权已经返回给调用者。这是这个想法;由于集合很大,我们让其他事情先发生,以防过滤器需要一段时间才能完成。

背骨

与 jQuery 不同,Backbone 是一个关注于为应用程序创建高级抽象的库。例如模型、集合和视图等概念,Backbone 程序员会扩展这些概念以提供与 API 数据的无缝集成。

Backbone 认识到自己的优势,并利用其他库如 jQuery 和 Underscore 来实现某些功能,例如获取和保存数据。这对于 jQuery 来说是非常适合的,因为它在 DOM 中渲染视图。对于更底层的任务,Backbone 利用 Underscore 的能力。由于 Backbone 利用这些库,它能够保持较小的代码体积。此外,由于它遵循简单的模式,它基本上不会干扰开发者,让他们能够根据特定用例调整库。

围绕 Backbone 有一个完整的生态系统,Lo-Dash 是这个生态系统的一部分。由于它最初被构想为 Underscore 的直接替代品,Lo-Dash 与 Backbone 集成得非常紧密。在本节中,我们将探讨在 Backbone 应用程序中替换 Underscore 以及通过 Underscore 中找不到的功能扩展 Backbone 的能力。

替换 Underscore.js

Backbone 需要 jQuery 和 Underscore。由于它被封装为 UMD 函数,如果我们把 Lo-Dash 定义为 AMD 模块,那么替换它相当简单。让我们看看一个用 RequireJS 配置替换 Underscore 为 Lo-Dash 的例子:

require.config({
    paths: {
        jquery: 'lib/jquery.min',
        underscore: 'lib/lodash.backbone.min'
    },
    shim: {
        underscore: { exports: '_' }
    }
});

当 Backbone 模块加载时,它会查找 jqueryunderscore 模块,这两个模块我们在这里都提供了。你会注意到 underscore 模块指向 lodash.backbone.min。这是一个特殊的 Lo-Dash 构建,它只包含 Backbone 所需的函数。换句话说,它不包含 Backbone 内部不使用的任何额外内容。现在让我们定义一个简单的模型:

define([
    'underscore',
    'lib/backbone'
], function(_, Backbone) {
    return Backbone.Model.extend({
        parse: function(data) {
            return _.extend({
                name: data.first + '' + data.last
            }, data);
        }
    });
});

你可以看到我们要求 underscore,实际上就是 Lo-Dash,因此我们可以使用 extend() 函数。Backbone 模型也将内部使用 Lo-Dash,因为它有相同的 Underscore 需求。现在让我们使用这个模型:

require([ 'modules/backbone-model' ], function(Model) {
    new Model({
        first: 'Lance',
        last: 'Newman'
    }, { parse: true }).toJSON();
    // → {name: "Lance Newman", first: "Lance", last: "Newman"}
});

你之所以会将 Lo-Dash 以这种方式集成到 Backbone 中,主要原因是 Lo-Dash 相比 Underscore 在速度和一致性方面的提升。

全功能的 Lo-Dash 和 Backbone

随着我们的应用程序变得更加复杂,我们可能会需要更多 Lo-Dash 功能,尽管这并不是 Backbone 的严格要求。另一种选择是用 Lo-Dash 的完整版本替换 Underscore。为此,我们可以使用 Lo-Dash 的 AMD 构建。以下是一个修改后的 RequireJS 配置示例:

require.config({
    paths: {
        jquery: 'lib/jquery.min',
        underscore: 'lib/lodash-amd/main'
    }
});

这个配置与之前的配置类似,只不过它不需要一个垫片来导出 _ 符号,并且它指向 main Lo-Dash 模块。让我们使用这种方法重新定义我们的模型:

define([
    'lib/lodash-amd/object/assign',
    'lib/backbone'
], function(assign, Backbone) {
    return Backbone.Model.extend({
        parse: function(data) {
            return assign({
                name: data.first + '' + data.last
            }, data);
        }
    });
});

在这个版本的模型定义中,我们不需要 Underscore。实际上,我们只需要除了 Backbone 之外的一个特定的 Lo-Dash 函数——assign()。请注意,Backbone 仍然会加载整个 Lo-Dash API。

增强集合和模型

如果我们要求 Lo-Dash 的完整版本,我们可以相当容易地扩展 Backbone 集合的功能。让我们定义一个扩展模块,该模块将不在 Underscore 中找到的方法扩展到 Backbone 集合中:

define([
    'lib/backbone',
    'lib/lodash-amd/array/slice',
    'lib/lodash-amd/array/takeRight',
    'lib/lodash-amd/array/dropWhile'
], function(Backbone, slice, takeRight, dropWhile) {

    function extendCollection(func, name) {
        Backbone.Collection.prototype[name] = function() {
            var args = slice(arguments);
            args.unshift(this.models);
            return func.apply(null, args);
        }
    }

    extendCollection(takeRight, 'takeRight');
    extendCollection(dropWhile, 'dropWhile');

    return Backbone;
});

此模块在需要时,将两个新方法 takeRight()dropWhile() 扩展到 Backbone.Collection 原型上。请注意,它返回 Backbone,所以每次我们要求 Backbone 时,我们都可以使用此模块并获取扩展版本作为结果。让我们看看这个扩展集合的使用情况:

require([
    'lib/lodash-amd/collection',
    'modules/backbone-extensions'
], function(_, Backbone) {

    function name(model) {
        return model.get('name');
    }

    var collection = new Backbone.Collection([
        { name: 'Frank' },
        { name: 'Darryl' },
        { name: 'Stacey' },
        { name: 'Robin' }
    ], { comparator: name });

    _.map(collection.takeRight(2), name );
    // → [ "Robin", "Stacey" ]

    _.map(collection.dropWhile(function(model, index, coll) {
        return index < (coll.length - 2);
    }), name);
    // → [ "Robin", "Stacey" ]

});

如你所见,集合现在有了 takeRight()dropWhile() 方法——由于这些函数已经由 Lo-Dash 实现,所以很容易添加。我们只需要将这些部分粘合在一起,就像 Backbone 对 Underscore 函数所做的那样。

Node.js

在本章的结尾部分,我们将把注意力转向编写用于后端的 Lo-Dash 代码。这当然意味着将 Lo-Dash 作为 Node.js 包安装。

安装 Lo-Dash 包

假设你已经安装了 Node,因为你必须这样做才能在 RequireJS 示例中运行 grunt 命令,你应该在你的系统上有一个 npm 命令。如果是这样,安装将非常简单:

npm install -g lodash

这将在全局范围内安装 Lo-Dash,这意味着它对任何希望使用它的其他 Node 项目都是可访问的。这可能是好主意,因为毕竟 Lo-Dash 是一个库。为了验证安装成功,你可以运行以下命令:

node -e "require('lodash');"

如果你看到一个长的错误消息,这意味着在安装时出了问题。如果它静默存在,那么你就设置好了。

创建一个简单的命令

为了与 Lo-Dash 开发一起深入了解 Node.js,让我们创建一个简单的命令,该命令对逗号分隔的输入进行排序:

var _ = require('lodash'),
    args = _(process.argv),
    input;

if (args.size() < 3) {
    console.error('Missing input');
    process.exit(1);
} else if (args.contains('-h')) {
    console.info('Sorts the comma-separated input');
    console.info('Use "-d" for descending order');
    process.exit(0);
}

input = _(process.argv[2].replace(/\s?(,)\s?/g, '$1').split(','))
    .sortBy();

if (args.contains('-d')) {
    input.reverse();
}

console.log(input.join(', '));

args 变量是一个 Lo-Dash 包装器,其中包含命令参数作为值。我们将在这个包装器上调用 size()contains() 来验证输入。然后创建第二个包装器并存储在 input 变量中。这是一个以逗号分隔的列表,我们将字符串分割并删除任何多余的空白。然后我们调用 sortBy() 来排序列表,如果设置了 -d 标志,则可选地反转顺序。然后将字符串重新连接起来。调用 join() 实际上会执行函数调用链,这是命令的输出。

自定义 Lo-Dash 构建

另一个安装 Node.js 的好理由是你可以安装 lodash-cli 包,这是 Lo-Dash 的构建系统。使用这个工具,你可以实时地以细粒度创建自定义构建。甚至可以指定到函数级别,使用以下命令来指定包含或排除的内容:

lodash modularize include=function

这将为我们运行 Lo-Dash 的 AMD 构建,只包括 function 类别中的函数所必需的内容。

摘要

本章重点介绍了在更广泛的前端开发背景下使用 Lo-Dash。借助 RequireJS 等技术,实现模块化变得更加容易。我们探讨了多种实现方式,Lo-Dash 在这些环境类型上提供了内置支持。我们了解到 Lo-Dash 是一个非常专注的库,帮助开发者编写干净且高效的代码,同时忽略其他事物。Lo-Dash 不擅长的地方则被其他稳定的库如 jQuery 和 Backbone 优雅地覆盖。我们还编写了一些 Lo-Dash 代码,这些代码直接帮助这些库,无论是在性能方面还是在功能方面。

我们以对 Node.js 的探讨结束本章,以及如何为运行在浏览器之外的程序编写 Lo-Dash 代码。还有一个用于构建 Lo-Dash 的 Node 包,你可以自定义这些构建以包含你喜欢的任何内容。现在我们已经从外部了解了作为一个 Lo-Dash 程序员你能做什么,让我们来看看 Lo-Dash 的内部。了解某些事物是如何和为什么被设计出来的,将更好地帮助你做出决策,以实现更好的性能。

第八章:内部设计和性能

本书最后一章将探讨关键 Lo-Dash 组件的内部设计。所有前面的章节都专注于库的外部特性。现在我们已经熟悉了 Lo-Dash 的用途,是时候看看其内部结构了。这并不是对 Lo-Dash 源代码的深入剖析。不过,好奇的读者当然可以查看代码。我们将触及 Lo-Dash 实现中最重要的一些部分。这些部分使得 Lo-Dash 不仅运行速度快,而且可预测。

在考虑到这些设计的基础上,我们将在本章剩余的部分中查看一些可以改进的 Lo-Dash 代码。了解一些设计动机可能会在您做出设计决策时提供指导。

在本章中,我们将涵盖以下主题:

  • 设计原则

  • 提高性能

  • 惰性评估

  • 缓存事项

设计原则

Lo-Dash 最初设定了一些相当低调的目标。Underscore 因其解决的问题以及其 API 的连贯性和易用性而受到大众的喜爱。Lo-Dash 的创造者,John-David Dalton,想要证明在提供跨浏览器的连贯性和性能的同时,实现一个像 Underscore 那样的优秀 API 是可能的。此外,Lo-Dash 有自由去实现 Underscore 不欢迎的新特性。

为了证明他的观点,John-David 必须建立一些指导设计原则。一些基本原则至今仍然存在,而其他一些则已经演变成其他形式,以更好地支持使用该库并为其做出贡献的程序员。如果没有适应变化的能力,Lo-Dash 将什么都不是。

函数编译到基础函数

Lo-Dash 的早期版本使用了一种称为函数编译的技术。这意味着存在一个骨架函数的模板。然后 Lo-Dash 会填充这些模板,并即时创建函数实例。这些实例随后通过 API 暴露出来。这种方法的优点是,无需实现该函数的多个版本,就可以轻松地为单个函数实现大量的可变性。能够在保持代码体积小的同时实现这样的通用函数,意味着 Lo-Dash 能够处理各种不同的用例,从性能角度和错误修复/一致性角度来看。

函数编译的第一个问题是代码的可读性——如此动态的东西并不容易被开发者接受。开源的这一方面也就消失了——你不会因为吓跑开发者而得到他们的代码审查。第二个问题是 JavaScript 引擎在持续改进其优化运行时 JavaScript 代码的能力。这也被称为即时JIT)优化。因此,从现在到 Lo-Dash 首次构思的时间,浏览器供应商已经取得了长足的进步。在如此短的时间内,这些改进并没有被 Lo-Dash 及其函数编译方法充分利用。

在 Lo-Dash 的最新版本(特别是 2.4 和 3.0)中,函数编译方法已被替换为基础函数。换句话说,基础函数是通用组件,被多个面向公众的函数所使用。库的早期版本由于担心不必要的间接引用会导致性能损失,因此回避了抽象。虽然抽象确实会产生额外的开销,但帮助浏览器执行 JIT 优化所带来的好处超过了这种成本。

这并不意味着 Lo-Dash 放弃了所有关于抽象开销的谨慎。实现方式既巧妙又易于阅读,解决了早期理解源代码的问题。一个特定的基础函数可能被用在多个地方,这减少了重复代码。更重要的是基础函数的结构方式。通过 API 公开的特定函数将进行一些初步工作,以便理解传递的参数。本质上,这是为了准备将更精确的参数传递给基础函数。这为 JavaScript 引擎提供了更好的可预测性。调用基础函数的成本通常可以忽略不计,尤其是在频繁调用相同函数时——引擎通常会将其内联到调用位置。

那么,这对 Lo-Dash 程序员有什么影响呢?实际上没有。这些内部基础函数的结构和使用方式对你的代码没有任何影响。然而,这应该能让你对 Lo-Dash 如何根据开发者反馈和不断变化的 JavaScript 技术快速演变有所了解。

优化常见情况

这个原则,“优化常见情况”,从 Lo-Dash 诞生之日起就存在。当然,细微的实现细节已经有所演变,但基本理念仍然保持不变,这可能会一直如此。把这看作是 Lo-Dash 的黄金法则(非官方规则)。正如 Linux 内核开发有一个被称为“不要破坏用户空间”的黄金法则一样,把“优化常见情况”看作是始终追求的目标。

采用现在更受欢迎的基函数方法,而不是函数编译。我们可以根据用户提供的参数选择要调用的基函数。例如,接受集合的函数可以使用仅适用于数组的基函数。它针对数组进行了优化。因此,当调用接受集合的函数时,它会检查是否正在处理数组。如果是,它将使用更快的基函数。以下是一个使用伪 JavaScript 的模式说明:

function apiCollectionFunction(collection) {
    if (_.isArray(collection)) {
        return baseArray(collection);
    } else {
        return baseGeneric(collection)
    }
}

常见路径是第一个测试的路径。执行的 baseArray() 函数足够通用且使用频率足够高,以至于 JIT 会给予特殊处理。策略是假设常见情况是传递一个数组。这种假设也不是任意的;它在开发过程中与典型用例进行了基准测试。最坏的情况是我们处理字符串或当普通对象不慢时,这并不是说它没有优化。因此,这些较慢的调用,尽管它们很少发生,但会被频繁发生的优化调用所抵消。

常见情况甚至可以分级。也就是说,当函数被调用时,它会抛出一个或多个情况,所有这些可能性都有它们出现频率的顺序。例如,如果最常见的情况没有满足,下一个最常见的情况是什么?以此类推。这种技术的影响是将不常见的代码推向函数的底部。单独来看,这不会对性能产生巨大影响,但当库中的每个函数都持续遵循相同的常见情况优化技术时,影响就非常大了。

循环很简单

Lo-Dash 在其代码中使用了大量的循环。这是因为有很多集合的迭代。这也是因为 Lo-Dash 不使用某些会消除循环需要的原生函数。这与 Underscore.js 在这个问题上的立场相反。它更喜欢在可用时使用原生方法。逻辑是,JavaScript 库不需要担心迭代性能。相反,浏览器供应商应该提高原生方法性能。

这种方法是有意义的,尤其是在副作用是编写更少代码的情况下。然而,Lo-Dash 不依赖于浏览器供应商提供性能。我们可以从简单的 while 循环中获得更好的性能,这可能会在可预见的未来继续。原生方法无疑比未优化的 JavaScript 代码更快,但它们无法执行与我们使用纯 JavaScript 时相同的优化。

注意

Lo-Dash 是一个策略性的动物。它不喜欢依赖于某些原生 JavaScript 方法,但它非常依赖任何给定 JavaScript 引擎的 JIT 能力来实现性能和成本平衡。

Lo-Dash 也不喜欢依赖 for 循环——更倾向于使用 while 循环。for 循环在用于遍历集合时很有用,从而增强了代码的可读性。在这些简单情况下,尝试使用 while 循环只是繁琐。尽管 while 循环确实比 for 循环有轻微的性能优势,但这并不是特别明显。在频繁遍历的几个大型集合的情况下,性能差异是明显的。这是 Lo-Dash 考虑的常见情况。考虑以下代码:

var collection = _.range(10000000),
    length = collection.length,
    i = 0;

console.time('for');
for (; i < length; i++) {
    collection[i];
}
console.timeEnd('for');

i = 0;

console.time('while');
while (++i < length) {
    collection[i];
}
console.timeEnd('while');
// →
// for: 13.459ms
// while: 10.670ms

两个循环之间的差异几乎不可察觉。可能在大约两年前,while 循环相对于 for 循环的领先优势可能更大,这也是 Lo-Dash 仍然在所有地方使用 while 循环的一个原因。另一个原因是一致性。由于 while 循环在 Lo-Dash 中的实现几乎完全相同,你可以预期其性能在整个过程中是可预测的。这一点在不是 while 循环、for 循环和原生 JavaScript 方法混合的情况下尤其正确。有时,可预测的性能比“有时它更快,但我永远无法确定”要好。

回调函数和函数绑定

回调函数在 Lo-Dash 中无处不在,无论是内部使用还是作为 API 函数的参数。因此,这些函数以尽可能少的开销执行是很重要的。导致这些函数调用变慢的主要问题是 this 上下文,即函数绑定到的上下文。如果没有上下文需要考虑,那么涉及的开销显然更少,特别是考虑到这些回调函数通常在函数对集合进行操作时每次迭代只被调用一次。

如果回调函数有特定的上下文,那么我们必须使用 call() 来调用该函数,因为它允许我们设置上下文。或者如果有未知数量的参数,我们使用 apply() 函数,将上下文和参数作为数组传递。如果迭代执行,这会特别慢。为了帮助克服这些性能障碍,Lo-Dash 使用一个基础函数来帮助构建回调函数。

此函数用于任何传递回调函数作为参数的地方。第一步是使用此函数构建一个可能被包装的回调函数。这种初步检查是值得的,因为当它需要迭代调用时,可以节省潜在的时间。以下是这个函数大致工作原理的简要说明:

function baseCallback(func, thisArg, argCount) {
    if (!thisArg) {
        return func;
    }

    if (alreadyBound(func)) {
        return func;
    }

    if (argCount == 1) {
        return function(collection) {
            return func.call(thisArg, collection);
        }
    }

    return function() {
        return func.apply(thisArg, arguments);
    }
}

这是对baseCallback()实际执行内容的粗略简化,但总体模式是准确的。首先检查的是构建回调函数最常见的案例。不常见且较慢的案例被推到后面。例如,如果没有thisArg,我们不需要绑定函数;它可以直接返回。接下来检查的是函数是否已经被绑定。如果已经绑定,则忽略thisArg值并返回函数。如果这两个检查都不通过,并且提供了argCount参数,我们可以使用call(),提供确切的参数数量。前面的伪代码显示了只有一个参数的情况,但在现实中,它会检查几个确切的参数数量。

不常见的案例是当提供了thisArg时,这意味着我们必须绑定函数,但我们不知道有多少个参数。因此,我们使用apply(),这是最慢的情况。baseCallback()能够处理的其它情况包括将字符串或纯对象作为func传递而不是函数实例。对于这种情况,会返回特定的回调函数,并且这也会在早期进行检查,因为它是一个常见的案例。

alreadyBound()函数是为了简洁而编造的。Lo-Dash 通过查看该函数的元数据来知道一个函数是否已经被绑定。在这个上下文中,元数据是指由 Lo-Dash 附加到函数上的数据,但对开发者来说是完全透明的。例如,许多回调会跟踪它们被调用的频率数据。如果函数变得热点,Lo-Dash 会将其与其他不经常执行的回调区别对待。

提高性能

虽然 Lo-Dash 从一开始就是为了最佳性能而设计的,但这并不意味着我们无法对我们的 Lo-Dash 代码进行一些基本的修改来提高性能。实际上,我们有时可以借鉴一些 Lo-Dash 的设计原则并将其直接应用于使用 Lo-Dash 的代码中。

改变操作顺序

在一个值(如数组)周围使用 Lo-Dash 包装器,让我们可以链式地对该值执行许多操作。正如我们在第六章中看到的,“应用构建块”,与逐个拼接调用 Lo-Dash 函数的多个语句相比,包装器有许多优势。例如,最终结果通常是更简洁、更易读的代码。我们在链中调用这些操作的顺序可能产生相同的结果,但性能影响却不同。让我们看看三种不同的过滤集合的方法,它们都能得到相同的结果:

var collection = _.map(_.range(100), function(item) {
    return {
        id: item,
        enabled: !!_.random()
    };
});

var cnt = 1000;

console.time('first');
while (--cnt) {
    _(collection)
        .filter('enabled')
        .filter(function(item) {
            return item.id > 75;
        })
        .value();
}
console.timeEnd('first');

cnt = 1000;

console.time('second');
while (--cnt) {
    _(collection)
        .filter(function(item) {
            return item.id > 75;
        })
        .filter('enabled')
        .value();
}
console.timeEnd('second');

cnt = 1000;

console.time('third');
while (--cnt) {
    _(collection)
        .filter(function(item) {
            return item.enabled && item.id > 75;
        })
        .value();
}
console.timeEnd('third');
// → 
// first: 13.368ms
// second: 6.263ms
// third: 3.198ms

collection 数组相当简单。它包含 100 个项,每个项都是一个具有两个属性的对象。第一个是一个数值 ID。第二个是一个随机的布尔值。目标是过滤掉任何未 启用 的项以及任何 id 值小于 75 的项。

第一种方法构建了一个由两个 filter() 调用组成的链。第一个 filter() 调用移除了任何禁用项。第二种方法移除了具有 id 属性且其值小于 75 的任何项。然而,这些过滤操作的顺序并不最优。你可能已经注意到,根据 id 值移除的项目数量很多。这是由于过滤器的性质和我们正在处理的数据集的特性所导致的。

filter() 的任何调用都意味着在集合上发生线性迭代。在第一种方法中,调用了两次 filter(),这意味着我们将不得不对集合进行两次线性迭代。鉴于我们现在对集合数据和过滤器所寻找的内容的了解,我们可以优化过滤调用的顺序。这是一个简单的更改。我们首先按 id 过滤,然后按 enabled 属性过滤。结果是性能有明显的提升,因为第二次调用 filter() 必须迭代的项目要少得多。

第三种方法更进一步,完全移除了一个迭代过程。由于两个过滤条件都在 filter() 回调函数中检查,因此没有必要对任何集合项进行两次迭代。

注意

当然,这里的权衡是在给定的回调函数中增加了更多的复杂性。如果你的应用程序进行大量的过滤,请记住这一点,因为你将想要避免定义高度专业化的、仅用于单一目的的回调函数。通常,保持函数小而通用是一个更好的主意。第二种方法达到了一个很好的平衡。这类优化通常不会一开始就发生,所以等到常见情况显现出来后再尝试对其进行优化。

排序和索引集合

如果你在开发的程序中,集合的顺序是一个重要因素,你可以引入一些调整,以利用其重要性。这些调整包括保持排序顺序。每次需要渲染时重新排序集合实际上并没有什么意义。相反,最好是一次性对集合进行排序,然后通过在正确的位置插入新项来维护其顺序。Lo-Dash 有一个 sortedIndex() 函数,它有助于找到新项的正确插入点。实际上,它执行二分搜索,比在集合中进行线性搜索要快得多。

对于更快的过滤操作,我们可以借用sortedIndex()函数。如果我们有一个已排序的集合,实际上根本不需要使用线性搜索来过滤项,这在最坏情况下表现相当差。让我们引入一个新的mixin函数,它执行与filter()函数相同的工作,但针对已排序的集合进行了优化:

_.mixin({ sortedFilter: function(collection, value, iteratee) {
    iteratee = _.callback(iteratee);
    var index = _.sortedIndex(collection, value, iteratee),
        result = [],
        item;
    while (true) {
        item = collection[index++];
        if (_.isEqual(iteratee(item), iteratee(value))) {
            result.push(item);
        } else {
            break;
        }
    }
    return result;
}});

var collection = _.map(_.range(100), function(item) {
    return {
        id: item,
        age: _.random(50)
    };
});

var shuffled = _.shuffle(collection),
    sorted = _.sortBy(shuffled, 'age');

console.time('shuffled');
console.log(_.filter(shuffled, { age: 25 }));
console.timeEnd('shuffled');
// → 
// [
//   { id: 63, age: 25 },
//   { id: 6, age: 25 },
//   { id: 89, age: 25 }
// ]
// shuffled: 4.936ms

console.time('sorted');
console.log(_.sortedFilter(sorted, { age: 25 }, 'age'));
console.timeEnd('sorted');
// → 
// [
//   { id: 63, age: 25 },
//   { id: 6, age: 25 },
//   { id: 89, age: 25 }
// ]
// sorted: 0.831ms

我们引入的新功能——sortedFilter()——比filter()函数更快。再次强调,这是因为我们不需要依赖线性搜索,因为集合已经排序。相反,我们使用sortedIndex()函数来找到我们想要的东西。它使用二分搜索,这意味着在更大的集合中,有很多项不会被检查。最终结果是更少的 CPU 周期和更快的执行时间。

我们的sortedFilter()实现,很大程度上得益于sortedIndex(),并不复杂。二分搜索为我们提供了插入新项的位置,但我们实际上并没有插入任何东西。我们只是在寻找它。可能有几个项符合我们的标准,或者可能没有。这就是我们在集合上迭代,以插入索引作为起点的地方。现在我们必须使用isEqual()显式检查值并构建结果数组。由于集合已排序,我们知道当项不再符合过滤标准时停止并返回。

注意

在提高 Lo-Dash 函数性能时,始终要注意验证代码的正确性。最简单的方法是设置一系列自动化测试,比较 Lo-Dash 函数的输出与你的更快变体的输出。这允许你在对新的发现速度过于兴奋之前,将所有各种边缘情况都抛给你的代码。Lo-Dash 处理了很多边缘情况,所以请确保你不会为了性能而牺牲安全性。

在加快集合上的过滤操作速度的另一种技术是对其进行索引。这意味着创建一个新的数据结构,它使用键来查找集合中的常见项。这是避免在大集合中进行昂贵的线性搜索的另一种方法。以下是一个使用groupBy()对集合进行索引的示例,以便快速搜索使用常见过滤标准的项:

var collection = _.map(_.range(100), function(item) {
    return {
        id: item,
        age: _.random(50),
        enabled: !!_.random()
    };
});

var indexed = _.groupBy(collection, function(item) {
    return +item.enabled * item.age;
});

console.time('where');
console.log(_.where(collection, { age: 25, enabled: true }));
console.timeEnd('where');
// → 
// [
//   { id: 23, age: 25, enabled: true },
//   { id: 89, age: 25, enabled: true }
// ]
// where: 5.528ms

console.time('indexed');
console.log(indexed[25] || []);
console.timeEnd('indexed');
// → 
// [
//   { id: 23, age: 25, enabled: true },
//   { id: 89, age: 25, enabled: true }
// ]
// indexed: 0.712ms

索引方法查找相同项所需的时间比where()方法少得多。当你的应用程序中有多个相同的过滤器实例时,这种方法很有用。indexed变量持有集合的索引版本。索引是通过groupBy()函数创建的。它接受一个数组作为输入并生成一个对象。索引键是对象键,传递给groupBy()的回调函数负责生成这些键。该函数返回键值,如果键已存在,则该项将被添加到该键。

这个想法是,我们希望根据age属性值和是否启用来索引项目。在这里,我们使用了一个巧妙的小技巧来实现这一点。enabled属性被转换成正整数,并乘以age值。因此,任何禁用的项目都将被索引在0下,那里没有人会去看。现在你可以看到,在indexed对象中查找项目会产生与where()过滤器相同的结果。在后一种方法中,我们执行的是一个简单的对象访问操作,而不是遍历一个集合并执行多个操作。

注意

虽然这里的速度提升相当令人印象深刻,但请务必考虑这个集合中项目的更新频率。如果你这么想,索引版本实际上只是一个常见过滤结果的缓存。所以如果集合从未更新,只要你对实际上索引集合的一次性付费没有问题,那么你就没问题。

绑定与未绑定回调

Lo-Dash 支持回调函数,并且在这方面做得非常好。例如,当不需要this上下文时,它避免了使用call()apply(),这有一个很好的原因——这些调用比未绑定函数调用要慢得多。因此,当我们编写利用 Lo-Dash 回调函数的应用程序时,我们有选择在每个回调函数应用于集合时提供上下文的选项。在以这种方式编写函数之前,花时间权衡利弊。

当我们想在不同的上下文中使用相同的函数时,将我们的函数绑定到上下文中是方便的。这并不总是必要的,这很大程度上取决于我们代码的整体设计。如果我们有很多对象需要回调函数访问,this上下文就非常方便。我们甚至可能有一个用于访问其他对象的单一应用程序对象,依此类推。如果是这样,我们肯定会需要一个方法将这个对象传递给我们的回调函数。这可能意味着绑定this上下文,通过函数闭包访问对象,或者为我们的回调创建一个部分函数。

这些选项都不是特别适合性能。因此,如果我们发现我们的回调函数经常需要访问某些对象,那么在回调函数中定义它,而不是将其定义为变量,可能是有意义的。以下代码说明了这个想法:

function callback(item) {
    return _.extend({
        version: this.version
    }, item);
}

function unbound(item) {
    return _.extend({
        version: 2.0
    }, item);
}

var cnt = 1000,
    app = { version: 2.0 },
    boundCallback = _.callback(callback, app),
    collection = _.map(_.range(1000), function(item) {
        return { id: item };
    });

console.time('bound');
while (--cnt) {
    _.map(collection, boundCallback);
}
console.timeEnd('bound');

cnt = 1000;

console.time('unbound');
while (--cnt) {
    _.map(collection, unbound);
}
console.timeEnd('unbound');
// → 
// bound: 662.418ms
// unbound: 594.799ms

我们可以看到,未绑定回调函数通常会比绑定回调函数表现更好。这里需要注意的是方法。bound()函数通过map()调用绑定到特定的上下文中,因为它需要从应用程序对象中获取一些东西。而unbound()函数,不依赖于外部实例,会自己声明变量。因此,我们可以在不需要绑定到特定回调函数的情况下,为回调函数获取所需的内容。

起初,这可能会让人觉得在回调函数内定义应用级变量是一种反直觉的方法。好吧,这归结到你的代码的其他部分。你有很多需要访问这些数据的回调函数吗?如果你将这个回调函数放在源树中容易找到的位置,那么这实际上与修改一个变量并没有太大的区别。

注意

当只有少数回调函数时,从绑定函数切换到非绑定函数并不会带来巨大的性能提升。即使有很多函数,只要没有影响性能,拥有几个绑定函数也是可以的。本节的想法是让你留意那些无谓地绑定到上下文中的函数。如果它们对你的设计没有明显影响,你可以尝试修复它们。

惰性求值

随着 Lo-Dash 3.0 的引入,一些函数使用惰性求值来计算它们的结果。这仅仅意味着给定集合中的项目在实际上需要之前不会迭代。确定它们何时需要才是棘手的部分。例如,仅仅调用单个 Lo-Dash 函数并不会触发任何惰性求值机制。然而,链式操作在某些情况下确实可能从这种方法中受益。例如,当我们只从结果中取 10 个项目时,就没有必要进一步迭代整个集合。

为了了解惰性求值的样子,让我们写一些代码来利用它。这里没有明确的事情要做。惰性机制在幕后透明地发生,取决于我们的链中包含哪些操作以及它们的调用顺序:

var collection = _.range(10);

_(collection)
    .reject(function(item) {
        console.log('checking ' + item);
        return item % 2;
    })
    .map(function(item) {
        console.log('mapping ' + item);
        return item * item;
     })
    .value();
// → 
// checking 1
// checking 2
// mapping 2
// checking 3

在这里,我们的链由两个函数组成——reject()map()。由于reject()首先被调用,Lo-Dash 将其作为一个惰性包装器。这意味着当调用value()时,事情会有些不同。而不是运行每个函数直到完成,链中的惰性函数会被要求提供一个值。例如,reject()只有在map()请求值时才会运行。当它运行时,reject()会一直运行直到产生一个值。我们实际上可以在输出中看到这种行为。reject()函数正在检查项目1,该项目被拒绝。然后它继续到项目2,该项目通过了测试。然后这个项目被传递给map()。然后检查项目3,依此类推。

这两个函数调用是交织在一起的,并且这种属性可以向上扩展到更复杂的链中的许多函数。优点是,如果这些函数在整个集合上运行成本太高,通常不需要运行。它们只会在被要求执行时执行。让我们看看这个概念的实际应用:

var collection = _.range(1000000).reverse();

console.time('motivated');
_.take(_.filter(collection, function(item) {
    return !(item % 10);
}), 10);
console.timeEnd('motivated');

console.time('lazy');
_(collection)
    .filter(function(item) {
        return !(item % 10);
    })
    .take(100)
    .value();
console.timeEnd('lazy');
// → 
// motivated: 8.454ms
// lazy: 0.889ms

你可以看到,懒惰的方法比有动力的方法花费的时间少得多,尽管它处理了100个结果,而后者只处理了10个。原因很简单——集合很大,整个集合都是通过有动力的方法进行过滤的。懒惰的方法使用的迭代次数要少得多。

缓存事物

提高操作性能的最佳方式是不要执行它——至少不要执行两次,或者更糟糕的是,数百或数千次。重复昂贵的计算是 CPU 周期的无谓浪费,可以通过缓存结果来避免。memoize()函数在这里帮助我们,通过缓存被调用函数的结果以供以后使用。然而,缓存有其自身的开销和需要注意的陷阱。让我们首先看看幂等函数——这些函数在给定相同的输入参数时总是产生相同的输出:

function primeFactors(number) {
    var factors = [],
        divisor = 2;

    while (number > 1) {
        while (number % divisor === 0) {
            factors.push(divisor);
            number /= divisor;
         }
        divisor += 1;
        if (divisor * divisor > number) {
            if (number > 1) {
                factors.push(number);
            }
            break;
        }
    }
    return factors;
}

var collection = _.map(_.range(10000), function() {
        return _.random(1000000, 1000010);
    }),
    primes = _.memoize(primeFactors);

console.time('primes');
_.each(collection, function(item) {
    primeFactors(item);
});
console.timeEnd('primes');

console.time('cached');
_.each(collection, function(item) {
    primes(item);
});
console.timeEnd('cached');
// → 
// primes: 17.564ms
// cached: 4.930ms

primeFactors()函数返回给定数字的质因数数组。它必须进行相当多的工作来计算返回的数组。没有什么是占用 CPU 大量时间的,但无论如何,它的工作——对于给定的输入产生相同结果的工作。像这样的幂等函数是进行记忆化的良好候选者。使用memoize()函数来做这件事很容易,我们使用这个函数来生成primes()函数。此外,请注意,缓存键是第一个参数,在这里很好也很简单,因为它是我们唯一感兴趣要缓存输入。

注意

考虑到查找缓存项所涉及的开销,这是很重要的。这并不是很多,但确实存在。通常,这种开销超过了最初缓存结果的价值。前面的代码是使用相对较大的集合进行测试的一个例子。随着集合大小的缩小,性能提升也随之减少。

虽然缓存幂等函数的结果很方便,因为你永远不必担心缓存失效,但让我们看看一个更常见的用例:

function mapAges(collection) {
    return _.map(collection, 'age');
}

var collection = _.map(_.range(100), function(item) {
        return {
            id: item,
            age: _.random(50)
        };
    }),
    ages = _.memoize(mapAges, function(collection) {
        if (_.has(collection, 'mapAges')) {
            return collection.mapAges;
        } else {
            collection.mapAges = _.uniqueId();
        }
    }),
    cnt = 1000;

console.time('mapAges');
while (--cnt) {
    _.reduce(mapAges(collection), function(result, item) {
        return result + item;
    }) / collection.length;
}
console.timeEnd('mapAges');

cnt = 1000;

console.time('ages');
while (--cnt) {
    _.reduce(ages(collection), function(result, item) {
        return result + item;
    }) / collection.length;
}
console.timeEnd('ages');
// → 
// mapAges: 6.878ms
// ages: 3.535ms

在这里,我们正在缓存将集合映射到不同表示形式的结果。换句话说,我们正在映射age属性。如果这个映射操作在整个应用程序中重复进行,可能会很昂贵。因此,我们使用memoize()函数来缓存映射年龄值的结果,从而生成ages()函数。然而,仍然存在查找缓存集合的问题——我们需要一个键解析函数。我们提供的这个函数相当简单。它为集合的mapAges属性分配一个唯一的标识符。下次调用ages()时,这个标识符会被找到,然后查找缓存的副本。

我们可以看到,不必再次映射集合可以节省 CPU 周期。这是一个简单的映射;其他带有回调函数的映射可能更昂贵,比简单地提取一个值要复杂得多。

注意

当然,这段代码假设这个集合是恒定的,永远不会改变。如果你正在构建一个包含许多动态部分的大型应用程序,像这样的静态集合实际上是非常常见的。如果集合,或者集合中的项目,在其生命周期内频繁改变,你必须开始考虑使缓存失效。对于这些易变的集合,缓存映射或其他转换可能并不值得,因为,除了命名之外,缓存失效是编程中所有问题中最难的一个。

摘要

在本章中,我们介绍了一些指导 Lo-Dash 设计和实现的影響因素。库的早期版本选择了函数编译的方法,动态构建函数以最佳地处理性能和环境之间的变化。而最近的版本则选择了通用基础函数。函数编译避免了与基础函数相关的一些间接操作。然而,现代浏览器有一个 JIT 优化器,它能够更好地优化基础函数。此外,使用基础函数的代码可读性也更高。

Lo-Dash 实现的金科玉律是对常见情况的优化。你将在 Lo-Dash 的各个地方看到这条规则在发挥作用,它是其卓越性能的关键因素。在任何给定的函数中,最常见的情况首先被高度优化,将不常见的情况推到函数的末尾。回调在 Lo-Dash 中无处不在,因此它们能够可预测地执行非常重要。基础回调机制为我们处理这个问题,并作为一个优化常见情况的绝佳例子。

我们接着探讨了优化 Lo-Dash 代码的一些技术,大多数情况下遵循 Lo-Dash 的设计原则。改变 Lo-Dash 包装器中链式操作的顺序可以消除不必要的迭代。与排序集合一起工作可以对过滤性能产生重大影响。延迟评估是最近引入到 Lo-Dash 中的一个概念,它允许我们在不必要遍历整个集合的情况下与大型集合一起工作。最后,我们探讨了缓存可以帮助提高性能的一些场景,特别是在计算成本高昂的地方。

话虽如此,你们已经准备就绪了。在这本书中,我们学习并实现了许多概念,从 Lo-Dash 中开箱即用的内容开始,到如何提高速度结束。在这个过程中,我们探讨了编写稳健 Lo-Dash 代码最常用的使用模式。到目前为止,Lo-Dash 中的每一部分如何与其它部分相关,从概念到低级函数调用,应该已经很清晰了。就像任何其他库一样,在 Lo-Dash 中做某事有十几种或更多的方式。我希望你现在已经准备好以最佳方式完成它。

posted @ 2025-10-09 13:24  绝不原创的飞龙  阅读(10)  评论(0)    收藏  举报