原文:
zh.annas-archive.org/md5/C01E768309CC6F31A9A1148399C85D90
译者:飞龙
第四章:测试 JavaScript
学习目标
在本章结束时,您将能够做到以下几点:
-
分析测试的好处
-
解释代码测试的各种形式
-
构建代码测试环境
-
为您的 JavaScript 代码实施测试
本章将涵盖测试的概念、测试框架以及如何有效地测试代码的不同方式。
介绍
在第一章中,我们介绍了 ES6 中发布的许多新功能和强大功能。我们讨论了 JavaScript 的发展历程,并突出了 ES6 中的关键添加。我们讨论了作用域规则、变量声明、箭头函数、模板文字、增强的对象属性、解构赋值、类和模块、转译以及迭代器和生成器。在第二章中,我们讨论了 JavaScript 的异步编程范式。我们讨论了 JavaScript 事件循环、回调、承诺和 async/await 语法。在第三章中,我们学习了文档对象模型(DOM)、JavaScript 事件对象和 jQuery 库。
在本章中,我们将学习有关 JavaScript 中测试代码和代码测试框架的知识。在第一个主题中,我们将介绍测试并讨论测试驱动开发。然后,我们将讨论应用测试驱动开发以及您可以测试代码和应用程序的几种不同方式。在最后一个主题中,我们将讨论几种 JavaScript 代码测试框架,您可以使用它们来为您的代码构建强大的测试。
测试
测试代码很像去健身房。你知道这对你有好处。所有的论点都说得通,但起身并开始健身之路是困难的。最初的冲动感觉很棒;然而,紧随其后的是酸痛的肌肉,你开始怀疑这是否真的值得。你花了一个小时甚至更多的时间,但你所能展示的只是酸痛的手臂和腿。但是,几周后,情况变得更容易。你开始注意到锻炼的好处。
就像去健身房一样,您可能已经听说过测试代码有多么重要。编写测试是编写良好和可持续代码的一个重要部分。当您开始编写测试时可能会感到困难。编写您的第一个测试并使其成功运行会带来一种兴奋感,但在工作日中花费一个小时来编写测试后的一两天后,您开始怀疑这是否真的值得。但您坚持下去。几周后,这变得不那么乏味,您开始注意到测试代码带来的一些小好处。
在本章中,我们将讨论测试代码的原因,您可能需要实施的测试类型,以及您可能使用的一些 JavaScript 框架来实施和运行测试。
测试代码的原因
测试代码有许多原因。这些原因包括程序正确性、敏捷开发、代码质量、错误捕捉、法律责任、满足感等等。我们将简要讨论列出的每个原因,并解释它们的好处。
- 正确性
测试代码的最简单和最重要的原因是测试代码检查代码的正确性。智能编写的测试将针对预定的输入值和相应的输出值测试代码中的所有逻辑。通过将程序的输出与预期输出进行比较,我们可以验证代码是否按预期工作,捕捉语义或语法错误,然后将其集成到代码中。
- 敏捷开发
测试代码使开发过程更加敏捷。敏捷开发周期是最受欢迎和最热门的开发风格之一,被包括洛克希德·马丁、Snapchat 和谷歌在内的软件公司采用。敏捷开发依赖于短期目标。更改旧的经过测试的代码是一个非常缓慢的过程。如果需要重构或添加或删除功能的任何旧代码,我们需要重新测试整个过程。有了编写的代码测试,我们可以自动化它们,加快测试过程,并节省大量时间。这可能是实现我们的敏捷冲刺目标和错过截止日期之间的区别。
注意:
敏捷开发周期专注于短期冲刺,设计、实施和发布新功能。这些冲刺通常为两到三周。这种短期和快速的开发策略使您能够将一个大型产品分解成较小的部分,并管理潜在的变化需求。
- 捕获错误
测试代码将使您能够在开发周期的早期发现错误。测试应该在集成到产品或模块之前进行。这意味着测试发现的任何错误将在集成到产品之前被发现和修复。调试已完全集成到应用程序中的模块比调试仍在开发中的模块要困难得多。在集成之前编写和运行测试将使您能够在它们与其他代码交互之前找到并修复这些错误,节省大量时间。在集成之前捕获错误并推送正确的工作代码是开发人员可以拥有的最重要的技能之一,代码测试可以极大地提高这一技能。
- 代码质量
代码测试提高了编写代码的质量。在编写带有测试的代码时,我们必须明确地考虑这些测试来设计和实施我们的代码。编写良好的测试有助于我们更全面地思考我们试图解决的问题以及我们将要解决问题的方式;我们必须考虑诸如边缘情况之类的事情,并设计一个满足测试要求的良好实现。编写测试将帮助您更好地理解代码的设计和实现,从而产生更高质量、更深思熟虑的代码。
- 法律责任
编写测试可以帮助预防和减轻法律责任。在许多司法管辖区和市场领域,供应商被要求确保或证明所提供的软件具有市场质量。有记录的测试过程有可能在某些情况下限制您的法律责任。这可能会防止您因软件漏洞而被起诉。在最糟糕的情况下,充分记录的测试过程也可以用来证明诉讼中涉及的软件漏洞并非出于过失。这可能会减少您的惩罚性赔偿或个人责任。
- 满足感
测试代码的最终原因经常被大多数人忽视。测试代码可以非常令人满意。测试可以立即给您关于代码正确性的视觉反馈。看到所有方面都有绿色的勾号是非常令人满意的。发布您知道写得很好、经过充分测试并且将会无故障运行的代码是非常令人满意的。知道您的代码经过了充分测试可以帮助您在截止日期到来时对发布感到自信。
测试驱动开发
测试驱动开发(TDD)是一种以编写测试为重点的软件开发形式,先于实现代码。它通常是敏捷开发周期的一部分,也是将测试整合到代码中的最简单方式之一。TDD 是围绕短而简单的开发周期构建的软件开发过程。在其最基本的形式中,该周期包括添加一个定义新功能应如何工作的测试,然后编写代码直到满足测试的要求。这个周期重复进行,直到所有功能都被添加。
测试驱动开发要求开发人员创建自动化测试。这些测试应该清楚地定义代码的要求,并且应该在编写任何代码之前定义。测试应该覆盖所有预期或潜在的用例,特别是边界情况。测试的通过将通知开发人员开发何时完成。
注意:
边界情况是发生在操作参数的极端情况。在代码中,边界情况指的是可能需要特殊处理的有效输入值。例如,斐波那契数列算法(F(n)=F(n-1)+F(n-2))在序列值为 0 或 1 时需要特殊处理。
TDD 允许开发人员在必要时将其代码分解为小而可管理的步骤。这是可能的,因为 TDD 要求每个添加的函数和功能都必须有测试。我们可以编写一个小测试,然后编写使该测试通过的代码。大型功能和函数可以分解为小部分,并逐步构建。这可以极大地帮助理解问题的所有部分。
TDD 还可以促进更模块化和可重用的代码。每一部分代码都必须经过测试,大段的代码可以分解为小部分。这可以导致更小、更专注的类和函数,以及代码文件之间更少的交叉依赖。这些小部分可以被包装在一个带有它们的测试的模块中,并通过程序共享。对模块的更新可以通过运行附加的测试套件来简单地验证其正确性。
TDD 周期
TDD 周期通常是一个六个步骤的序列:
-
**添加测试:**在 TDD 中,每个新功能都应该以编写测试开始。要编写新测试,必须清楚地理解功能的规格和要求。功能的要求必须经过深思熟虑,并分解成可以逐一编写为测试的可测试部分。
-
**运行所有测试并查看是否有失败:**为了检查新测试是否通过,测试显然应该失败,因为我们正在添加的功能尚未实现。如果测试没有失败,那么该功能已经存在,或者测试编写错误。这是对编写的测试进行理智检查。测试应该为预期目的而失败,并有助于检查所测试的逻辑是否正确。
-
**编写代码修复测试:**在这个阶段,代码不需要完美。测试可能以低效的方式修复,但这是可以接受的,因为它可以在后续的过程中进行重构。
-
**运行测试并确保它们通过:**测试应该全部通过,包括之前添加的所有测试。如果新代码破坏了之前通过的测试,可以撤销更改以找出可能的破坏性变化。
-
**重构/清理代码:**如果需要进行任何代码清理,可以在这一步完成。在这里,您可以改进新添加的代码的实现,或者修复在添加新代码时可能已经破坏的任何测试。在任何重构之后,应该再次运行测试以确保所有更改都是正确的。根据需要重复重构和运行测试步骤,直到重构正确为止。
-
**重复:**添加一个新的测试,并重复 TDD 周期,直到功能已经完全实现和测试。
测试驱动开发是确保所有代码都经过测试的强大方法,但如果开发人员不够谨慎,它可能会导致几个陷阱。当需要完整堆栈或功能测试时,TDD 可能很难使用。完整堆栈或功能测试是一次对技术堆栈的多个部分进行测试。需要用户界面元素、数据库调用或网络调用的测试可能非常难编写。通常情况下,代码中测试的外部世界交互可以通过使用模拟数据或网络调用来欺骗。
如果测试不经常运行或维护不当,TDD 也可能会开始崩溃。如果测试被放弃并且从不运行,或者只是偶尔运行,TDD 的整个目的就会崩溃。添加到程序中的功能是根据测试设计的,并且测试用于验证功能是否被正确实现。如果测试从未运行,TDD 的整个目的就被忽视了。维护不当的测试也会阻止 TDD 的有效性。维护不当可能是因为没有更新以满足调整后的功能要求,或者没有添加概述新功能要求的新测试。维护不当的测试将无法正确地告诉您编写的代码是否按照我们想要的方式执行。
TDD 也可能会受到测试编写不当或懒散的影响。如果测试太粗糙,它们将无法找到代码中的错误。测试必须具有足够的特异性,以独立地测试每一点逻辑,而不受其他逻辑的影响。另一方面,如果添加了琐碎的测试,我们会在 TDD 敏捷过程中浪费时间。如果编写了琐碎的测试或重复了以前的测试,我们将降低开发效率。
最后,如果团队中的任何成员不采用开发策略,TDD 可能会崩溃。如果只有部分开发团队在添加新代码之前编写测试,我们只能测试和验证代码库的一小部分。为了使 TDD 取得最佳结果,所有开发团队成员都必须完全采用它。
结论
测试代码是确保代码按预期方式运行的最佳方法。如果您目前不测试代码,要开始实施测试可能会非常困难;然而,这是必须要做的。测试代码可以使您的代码更正确、更容易编写和更高质量。
测试驱动开发是在项目中开始集成测试的最简单方法之一。TDD 围绕着在编写任何实现代码之前添加概述任何功能或函数要求的测试。它迫使开发人员准确了解每个功能将如何实现。TDD 是一个简单的六步过程:添加测试,运行测试,编写代码,运行测试,重构,重复。这个过程确保了每个功能的小部分都得到了测试。
练习 24:应用测试驱动开发
你被要求编写一个斐波那契数生成器。使用测试驱动开发周期编写测试并开发斐波那契算法。您可以参考第一章:介绍 ECMAScript 6中的斐波那契代码,进行修改。您应该为n=0
条件编写测试,然后实现n=0
条件,然后为n=1
条件编写测试并实现,然后为n=2
条件编写测试并实现,最后为n=5
、n=7
和n=9
条件编写测试并实现。如果测试通过,则记录测试通过
。否则,抛出错误。
使用 TDD 开发和测试算法,执行以下步骤:
-
手工计算斐波那契数列在 n=0,n=1,n=2,n=5,n=7 和 n=9 时的值。
-
编写一个名为
fibonacci
的函数,该函数以变量i
作为输入,递归计算斐波那契数列的值,并检查i<=0
。
如果是,返回1
,然后检查if i==1
。
如果是,则返回1
。否则,它会递归获取斐波那契值。
然后返回fibonacci(i-1) + fibonacci(i-2)
。
-
编写一个名为
test
的通用测试函数,它接受两个参数:计算出的值(value
)和预期值(expected
)。 -
检查两个值是否不同。如果它们不同,则抛出错误。
-
如果两个值相同,请打印
测试通过
消息。 -
对于每个要测试的条件(在步骤 1 中计算,n=0,n=1,n=2,n=5,n=7 和 n=9),使用
test
函数编写测试条件的测试。 -
调用
test
函数,并传入从fibonacci
函数返回的值和手动计算的值。 -
运行测试。
-
如果测试失败,请修复
fibonacci
函数中的错误。 -
修复错误后再次运行测试。
-
如果测试通过,请继续下一个测试条件。
-
如果测试失败,请修复错误并重新运行测试。
代码
index.js
function fibonacci( i ) {
if ( i <= 0 ) {
return 0;
} else if ( i === 1 ) {
return 1;
} else {
return fibonacci( i - 1 ) + fibonacci( i - 2 );
}
}
function test( value, expected ) {
if ( value !== expected ) {
throw new Error( 'Value did not match expected value' );
} else {
console.log( 'Test passed.' );
}
}
test( fibonacci( 0 ), 0 );
test( fibonacci( 1 ), 1 );
test( fibonacci( 2 ), 1 );
test( fibonacci( 5 ), 5 );
test( fibonacci( 7 ), 13 );
test( fibonacci( 9 ), 34 );
https://bit.ly/2H5CNv0
代码片段 4.1:测试代码
输出
图 4.1:斐波那契测试
您已成功应用测试驱动开发来开发和测试算法。
测试类型
软件测试有许多不同的形式。在本节中,我们将讨论测试代码的不同方法,并涵盖最常见的代码测试类型。
黑盒和白盒测试
测试代码有两种方法,黑盒和白盒。术语黑盒表示内部工作原理未知的系统。观察系统的唯一方法是通过其输入和输出。白盒系统是已知内部工作原理的系统。可以通过其输入、输出和确切的内部工作原理来观察。黑盒和白盒系统可以是任何东西,从软件程序到机械设备或任何其他系统。
黑盒测试是指在测试软件时,测试人员不知道代码的内部结构或实现。我们只能观察代码系统的输入和输出。白盒测试是指在测试软件时,测试人员知道代码的内部结构或实现。我们能够观察输入和输出,并确切地了解程序每一步的内部状态如何改变。几乎所有形式的代码测试都基于黑盒或白盒测试原则。以下图示显示了黑盒与白盒的对比:
图 4.2:黑盒和白盒可视化
我们将讨论三种类型的测试:单元测试,功能测试和集成测试。单元测试旨在验证所有可测试代码的预期目的。它们测试最小的逻辑片段,以确保实现的正确性。功能测试旨在确认功能或组件的功能。集成测试旨在测试集成的组件,以验证它们在集成系统中一起按预期工作。这三种代码测试为您提供了一个良好的基础,可以从中进行代码测试。
单元测试
单元测试是最常见的测试形式之一。单元测试用于确保函数的特定功能部分已满足要求。单元测试通常从白盒测试的角度构建,我们将在本章中讨论单元测试,假设已知代码的内部功能。虽然单元测试可以从黑盒的角度构建,但这更接近功能测试,并将在下一节中更多地讨论。
单元测试只是测试尽可能小的代码单元的测试。代码的“单元”是一个与代码的其他部分逻辑上隔离的小片段。换句话说,它是一段不依赖于代码其他部分的逻辑的代码。代码单元可以更新而不影响其周围代码的功能。例如,考虑以下代码片段中显示的代码:
function adjustValue( value ) {
if ( value > 5 ) {
value--;
} else if ( value < -5 ) {
value++;
}
return value
}
片段 4.2:代码单元示例
函数adjustValue()
接受一个数字。如果数字大于 5,则从数字中减去 1,如果值小于-5,则向数字中添加 1。我们可以将这段代码分解为三个逻辑单元,如下所示:
-
第一个单元是检查值是否大于 5 的
if
语句和减量运算符(value--
)。 -
第二个单元是
else if
语句,检查值是否小于-5,并且增量运算符(value++
)。 -
第三个逻辑单元是
return
语句。更改这三个逻辑单元中的任何一个都不会影响其周围代码的逻辑结构。
我们可以为每个单元创建一个单元测试,以确保它们的功能正确。我们的单元测试应该一次只测试一个代码单元。对于这个例子,我们将需要 3 个单元测试。我们将构建测试来检查返回值、大于 5 的条件和小于-5 的条件。要测试返回条件,我们只需要传入一个小于或等于 5 且大于或等于-5 的值。返回的值应该与传入函数的值相同。要测试大于 5 的条件,我们必须传入一个大于 5 的值。我们知道返回的值必须比输入的值低 1。要测试小于条件,我们必须传入一个小于-5 的值。我们知道返回的值应该比输入的值高 1。这三个单元测试可以放入一个代码文件中,并在对代码进行修改后运行。
单元测试应尽可能频繁地运行。单元测试应该放入文件中,并在任何代码逻辑发生变化时运行。代码片段逻辑的微小变化可能导致结果的重大变化。持续测试将有助于确保没有小错误悄然产生。许多公司都有自动化测试系统,将在 Git 存储库提交或版本发布时自动运行单元测试。这种自动化测试对于帮助追踪破坏代码的提交和更改非常有用。这可以大大减少调试时间和精力。
练习 25:构建单元测试
你被要求为一段代码构建单元测试。要完成这个任务,请按照以下说明进行操作:
-
参考
exercises/exercise25/exercise.js
中提供的文件,并查看名为fakeRounding
的函数。我们将为这个函数构建单元测试。 -
在文件中,编写一个名为
test
的通用测试函数,该函数接受两个参数:计算出的值(value
)和预期值(expected
)。检查这两个值是否不同。如果它们不同,就抛出一个错误。
如果这两个值相同,就打印测试通过的消息。如果愿意,可以使用练习 24中的test
函数。
- 参考
fakeRounding
函数,逐行分析函数对输入和输出的影响。
它获取传入数字的绝对值的小数部分。如果小数<=0.5,则返回最接近整数的输入。接下来,如果小数>0.5,则返回最接近整数的输入向下取整。
- 使用我们创建的
test
函数编写测试,检查以下情况。从提供的输入计算预期值。
为多个输入编写测试,0、0.4999、0.5、0.5001、-0.4999、-0.5 和-0.5001:
代码:
solution.js
test( fakeRounding( 0 ), 0 );
test( fakeRounding( 0.4999 ), 1 );
test( fakeRounding( 0.5 ), 1 );
test( fakeRounding( 0.5001 ), 0 );
test( fakeRounding( -0.4999 ), 0 );
test( fakeRounding( -0.5 ), 0 );
test( fakeRounding( -0.5001 ), -1 );
片段 4.3:单元测试
https://bit.ly/2Fjulqw
输出:
图 4.3:单元测试
您已经成功为一段代码构建了单元测试。
功能测试
功能测试是一种黑盒测试方法,用于确定应用程序的组件是否按照定义的规范工作。功能测试通常比单元测试更复杂。单元测试测试组件内部函数的逻辑,而功能测试旨在测试组件是否符合规范表或数据表中定义的规范。例如,如果我们在网页上有一个只接受数字的表单,我们可能会使用数字和字符串进行功能测试,以确保正确满足仅接受数字的规范。
功能测试可以分为五个步骤:
-
确定功能
-
创建输入数据
-
确定输出数据
-
比较输入和输出
-
修复错误
构建功能测试的第一步是确定需要测试的功能。功能测试通常测试主要功能、错误条件、可用性等。通常最容易确定需要构建的测试是通过查看特性/组件规范或数据表来确定的。您可以从数据表中获取组件的所需程序行为和错误处理,并将其分解为一系列测试。
一旦确定了需要测试的功能以及如何测试该功能,您必须创建输入数据进行测试。测试所需的输入数据严重依赖于正在构建的组件或特性,因此很难为教科书的目的进行概括。但是,您应该使用您期望程序接受的值和可能对程序来说意外的值进行测试。例如,如果我们正在创建一个电子邮件输入表单,我们应该使用有效的电子邮件([email protected]
)和无效的电子邮件(12344312
)来测试输入字段。在生成任意测试数据时,通常最好使用数组、字符串或其他数据结构中的非顺序值进行测试。使用随机值可以帮助您发现逻辑错误。
确定测试所需的输入数据后,您必须确定特性的预期输出。这个过程的这一部分可以说是最重要的,不应该草率对待。输出值绝对不能通过将输入通过正在测试的程序来计算。这将导致在运行测试时出现重言,不会发现任何错误。我曾经看到许多测试失败,因为程序员没有正确计算预期的输出值,测试无效。
一旦确定了输出值,我们就可以运行我们的测试了。输入值应该通过特性或组件,并与输出值进行比较。如果组件的输出值与前一步计算的预期输出值相匹配,则测试通过。如果值不匹配,则测试未通过,需要修复错误。
该过程的最后一步是修复错误。如果测试未通过,则组件中存在错误。修复错误后,可以重新运行测试。如果所有功能的所有测试都通过,则该组件可能被认为已准备好进行集成。
构建测试可能是功能测试中最困难的部分之一。我们需要构建两种不同类型的测试:正向测试和负向测试。正向测试测试预期的程序使用流程,而负向测试测试意外的使用流程。
正面测试相对容易生成。任何您希望或期望用户执行的操作都可以转化为正面测试用例。例如,单击应用程序中的按钮或在文本字段中输入信息。这两个用例可以转化为单击按钮的功能测试和输入文本字段的功能测试。由于正面测试旨在测试预期的程序流程,因此应使用有效和预期的数据。在测试不使用数据而是使用其他功能的情况下,例如用户的鼠标点击,我们只需要为预期行为编写正面测试。
负面测试更难创建。它们需要更多的创造力来有效地构建和实施,因为你必须想出奇怪的方法来破坏自己的代码。往往很难预料用户可能如何误用功能。负面测试旨在测试错误路径和失败。例如,如果我们打算让用户在我们的网站上单击一个按钮,可能会明智地为双击条件编写负面测试。双击是意外行为,如果没有妥善考虑,可能会导致表单重新提交。负面测试对于充分测试一个功能是必不可少的。
集成测试
集成测试是从功能测试中退一步。集成测试旨在测试模块和组件在完全集成时的工作方式。单元测试逐个测试功能。功能测试逐个测试完整的组件或模块。集成测试测试组合的组件,以确保它们正确地相互交互。集成测试通常比单元测试或功能测试更复杂。一旦所有组件都建立并集成在一起,集成测试可以为简单的单个网页编写,也可以为包含 API、多个服务器和数据库的完整前端应用程序编写。集成测试通常是最困难和耗时的测试形式。
集成测试可以简化并且可以像制造圆珠笔的过程一样思考。盖子、笔身、墨水、圆珠和带夹的尾盖都是圆珠笔的组成部分。它们都是分别制造和测试,以确保每个组件都符合其设定的规格。当这些部件准备好后,它们被放在一起进行集成测试,以测试这些组件是否能正确地一起运行。例如,我们的集成测试可能测试圆珠是否能放入墨水盒中,墨水和圆珠是否能放入笔身中,或者盖子是否能放在笔身上。如果其中一个测试失败,集成系统(圆珠笔)将无法按规格运行,一个或多个组件必须更新。
进行集成测试有几种方法。它们包括大爆炸测试、自下而上测试、自上而下测试和夹层测试。每种方法都有其优点和缺点。
大爆炸测试包括一次性组合所有组件,然后运行测试。它被称为大爆炸测试,因为你一次性将所有东西放在一起,然后会出现(很可能)失败的集成测试。大爆炸测试对于没有太多组件之间交互的小型系统非常方便。但是,当应用于大型系统时,大爆炸测试通常会出现问题。第一个问题是在非常大型和非常复杂的系统中,故障定位可能会更加困难。如果找到错误源需要很长时间,我们的测试周期将会非常缓慢。第二个问题是由于系统的复杂性,一些组件之间的链接可能会被忽略而未经测试。如果有数百个需要测试的组件链接,一旦它们全部同时链接起来,要跟踪它们可能会很困难。大爆炸测试的第三个问题是,集成测试无法在所有模块或组件被设计和完全构建之前开始。由于必须一次性组合所有模块,一个模块的延迟会推迟整个系统的集成测试。
集成测试的第二种形式是自下而上的测试。在自下而上的测试中,我们必须将系统的层次结构想象成一棵树。我们首先集成底层模块,然后一旦所有测试通过,我们就添加下一层模块或组件,直到整个系统都被测试。为了以这种方式进行测试,我们必须使用驱动程序来模拟上层并调用我们正在测试的底层模块或组件。驱动程序只是模拟高级模块和它们对低级模块的调用的代码片段,用于测试目的。自下而上的测试有两个主要好处。第一个是故障定位非常容易。模块从最低级别开始集成。如果新集成的模块失败,那么我们可以快速找出需要修复的模块。第二个好处是不需要等待所有模块都开发完成。如果模块也是按自下而上的方式开发的,那么一旦准备就绪,我们就可以将它们添加到集成测试中。我们可以在准备就绪时进行集成测试,而不是等到整个系统构建完成。自下而上的测试有两个主要缺点。第一个是很难创建早期的工作原型。由于模块是自下而上构建和集成的,用户界面功能和模块通常是最后实施和测试的。由于原型组件通常最后准备就绪,因此很难拥有早期原型。第二个缺点是控制应用程序流程的顶层关键组件和模块最后进行测试,可能无法像首先测试的模块那样进行充分测试。对于大型集成系统,我一般认为自下而上的测试比大爆炸测试更好。
集成测试的第三种形式是自顶向下测试。在自顶向下测试中,我们必须将系统层次结构想象成一棵树。我们首先集成系统的顶层。这些通常是面向用户的组件和程序流模块。自顶向下测试要求测试人员构建存根来模拟较低级别模块的功能。存根模仿未开发的模块,以便正在测试的模块可以进行所需的调用。自顶向下测试有三个主要优点。与自底向上测试一样,第一个主要优点是故障定位非常容易,我们不需要等待整个系统构建完成才能开始集成测试。组件可以一次添加一个,一旦它们被构建。自顶向下测试的第二个优点是可以非常容易地创建早期原型。首先构建和测试面向用户和最关键的组件,因此很容易将它们集成到早期演示的原型中。最后一个主要优点是对关键模块进行了优先测试。关键模块首先构建,因此更频繁地进行测试,通常更完整。自顶向下测试有两个主要缺点。第一个是需要许多存根。每个较低级别的模块或组件必须构建成一个用于测试的存根。这可能需要编写大量额外的代码。第二个缺点是较低级别的模块通常是最后构建和测试的。通常,它们没有经过如此彻底的测试。
集成测试的最终形式是夹层测试。夹层测试是自顶向下和自底向上方法的结合。最重要和最低级别的模块同时构建和集成。这种方法的好处是提供了更一般和大爆炸式的集成测试方法,同时保持了自顶向下和自底向上测试的优点。夹层测试的最大缺点是需要构建存根和驱动程序。如果系统非常复杂,有时很难分清存根和驱动程序。
构建测试
构建测试可能看起来是一个非常艰巨的过程。从头开始构建整个测试套件可能非常困难。然而,测试驱动开发为我们提供了一个非常好的测试起点。如前所述,在测试驱动开发部分,构建测试应始终从编写需求表开始。
需求表是用于构建功能、特性或整个系统的数据表。需求表应将功能的要求细分为非常详细和具体的列表。为软件应用程序编写需求表超出了本书的范围,但我们将通过一个简要的示例进行介绍。假设我们被要求构建一个类似 Facebook 的评论创建组件。该组件必须具有一个带有字符限制的文本字段和一个在点击事件后发表评论的按钮。我们可以从这个场景中轻松构建出两个一般要求:文本字段的字符限制和按钮在点击事件后进行 API 调用。然后,这两个要求可以细化为以下要求列表:
-
文本字段必须接受用户输入的字符。
-
文本字段包含 250 个或更多字符时,无法向文本字段添加字符。
-
在文本字段中,按下退格键可以删除任何字符。
-
按钮必须对
onclick
事件做出响应。 -
在点击事件中,组件必须使用文本字段数据调用 API。
这不是功能或功能组件的完整需求列表,但对于这个示例来说,已经足够了。有了这些需求,我们就可以开始编写我们的测试了。
我们可以开始编写测试,逐项通过我们的需求列表。每个需求都应该分解为一个或多个测试。每个测试应该测试一件事,并具有非常具体的成功标准。
第一个要求是文本区域必须接受用户输入的字符。如果我们在键盘上按键,按下的字符应该添加到文本区域,所以我们的第一个测试应该是在键盘上按键,并验证相同的字符是否添加到文本区域。
第二个要求规定,当文本区域包含 250 个或更多字符时,不能添加任何字符到文本字段。这可以分为两个测试:当文本区域有 250 个字符时,不能添加任何按键到文本区域,当文本区域有超过 250 个字符时,不能添加任何按键到文本区域。
第三个要求规定,可以通过按下退格键删除文本字段中的任何字符。这个要求可以很容易地转化为一个测试。我们必须测试,如果按下退格键,一个字符将从文本区域中删除。为了正确测试边缘情况,我们应该运行这个测试四次:一次是空的文本区域,一次是有 0 个但少于 250 个字符的文本区域,一次是 250 个字符,一次是超过 250 个字符。测试文本区域的所有操作条件(甚至是我们从未预期达到的超过 250 个字符的测试用例)将确保不会发生任何故障。
第四个要求规定按钮必须响应点击事件。这个测试非常容易编写。我们只需要添加一个测试,用户点击按钮。最后一个要求规定,按钮上的点击事件必须调用 API。我们可以很容易地将这转化为一个测试,通过模拟点击事件,并确保网站使用正确的数据调用 API。
我们已经在一系列测试中概述了五个要求的列表。现在可以将这些测试编译在一起,并以代码形式编写在一个测试文件中。这个测试文件将用于验证我们需求表中概述的需求是否得到了正确满足。
练习 26:编写测试
你的团队被要求为你的通讯订阅建立一个注册页面。注册页面必须有三个文本字段,用于姓名、电子邮件和年龄,以及一个提交按钮。您的注册页面必须接受 1 到 50 个字符(包括)之间的姓名,1 到 50 个字符(包括,不验证电子邮件格式)之间的电子邮件,以及用户的年龄(必须大于 13 岁)。当按下提交按钮时,用户信息必须经过验证(根据前一节提供的规格)。如果规格的任何部分未满足,就在浏览器控制台中抛出错误。编写一个非常基本的规格表,详细说明每个输入和提交按钮的要求,然后从规格表中构建测试。实现页面(使用exercises/exercise26/exercise.html
作为起点),并从 UI 手动执行测试。起始文件包含了您必须编写的测试的提示。在打开起始文件之前编写规格表和测试。
构建一个基本的规格表,并从规格表中运行测试,执行以下步骤:
-
通过将包含场景描述中规格信息的每个句子拆分为一个或多个需求来编写规格表。
-
将规格表分解为手动 UI 测试,方法是将规格表上的每一项都写成一个或多个测试。
-
打开
exercises/exercise26/exercise.html
中的起始 HTML 文件。 -
添加三个带有 ID
name
、email
和age
的输入字段。如下图所示:
图 4.4:数据表(第 4 步后)
-
将提交按钮添加到 HTML 文档中,并在单击时调用
validate
函数。 -
在验证函数中,通过 id 获取
name
文本字段并将其值保存在name
变量中。
通过 id 获取email
文本字段并将其值保存在email
变量中。
通过 id 获取age
文本字段,获取其值,解析数字的值,然后将解析后的值保存在age
变量中。
检查与name
字段相关的规范表上的条件。还要检查名称是否不存在,或者为 false,如果是,则抛出错误。检查name length <= 0 or > 50
,如果是,则抛出错误。
检查与email
字段相关的规范表上的条件。还要检查电子邮件是否不存在,或者为假;如果是,则抛出错误。检查email length is <=0 or > 50
,如果是,则抛出错误。
检查与age
字段相关的规范表上的条件。还要检查年龄是否不存在,或者为假;如果是,则抛出错误。检查age < 13
,如果是,则抛出错误。
将用户详细信息(name
,email
和age
)记录到控制台。
- 对于规范表中编写的每个测试,手动测试它。填写文本字段中的值,然后单击提交。
将记录在控制台的错误与测试的预期结果进行比较。
如果测试失败,则更新验证函数以修复错误并重新运行测试。
代码
solution.html
<body>
<input type="text" id="name" value="Name">
<input type="text" id="email" value="Email">
<input type="text" id="age" value="Age">
<button onclick="validate()">Submit</button>
<script>
function validate() {
const name = document.getElementById( 'name' ).value;
const email = document.getElementById( 'email' ).value;
const age = parseInt( document.getElementById( 'age' ).value, 10 );
if ( !name ) {
throw new Error( 'Must provide a name.' );
} else if ( name.length <= 0 || name.length > 50 ) {
throw new Error( 'Name must be between 1 and 50 characters.' );
}
if ( !email ) {
throw new Error( 'Must provide an email.' );
} else if ( email.length <= 0 || email.length > 50 ) {
throw new Error( 'Email must be between 1 and 50 characters.' );
}
if ( !age ) {
throw new Error( 'Must provide an age that is also a number.' );
} else if ( age < 13 ) {
throw new Error( 'Age must be at least 13.' );
}
console.log( 'User details:
Name: ${
name}
Email: ${
email}
Age: ${
age}' )
}
</script>
</body>
片段 4.4:测试前端输入代码
https://bit.ly/2H5E7OJ
输出
图 4.5:数据表(最终输出)
您已成功构建了基本的规范表并从规范表中运行了测试。
测试工具和环境
测试工具、框架和环境旨在使测试代码更简单、更快速。JavaScript 有许多可用的测试框架,最受欢迎的将会简要提到。然后我们将深入研究其中一个框架,并演示如何使用该框架编写良好的测试。
测试框架
您需要根据希望进行的测试类型选择测试框架。通常使用三种方式对 JavaScript 进行测试:一般测试,代码覆盖测试和用户界面测试。在选择框架时,必须决定要测试什么以及希望如何进行测试。
一般测试将包括单元测试、功能测试和集成测试。这是您测试的一种综合方式。最受欢迎的测试框架是Mocha,Jasmine和Jest。Jest 由 Facebook 使用,是设置最简单的框架之一。Mocha 是 JavaScript 中最受欢迎的测试框架,并且稍后将更详细地介绍它。
代码覆盖测试用于帮助检查测试的完整性。代码覆盖可以定义为您的自动化测试覆盖的代码基数的百分比。代码覆盖可以用作代码测试完整性的一般指导。理论上,应用程序的代码覆盖率越高,测试就越完整和更好。但是,在实践中,拥有 100%的代码覆盖并不意味着代码的测试经过深思熟虑且有效。这只意味着每个代码路径在某种程度上都在测试中引用。编写深思熟虑的测试比随意组合的测试更重要,后者会触及每行代码。最受欢迎且最简单的代码覆盖库是Istanbul。它与许多测试框架兼容,并且可以轻松地融入大多数测试套件中。如果需要第三方库进行代码覆盖测试,我建议使用 Istanbul。
测试的最终形式是用户界面(UI)测试。与一般测试一样,我们可以将 UI 测试分为集成测试、功能测试和单元测试。然而,UI 测试通常不包括在一般测试中,因为它们需要特殊和更复杂的框架。要执行 UI 测试,我们必须加载用户视图并模拟用户交互。一些更常见的 UI 测试框架包括 Testcafe、WebdriverIO 和 Casper。
Mocha
Mocha是一个用于在 Node.js 中测试 JavaScript 的框架。它是一个简单的库,旨在简化和自动化测试过程。Mocha 被设计为简单、灵活和可扩展的。我的公司使用 Mocha 进行单元测试、功能测试和集成测试。我们将讨论使用 Mocha 而不是其他框架的一些好处,介绍如何设置和运行 Mocha 的第一个测试,并解释 Mocha 提供的一些高级功能。
注意
Mocha 的完整文档可以在mochajs.org/
找到。
Mocha 有许多好处。正如前面所述,Mocha 是 Node.js 最流行的测试框架。这立即给 Mocha 带来了最大的优势:Mocha 拥有最大的开发社区。这对于支持和扩展非常重要。如果您在 Mocha 测试中遇到问题,这个社区可以提供广泛的支持。Stack Overflow 社区很快就会回答关于 Mocha 的问题。Mocha 社区还为独特的测试场景构建了许多插件或扩展。如果您的项目有独特的测试需求,很可能已经构建了适合您需求的插件。
除了庞大的社区支持外,Mocha 还提供了简单的设置、断言和简单的异步测试等优势。通过 npm 可以通过命令行设置 Mocha。对于任何测试框架,我们希望确保设置它不会花费太多时间。Mocha 还允许使用断言模块。虽然不是必需的,但如果您的团队希望从断言标准来进行测试,Mocha 允许您安装和导入许多 JavaScript 断言库。最后,Mocha 专为异步测试而设计。对于任何 JavaScript 测试模块,我们必须依赖异步支持来编写完整的测试。Mocha 被设计为与回调、promises 和 ES6 async/await 语法一起工作。它可以轻松地集成到大多数后端设置中。
设置 Mocha
安装 Mocha 是通过 npm 命令npm install -g mocha
完成的。这个命令会在系统上全局安装 Mocha。任何 Node.js 项目现在都可以使用 Mocha 来运行测试。一旦全局安装,我们就可以使用命令行运行测试,使用mocha
关键字。
一旦 Mocha 在我们的系统上安装好了,我们必须将其添加到一个项目中。如果您没有 Node.js 项目,请创建一个到所需项目目录的路径,并使用npm init
初始化项目。这是在第一章中讨论转译和 Babel 时设置项目时使用的相同命令。npm init
命令将创建一个名为package.json
的文件。创建 JavaScript 项目后,我们需要创建项目文件。创建一个名为index.js
的文件和一个名为test.js
的文件。index.js
将包含我们的项目代码,test.js
将包含我们的测试代码。
在package.json
文件中,将会有一个名为scripts
的字段。要从 npm 运行我们的测试,我们必须向scripts
对象添加一个字段。用以下片段中显示的代码替换scripts
对象:
"scripts": {
"test": "mocha ./test.js"
}
片段 4.5:package.json 中的测试脚本
前面片段中的代码向package
对象添加了一个名为test
的脚本。我们可以使用npm run test
命令运行此脚本。运行此命令时,它会调用mocha
关键字和./test.js
参数。Mocha 测试框架将运行test.js
文件中包含的测试。现在我们已经准备好开始向test.js
添加测试了。
Mocha 使用describe
和it
关键字组织测试。两者都是以字符串作为第一个参数和函数作为第二个参数的函数。describe
函数用于将测试分组在一起。it
函数用于定义一个测试。describe()
的函数参数包含测试声明(使用it()
)或更多的描述函数。it()
的函数参数包含要运行的测试函数。
您可以将 describe 函数视为描述和组合一组测试的方式。例如,如果我们有一组测试都测试一个名为calculateModifier
的函数,我们可以使用 describe 函数将这些测试组合在一起,并使用描述:describe( 'calculateModifier tests', () => { ... } )
。这将把包含在函数中的测试分组在calculateModifier
测试下。
您可以将it
函数视为定义测试的一种方式,形式为“它应该……”。传递给it
函数的字符串描述了测试,通常是测试试图实现的内容。函数参数包含实际的测试代码。例如,如果我们想定义一个检查两个值是否相等的测试,我们可以使用it
函数来做到这一点:it( 'should have two inputs that are equal', () => { ... } )
。描述告诉我们应该发生什么,检查值的代码将放在函数参数中。
Mocha 基础知识
了解测试的基础知识后,我们可以查看 Mocha 入门文档,并查看以下代码片段中显示的代码:
var assert = require('assert');describe('Array', function() {
describe('#indexOf()', function() {
it('should return -1 when the value is not present', function() {
assert.equal([1,2,3].indexOf(4), -1); }); });});
代码片段 4.6:Mocha 基础知识
您认为这段代码在做什么?首先,我们用描述Array
描述了一组测试。在第一个describe
块的函数参数内部,我们有另一个describe
块。这个新块描述了一个带有描述#indexOf
的测试集;因为这些描述块是嵌套的,我们可以假设我们正在测试数组的indexOf
功能。在第二个describe
块内部,我们使用it
函数定义了一个测试。我们定义了一个测试,说当值不存在时应返回-1
。根据测试的描述,我们期望indexOf
函数在数组中的值不存在时返回值-1
。在这个例子中,我们使用 assert 库来断言预期值-1
等于实际值。assert 库并不是严格必要的,但使这个例子更容易理解。
练习 27:设置 Mocha 测试环境
目标是设置 Mocha 测试环境并准备一个测试文件。要完成此任务,请按照以下步骤操作:
-
运行
npm init
在练习目录中创建一个package.json
文件。 -
运行
npm install mocha -g
来安装测试包。 -
创建一个名为
test.js
的文件,我们的测试将放在其中。 -
在
package.json
文件中添加一个脚本,以在test.js
文件上运行 mocha 测试套件。 -
在
test.js
文件中,添加一个describe()
块,将测试描述为My first test!
-
在
describe
块的回调内部,添加一个带有it()
的测试,通过并具有描述Passing test!
-
通过调用
package.json
中添加的npm
脚本来运行测试。
代码:
test.js
describe( 'My first test!', () => {
it( 'Passing test!', ( done ) => done( false ) );
} );
代码片段 4.7:Mocha 基础知识
https://bit.ly/2RhzNAy
输出:
图 4.6:Mocha 测试
您已成功设置了 Mocha 测试环境并准备了一个测试文件。
Mocha 异步
Mocha 支持异步测试和同步测试。在 Snippet 4.6 中显示的示例中,我们执行同步测试。要支持异步测试,我们只需要将一个 done 回调参数传递到it()
函数的函数参数中:it( 'description', ( done ) => {} )
。这告诉 mocha 在继续进行下一个测试之前等待done
回调被调用。done
参数是一个函数。如果测试成功,应该使用一个falsy
值(没有错误)调用 done。如果使用一个truthy
值调用 done,mocha 将解释该值为错误。最佳实践是将错误对象传递给 done 回调,但任何评估为 true 的值都会告诉 Mocha 测试失败。
Mocha 以同步方式按照测试文件中定义的顺序同步执行异步测试。测试可能会异步查询资源,但在上一个测试完全完成(done 已被调用)之前,下一个测试不会开始运行。同步运行测试非常重要。即使同步运行测试可能导致更长的测试时间,它也允许我们测试依赖于一些共享状态的异步系统。例如,我们可以使用 Mocha 测试数据库和数据库接口等系统。如果我们需要执行一个集成测试,测试向数据库添加和删除的过程,我们可以创建一个测试来向数据库添加项目,以及一个测试来从数据库中删除添加的项目。如果这两个测试异步运行,我们可能会遇到时间问题。由于网络延迟或其他意外错误,删除操作可能在添加操作之前被处理,测试将失败。Mocha 通过强制测试同步运行来避免调试此类问题的需要。
Mocha Hooks
对于更复杂的测试,Mocha 允许我们将钩子附加到我们的测试上。Hooks可以用于设置测试的前提条件和后置条件。简单来说,钩子允许我们在测试之前和之后进行设置。Mocha 提供以下钩子:before
,after
,beforeEach
和afterEach
。钩子接受两个参数,一个description
和一个callback
函数参数。这个函数参数可以接受一个参数 - 一个 done 函数。钩子的语法示例如下所示:
describe( ‘Array’, () => {
before( 'description', done => {
... } );
after( 'description', done => {
... } );
beforeEach( 'description', done => {
... } );
afterEach( 'description', done => {
... } );
} );
Snippet 4.8: Mocha hooks
钩子只在它们所包含的描述块中的测试之前或之后运行。before
钩子在任何定义的测试开始之前运行一次。它们可以用于在测试之间设置一个共享状态。beforeEach
钩子在每个测试开始之前在describe
块内运行。它们可以用于设置或重置每个测试所需的共享状态或变量集。after
钩子在所有测试完成后运行一次。它们可以用于清理或重置测试之间共享的状态。afterEach
钩子在每个测试完成后但下一个测试开始之前运行。它可以用于清理或重置特定于测试的共享状态。
Activity 4: Utilizing Test Environments
您的任务是将斐波那契序列测试代码升级为使用 Mocha 测试框架。取出斐波那契序列代码并测试您为Activity 1: Implementing Generators创建的代码,并升级为使用 Mocha 测试框架来测试代码。您应该为n=0
条件编写测试,实现它,然后为n=1
条件编写测试并实现。对于n=5
,n=6
以及n=8
也重复这个过程。如果it()
测试通过,调用没有参数的 done 回调,否则使用错误或其他truthy
值调用测试完成回调。
要使用 Mocha 测试框架编写和运行测试,请执行以下步骤:
-
设置 NPM 项目并安装 mocha 模块。
-
在
package.json
中添加一个测试脚本,运行 mocha 和test.js
中的测试。 -
创建一个
index.js
文件,其中包含一个斐波那契数列计算器函数。导出这个函数。 -
创建
test.js
,使用 mocha 框架测试斐波那契数列函数。测试fibonacci
,n=0,n=1,n=2,n=5,n=7 和 n=9。
输出
图 4.7:使用 Mocha 测试斐波那契数列
你已成功利用 Mocha 测试框架编写和运行测试。
注意:
此活动的解决方案可在第 288 页找到。
总结
代码测试是开发人员可以拥有的最重要的技能之一。测试代码就像去健身房一样。你知道这对你有好处,但往往很难开始。在本章中,我们讨论了测试代码的原因,几种代码测试类型以及几种 JavaScript 代码测试框架。需要进行代码测试以确保程序的正确性。测试驱动开发是将测试整合到项目中的最简单方法之一。TDD 围绕着编写测试来概述任何添加的功能或函数的要求,然后再编写任何实现代码。有许多形式的代码测试。在本章中,我们介绍了单元测试、功能测试和集成测试。这些类型的代码测试是最常见的,通常是从黑盒和白盒两种方法中构建的。功能、单元和集成测试都可以在前面主题中涵盖的许多框架中构建。
在下一章中,我们将介绍函数式编程编码原则,并定义面向对象编程和函数式编程。
第五章:函数式编程
学习目标
在本章结束时,您将能够做到以下几点:
-
解释函数式编程
-
实现函数式编程的关键概念
-
将函数式编程概念应用于您的代码
-
以函数式编程风格构建新的代码库
本章解释了编程的类型,包括面向对象编程和函数式编程,以及如何使用不同类型的函数。
介绍
在第一章中,我们涵盖了 ES6 中发布的许多新功能和强大功能。我们讨论了 JavaScript 的发展,并突出了 ES6 中的关键增强。我们讨论了作用域规则、变量声明、箭头函数、模板文字、增强对象属性、解构赋值、类和模块、转译以及迭代器和生成器。
在第二章中,我们涵盖了 JavaScript 的异步编程范式。我们讨论了 JavaScript 事件循环、回调、承诺和 async/await 语法。
在第三章中,我们学习了文档对象模型(DOM)、JavaScript 事件对象和 jQuery 库。
在第四章中,我们讨论了测试 JavaScript 代码。我们涵盖了测试的原因以及如何添加测试的方法。然后,我们讨论了不同类型的代码测试以及它们如何应用于您的代码库。最后,我们讨论了各种 JavaScript 代码测试框架以及如何在其中构建测试。
在本章中,我们将介绍函数式编程的编码原则。在第一个主题中,我们将定义面向对象编程和函数式编程,讨论两者之间的区别,并概述我们使用函数式编程的原因。然后,在随后的部分中,我们将讨论函数式编程的每个关键概念。对于每个关键概念,我们将概述定义并展示其在函数式编程中的应用。
引入函数式编程
有许多不同的方法来处理软件设计和构建。最知名的设计哲学或编程范式是面向对象编程(OOP)和函数式编程(FP)。编程范式是一种思考软件设计和构建的方式。编程范式基于几个定义原则,并用于组织和描述软件应用的设计和构建。函数式编程是一种通过表达式和声明构建软件的编程范式。在本节中,我们将讨论面向对象编程和函数式编程的基础知识,并比较这两种编程范式。
面向对象编程
面向对象编程(OOP)是一种基于对象和语句的编程范式。对象是用于组织应用程序部分的编程抽象。在 OOP 中,对象通常包含并存储属性中的数据,具有可以在方法中运行的过程,并且具有this或self的概念,这是对象引用自身的一种方式。一般来说,对象以类的形式存在。类可以被视为对象的定义,具有其属性、方法和this范围。对象是类的实例化。在 OOP 中,语句是指令驱动的代码。这将在声明式与命令式主题中更详细地介绍。许多编程语言都适用于面向对象编程软件开发。最流行的面向对象编程语言是 C++、Java 和 Python。
函数式编程
函数式编程(FP)是一种基于表达式和声明而不是对象和语句的编程范式。简而言之,这意味着 FP 依赖于函数而不是对象来组织代码和构建应用程序。函数式编程被认为起源于λ演算,这是在上世纪 30 年代创造的。函数式编程依赖于七个关键概念:声明式函数,纯函数,高阶函数,共享状态,不可变性,副作用和函数组合。这些概念中的每一个将在本章的后续主题中进行讨论。
函数式编程旨在更简洁,可预测和可测试。然而,这些好处可能导致 FP 代码比其他编程范式更密集。一些最常见的函数式编程语言是 JavaScript,PHP 和 Python。
声明式与命令式
有两种一般的编写代码的方式:声明式和命令式。在函数式编程范式中编写的代码应该是声明式的。
声明式代码是表达计算逻辑而不描述其控制流的代码。命令式代码是使用语句来改变程序的状态的代码。
如果你以前从未学习过声明式和命令式代码,这些定义很难理解。声明式代码通常与函数式编程一起使用,而命令式代码通常与面向对象编程一起使用。在决定使用哪种编码风格时,没有“正确答案”;它们都有各自的权衡。然而,声明式代码比命令式更适合函数式编程范式。
命令式函数
命令式代码在面向对象编程中最常见。技术上的定义很复杂,但我们可以简化它。编写命令式代码是关于你如何解决问题。考虑在餐厅找到一张桌子。你走向主人/女主人说:“我看到角落的桌子是空的。我和我的妻子要走过去坐下。”这是一种命令式方法,因为你描述了你将如何从主人/女主人那里到你的团队的桌子。
声明式函数
声明式编程在 FP 中最常见。编写声明式代码的方法可以简化为我们需要做什么。考虑前面段落中的餐厅例子。获取桌子的声明式方法是走向主人/女主人说:“请给我们两个人的桌子。”我们描述我们需要什么,而不是我们将采取的每一步来获得桌子。声明式编程是符合开发者的思维模型而不是机器的操作模型。从这些定义和比喻中,我们可以得出结论,声明式编程是对一些命令式实现的抽象。
现在,让我们从比喻转到实际代码。考虑下面片段中显示的代码:
function addImperative( arr ) {
let result = 0;
for ( let i = 0; i < arr.length; i++ ) {
result += arr[ i ];
}
return result;
}
function addDeclarative( arr ) {
return arr.reduce( ( red, val ) => red + val, 0 );
}
片段 5.1:声明式与命令式函数
在上面的片段中,我们创建了两个函数来添加数组中的值。第一个函数addImperative
是这个问题的一种命令式方法。代码逐步说明了数组将如何被添加。第二个函数addDeclarative
是同一个问题的一种声明式方法。代码说明了数组将如何被添加。它通过使用 JavaScript 数组 reduce 操作来抽象出大部分命令式解决方案(for 循环)。
开始编写声明式代码而不是命令式代码的最简单方法是创建函数。这些函数应该抽象出命令式代码的逐步性质。考虑数组操作,如find
,map
和reduce
。这些函数都是声明式的数组成员函数。它们抽象出了对数组进行迭代的逐步性质。使用它们将有助于将声明式概念引入您的代码,并减少您编写的一些命令式代码。
练习 28:构建命令式和声明式函数
您的研究团队已经获得了最新实验的值列表;但是,由于校准错误,只有部分数据可以使用,并且可以使用的任何数据都需要进行缩放。您必须构建一个实用函数,该函数接受一个数组,过滤掉小于或等于 0 的任何值,将剩余的值缩放为乘以2
,并返回最终结果。首先,构建一个命令式函数来执行此操作,然后构建一个声明式函数来执行相同的操作。
要使用命令式和声明式编码实践创建函数,请执行以下步骤:
- 定义一个名为
imperative
的函数,采用以下方法:
接受一个名为arr
的数组参数。创建一个名为filtered
的数组,用于保存过滤后的值。
创建一个for
循环来遍历输入数组arr
。对于每个项目,检查数组项的值。如果大于0
,将该值推送到过滤后的数组中。
创建一个for
循环来遍历过滤后的数组。对于每个项目,将其乘以2
并保存在相同索引的过滤后的数组中。
返回过滤后的数组。
- 定义一个名为
declarative
的函数,执行以下操作:
使用Array.filter()
过滤输入数组。在过滤器的callback
函数中,检查值是否大于0
。如果是,返回 true;否则,返回 false。
将一个 map 调用链接到filter
输出。
使用Array.map()
映射过滤后的数组。
在回调中,将每个值乘以2
。
返回修改后的数组。
-
创建一个值从
-5
到+5
的测试值数组。 -
使用值数组运行
imperative
并记录输出。 -
使用值数组运行
declarative
并记录输出。
代码
index.js
function imperative( arr ) {
const filtered = [];
for ( let i = 0; i < arr.length; i++ ) {
if ( arr[ i ] > 0 ) {
filtered.push( arr[ i ] );
}
}
for ( let j = 0; j < filtered.length; j++ ) {
filtered[ j ] = 2 * filtered[ j ];
}
return filtered;
}
function declarative( arr ) {
return arr.filter( v => v > 0 ).map( v => 2 * v );
}
代码段 5.2:命令式和声明式代码比较
https://bit.ly/2skAnic
结果
图 5.1:测试值输出
图 5.2:修改后的数组输出
您已成功利用了命令式和声明式编码实践来编写函数。
纯函数
纯函数是函数式编程的关键组成部分。纯函数可以定义为不对函数外部的状态产生任何影响或利用任何状态的函数。要被视为纯函数,函数必须满足三个关键标准:
-
当给定相同的输入时,函数必须始终返回相同的输出。
-
函数不能有副作用。
-
函数必须具有引用透明性。
相同的输入给出相同的输出
给定一组输入值,纯函数在提供这些输入值时必须始终返回相同的值。这听起来比实际情况复杂得多。简而言之,纯函数的输出不能改变,除非更改输入值。这意味着函数的内部代码不能依赖于函数外部的任何程序状态。纯函数不能使用函数外部的任何变量来进行计算或代码路径决策。以下代码段显示了这一点的示例:
const state = {
prop1: 5 };
function notPure () {
return state.prop1 > 0 ? 'Valid': 'Invalid';
}
function pure( val ) {
return val > 0 ? 'Valid': 'Invalid';
}
notPure(); // Expected output: 'Valid'
pure( state.prop ); // Expected output: 'Valid'
代码段 5.3:依赖外部状态
在前面的片段中,我们创建了一个名为 state 的变量,其中的prop1
属性设置为5
。然后我们定义了两个函数,根据值的比较返回字符串Valid
或Invalid
。在第一个函数notPure
中,我们检查 state 的prop1
值,并根据此返回一个值。在第二个函数 pure 中,我们检查传入函数的值来决定返回什么。第一个函数不是一个纯函数。它依赖于函数外部的状态来确定函数的返回值。第二个函数是纯的,因为它依赖于函数的输入值,而不是全局状态变量。
无副作用
纯函数不能有副作用。这简单地意味着纯函数不能修改通过引用传递的任何对象或值。副作用将在副作用主题中更详细地讨论。在 JavaScript 中,只有对象和数组可以通过引用传递给函数。纯函数不能以任何方式修改这些对象或数组。如果您的函数需要在内部更新或修改数组或对象,我们必须首先创建数组/对象的副本。重要的是要注意,在 JavaScript 中,复制对象或数组只会复制实体的第一层。这意味着如果一个数组或对象中嵌套了数组或对象,这些嵌套的引用将不会被复制。当复制的对象按引用传递时,嵌套的对象也将被传递。这意味着如果嵌套引用没有被显式复制,可能会导致副作用。要正确地复制一个对象,我们必须创建一个深拷贝。对象的深拷贝是一个复制所有嵌套引用的副本。这可以通过递归或通过 Node.js 的deepcopy
模块来完成。副作用的一个示例在下面的片段中显示:
function notPure( input ) {
input.prop2 = 'test';
}
function pure( input ) {
input = JSON.parse( JSON.stringify( input ) );
input.prop2 = 'test';
return input;
}
片段 5.4:避免副作用
在前面的片段中,我们定义了两个函数notPure
和pure
。这两个函数都向传入函数的input
对象添加一个属性。函数的不纯版本(notPure()
)直接修改了input
对象。因为对象是按引用传递的,这个更新将在所有其他使用对象的范围内可见。这是一个副作用。函数的纯版本(pure()
)通过 JSON 操作创建了对象的深拷贝,然后向新对象添加了一个新属性并返回了新对象。由于对象被克隆了,原始对象没有被修改。没有产生副作用。
引用透明度
引用透明度是纯函数的一个属性,使得弄清楚函数行为更简单。如果一个函数具有引用透明性,那么对该函数的调用可以用函数调用的结果值(函数返回的值)替换,而不改变代码的含义。简而言之,这意味着函数应该返回在其被使用的代码上下文中有意义的值,并且不应该依赖或修改函数外部的状态。
编写纯函数给我们带来了两个关键的好处:
第一个好处是纯函数非常容易进行单元测试。纯函数不依赖外部状态,因此在编写测试时不需要考虑其他上下文。我们只需要考虑输入和输出值。
其次,纯函数使代码更简单、更灵活。纯函数不依赖外部状态,也不产生副作用。这意味着它们可以在任何特殊的上下文中使用。它们可以在更多的地方使用,因此更灵活。
练习 29:构建纯控制器
您已被聘为开发人员,以升级在线商店的购物车实现。构建一个函数来向购物车添加物品。您的函数应该是纯的。您可以假设有一个名为cart
的全局数组,其中包含购物车。该函数应该至少接受一个物品(字符串)和一个数量(number
)。在提供的文件(exercise-test.js
)中创建名为addItem()
的函数。该文件将有基本测试来测试纯度。
要使用纯函数概念构建应用程序的一部分,请执行以下步骤:
-
打开exercises/exercise29/exercise-test.js中的测试文件。
-
创建一个名为
addItem
的函数,它接受三个参数:cart
、item
和quantity
。 -
复制传入函数的
cart
,并将复制的值保存到名为newCart
的变量中。使用以下方法之一复制cart
:
对于最简单的复制,请使用 JSON 操作:JSON.parse( JSON.stringify( cart ) )
。
通过循环遍历原始购物车数组,并将每个项目推送到新数组中。
使用cart.map( () => {} )
,因为数组中的所有项目都是简单类型。
使用rest/spread
运算符,newCart= [ ...cart ]
,因为所有项目都是简单类型。
-
将传入函数的项目推送到
cart
数组中,quantity
次数。 -
返回
newCart
数组。 -
运行
exercise-test.js
中提供的代码。
如果抛出错误,请修复代码中的错误,然后再次运行测试。
代码
exercise-solution.js
function addItem( cart, item, quantity ) {
// Duplicate cart
const newCart = JSON.parse( JSON.stringify( cart ) );
newCart.push( ...Array( quantity ).fill( item ) );
return newCart;
}
Snippet 5.5:函数纯度测试
https://bit.ly/2H2TXJG
输出
图 5.3:返回新的购物车数组
您已成功应用了纯函数的概念来构建应用程序的一部分。
高阶函数
正如我们在第一个主题中学到的,高阶函数是一个要么将另一个函数作为输入参数,要么返回另一个函数作为返回值的函数。几乎所有 JavaScript 中的异步代码都利用高阶函数,通过将回调函数作为输入参数传递。除了它们在 JavaScript 中的广泛使用之外,高阶函数是函数式编程的关键部分,用于三个关键好处:抽象、实用程序和复杂性减少。
高阶函数对于抽象非常重要。抽象是隐藏过程的内部工作或细节的一种方式。例如,考虑根据食谱烹饪一餐的过程。食谱可能要求您切碎食物。什么是切碎?它是一个动作的抽象。完成该动作的动作和步骤是拿起刀,将其放在食物上,向下按压。然后,将刀移动一小段距离沿着食物,并重复该过程,直到没有大块残留。切碎是这个动作的抽象。说“切胡萝卜”比长篇描述更简单更快。与准备食物一样,代码使用抽象来包装复杂的过程,并隐藏代码的内部工作。
高阶函数对于创建功能性实用程序非常有用。作为程序员,我们经常创建旨在对一组值执行操作的实用函数。通常,我们希望最大限度地提高灵活性,并创建可以在各种潜在输入值或格式上工作的函数。创建接受一些参数并返回新函数的高阶实用函数可以是一个很好的方法。这些函数在 JavaScript 中通常称为闭包。考虑以下片段中显示的函数:
function sortObjField1( field ) {
return function ( v1, v2 ) {
return v1[ field ] > v2[ field ];
}
}
function sortObjField2( field, v1, v2 ) {
return v1[ field ] > v2[ field ];
}
Snippet 5.6:高阶实用程序
在前面的片段中,我们创建了两个用于按指定字段中存储的值对对象数组进行排序的实用函数。这两个实用函数都需要指定字段。它们的区别在于返回值。SortObjField1
是一个高阶函数,它接受字段名称并返回一个闭包函数。闭包函数接受我们尝试排序的两个对象,并返回排序值。第二个辅助函数sortObjField2
一次接受字段和两个对象,并返回排序值。高阶实用函数更加强大,因为我们不需要同时知道所有的值。我们可以将sortObjField( 'field' )
作为参数传递给另一个函数,以在程序的另一个部分中使用。
高阶函数对于减少复杂性也非常有用。代码越长越复杂,就越容易出现错误。高阶函数将复杂部分的内部工作抽象出来,并可以使用实用函数来减少需要编写的代码行数。这两种效果都将减少代码库的大小,从而减少复杂性。简化代码将有助于减少您必须花费在修复错误上的时间。
练习 30:编辑对象数组
目标是将高阶函数的概念应用于编辑对象数组。要使用必要的函数编辑数组,请执行以下步骤:
-
创建一个名为
data
的数组,其中包含以下数据:[ { f1: 6, f2: 3 }, { f1: 12, f2: 0 }, { f1: 9, f2: 1 }, { f1: 6, f2: 7 } ]
。 -
创建一个名为
swap
的函数,它接受两个参数key1
和key2
。 -
向
swap
函数添加一个return
语句。return
语句应返回一个函数。这个函数应该接受一个参数obj
。 -
在返回的函数内部,使用数组解构,交换
obj
中存储的key1
和key2
的值。
提示:使用[a, b] = [b, a]
来使用数组解构交换值。
-
从函数中返回修改后的对象
obj
。 -
通过在
data
上调用map
函数来编辑数据数组。将带有参数f1
和f2
的调用传递给map
函数。
提示:data.map( swap( 'f1', 'f2' ) );
- 记录对
data.map()
的调用的输出。
代码
index.js
const data = [ {
f1: 6, f2: 3 }, {
f1: 12, f2: 0 }, {
f1: 9, f2: 1 }, {
f1: 6, f2: 7 } ];
function swap( key1, key2 ) {
return obj => {
[ obj[ key1 ], obj[ key2 ] ] = [ obj[ key2 ], obj[ key1 ] ];
return obj;
}
}
console.log( data.map( swap( 'f1', 'f2' ) ) );
https://bit.ly/2D0t70K
输出
图 5.4:最终输出
您已成功将高阶函数的概念应用于编辑对象数组。
共享状态
共享状态是存在于共享范围中的任何变量、对象或内存空间。任何被多个独立范围使用的非常量变量,包括全局范围和闭包范围,都被视为处于共享状态。在函数式编程中,应该避免共享状态。共享状态会阻止函数变得纯粹。当违反共享状态规则并且程序修改变量时,就会产生副作用。在面向对象编程中,共享状态通常作为对象传递。面向对象编程函数可能会修改共享状态。这与函数式编程规则背道而驰。下面的片段中展示了一个共享状态的示例:
const state = {
age: 15 }
function doSomething( name ) {
return state.age > 13 ? '${name} is old enough' : '${name} is not old enough';
}
片段 5.7:共享状态
在前面的例子中,我们有一个全局范围内的变量称为state
。在我们的名为doSomething
的函数中,我们引用变量 state 来做出逻辑代码决定。由于state
变量是在doSomething
函数的范围之外定义的,并且不是一个不可变对象(创建后其状态无法修改的对象),它被认为是一个共享状态。这是函数式编程中应该避免的事情,因为它会阻止我们的函数变得纯粹。
共享状态必须避免,原因有几个。首先,共享状态可能会使理解函数变得困难。要真正理解函数的工作原理以及给定输入的输出结果,我们必须理解函数所在的整个状态。如果我们的函数使用共享状态,那么在正确理解函数之前,我们必须理解一个更加复杂的状态。详细理解共享状态非常困难。要正确理解共享状态,必须理解状态如何更新以及它如何在与之共享的每个函数中使用。
虽然起初听起来可能不是一个主要的缺点,但不理解我们的函数如何工作将导致开发速度变慢,出现更多的错误和不充分的测试。共享状态会减慢开发速度,因为我们必须花更多的时间来理解依赖于它们的函数。如果我们不花时间理解共享状态和依赖于它们的函数,那么我们很可能不会编写高效和无错误的代码。这显然会导致更多时间用于调试和重构代码。不完全理解的函数往往更容易出现错误。如果我们不完全理解函数在共享状态中定义的所有可能性和限制下需要如何操作,那么我们很可能会忘记在开发中处理边缘情况。如果这些错误没有被发现,那么有缺陷的代码可能会被发布。最后,不理解函数几乎不可能完全测试一个函数。要完全测试任何函数,我们必须完全理解它在所有条件下的操作方式,换句话说,就是在所有可能被调用的状态下。
练习 31:修复共享状态
目标是重构代码以消除共享状态。要正确地重构代码,请执行以下步骤:
-
打开
exercises/exercise31/exercise.js
文件。您将更新此文件以解决练习。 -
运行步骤 1中打开的文件中的代码,并观察输出。
-
更新
getOlder
函数声明,以接受一个名为age
的参数。 -
更新
getOlder
的主体,使其返回age+1
或++age
,而不是修改全局变量。 -
将
formatName
函数声明更新为接受两个参数,first
和last
。 -
更新
formatName
的主体,使其返回Mrs. ${first} ${last}
字符串,其中first
和last
是存储在输入参数first
和last
中的值。 -
更新对
getOlder
函数的调用,并将person.age
作为参数传入。将返回的值保存到person.age
中。 -
更新对
formatName
的函数调用,并将person.firstName
和person.lastName
作为参数传入。将返回的值保存到person.name
中。 -
运行代码并将输出与步骤 2的输出进行比较。它们应该是相同的。
代码
solution.js
const person = {
age: 10, firstName: 'Sandra', lastName: 'Jeffereys' };
function getOlder( age ) {
return ++age;
}
function formatName( first, last ) {
return 'Mrs. ${first} ${last}';
}
console.log( person );
person.age = getOlder( person.age );
person.name = formatName( person.firstName, person.lastName );
console.log( person );
https://bit.ly/2CZwyoC
输出
图 5.5:最终输出
您已成功地重构了代码以消除共享状态。
不可变性
不可变性是函数式编程中非常简单但非常重要的概念。不可变性的教科书定义只是“不可改变的”一词。在编程中,我们使用这个词来表示对象和变量在创建后不能改变其状态。
在软件开发中,值可以通过引用传递给函数。当变量通过引用传递时,意味着传递的是指向内存位置(指针)的引用,而不是内存中该位置包含的对象的序列化值。由于所有指向引用传递的变量的指针都指向同一块内存,对通过引用传递的变量值的任何更新都将被指向该内存块的任何指针看到。任何通过引用传递而不是通过值传递的变量都可以被视为共享状态,因为它可以被多个独立作用域修改。编写防止数据突变的函数非常重要,因为对通过引用传递的值的任何更改都将被视为对共享状态的更改。修改通过引用传递的变量将违反函数式编程的原则,并导致副作用。
在 JavaScript 中,不可变性的概念通常适用于传入函数的变量,以及函数返回的变量。在 JavaScript 中,简单类型(字符串、数字、布尔值)是按值传递的,而复杂类型(对象、数组等)是按引用传递的。对这些复杂数据类型的任何更改都会影响所有出现的地方,因为它们本质上只是指向同一块内存的指针。
JavaScript 对不可变性的支持并不完整。JavaScript 没有内置的不可变数组或对象。需要注意的是,变量创建关键字const
不会创建不可变对象或数组。正如在第一章中讨论的那样,const 只是锁定名称绑定,使得名称绑定不能被重新分配。它不会阻止被变量引用的对象被修改。在 JavaScript 中,可以通过两种方式创建不可变对象:使用freeze
函数和使用第三方库。
不可变对象可以使用 freeze 函数创建。freeze
是全局Object prototype ( Object.freeze()
)上的一个函数。它接受一个参数,即要冻结的对象,并返回相同的对象。freeze 防止向对象中添加、删除或修改任何内容。如果一个数组被冻结,它将锁定元素的值,并防止向数组中添加或删除元素。需要注意的是,freeze 函数只是浅冻结。作为属性(在对象中)或元素(在数组中)嵌套的对象和数组不会被freeze
函数冻结。如果要完全冻结所有嵌套属性,必须编写一个辅助函数来遍历对象或数组树,冻结每个嵌套级别,或者找到一个第三方库。Object.freeze()
的使用如下所示:
const data = {
prop1: 'value1',
objectProp: {
p1: 'v1', p2: 'v2' },
arrayProp: [ 1, 'test' , {
p1: 'v1' }, [ 1, 2, 3 ] ]
};
Object.freeze( data );
Object.freeze( data.objectProp );
Object.freeze( data.arrayProp );
Object.freeze( data.arrayProp[2] );
Object.freeze( data.arrayProp[3] );
片段 5.8:冻结一个对象
JavaScript 中的不可变性
存在几个第三方库可以为 JavaScript 添加不可变功能。有两个库通常被认为是 JavaScript 中最好的不可变性库。它们是Mori和Immutable。Mori 是一个将 ClojurScript 的持久数据结构和不可变性引入 JavaScript 的库。Immutable是 Facebook 的不可变性库的实现,具有 JS API,将许多不可变数据结构引入 JavaScript。这两个库被认为非常高效,并且在许多大型项目中通常被使用。
注意
有关 Mori 和 Immutable 的更多信息,以及完整的文档,请参阅github.com/swannodette/mori
和facebook.github.io/immutable-js/
上的库页面。
在 JavaScript 中有一种最终实现不可变性的方法;然而,这并不是真正的不可变性。为了避免使用第三方库或冻结传递给函数的任何对象或数组,我们可以简单地创建传递引用的任何变量的副本,并修改副本而不是原始值。这将防止通过引用传递数据的共享状态问题,但它会带来内存效率和效率的折衷。简单地将引用分配给一个新变量不会复制数据。我们可以通过三种方式之一复制对象或数组——使用第三方库,通过遍历对象树,或者使用 JSON 操作。
存在用于创建对象的深层副本的第三方库。这通常是复制对象的最简单方法。我们还可以遍历对象的树,并将每个值和属性复制到一个新对象中。这通常需要我们编写和测试自己的函数。最后,我们可以使用 JSON 操作 stringify 和 parse 来复制一个对象。首先将对象字符串化,然后解析字符串(JSON.parse(JSON.stringify(obj))
)。JSON 操作通常是复制对象的最简单方法,但它带来了最多的缺点和限制。如果对象具有不兼容 JSON 的属性,例如函数或类,这种方法将无效。将整个对象转换为字符串,然后将整个字符串解析为对象也非常低效。对于小对象,这可能不会影响性能,但如果您必须复制一个大对象,则不建议使用此方法,因为它是一个阻塞操作。
副作用
副作用是我们采取行动后产生的任何次要效果或反应。副作用可以是好的也可以是坏的,但通常是无意的。在函数式编程中,副作用是指除函数返回值之外可以在函数调用之外看到的任何状态更改。根据函数式编程的规则,函数不允许修改函数之外的任何状态。如果函数有意或无意地修改了状态,这被视为副作用,因为它违反了函数式编程的原则。
副作用是不好的,因为它使程序变得更加复杂。正如前面讨论的,共享状态会增加程序的复杂性。函数中的副作用会修改共享状态,因此增加了复杂性。无论有意还是无意,副作用都会使代码更难以测试和调试。以下列表显示了 JavaScript 中副作用最常见的原因的简单分解:
- 修改任何外部状态(变量)
两种变量类型包括全局变量和父函数作用域中的变量。
这个列表中的第一条应该从 FP 副作用的定义中是不言自明的。对任何外部状态的改变,包括函数范围之外的任何变量,都是副作用。变量的作用域级别并不重要。它可以在全局作用域中,也可以在父函数作用域树中的任何地方;对函数范围之外的变量的任何改变都被视为副作用。
- 输入/输出
列表包括记录到控制台,写入屏幕或显示器,文件 I/O 操作,网络操作,HTTP 请求,消息队列和数据库请求。
副作用列表中的第二个要点并不那么直观。考虑一下 I/O 操作。它们做什么?它们修改一些外部资源。这可以是控制台的内容,网页上显示的视图或显示,文件系统中的文件,或者仅通过网络访问的外部资源。这些外部资源不直接限定于修改它们的代码块,并且可以被其他完全无关的应用程序修改和查看。根据定义,文件系统和控制台等资源是共享状态。对这些资源的修改算作副作用。
- 启动或结束外部进程
副作用列表中的第三个要点与第二个类似。启动外部进程,例如辅助线程以卸载一些大量的同步工作,会产生副作用。当我们启动一个新进程时,我们直接改变了系统的状态。创建了一个新线程,它超出了创建它的函数的范围。根据定义,这是一个副作用。
- 调用任何具有副作用的其他函数
副作用列表中的第四项也不那么直观。调用具有副作用的函数的任何函数都被认为具有副作用。考虑一个程序设置,其中函数 A 调用函数 B,并且函数 B 导致全局状态的更改。对全局状态的更改可以由对函数 B 的直接调用或通过调用函数 A 而引起。由于对函数 A 的调用仍然会导致全局状态的更改,即使函数 A 的代码不直接修改全局状态,函数 A 仍然被认为具有副作用。
在编写 FP 代码时,我们必须考虑以下问题:
如果任何 I/O 操作引起副作用,我们如何将 FP 原则应用于编写没有副作用的有用代码?由于 I/O 操作会引起副作用,那么我们代码中使用的每个网络调用或文件系统操作都会引起副作用吗?是的。 I/O 引起副作用,它们是不可避免的。解决此问题的方法是将具有副作用的代码与软件的其余部分隔离开来。任何具有副作用或依赖具有副作用的模块或操作(数据库操作等)的代码必须与不具有副作用的代码隔离开来。这通常是通过模块完成的。大多数前端和后端框架鼓励我们使用模块将状态管理与代码的其余部分分离。引起副作用的代码被移除并放入自己的模块中,以便代码库的其余部分可以在没有副作用的情况下进行测试和维护。
避免副作用
几乎不可能编写一个没有副作用的完整应用程序。Web 应用程序/服务器必须处理/发出 HTTP 请求-根据定义是副作用。为了实现这一点,您可以执行以下操作:
-
将具有副作用的代码与代码库的其余部分隔离。
-
将状态管理代码和具有副作用的代码与应用程序的其余部分分开。
这些方法使测试和调试更容易。
函数组合
函数组合是理解函数式编程的最后关键。函数组合将本章学到的许多概念很好地融入到函数式编程的核心中。函数组合的广泛使用定义是函数组合是一个数学概念,允许您组合多个函数以创建一个新函数。这个定义告诉我们函数组合是什么,但并没有真正告诉我们如何组合函数或者为什么我们需要使用它。
根据定义,函数组合是将函数组合在一起创建新函数的行为。这到底意味着什么?在数学中,我们经常看到像这样组合的函数:f(g(x))。如果这对你来说不熟悉,在表达式 f(g(x))中,我们将变量 x 传递给函数 g,然后将 g(x)的结果传递给函数 f。表达式 f(g(x))从内到外,从右到左,按顺序 x,g,f 进行评估。在函数 g 中使用输入参数的每个实例,我们可以替换为 x 的值。在函数 f 中使用输入参数的每个实例,我们可以替换为 g(x)的值。现在,让我们用代码考虑这种函数组合的方法。考虑以下代码片段:
function multiplyBy2( c ) {
return 2 * c;
}
function sumNumbers( a, b ) {
return a + b;
}
const v1 = sumNumbers( 2, 4 ); // 2 + 4 = 6
const v2 = multiplyBy2( v2 ); // 2 * 6 = 12
const v3 = multiplyBy2( sumNumbers( 2, 4 ) ); // 2 * ( 2 + 4 ) = 12
代码段 5.10:函数组合
在上述代码片段中,我们创建了一个将值乘以 2 的函数和一个将两个数字相加的函数。我们可以使用这些函数以两种方式计算一个值。首先,我们独立使用这些函数,依次使用。这需要我们创建一个变量并保存第一个函数的输出,使用该值调用第二个函数,然后将第二个函数的结果保存到一个变量中。这需要两行代码和两个变量。我们计算值的第二个选项是使用函数组合。我们只需要在第二个函数的输入参数中调用一个函数,并保存结果变量。这只需要一行代码和一个变量。从代码片段中可以看出,使用函数组合将有助于简化我们的代码,并减少我们需要编写的代码行数。
函数组合非常有用,可以减少我们需要编写的代码行数,同时减少代码的复杂性。在函数式编程范式中编写代码时,重要的是要认识到我们可以利用函数组合的优势的情况。
活动 5:递归不可变性
您正在使用 JavaScript 构建应用程序,并且已被告知出于安全原因不能使用任何第三方库。现在,您必须为此应用程序使用 FP 原则,并且需要一种算法来创建不可变的对象和数组。创建一个递归函数,使用Object.freeze()
来强制对象和数组在所有嵌套级别上的不可变性。为简单起见,您可以假设对象中没有嵌套的空值或类。在'Lesson 5/topic f - immutability/activity-test.js'
中编写您的函数。此文件包含测试您实现的代码。
要强制对象的不可变性,请执行以下步骤:
-
创建一个名为
immutable
的函数,它接受一个参数data
。 -
冻结
data
对象。 -
循环遍历对象值,并对每个值递归调用不可变函数。
代码
结果
图 5.6:返回新的购物车数组
您已成功演示了强制对象的不可变性。
注意
此活动的解决方案可在第 291 页找到。
摘要
函数式编程是一种侧重于表达式和声明来设计应用程序和构建代码库的编程范式。函数式编程是炙手可热的新编程风格之一,被认为是 JavaScript 编程的最佳风格。函数式编程可以帮助我们的 JavaScript 更加简洁,可预测和可测试。函数式编程建立在七个关键概念上:声明式函数,纯函数,高阶函数,共享状态,不可变性,副作用和函数组合。
声明性函数关注的是解决方案或目标,而不是我们如何得到解决方案。声明性函数旨在抽象掉大量的命令式代码。它们帮助开发人员更符合开发者的思维模型,而不是运行代码的机器的操作模型。
纯函数旨在使我们的代码更易于测试、更易于调试,并且更灵活和可重用。我们在 JavaScript 中编写的所有函数都应该努力成为纯函数。纯函数在给定相同的输入值时必须始终返回相同的输出值。它们不能通过修改外部状态来引起任何副作用,并且必须具有引用透明性。
高阶函数是 JavaScript 异步编程中最常用的函数类型之一。高阶函数是任何以函数作为输入并返回函数作为输出的函数。高阶函数非常有用,可以用于抽象代码、减少复杂性以及创建和管理实用函数。它们是闭包的关键,允许我们对代码非常灵活。
共享状态是函数式编程中要避免的最重要的事情之一。共享状态是存在于共享作用域中的任何非常量变量或非不可变对象或内存空间。共享作用域可以是全局作用域或父函数作用域树中的任何作用域。共享状态会阻止函数成为纯函数,并可能导致更多的错误、不充分的测试和开发速度变慢。
不变性是无法改变某物的能力。在 JavaScript 中,所有按引用传递的变量都应该是不可变的。对可变变量的更改可能会导致副作用,并无意中修改不应共享的状态。在 JavaScript 中,可以通过Object.freeze()
函数、第三方库和 JSON 操作来实现不可变性。
在 JavaScript 中,副作用是指可以从函数调用外部看到的任何状态更改,不包括函数的返回值。副作用可以由对共享状态变量的任何修改、任何 I/O 操作、任何外部进程执行或调用具有副作用的任何函数引起。要完全消除 JavaScript 应用程序中的副作用可能非常困难。为了最小化副作用的影响,我们必须将具有副作用的代码与代码库的其余部分隔离开来。引起副作用的代码应该移入模块以进行隔离。
函数组合是函数式编程的最后一个关键概念。我们可以通过以新的方式组合更简单的函数来简单地创建复杂而强大的函数。函数组合旨在帮助抽象和减少我们代码的复杂性。
在下一章中,您将介绍服务器端 JavaScript 的基本概念,并构建一个 Node.js 和 Express 服务器。
第六章:JavaScript 生态系统
学习目标
在本章结束时,您将能够做到以下事情:
-
比较不同的 JavaScript 生态系统
-
解释服务器端 JavaScript 的基本概念
-
构建一个 Node.js 和 Express 服务器
-
构建一个 React 前端网站
-
将前端框架与后端服务器结合起来
最后一章详细介绍了 JavaScript 生态系统,并教导学生如何使用 Node.js 的不同功能和部分,以及 Node 包管理器(NPM)。
介绍
在第五章“函数式编程”中,我们介绍了“函数式编程范式”。我们讨论了面向对象编程和函数式编程,讨论了两者之间的区别,并概述了为什么我们应该使用函数式编程。在第二部分中,我们讨论了函数式编程的关键概念,并演示了它们如何应用于 JavaScript 代码。
在过去的 10 多年里,JavaScript 生态系统已经大幅增长。JavaScript 不再只是用于在基本的 HTML 网页上添加动画等效果的编程语言。现在 JavaScript 可以用于构建完整的后端 Web 服务器和服务、命令行界面、移动应用程序和前端网站。在本章中,我们将介绍 JavaScript 生态系统,讨论使用 Node.js 在 JavaScript 中构建 Web 服务器,并讨论使用 React 框架在 JavaScript 中构建网站。
JavaScript 生态系统
我们将讨论 JavaScript 生态系统的四个主要类别:前端、命令行界面、移动和后端。
-
前端 JavaScript 用于用户界面网站。
-
命令行界面(CLI)JavaScript 用于构建命令行任务,以帮助开发人员。
-
移动开发 JavaScript 用于构建手机应用程序。
-
后端 JavaScript 用于构建 Web 服务器和服务。
对于最初是为了在浏览器中嵌入简单应用程序而创建的语言来说,JavaScript 已经走了很长的路。
前端 JavaScript
前端 JavaScript 用于创建复杂和动态的用户界面网站。Facebook、Google Maps、Spotify 和 YouTube 等网站都严重依赖 JavaScript。在前端开发中,JavaScript 用于操作 DOM 和处理事件。许多 JavaScript 库,如 jQuery,已被创建以通过将每个浏览器的 DOM 操作 API 封装成标准化 API 来增加 JavaScript DOM 操作的效率和便利性。最常见的 DOM 操作库是 jQuery,在第三章“DOM 操作和事件处理”中进行了讨论。还创建了 JavaScript 框架,以更无缝地将 DOM 操作和事件与 HTML 设计方面整合在一起。前端开发中最常见的两个 JavaScript 框架是 AngularJS 和 React。AngularJS 由 Google 创建和维护,React 由 Facebook 创建和维护。
Facebook 和 Google 管理其各自框架的错误修复和版本发布。React 将在本章的后面部分进行更详细的讨论。
命令行界面
命令行集成(CLI)JavaScript 通常用于创建实用程序,以帮助开发人员处理重复或耗时的任务。JavaScript 的 CLI 程序通常用于诸如代码检查、启动服务器、构建发布、转译代码、文件最小化以及安装开发依赖和包等任务。JavaScript 的 CLI 程序通常是用 Node.js 编写的。Node.js 是一个跨平台环境,允许开发人员在浏览器之外执行 JavaScript 代码。Node.js 将在本章的后面部分进行更详细的讨论。许多开发人员在日常开发中依赖 CLI 实用程序。
移动开发
使用 JavaScript 进行移动开发正在迅速成为主流。自智能手机兴起以来,移动开发人员已成为炙手可热的商品。尽管 JavaScript 不能在大多数移动操作系统上本地运行,但存在允许将 JavaScript 和 HTML 构建到 Android 和 IOS 手机应用程序中的框架。JavaScript 移动开发最常见的框架是 Ionic、React Native 和 Cordova/PhoneGap。这些框架都允许您编写 JavaScript 来构建应用程序的框架和逻辑,然后将 JavaScript 编译为本机移动操作系统代码。移动开发框架非常强大,因为它们允许我们使用首选的 JavaScript 构建完整的移动应用程序。
后端开发
使用 JavaScript 进行后端开发通常使用 Node.js。Node.js 可用于构建强大的 Web 服务器和服务。正如前面所述,Node.js 及其在后端服务器开发中的应用将在本章的后续部分中进行更详细的讨论。
JavaScript 生态系统非常广泛。几乎可以用 JavaScript 编写任何类型的程序。尽管现代 JavaScript 具有许多框架和功能,但重要的是要记住,框架不能取代对核心 JavaScript 的深入理解。框架很好地封装了核心 JavaScript,使我们能够执行强大的任务,如构建移动和桌面应用程序,但如果不深刻理解 JavaScript 和异步编程的核心原则,应用程序可能会出现缺陷。
Node.js
Node.js(简称 Node),由 Ryan Dahl 于 2009 年开发,是最流行的非浏览器 JavaScript 引擎。Node 是一个基于 Chrome 的 V8 JavaScript 引擎的开源、跨平台 JavaScript 运行时环境。它用于在浏览器之外运行 JavaScript 代码,用于非客户端的应用程序。
与 Chrome 中的 Google V8 JavaScript 引擎一样,Node.js 使用单线程、事件驱动、异步架构。它允许开发人员使用 JavaScript 的事件驱动编程风格来构建 Web 服务器、服务和 CLI 工具。如第二章,异步 JavaScript中所讨论的,JavaScript 是一种非阻塞和事件驱动的编程语言。JavaScript 的异步特性(单线程事件循环),加上 Node 的轻量设计,使我们能够构建非常可扩展的网络应用程序,而无需担心线程。
注意
如第二章,异步 JavaScript中所讨论的,JavaScript 是单线程的。在单线程上运行的同步代码是阻塞的。CPU 密集型操作将阻塞事件,如 I/O 文件系统操作和网络操作。
设置 Node.js
Node.js 可以从 Node.js 网站下载,网址为nodejs.org/en/
。有两个可供下载的版本:**长期支持(LTS)**版本和当前版本。我们建议您下载 LTS 版本。当前版本具有最新的功能,但可能不完全没有错误。请务必遂行您操作系统的特定安装说明。可以为所有三种主要操作系统下载安装程序文件,并且可以使用许多软件包管理器安装 Node.js。Node.js 安装调试不在本书的范围之内。但是,可以通过谷歌搜索轻松找到安装提示和调试提示。
注意
Node.js 的下载链接如下:nodejs.org/en/download/
。
一旦 Node.js 被下载并安装,就可以使用node
命令从终端运行它。在执行此命令后不跟任何参数,将运行 Node.js 终端。JavaScript 代码可以直接在终端中输入,就像浏览器的调试控制台一样。重要的是要注意,在终端实例之间没有状态传递。当运行 Node.js 命令行的终端实例关闭时,所有计算将停止,并且 Node.js 命令行进程使用的所有内存将释放回操作系统。要使用 Node.js 运行 JavaScript 代码文件,只需在node
命令后直接添加文件路径。例如,以下命令将在./path/to/file
位置以文件名my_file.js
运行文件:node ./path/to/file/my_file.js
。
Node 包管理器
Node.js 是一个开源平台。Node 的最大优势之一是可用的开源第三方库,称为模块。Node 使用**Node 包管理器(NPM)**来处理应用程序使用的第三方模块的安装和管理。NPM 通常与 Node.js 一起安装。要测试 NPM 是否已正确安装到 Node,请打开终端窗口并运行npm -v
命令。如果 NPM 已正确安装,终端将打印出当前 NPM 的版本。如果 NPM 未随 Node 一起安装,可能需要重新运行 Node.js 安装程序。
注意
本节未涵盖的所有功能的 NPM 文档可以在docs.npmjs.com/
找到。
在第一章,介绍 ECMAScript 6中,我们学习了关于 ES6 模块。非常重要的是,我们要区分 ES6 模块和 Node.js 模块。Node.js 模块是在 ES6 和原始 JavaScript 对模块的支持之前创建的。虽然 Node.js 模块和 ES6 模块用于相同的目的,但它们不遵循相同的技术规范。Node.js 模块和 ES6 模块的加载、解析和构建方式不同。Node.js 模块是同步从磁盘加载、同步解析和同步构建的。在模块加载完成之前,没有其他代码可以运行。不幸的是,ES6 模块的加载方式不同。它们是异步从磁盘加载的。这两种不同的模块加载方法不兼容。在撰写本书时,Node.js 对 ES6 模块的支持处于测试阶段,并且默认情况下未启用。可以启用对 ES6 模块的支持,但我们建议您在 ES6 模块的完全支持发布之前使用标准的 Node 模块。
NPM 包是通过命令行使用npm install
命令安装的。您可以使用此命令将特定包添加到项目中,或安装所有缺少的依赖项。如果没有向安装命令提供参数,npm
将在当前目录中查找package.json
文件。在package.json
文件中,有一个dependencies
字段,其中包含为 Node.js 项目安装的所有依赖项。NPM 将遍历依赖项列表,并验证该列表中指定的每个包是否已安装。packages.json
中的依赖项列表将类似于以下代码片段中显示的代码:
"dependencies": {
"amqplib": "⁰.5.2",
"body-parser": "¹.18.3",
"cookie-parser": "¹.4.3",
"express": "⁴.16.3",
"uuid": "³.3.2"
}
代码片段 6.1:package.json 中的依赖项列表
package.json
中的依赖项字段列出了为项目安装的 NPM 模块,以及版本号。在这个片段中,我们安装了amqplib
模块的版本为0.5.2
或更高版本,安装了body-parser
模块的版本为1.18.3
或更高版本,以及其他几个模块。NPM 模块遵循语义化版本。版本号由三个数字组成,用句点分隔。第一个数字是主要版本。主要版本号的增加表示破坏向后兼容的重大更改。第二个数字是次要版本。次要版本号的更改表示发布了不会破坏向后兼容性的新功能。最后一个数字是补丁号。补丁号的增加表示修复错误或对功能进行小更新。补丁号的增加不包括新功能,也不会破坏向后兼容性。
注意
有关语义化版本的更多信息,请访问www.npmjs.com/
。
安装模块时,可以在npm install
命令的install后添加参数(例如,npm install express
)。参数可以是包名称、Git 存储库、tarball或文件夹。如果参数是一个包,NPM 将在其注册的包列表中搜索并安装与名称匹配的包。如果参数是 Git 存储库,NPM 将尝试从 Git 存储库下载并安装文件。如果没有提供适当的访问凭据,安装可能会失败。
注意
请参阅 NPM 文档,了解如何从私有 git 存储库安装包。
如果参数是一个 tarball,NPM 将解压 tarball 并安装文件。tarball 可以通过指向 tarball 的 URL 或本地文件进行安装。最后,如果指定的参数是本地机器上的文件夹,NPM 将尝试从指定的文件夹安装 NPM 包。
在使用 NPM 安装包时,重要考虑包的安装方式。默认情况下,包是在本地项目范围内安装的,并且不会保存为项目依赖项。如果要安装一个 NPM 包,并希望将其保存在package.json
中作为项目依赖项,必须在安装命令的包名称后包含--save
或-s
参数(例如,npm install express -s
)。此参数告诉 NPM 将依赖项保存在package.json
中,以便以后的npm install
命令会安装它。
NPM 包可以安装在两个范围内:全局范围和本地范围。在本地范围内安装的包,或本地包,只能在安装它们的 Node.js 项目中使用。在全局范围内安装的包,或全局包,可以被任何 Node.js 项目使用。默认情况下,包是本地安装的。要强制安装模块为全局安装,可以在包名称后添加-g
或--global
标志到npm install
命令(例如,npm install express -g
)。
并不总是明显应该在哪里安装包,但如果不确定,可以遵循以下一般规则。如果要在具有require()
函数的项目中使用包,请在本地安装包。如果计划在命令行上使用包,请全局安装包。如果仍然无法决定并且需要在项目和命令行中使用包,可以在两个地方都安装它。
加载和创建模块
Node.js 使用CommonJS风格的模块规范作为加载和处理模块的标准。CommonJS 是一个旨在为浏览器外的 JavaScript 指定 JavaScript 生态系统的项目。CommonJS 定义了一个模块规范,被 Node.js 采纳。模块允许开发人员封装功能,并仅向其他 JavaScript 文件公开所需部分的封装功能。
在 Node.js 中,我们使用 require 函数将模块导入到我们的代码中(require('module')
)。require
函数可以加载任何有效的 JavaScript 文件、NPM 模块或 JSON 文件。我们将使用require
函数加载为我们的项目安装的任何 NPM 包。要加载一个模块,只需将模块的名称作为参数传递给require
函数,并将返回的对象保存到一个变量中。例如,我们可以使用以下代码加载 NPM 模块body-parser
:const bodyParser = require( 'body-parser' )
。这将导入导出的函数和变量到bodyParser
对象中。require 函数还可以用于加载 JavaScript 文件和 JSON 文件。要加载其中一个文件,只需将文件路径传递给require
函数,而不是模块名称。如果未提供文件扩展名,Node.js 将默认查找 JavaScript 文件。
注意
还可以通过 require 函数加载目录。如果提供的是目录而不是 JS 文件,则 require 函数将在指定目录中查找名为index.js
的文件并加载该文件。如果找不到该文件,将抛出错误。
要创建一个模块,即 Node.js 模块,我们使用module.exports
属性。在 Node.js 中,每个 JavaScript 文件都有一个名为module
的全局变量对象。module
对象中的exports
字段定义了将从模块中导出的项目。当使用require()
函数导入模块时,require()
的返回值是模块的module.exports
字段中设置的值。模块通常导出一个函数或具有每个导出的函数或变量的属性的对象。以下是导出模块的示例:
module.exports = {
exportedVariable,
exportedFn
}
const exportedVariable = 10;
function exportedFn( args ){
console.log( 'exportedFn' ) ;}
Snippet 6.2:导出 Node.js 模块
练习 32:导出和导入 NPM 模块
要构建、导出和导入 NPM 模块,请执行以下步骤:
-
为我们的模块创建一个名为
module.js
的 JavaScript 文件。 -
将
module.exports
属性设置为一个对象。 -
将
exportedConstant
字段添加到对象中,并将其值设置为An exported constant!
-
将
exportedFunction
字段添加到对象中,并将其值设置为记录到控制台的函数,文本为An exported function!
-
为我们的主要代码创建一个
index.js
文件。 -
使用
require
函数从module.js
导入模块,并将其保存到ourModule
变量中。 -
从 ourModule 中记录
exportedString
的值。 -
从
ourModule
调用exportedFunction
函数。
代码
module.js
module.js
module.exports = {
exportedString: 'An exported string!',
exportedFunction(){
console.log( 'An exported function!' ) }
};
Snippet 6.3:将代码导出为模块
https://bit.ly/2M3SIsT
index.js
const ourModule = require('./module.js');
console.log( ourModule.exportedString );
ourModule.exportedFunction();
Snippet 6.4:将代码导出为模块
https://bit.ly/2RwOIXP
结果
图 6.1:测试值输出
您已成功构建、导出和导入 NPM 模块。
基本 Node.js 服务器
Node.js 最常见的应用是 Web 服务器。Node.js 使构建高效和可扩展的 Web 服务器变得非常容易,因为开发人员不需要担心线程。在本节中,我们将演示在 Node.js 中创建基本 Web 服务器所需的代码。
Node.js 服务器可以设置为 HTTP、HTTPS 或 HTTP2 服务器。在本例中,我们将创建一个基本的 HTTP 服务器。Node.js 通过 HTTP 模块提供了 HTTP 服务器的基本功能。使用 require 语句导入 HTTP 模块。如下所示:
const http = require( 'http' );
Snippet 6.5:加载 HTTP 模块
这行代码将导入模块’HTTP’中包含的功能,并将其保存在变量http
中以供以后使用。现在我们已经加载了 HTTP 模块,我们需要选择一个主机名和一个端口来运行我们的服务器。由于这个服务器只会在我们的计算机本地运行,我们可以使用机器内部本地网络的 IP 地址,即 localhost(‘127.0.0.1’)作为我们的主机名地址。我们可以在任何尚未被其他应用程序使用的网络端口上运行我们的本地服务器。
您可以选择任何有效的端口号,但通常情况下程序不会默认使用端口8000
,所以在这个演示中使用了这个端口号。在您的代码中添加一个变量来包含端口号和主机名。到目前为止的完整代码如下所示:
const http = require('http');
const hostname = '127.0.0.1';
const port = 8000;
代码段 6.6:简单服务器的常量
现在我们已经为我们的服务器设置了所有基本参数,我们可以编写代码来创建和启动服务器。HTTP 模块包含一个名为createServer()
的函数,它返回一个服务器对象。这个函数可以接受一个可选的回调函数,作为 HTTP 请求监听器。当任何 HTTP 请求进入服务器时,提供的回调方法会被调用。我们需要使用带有请求监听器回调的createServer
函数,这样我们的服务器才能正确地响应传入的 HTTP 请求。这是在以下代码段中显示的代码行:
const server = http.createServer((req, res) => {
res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); res.end('Welcome to my server!\n');});
代码段 6.7:创建一个简单的服务器
在前面的代码段中,我们调用create server
函数并将返回的服务器保存到server
变量中。我们将一个回调传递给createServer()
。这个回调接受两个参数:req
和res
。req
参数表示传入的 HTTP 请求,res
参数表示服务器的 HTTP 响应。在回调的第一行代码中,我们将响应状态码设置为200
。响应中的200
状态码表示服务器对 HTTP 请求成功。在状态码之后的一行中,我们在响应中设置了Content-Type
头为text/plain
。这一步告诉响应传入的数据将是纯文本。在回调的最后一行中,我们调用了res.end()
函数。这个函数将传入的数据附加到响应中,然后关闭响应并将其发送回请求者。在这个代码段中,我们将Welcome to my server!
字符串传递给end()
函数。响应中附加了这个字符串,并将文本发送回请求者。我们的服务器现在使用这个处理程序处理所有对它的 HTTP 调用。
将我们的迷你服务器启动并运行的最后一步是在服务器对象上调用.listen()
函数。listen
函数在指定的port
和hostname
上启动 HTTP 服务器。一旦服务器开始监听,它就可以接受 HTTP 请求。以下代码段显示了如何使服务器在指定的port
和指定的hostname
上监听:
server.listen( port, hostname, () => {
console.log('Server running at http://${hostname}:${port}/');});
代码段 6.8:服务器开始在主机名和端口上监听
前面的代码段显示了如何调用server.listen()
函数。传递给函数的第一个参数是我们的服务器将暴露在的端口号。第二个参数是我们的服务器将从中访问的主机名。在这个例子中,端口评估为8000
,主机名评估为127.0.0.1
(您的计算机的本地网络)。在这个例子中,我们的服务器将在127.0.0.1:8000
上监听。传递给.listen()
的最后一个参数是一个回调函数。一旦服务器开始在指定的端口和主机名上监听 HTTP 请求,提供的回调函数就会被调用。在前面的代码段中,回调函数只是打印出我们的服务器可以在本地访问的 URL。您可以将此 URL 输入到浏览器中,然后一个网页将加载。
练习 33:创建基本的 HTTP 服务器
要构建一个基本的 HTTP 服务器,请执行以下步骤:
-
导入
http
模块。 -
为主机名和端口设置变量,并分别给它们赋值
127.0.0.1
和8000
。 -
使用
http.createServer
创建服务器。 -
为
createServer
函数提供一个回调,该回调接受参数req
和res
。 -
将响应状态码设置为
200
。 -
将响应内容类型设置为
text/plain
。 -
使用
My first server!
响应请求 -
使用
server.listen
函数使服务器监听指定的端口和主机。 -
为
listen
函数提供一个回调,记录Server running at ${server uri}
。 -
启动服务器并加载已记录的网页。
代码
index.js
const http = require( 'http' );
const hostname = '127.0.0.1';
const port = 8000;
const server = http.createServer( ( req, res ) => {
res.statusCode = 200;
res.setHeader( 'Content-Type', 'text/plain' );
res.end( 'My first server!\n' );
} );
server.listen( port, hostname, () => console.log( 'Server running at http://${hostname}:${port}/' ) );
代码片段 6.9:简单的 HTTP 服务器
https://bit.ly/2sihcFw
结果
图 6.2:返回新的购物车数组
图 6.3:返回新的购物车数组
您已成功构建了一个基本的 HTTP 服务器。
流和管道
流数据可能是 Node.js 中最复杂和最被误解的方面之一。流也可以说是 Node.js 提供的最强大的功能之一。流只是数据的集合,就像标准数组或字符串一样。主要区别在于,使用流时,所有数据可能不会同时可用。你可以把它想象成从 YouTube 或 Netflix 上流视频。你不需要在开始观看视频之前下载整个视频。视频提供者(YouTube 或 Netflix)以小块的方式向你的计算机发送视频。你可以开始观看视频的一部分,而不需要等待其他部分被加载。流非常强大,因为它们允许服务器和客户端不需要一次性将整个大量数据集加载到内存中。在 JavaScript 服务器中,流对于内存管理至关重要。
Node.js 中的许多内置模块依赖于流。这些模块包括 HTTP 模块(http
)中的请求和响应对象,文件系统模块(fs
)中的文件,加密模块(crypto)和子进程模块(child_process
)。在 Node.js 中,流有四种类型——可读,可写,双工和转换。理解它们的作用非常简单。
流的类型
数据从可读流中消耗。它们抽象了源的加载和分块。数据以一次一个数据块的方式呈现给可读流进行消耗(使用)。在数据块被消耗后,它被流释放,并呈现下一个数据块。可读流不能由消费者推送数据进入其中。可读流的一个例子是 HTTP 请求体。
可读流有两种模式——流动和暂停。这些模式决定了流的数据流动。当流处于流动模式时,数据会自动从底层流系统中读取,并提供给消费者。当流处于暂停模式时,数据不会自动从底层系统中读取。消费者必须使用stream.read()
函数显式请求流中的数据。所有可读流都以暂停模式开始,并可以通过附加data
事件处理程序、调用stream.resume()
或调用 stream.pipe()
来切换到流动模式。事件处理程序和流管道将在本节后面介绍。可读流可以使用stream.pause()
方法或stream.unpipe()
方法从流动切换到暂停。
可写流是可以写入或推送数据的流。可写流将源的组合和处理抽象化。数据被呈现给流以供提供者消耗。流将一次消耗一个数据块,直到被告知停止。在流消耗了一个数据块并适当处理后,它将消耗或请求下一个可用的数据块。一个可写流的例子是文件系统模块的createWriteStream
函数,它允许我们将数据流到磁盘上的文件中。
双工流是既可读又可写的流。数据可以由提供者以块的形式推送到流中,也可以由消费者以块的形式从流中消耗。双工流的一个例子是网络套接字,比如 TCP 套接字。
转换流是允许数据块在流中移动时进行变异的双工流。一个转换流的例子是 Node.js 的ZLib
模块中的gzip
方法,它使用gzip
压缩方法压缩数据。
流以块的形式加载数据,而不是一次性加载,因此为了有效地使用流,我们需要一种方法来确定流是否已加载数据。在 Node.js 中,流是EventEmitter
原型的实例。当关键事件发生时,流会发出事件,比如错误或数据可用性。事件监听器可以使用.on()
和.once()
方法附加到流上。可读流和可写流都有用于数据处理、错误处理和流管理的事件。
以下表格显示了可用的事件及其目的:
可写流事件:
图 6.4:可写流事件
可读流事件:
图 6.5:可读流事件
注意
这些事件监听器可以附加到流上,以处理数据流和管理流的状态。完整的文档可以在 Node.js 网站的流 API 下找到。
现在你了解了流的基础知识,我们必须实现它们。可读流遵循一个基本的工作流程。通常会调用一个返回可读流的方法。一个例子是文件系统 API 的createReadStream()
函数,它创建一个从磁盘上流出文件的可读流。在返回可读流之后,我们可以通过附加data
事件处理程序来开始从流中拉取数据。以下片段展示了一个例子:
const fs = require( 'fs' );
fs.createReadStream( './path/to/files.ext' ).on( 'data', data => {
console.log( data );
} );
片段 6.10:使用可读流
在上面的例子中,我们导入了fs
模块并调用了createReadStream
函数。这个函数返回一个可读流。然后我们给data
事件附加了一个事件监听器。这将把流放入流动模式,每当数据块准备就绪时,提供的回调函数将被调用。在这个例子中,我们的回调函数简单地记录了可读流放弃的数据。
就像可读流一样,可写流也遵循一个相当标准的工作流程。可写流的最基本工作流程是首先调用一个返回可写流的方法。一个例子是fs
模块的createWriteStream
函数。创建了可写流之后,我们可以使用stream.write()
函数向其写入数据。这个函数将传入的数据写入流中。以下片段展示了一个例子:
const fs = require( 'fs' );
const writeable = fs.createWriteStream( './path/to/files.ext' );
writeable.write( 'some data' );
writeable.write( 'more data!' );
片段 6.11:使用可写流
在上面的片段中,我们加载了fs
模块并调用了createWriteStream
函数。这返回一个将数据写入文件系统的可写流。然后我们多次调用stream.write()
函数。每次调用write
函数时,我们传入的数据都被推送到可写流并写入磁盘。
Node.js 中最强大的功能之一是流的管道功能。管道流简单地将源流“管道”到目标流。您将一个流的数据输出管道到另一个流的输入。这非常强大,因为它允许我们简化连接流的过程。
考虑一个问题,我们必须从磁盘加载文件并将其作为 HTTP 响应发送给客户端。我们可以用两种方式来做这件事。我们可以构建的第一种实现是将整个文件加载到内存中,然后一次性将其推送给客户端。这对我们的服务器来说非常低效。第二种方法是利用流。我们从磁盘流式传输文件,并将数据块推送到请求流中。要做到这一点,我们必须在读取流上附加监听器,并捕获每个数据块,然后将数据块推送到 HTTP 响应。此伪代码如下所示:
const fileSystemStream = load( 'filePath' );
fileSystemStream.on( 'data', data => HTTP_Response.push( data ) );
fileSystemStream.on( 'end', HTTP_Response.end() );
片段 6.12:使用流将数据发送到 HTTP 响应
在前面片段的伪代码中,我们创建了一个从指定文件路径加载的流。然后为data
事件和end
事件添加了事件处理程序。每当数据事件有数据时,我们将该数据推送到HTTP_Response
流。一旦没有更多数据并且触发了 end 事件,我们关闭HTTP_Response
流,数据被发送到客户端。这需要几行代码,并要求开发人员管理数据和数据流。我们可以使用单行代码构建完全相同的功能,使用流管道。
使用Stream.pipe()
函数进行流的管道传输。管道是在源流上调用的,并将目标流作为参数传递(例如,readableStream.pipe( writeableStream )
)。管道返回目标流,允许它用于链接管道命令。使用与前面示例相同的场景,我们可以使用管道命令将伪代码简化为一行。如下所示:
load( 'filePath' ).pipe( HTTP_Response );
片段 6.13:管道数据伪代码
在前面的片段中,我们加载了文件数据并将其传输到HTTP_response
。可读流加载的每个数据块都会自动传递给可写流HTTP_Response
。当可读流完成加载数据时,它会自动关闭并告诉写流也关闭。
文件系统操作
Node 的文件系统模块,名为’fs
’,提供了一个 API,我们可以与文件系统交互。文件系统 API 是围绕 POSIX 标准建模的。**POSIX(可移植操作系统接口)**标准是由 IEEE 计算机学会指定的标准,旨在帮助不同操作系统文件系统之间保持一般兼容性。您不需要学习标准的细节,但要了解 fs 模块遵循它以保持跨平台兼容性。要导入文件系统模块,我们可以使用以下命令:const fs = require( 'fs' );
。
Node.js 中的大多数文件系统函数都要求您指定要使用的文件路径。在为 fs 模块指定文件路径时,路径可以以三种方式之一指定:作为字符串,作为缓冲区,或者使用file:
协议的URL对象。当路径是字符串时,文件系统模块将尝试解析字符串以获得有效的文件路径。如果文件路径是缓冲区,文件系统模块将尝试解析缓冲区的内容以获得有效的文件路径。如果路径是 URL 对象,则文件系统将将对象转换为有效的 URL 字符串,然后尝试解析字符串以获得有效的文件路径。三种显示文件路径的示例如下所示:
fs.existsSync( '/some/path/to/file.txt' );
fs.existsSync( Buffer.from( '/some/path/to/file.txt' ) );
fs.existsSync( new URL( 'file://some/path/to/file.txt' ) );
片段 6.14:文件系统路径格式
正如您在前面的示例中看到的,我们使用了fs
模块的existsSync
函数。在第一行中,我们将文件路径作为字符串传递。在第二行中,我们从文件路径字符串创建了一个缓冲区,并将缓冲区传递给existsSync
函数。在最后一个示例中,我们从文件路径的file:
协议 URL 创建了一个 URL 对象,并将 URL 对象传递给existsSync
函数。
文件路径可以解析为相对路径或绝对路径。绝对路径是从操作系统的根文件夹解析的。相对路径是从当前工作目录解析的。当前工作目录可以通过process.cwd()
函数获得。通过字符串或缓冲区指定的路径可以是相对的或绝对的。使用 URL 对象指定的路径必须是对象的绝对路径。
文件系统模块引入了许多函数,允许我们与硬盘交互。对于这些函数的大部分,都有同步和异步实现。同步的 fs 函数是阻塞的!当您编写使用 fs 模块的任何代码时,记住这一点非常重要。
注意
还记得第二章,异步 JavaScript中对阻塞操作的定义吗?阻塞操作将阻止事件循环处理任何事件。
如果您使用同步的fs
函数加载大文件,它将阻塞事件循环。在同步的fs
函数完成工作之前,不会处理任何事件。Node.js 线程不会执行任何其他操作,包括响应 HTTP 请求,处理事件或任何其他异步工作。您几乎总是应该使用fs
函数的异步版本。唯一需要使用同步版本的情况是在必须在任何其他操作之前执行文件系统操作时。这可能是加载整个系统或服务器依赖的文件的一个例子。
Express 服务器
我们在本主题的早期部分讨论了基本的 Node.js HTTP 服务器。我们创建的服务器非常基本,缺乏我们从真正的 Web 服务器中期望的许多功能。在 Node.js 中,用于创建最小和灵活的 Web 服务器的最常见模块之一是Express。Express 将 Node.js 服务器对象包装在一个简化功能的 API 中。Express 可以通过 NPM(npm install express --save
)安装。
注意
Express 的完整文档可以在expressjs.com
找到。
基本的 Express 服务器非常容易创建。让我们回顾一下本章前面创建的基本 Node.js HTTP 服务器。在基本的 HTTP 服务器示例中,我们首先使用HTTP.createServer()
函数创建了一个服务器,并传递了一个基本的请求处理程序。然后使用server.listen()
函数启动了服务器。Express 服务器的创建方式类似。就像 HTTP 服务器一样,我们首先需要引入我们的模块。为Express
模块添加一个require
语句,并创建变量来保存我们的主机名和端口号。接下来,我们必须创建我们的 Express 服务器。这只需调用默认从require('express')
语句导入的函数。调用导入的函数并将结果保存在一个变量中。如下面的片段所示:
注意
简单 HTTP 服务器的代码可以在练习 33 的代码下找到。
const express = require( 'express' );
const hostname = '127.0.0.1';
const port = 8000;
const app = express();
片段 6.15:设置 Express 服务器
在前面的片段中,我们导入了Express
模块并将其保存到变量Express
中。然后创建了两个常量变量——一个用于保存主机名,一个用于保存端口号。在代码的最后一行,我们调用了通过 require 语句导入的函数。这将创建一个带有所有默认参数的基本Express
服务器。
我们必须做的下一步是复制我们的基本 HTTP 服务器,添加一个基本的 HTTP 请求处理程序。这可以通过app.get()
函数完成。App.get
为其提供的路径设置一个 HTTP GET 请求处理程序。它接受两个参数——路径和回调。路径指定处理程序将捕获请求的 URL 路径。callback
是处理 HTTP 请求时调用的函数。我们应该为服务器的根路径(‘/
’)添加一个路由处理程序。如下片段所示:
app.get( '/', ( req, res ) => res.end( 'Working express server!' ) )
片段 6.16:设置路由处理程序
在前面的代码片段中,我们使用app.get()
添加了一个路由处理程序。我们传入根路径(‘/
’),这样当基本路径(‘localhost/
’)被 HTTP 请求命中时,指定的回调将被调用。在我们的回调中,我们传入一个具有两个参数的函数:req
和res
。就像简单的 HTTP 服务器一样,req
代表传入的 HTTP 请求,res
代表传出的 HTTP 响应。在函数的主体中,我们使用字符串Working express server!
关闭 HTTP 响应。这告诉Express
使用基本的 200 HTTP 响应代码,并将文本作为响应的主体发送。
最后一步,我们必须采取的步骤来使我们的基本Express
服务器工作是让它监听 HTTP 请求。为此,我们可以使用app.listen()
函数。此函数告诉服务器开始在指定端口监听 HTTP 请求。我们将三个参数传递给app.listen()
。第一个参数是端口号。第二个参数是主机名。第三个参数是一个回调函数,一旦服务器开始监听,就会被调用。使用正确的端口、主机名和一个打印我们可以访问服务器的 URL 的回调来调用listen
函数。以下是一个示例:
app.listen( port, hostname, () => console.log( 'Server running at http://${hostname}:${port}/' ) );
片段 6.17:使 Express 服务器监听传入请求
在前面的片段中,我们调用了listen
函数。我们传入端口号,解析为8000
;主机名,解析为127.0.0.1
;和一个callback
函数,记录服务器 URL。一旦服务器开始在本地网络上的端口8000
监听 HTTP 请求,就会调用callback
函数。转到控制台上记录的 URL,看看你的基本服务器是如何工作的!
练习 34:创建一个基本的 Express 服务器
要构建一个基本的 Express 服务器,请执行以下步骤:
-
导入
express
模块。 -
设置主机名和端口的变量,并分别给它们赋值
127.0.0.1
和8000
。 -
通过调用
express()
创建服务器应用程序,并将其保存到app
变量中。 -
在基本路由
/
上添加一个 get 请求处理程序。 -
提供一个接受
req
和res
的callback
函数,并使用文本Working express server!
关闭响应。 -
使服务器在指定的端口和主机上侦听
app.listen()
。 -
提供一个回调函数给
app.listen()
,记录Server running at ${server uri}
。 -
启动服务器并在浏览器中加载指定的 URL。
代码
index.js
const express = require( 'express' );
const hostname = '127.0.0.1';
const port = 8000;
const app = express();
app.get( '/', ( req, res ) => res.end( 'Working express server!' ) );
app.listen( port, hostname, () => console.log( 'Server running at http://${hostname}:${port}/' ) );
片段 6.18:简单的 Express 服务器
https://bit.ly/2Qz4Z93
结果
图 6.6:返回新的购物车数组
图 6.7:返回新的购物车数组
您已成功构建了一个基本的 Express 服务器。
路由
Express 最强大的功能之一是其灵活的路由。路由指的是 Web 服务器的端点 URI 如何响应客户端请求。当客户端向 Web 服务器发出请求时,它请求指定的端点(URI 或路径)以及指定的 HTTP 方法(GET
,POST
等)。Web 服务器必须明确处理它将接受的路径和方法,以及说明如何处理请求的回调函数。在 Express 中,可以使用以下代码行来实现:app.METHOD( PATH, HANDLER );
。app
变量是 Express 服务器的实例。Method 是要为其设置处理程序的 HTTP 方法。方法应为小写。路径是服务器上的 URI 路径,处理程序将对其进行响应。处理程序是如果路径和方法匹配请求将执行的回调函数。以下是此功能的示例:
app.get( '/', ( req, res ) => res.end('GET request at /') );
app.post( '/user', ( req, res ) => res.end( 'POST request at /user') );
app.delete( '/cart/item', ( req, res ) => res.end('DELETE request at /cart/item') );
片段 6.19:Express 路由示例
在上述片段中,我们为 Express 服务器设置了三个路由处理程序。第一个是使用.get()
函数设置的。这意味着服务器将寻找对指定路由的GET
请求。我们传入了服务器的基本路由(/
)。当基本路由收到GET
请求时,将调用提供的回调函数。在我们的回调函数中,我们用字符串GET request at /
进行响应。在第二行代码中,我们设置服务器响应路径/user
的POST
请求。当POST
请求到达 Express 服务器时,我们调用提供的回调函数,关闭响应并返回字符串POST request at /user.
在最后一行代码中,我们为DELETE
请求设置了处理程序。当DELETE
请求进入 URI/cart/item
时,我们用提供的回调进行响应。
Express 还支持特殊函数app.all()
。如果您经常使用 HTTP 请求,您会意识到ALL
不是有效的 HTTP 方法。app.all()
是一个特殊的处理程序函数,告诉 Express 响应指定 URI 的所有有效 HTTP 请求方法,并使用指定的回调。它被添加到 Express 中,以帮助减少重复的代码,如果一个路由打算接受任何请求方法。
Express 支持为请求 URI 和 HTTP 方法设置多个回调函数。为了实现这一点,我们必须向回调函数添加第三个参数:next
。next
是一个函数,当调用next
时,Express 将移动到匹配方法和 URI 的下一个回调处理程序。以下是一个示例:
app.get( '/', ( req, res, next ) => next() );
app.get( '/', ( req, res ) => res.end( 'Second handler!' ) );
片段 6.20:相同路由的多个请求处理程序
在上述片段中,我们为基本 URI 设置了两个不同的路由处理程序和GET
请求。当捕获到对基本路由的GET
请求时,将调用第一个处理程序。此处理程序仅调用next()
函数,告诉 Express 寻找下一个匹配的处理程序。Express 看到有第二个匹配的处理程序,并调用第二个处理程序函数,关闭 HTTP 响应。重要的是要注意,HTTP 响应只能关闭并一次返回给客户端。如果为 URI 服务器和 HTTP 方法设置了多个处理程序,必须确保只有一个处理程序关闭 HTTP 请求,否则将会出现错误。多个处理程序提供的功能对于 Express 中的中间件和错误处理非常重要。这些应用程序将在本节后面更详细地讨论。
高级路由
如前所述,在 Express 中,路由路径是它匹配的路径 URI,以及 HTTP 方法,在检查要调用哪个处理程序回调时。路由路径作为第一个参数传递给函数,例如app.get()
。Express 的强大之处在于能够创建极其动态的路由路径,以匹配多个 URI。在 Express 中,路由路径可以是字符串、字符串模式或正则表达式。Express 将解析基于字符串的路由,以查找特殊字符?
,+
,*
,()
,$
,[
和]
。在字符串路径中使用时,特殊字符?
,+
,*
和()
是正则表达式对应字符的子集。[
和]
字符用于转义 URL 的部分,在字符串中不会被字面解释。$
字符是 Express 路径解析模块中的保留字符。如果必须在路径字符串中使用$
字符,则必须使用[
和]
进行转义。例如,/user/$22515
应该在 Express 路由处理程序中写成/data/[\$]22515
。
*
字符的功能类似于+
字符,但匹配零个或多个字符的重复。Express 将匹配与字符串完全匹配但不包含额外字符的路由。一个或多个连续字符可以用来代替星号。示例如下:
app.get( '/abc?de', ( req, res ) => {
console.log( 'Matched /abde or /abcde' );
} );
片段 6.23:使用零个或多个字符进行路由
在前面的片段中,我们为 URL 路径/abc?de
设置了一个GET
处理程序。当命中此 URL 时,将调用回调函数,该函数记录两个可能的 URI 匹配选项。由于?
字符跟在c
字符后面,因此c
字符被视为可选的。Express 将匹配包含或不包含可选字符的 URI 的GET
请求。对/abde
和/abcde
的请求都将匹配。
+
符号用于指示字符或字符组的零个或多个重复。Express 将匹配与重复字符的字符串完全匹配的路由,以及包含一个或多个标记字符的连续重复的任何字符串。示例如下:
app.get( '/fo+d', ( req, res ) => {
console.log( 'Matched /fd, /fod, /food, /fooooooooood' );
} );
片段 6.22:路由路径中零个或多个重复字符
在前面的片段中,我们为 URL 路径fo+d
设置了一个GET
处理程序。当命中此 URI 时,将调用回调函数,该函数记录一些匹配选项。由于 o 字符后面跟着+
字符,Express 将解析任何包含零个或多个o
的路由。Express 将匹配fd
,fod
,food
,foooooooooooood
和任何其他具有连续o
的字符串 URI。
片段 6.21:路由路径中的可选字符
app.get( '/fo*d', ( req, res ) => {
console.log( 'Matched /fd, /fod, /fad, /faeioud' );
} );
如果我们希望在路由中使用特殊字符以增加灵活性,我们可以使用字符?
,+
,*
和()
。这些字符的操作方式与它们的正则表达式对应字符相同。这意味着?
字符用于表示可选字符。跟在?
符号后面的任何字符或字符组都将被视为可选的,Express 将匹配要么与可选字符的完整字符串匹配,要么与不包含可选字符的完整字符串匹配。示例如下:
在前面的片段中,我们为 URL 路径fo*d
设置了一个GET
处理程序。当命中此 URI 时,将调用回调函数,该函数记录一些匹配选项。由于o
字符后面跟着*
字符,Express 将解析任何包含零个或多个额外字符的路由。Express 将匹配fd
,fod
,fad
,foood
,faeioud
和任何其他具有连续字符的字符串 URI。在比较+
和*
字符的匹配字符串时,请注意匹配字符串之间的差异。*
字符将匹配+
字符匹配的所有字符串,还会匹配任何有效字符代替星号的字符串。
最后一组字符是()
。括号将一组字符分组在一起。当与其他特殊字符(?
,+
或*
)一起使用时,分组字符将被视为单个单位。例如,URI/ab(cd)?ef
将匹配 URI/abef
和/abcdef
。字符cd
被分组在一起,并且整个组受到?
字符的影响。示例显示了这种与每个特殊字符的交互在以下片段中:
app.get( '/b(es)?t', ( req, res ) => {
console.log( 'Matched /bt and /best' );
} );
app.get( '/b(es)+t', ( req, res ) => {
console.log( 'Matched /bt, /best, /besest, /besesest' );
} );
app.get( '/b(es)*t', ( req, res ) => {
console.log( 'Matched /bt, /best, /besest, /besesest' );
} );
片段 6.24:使用字符组进行路由
在前面的片段中,我们为路径b(es)?t
,b(es)+t
,b(es)*t
设置了GET
请求处理程序。每个处理程序调用一个回调函数,记录一些匹配选项。在所有处理程序中,字符es
被分组在一起。在第一个处理程序中,分组字符受到?
字符的影响,并被视为可选的。处理程序将匹配包含完整字符串并且只包含非可选字符的 URI。两个选项是bt
和best
。在第二个处理程序中,字符组受到+
字符的影响。具有零个或多个连续重复字符组的 URI 将匹配。匹配选项是bt
,best
,besest
,besesest
,以及任何其他具有更多连续重复的字符串。
Express 还允许我们在路由字符串中设置路由参数。路由参数是命名路由部分,允许我们指定要捕获并保存到变量中的路由 URL 的部分。URL 的捕获部分保存在req.params
对象中,对象的键名与捕获的名称匹配。URL 参数使用:
字符指定,后跟捕获名称。任何落在路由的那部分字符串都将被捕获并保存。示例显示在以下片段中:
app.get( '/amazon/audible/:userId/books/:bookId', ( req, res ) => {
console.log( req.params );
} );
片段 6.25:使用 URL 参数进行路由
在前面的片段中,我们为路由/amazon/audible/:userId/books/:bookId
设置了一个 get 参数。这个路由有两个命名参数捕获:一个是userId
,另一个是bookId
。这两个命名捕获可以包含任何一组有效的 URL 字符。在audible/
和/books
之间包含的任何字符都将保存在req.params.userId
变量中,而在books/
之后的任何字符都将保存在req.params.bookId
中。重要的是要注意,/
字符是用来分割 URL 的。保存的捕获组将不包含/
字符,因为 Express 将其解析为 URL 分隔符。
Express 路由还可以在路径字符串的位置使用正则表达式。如果将正则表达式传递给请求处理程序的第一个参数而不是字符串,Express 将解析正则表达式,并且与正则表达式匹配的任何字符串都将触发提供的回调处理程序。如果您对正则表达式不熟悉,可以在网上找到许多教授基础知识的教程。正则表达式路径的示例显示在以下片段中:
app.get( /^web.*/, ( req, res ) => {
console.log( 'Matched strings like web, website, and webmail' );
} );
片段 6.26:使用正则表达式进行路由
在前面的片段中,我们为正则表达式路由/^web.*/
设置了一个GET
处理程序。如果匹配此处理程序,服务器将记录两个匹配的字符串示例。我们提供给GET
处理程序的正则表达式指定了 URI 必须以字符串web
开头,可以跟随任意数量的字符。这将匹配诸如/web
,/website
和/webmail
等 URI。
中间件
Express 还通过一个名为中间件的功能扩展了服务器的灵活性。Express 是一个路由和中间件框架,本身功能有限。中间件是具有对 HTTP 请求请求和响应对象的访问权限并在处理序列的中间某处运行的函数。中间件可以执行四项任务中的一项:执行代码,对请求和响应对象进行更改,结束 HTTP 请求-响应序列,并调用适用于请求的下一个中间件。
注意
中间件函数可以手动编写,也可以通过第三方 NPM 模块下载。在编写中间件之前,请检查中间件是否已经存在。官方中间件模块和一些热门模块可以在expressjs.com/en/resources/middleware.html
找到。
中间件函数有三个输入变量:req
,res
和next
。Req
表示请求对象,res
表示响应对象,next
是一个告诉 Express 继续到下一个中间件处理程序的函数。我们在本节的前面看到了next
函数,当将多个路由处理程序注册到一个 URI 时。next
函数告诉中间件处理程序将控制权传递给处理程序堆栈中的下一个中间件。简单来说,它告诉next
中间件运行。如果中间件没有结束请求-响应序列,它必须调用next
函数。如果不这样做,请求将挂起并最终超时。中间件可以使用app.use()
和app.METHOD()
函数附加。使用app.use()
设置的中间件将对匹配指定可选路径的所有 HTTP 方法触发。使用 HTTP 方法函数附加的中间件将对匹配方法和指定路径的所有请求触发。下面的片段显示了中间件的示例:
app.use( ( req, res, next ) => {
req.currentTime = new Date();
next();
} );
app.get( '/', ( req, res ) => {
console.log( req.currentTime );
} );
片段 6.27:设置中间件
在前面的片段中,我们使用app.use()
设置了一个中间件函数。我们没有为app.use()
提供路径,因此所有请求都将触发中间件。中间件通过在请求中设置currentTime
字段为一个新的日期对象来更新请求对象。然后中间件调用下一个函数,该函数将控制权传递给下一个中间件或路由处理程序。假设请求到基本 URI,下一个被触发的处理程序是注册的处理程序,它打印req.currentTime
中保存的值。
错误处理
Express 的最后一个重要方面是错误处理。错误处理是 Express 处理和管理错误的过程。Express 可以处理同步错误和异步错误。Express 具有内置的错误处理,因此您不需要编写自己的错误处理程序。Express 的内置错误处理程序将在响应正文中将错误返回给客户端。这可能包括错误详细信息,如堆栈跟踪。如果您希望用户看到自定义错误消息或页面,则必须附加自己的错误处理程序。
内置的 Express 错误处理程序将捕获路由处理程序或中间件函数中同步代码中抛出的任何错误。这包括运行时错误和使用 throw 关键字抛出的错误。但是,Express 不会捕获在异步函数中抛出的错误。要在异步函数中调用错误,必须将next
函数添加到回调处理程序中。如果发生错误,必须使用要处理的错误调用 next。下面的片段显示了使用默认错误处理程序进行同步和异步错误处理的示例:
app.get( '/synchronousError', ( req, res ) => {
throw new Error( 'Synchronous error' );
} );
app.get( '/asynchronousError', ( req, res, next ) => {
setTimeout( () => next( new Error( 'Asynchronous error' ) ), 0 );
} );
片段 6.28:同步和异步错误处理
在前面的片段中,我们首先创建了一个GET
请求处理程序,路径为/synchronousError
。如果触发了这个处理程序,我们调用回调函数,在同步代码块中抛出一个错误。由于错误是在同步代码块中抛出的,Express 会自动捕获错误并将其传递给客户端。在第二个示例中,我们为路径/asynchronousError
创建了一个GET
请求处理程序。当触发了这个处理程序时,我们调用一个回调函数,开始一个超时,并使用错误调用next
函数。错误发生在一个异步代码块中,因此必须通过 next 函数传递给 Express。当 Express 捕获到错误时,无论是通过抛出事件同步地还是通过 next 函数异步地,它会立即跳过所有适用的中间件和路由处理程序,并跳转到第一个适用的错误处理程序。
定义我们自己的错误处理中间件函数,我们以与其他中间件函数相同的方式添加它,只是有一个关键的区别。错误处理中间件回调函数在回调中有四个参数,而不是三个。参数依次是err
、req
、res
和next
。它们的解释如下:
-
err
代表正在处理的错误。 -
req
代表请求对象。 -
res
代表响应对象。 -
next
是一个告诉 Express 继续下一个错误处理程序的函数。
自定义错误处理程序应该是最后定义的中间件。下面是自定义错误处理的示例:
app.get( '/', ( req, res ) => {
throw new Error( 'OH NO AN ERROR!' );
} );
app.use( ( err, req, res, next ) => {
req.json( 'Got an error!' + err.message );
} );
片段 6.29:添加自定义错误处理程序
在前面的片段中,我们为基本路由添加了一个GET
请求处理程序。当处理程序被触发时,它调用一个回调函数,该函数抛出一个错误。这个错误会被 Express 自动捕获并传递给下一个错误处理程序。下一个错误处理程序是我们用app.use()
函数定义的。这个错误处理程序捕获错误,然后用错误消息响应客户端。
练习 35:使用 Node.js 构建后端
你被要求为一个笔记应用构建一个 Node.js Express 服务器。服务器应该为基本路由(/
)提供一个基本的 HTML 页面(在活动文件夹下的index.html
中提供)。服务器将需要一个 API 路由,从服务器的本地文件系统中的文本文件加载保存的笔记,并且一个 API 路由,将对笔记的更改保存到服务器的本地文件系统中的文本文件。服务器应该在加载笔记时接受一个GET
请求到 URI/load
,在保存笔记时接受一个POST
请求到 URI/save
。提供的 HTML 文件将假定这些是服务器上使用的 API 路径。在构建服务器时,您可能希望使用body-parser
中间件,并将 strict 选项设置为 false,以简化请求的处理。
要构建一个工作的 Node.js 服务器,提供一个 HTML 文件并接受 API 调用,执行以下步骤:
-
使用
npm init
设置项目。 -
使用 npm 安装
express
和body-parser
。 -
导入模块
express
、http
和body-parser
保存为bodyParser
,以及fs
,并将它们保存在变量中。 -
创建一个名为
notePath
的变量,其中包含文本文件的路径(./note.txt
)。 -
记录我们正在创建一个服务器。
-
使用
express()
创建服务器应用,并将其保存在app
变量中。 -
使用
http.createServer(app)
从 Express 应用创建一个 HTTP 服务器,并将其保存在 server 变量中。 -
记录我们正在配置服务器。
-
使用
body-parser
中间件来解析 JSON 请求体。
告诉 Express 应用使用中间件app.use()
。
将bodyParser.json()
传递给 use 函数。
将一个选项对象传递给bodyParser.json()
,使用key/value
对。strict:false。
-
使用
express.Router()
创建一个路由来处理路由,并将其保存在变量 router 中。 -
为基本路由添加一个
GET
路由处理程序,使用router.route('/').get
。
添加一个接受req
和res
的回调函数。
在回调中,使用res.sendFile()
发送index.html
文件。
将index.html
作为第一个参数传递,并将选项对象{
root: __dirname
}
作为第二个参数传递。
- 为
/save
路由添加一个路由,接受POST
请求router.route( '/save' ).post
。
路由处理程序回调应该接受参数req
和res
。
在回调中,使用fs
函数writeFile()
和notePath
以及req.body
参数和回调函数。
在回调函数中,接受参数err
和data
。
如果提供了err
,则使用状态码500
和 JSON 形式的错误关闭响应。
如果没有提供错误,则使用数据对象的 JSON 关闭响应,并使用200
状态码。
- 为
/load
路由添加一个路由,接受GET
请求router.route( '/load ).get
。
路由处理程序回调应该接受参数req
和res
。
在回调中,使用fs
函数readFile
和notePath
以及utf8
参数和回调函数。
在回调函数中,接受参数err
和data
。
如果提供了err
,则使用状态码500
和 JSON 形式的错误关闭响应。
如果没有提供错误,则使用数据对象的 JSON 关闭响应,并使用200
状态码。
-
使
express
应用程序使用路由器处理基本路由的请求app.use('/', router)
。 -
设置服务器以侦听正确的端口和主机名,并使用
server.listen( port, hostname, callback )
传入回调。
回调函数应该接受一个错误参数。如果找到错误,抛出该错误。
否则,记录服务器正在侦听的端口。
-
启动服务器并加载运行在
(localhost:PORT)
的 URL。 -
通过保存一个笔记,刷新网页,加载保存的笔记(应该与之前保存的内容匹配),然后更新笔记来测试服务器的路由和功能。
代码
index.js
router.route( '/' ).get( ( req, res ) => res.sendFile( 'index.html', {
root: __dirname } ) );
router.route( '/save' ).post( ( req, res ) => {
fs.writeFile( notePath, req.body, 'utf8', err => {
if ( err ) {
res.status( 500 );
}
res.end();
} );
} );
router.route( '/load' ).get( ( req, res ) => {
fs.readFile( notePath, 'utf8', ( err, data ) => {
if ( err ) {
res.status( 500 ).end();
}
res.json( data );
} );
} );
片段 6.30:复杂应用的 Express 服务器路由
https://bit.ly/2C4FR64
结果
图 6.8:监听端口 8000
图 6.9:加载测试笔记
您已成功构建了一个工作的 Node.js 服务器,可以提供 HTML 文件并接受 API 调用。
React
React是一个用于构建用户界面的 JavaScript 库。React 主要由 FaceBook 维护。React 最初是由 Facebook 软件工程师 Jordal Walke 创建的,并于 2013 年开源。React 旨在简化 Web 开发,使开发人员能够轻松构建单页网站和移动应用程序。
注意
React 的完整文档以及扩展教程可以在它们的主页找到:reactjs.org/
。
React 使用声明性方法来设计视图,以提高页面的可预测性和调试性。开发人员可以为应用程序中的每个状态声明和设计简单的视图。React 将处理视图的更新和渲染,随着状态的改变。React 依赖于基于组件的模型。开发人员构建封装的组件,跟踪和处理它们自己的内部状态。我们可以组合我们的组件以创建复杂的用户界面,类似于我们如何使用函数组合从简单函数构建复杂函数。通过组件,我们可以通过应用程序在组件之间传递丰富的数据类型。这是允许的,因为组件逻辑纯粹是用 JavaScript 编写的。最后,React 允许我们在框架中非常灵活。不会对应用程序背后的技术栈做出任何假设。React 可以在浏览器中加载时编译,在 Node.js 服务器上编译,或者使用 React Native 编译成移动应用程序。这使得可以在不需要重构现有代码的情况下逐步将 React 纳入新功能。您可以在技术栈的任何点开始纳入 React。
安装 React
React 可以通过 NPM 安装,并在服务器上编译,或通过脚本标签集成到应用程序中。有几种安装 React 并将其添加到项目中的方法。
将 React 添加到应用程序的最快方法是通过<script>
标签包含内置库。如果您有现有项目并希望逐步开始将 React 纳入其中,这种方法通常是最简单的。以这种方式添加 React 不到一分钟,可以让您立即开始添加组件。首先,我们需要在 HTML 页面中添加一个 DOM 容器,我们希望我们的 React 组件附加到其中。通常这是一个带有唯一 ID 的 div。然后使用脚本标签添加React和ReactDOM
模块。添加了脚本标签后,可以使用脚本标签加载 React 组件。以下是一个示例。
<div id="react-attach-point"></div>
<script src="https://unpkg.com/react@16/umd/react.development.js" crossorigin></script><script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js" crossorigin></script>
<script src="react_components.js"></script>
代码段 6.31:将 React 添加到网页
设置 React 应用程序并将 React 安装到新项目中的下一个最简单的方法是使用 React 应用程序创建者。这个模块是一个 Node.js 命令行界面,可以自动设置一个具有简单预定义文件夹结构和基本依赖项安装的 React 项目。可以使用命令行命令npm install create-react-app -g
安装 CLI 工具。这个命令告诉 NPM 在全局范围内安装 CLI 模块,以便可以从命令行运行它。安装了 CLI 后,可以通过运行create-react-app my-app-name
命令创建一个新的 React 项目。CLI 工具将在工作目录中创建一个文件夹,名称为提供的名称(例如命令中的my-app-name
),安装 React 依赖项,并为应用程序资源创建两个文件夹。CLI 工具将使用示例应用程序填充源代码文件夹,命名为src
。可以使用npm start
命令启动应用程序。从这一点开始,可以开始修改文件,看看 React 是如何工作的,或者可以删除src
中的所有文件,开始编写自己的应用程序。
安装 React 最困难的方法是逐个安装各个依赖项。这种方法提供了最大的灵活性,允许您将 React 集成到现有的工具链中。要安装 React,必须使用 NPM 安装react
和react-dom
模块。这两个模块可以在本地项目范围内安装,并应该使用--save
或-s
标志保存到package.json
依赖项列表中。安装了模块后,可以使用现有的工具链创建和构建 React 组件。
在本主题中,我们将使用带有 JSX 的 React。JSX是 JavaScript 的一种语法糖,默认情况下不受浏览器支持。JSX 必须通过 Babel 转译为有效的 JavaScript 代码。要完成 React 的设置,您需要设置 Babel 来将您的 React 和 JSX 代码转译为 JavaScript。如果您的项目尚未安装 Babel,可以使用npm install babel -s
命令进行安装。
这将保存 Babel 作为项目的依赖项。要将 React JSX 插件添加到 Babel 中,请运行npm install babel-preset-react-app -s
命令。此命令添加了 Babel 的 JSX 转换库。设置好 Babel 后,我们必须创建一个构建脚本,可以运行以转换我们所有的代码。在 package.json 中,添加以下行:build": "npx babel src -d lib --presets react-app/prod
。请注意,npx
不是拼写错误。它是一个随 NPM 一起提供的包运行工具。这行告诉 Babel 将代码从src
目录编译到lib
目录,并使用react-app/prod
预设。每次我们对 React 代码进行更改并希望在前端反映这些更改时,都应该运行此命令。现在,您已经准备好开始构建 React 应用程序了。
注意
您可以提供上一段中所述的 Babel 设置命令,以演示如何为转译设置项目。
React 基础
React 是围绕称为组件的小型封装代码构建的。在 React 中,组件是通过对React.Component
或React.PureComponent
进行子类化来定义的。最常见的方法是使用React.Component
。在最简单的形式中,React 组件接受属性(通常称为props
)并通过调用render()
返回要显示的视图。在初始化组件时定义属性。每个创建的组件必须在子类中定义一个名为render()
的方法。render 函数以 JSX 形式返回屏幕上将呈现的内容的描述。以下是一个示例组件声明:
class HelloWorld extends React.Component {
render() {
return (
<div>
Hello World!!! Made by {
this.props.by}!!!
</div>
);
}
}
ReactDOM.render(
<HelloWorld by="Zach"/>,
document.getElementById('root')
);
片段 6.32:基本的 React 元素
在前面的片段中,我们定义了一个名为HelloWorld
的新的 React 组件class
。这个新类扩展了基本的React.Component
。在声明内部,我们定义了render()
函数。render()
函数返回一个 JSX 块,定义了将在屏幕上呈现的内容。在这个 JSX 块中,我们创建了一个带有文本Hello World!!! Made by *!!!
的div
,其中*
字符被通过属性传递的值替换。在最后几行中,我们调用了ReactDom.render()
函数。这告诉ReactDom
模块呈现我们传递给render()
函数的所有组件和视图。在前面的片段中,我们将我们的HelloWorld
组件与属性by
设置为Zach
,并告诉渲染函数将呈现的 DOM 附加到根元素上。传递到属性中的数据被传递到我们的组件内部的this.props
中,并填充到Hello World!!! div
中。
注意
如果您的代码库不使用 ES6 或 ES6 类,您可以使用 create-react-class 模块,但是,此模块的具体细节超出了本书的范围。
恭喜!您已经了解了 React 的最基本形式。通过扩展这个例子,您现在可以构建基本的静态网页。这可能看起来并不是很有用,但它是所有网页开发的最基本构建块。
React 特定
在我们之前的片段中的基本示例中,我们可以看到 React 使用了一种奇怪的语法糖叫做 JSX。JSX 既不是 HTML 也不是 JavaScript。它是 JavaScript 的语法扩展,结合了 HTML 和 XML 的一些概念,帮助描述用户界面应该是什么样子。JSX 对于 React 应用并非必需,但建议在构建 React UI 时使用它。它看起来像一个模板语言,但具有 JavaScript 的全部功能。它可以通过 Babel React 插件编译成标准的 JavaScript。以下片段显示了 JSX 和等效的 JavaScript 示例:
const elementJSX = <div>Hello, world!</div>;
const elementJS = React.createElement( "div", null, "Hello, world!" );
片段 6.33:JSX vs JS
在前面的片段中,我们定义了一个名为elementJSX
的变量,并将一个 JSX 元素保存到其中。在第二行,我们创建了一个名为elementJS
的变量,并用纯 JavaScript 保存了等效的元素。在这个示例中,你可以清楚地看到 JSX 的标记风格如何简化了在 JavaScript 中定义元素的方法。
JSX
JSX可以像标准 JavaScript 中的模板文字一样嵌入表达式。然而,主要区别在于 JSX 只使用花括号({}
)来定义表达式。与模板文字一样,在 JSX 中使用的表达式可以是变量、对象引用或函数调用。这使我们能够在 React 中使用 JSX 创建动态元素。以下片段显示了 JSX 表达式的示例:
const name = "David";
function multiplyBy2( num ) {
return num * 2; }
const element1 = <div>Hello {
name}!</div>;
const element2 = <div>6 * 2 = {
multiplyBy2(6)}</div>;
片段 6.34:JSX 表达式
在前面的片段中,我们首先创建了一个名为 name 的变量,其中包含字符串David
,以及一个名为multiplyBy2
的函数,该函数接受一个数字并返回乘以2
的数字。然后我们创建了一个名为element1
的变量,并将一个 JSX 元素保存到其中。这个 JSX 元素包含一个包含对name
变量的引用的表达式的div
。当构建这个 JSX 元素时,表达式将name
变量评估为字符串David
并将其插入到最终的标记中。在代码的最后一行,我们创建了一个名为element2
的变量,并将另一个 JSX 元素保存到其中。这个 JSX 元素包含一个包含对multiplyBy2
函数的表达式的 div。当创建 JSX 元素时,表达式将评估其中的代码并调用函数。函数的返回值被放入最终的标记中。正如你所看到的,JSX 中的表达式与模板文字中的表达式非常相似。
ReactDOM
当我们创建 React 元素时,我们必须有一种方法将它们渲染到 DOM 中。这在 React 介绍示例中被非常简要地提及了。在那个示例中,我们使用了ReactDOM
库来渲染我们创建的组件。从react-dom
模块导入的ReactDOM
对象提供了可以在整个应用程序中使用的特定于 DOM 的方法;然而,大多数组件并不需要这些方法。你将最常使用的函数是render()
函数。这个函数接受三个参数。
第一个参数是我们将要渲染或附加到 DOM 的 React 元素。第二个参数是 React 组件将被渲染到的容器或 DOM 节点。最后一个参数是一个可选的回调方法。回调函数将在组件渲染后执行。对于完整的 React 应用程序,ReactDOM.render()
通常只需要在应用程序的顶层使用,并用于在视图中渲染整个应用程序。在将 React 逐渐纳入现有代码库的应用程序中,ReactDOM.render()
可能在每个新的 React 组件被纳入非 React 代码的地方使用。以下片段显示了ReactDOM.render()
的示例:
import ReactDOM from 'react-dom';
const element = <div>HELLO WORLD!!!</div>;
ReactDOM.render( element, document.getElementById('root'), () => {
console.log( 'Done rendering' );
});
片段 6.35:将元素渲染到 DOM 中
在上面的示例中,我们首先导入了ReactDOM
模块。然后我们使用 JSX 创建了一个新的 React 元素。这个简单的元素只包含一个带有文本HELLO WORLD!!!
的div
。然后我们调用了ReactDOM.render()
函数并传入了所有三个参数。这个函数调用告诉浏览器选择根 DOM 节点并附加我们的 React 元素渲染的标记。当渲染完成时,将调用提供的回调,并将Done rendering
字符串记录到控制台中。
React.Component
React 围绕组件展开。正如我们之前学到的,创建新组件的最简单方法是创建一个扩展React.Component
类的新子类。React.Component
类可以通过从 React NPM 模块导入的 React 对象访问。当我们定义一个 React 组件时,我们必须至少定义一个render()
函数。render
函数返回组件将包含的 JSX 描述。如果我们希望创建更复杂的组件,例如具有状态的组件,我们可以向组件添加构造函数。构造函数必须接受props
变量,并且必须使用props
变量调用super()
函数。props
变量将包含在创建 React 组件时分配的属性的对象。以下是一个具有构造函数的 React 组件的示例:
class ConstructorExample extends React.Component{
constructor( props ){
super( props );
this.variable = 'test';
}
render() {
return <div>Constructor Example</div>; }
}
片段 6.36:React 类构造函数
在上面的片段中,我们创建了一个名为ConstructorExample
的新组件。在同一个片段中,我们调用了constructor
函数。constructor
函数接受一个参数,即包含属性的对象。在构造函数中,我们调用了super()
函数并传入props
变量。然后我们创建了一个名为variable
的class
变量,并赋予了值test
。在类的末尾,作为所有 React 组件所需的,我们添加了一个返回组件的 JSX 标记的render()
函数。
状态
要向 React 组件添加本地状态,我们只需在构造函数内部(this.state = {}
)初始化状态变量。状态变量是 React 中的一个特殊变量关键字名称。对this.state
的任何更改都将导致调用render()
函数。这使我们能够根据组件的当前状态动态更改视图。
关于状态变量,有三个关键要知道的事情。首先,您不应该直接修改状态,比如this.state.value = 'value'
。以这种方式修改状态不会导致调用render()
和视图更新。相反,您必须使用setState()
函数。这将使用传递给函数的数据更新状态。例如,我们必须这样设置状态:this.setState( { name: 'Zach' } )
。第二个关键细节是状态更新可能是异步的。React 可能会将多个setState
调用合并为单个更新以提高性能。因此,我们不能依赖它们的值来计算状态。如果我们必须使用当前状态或属性的当前值来计算下一个状态,我们可以使用setState
的第二种形式,它接受一个函数而不是一个对象。该函数将接收前一个状态作为第一个参数,并在应用状态更新时接收属性对象。这可靠地允许我们使用先前的状态和属性信息来计算下一个状态。最后,状态更新是合并而不是覆盖。与Object.assign
函数类似,setState
对状态对象和新状态进行浅合并。在设置状态时,新对象将与旧状态对象合并。只有新状态对象中指定的属性将更改。旧状态对象中的所有属性,如果不在新状态对象中,将保持不变。
在 React 组件中,属性对象从组件内部是只读的。这意味着从组件内部对属性对象的更改不会反映到父组件或 DOM 结构中的任何变量。数据只能向下流动。因此,对子组件属性的父组件 JSX 标记的任何更改都将导致子组件使用新的属性值重新渲染。要使数据向上流动,我们必须以属性的形式将函数从父组件传递到子组件。以下片段显示了一个示例。
class ChildElement extends React.Component {
render() {
return (
<button onClick={
this.props.onClick}>
Click me!
</button>
);
}
}
class ParentElement extends React.Component {
clicked() {
console.log( 'clicked' ); }
render() {
return <ChildElement onClick={
this.clicked.bind(this)}/>;
}
}
片段 6.37:渲染子组件
在这个片段中,我们创建了两个组件。第一个称为ChildElement
,第二个称为ParentElement
。ChildElement
简单地包含了一个按钮的 JSX,当点击时,通过onClick
属性传递的函数被调用。ParentElement
包含一个名为clicked
的函数,用于记录到控制台,并在渲染时返回带有ChildElement
实例的 JSX。在ParentElement
的 JSX 中创建的ChildElement
将onClick
属性设置为ParentElement
的clicked()
函数。当点击ChildElement
中的按钮时,将调用clicked()
函数。在这个例子中,当我们将它传递给子元素时,将父级范围绑定到this.clicked
(this.clicked.bind(this)
)。如果this.clicked
需要访问父组件中的任何内容,我们必须将其范围绑定到父组件的范围。在您的 React 应用程序中,您可以使用此功能创建向上的数据流。
在 React 中处理 DOM 事件与 HTML DOM 元素事件处理非常相似,但有一些主要区别。首先,在 React 中,事件名称使用驼峰命名法
而不是小写。这意味着在名称的每个“新单词”中,该单词的第一个字母是大写的。例如,在 React 中,DOM 事件onclick
变成了onClick
。其次,在 JSX 中,函数事件处理程序直接作为函数传递到处理程序定义中,而不是作为包含处理程序函数名称的字符串。以下代码显示了标准 HTML 和 React 之间的差异:
<button onclick="doSomething()">HTML</button>
<button onClick={
doSomething}>JSX and React</button>
片段 6.38:JSX 与 HTML 事件
在前面的片段中,我们创建了两个按钮。第一个是 HTML 格式的,它附加了一个onclick
侦听器,调用doSomething
函数。第二个按钮是 JSX 格式的,也有一个onclick
侦听器,调用doSomething
函数。请注意侦听器定义的不同之处。JSX 事件名称是camelcase
,HTML 事件名称是小写。在 JSX 中,我们通过表达式设置处理程序函数,该表达式求值为函数。在 HTML 中,我们将事件处理程序设置为调用函数的字符串。
注意
我们在第三章,DOM 操作和事件处理中学到,直接在 DOM 中附加事件是一种不好的做法。JSX 不是 HTML,这种做法是可以接受的,因为 JSX 通过转义 JSX 中嵌入的任何值来防止注入攻击。
React 事件处理和标准 DOM 事件处理之间的另一个重要区别是,在 React 中,事件处理程序函数不能返回 false 以阻止默认行为。您必须在事件对象上明确调用preventDefault()
函数。
在 React 中附加事件侦听器时,我们必须小心处理this
作用域。在 JavaScript 中,类方法默认情况下不绑定到this
作用域。如果函数被传递到其他地方并从其他地方调用,则this
作用域可能无法正确设置。在将它们附加到侦听器或作为属性传递方法时,应确保正确绑定this
作用域。
条件渲染
在 React 中,我们创建不同的组件来封装我们需要的视图或行为。我们需要一种方式,根据应用程序的状态,只渲染我们创建的一些组件。在 React 中,这称为条件渲染。在 React 中,条件渲染的工作方式与 JavaScript 条件语句相同。我们可以使用 JavaScript 的 if 或条件运算符来决定渲染哪些元素。这可以通过几种方式来实现。
在两种简单的方式中,一种是根据当前状态返回一个 React 元素(JSX)的函数,而另一种是在 JSX 中有一个条件语句,根据当前状态返回一个 React 元素。这些条件渲染形式的示例显示在以下片段中:
class AccountControl extends React.Component {
constructor( props ) {
super( props );
this.state = {
account: this.props.account };
}
isLoggedIn() {
if ( this.state.account ) {
return <LogoutButton/>; }
else {
return <LoginButton/>; }
}
render() {
return (
<div>
{
this.isLoggedIn()}
{
this.state.account ? <LogoutButton/> : <LoginButton/>}
</div>
);
}
}
片段 6.39:条件渲染
在前面的片段中,我们创建了一个名为AccountControl
的元素。在构造函数中,我们将本地状态设置为包含从属性变量传入的帐户信息的对象。渲染函数简单地返回一个带有两个表达式的div
。这两个表达式都利用条件渲染来根据当前状态显示信息。第一个表达式调用isLoggedIn
函数,该函数检查this.state.account
并根据当前状态返回LogoutButton
或LoginButton
。第二个表达式使用条件运算符来内联检查this.state.account
,并根据本地状态返回LogoutButton
或LoginButton
。
项目列表
在 React 中渲染项目列表非常简单。它基于 JSX 和表达式的概念。正如我们之前学到的,JSX 使用表达式来创建动态代码。如果表达式求值为组件数组,则所有组件将被呈现为如果它们被内联添加到 JSX 中。我们可以构建一个组件的集合或数组,将集合保存在一个变量中,并将变量包含在 JSX 表达式中。这种示例显示在以下片段中:
class ListElement extends React.Component {
render() {
return (
<ul> {
this.props.items.map( i => <li>{
i}</li> )} </ul>
);
}
}
ReactDOM.render(
<ListElement items={
[ 1, 4, 5, 5, 7, 9 ]}/>,
document.getElementById( 'root' )
);
片段 6.40:渲染列表
在前面的片段中,我们创建了一个名为ListElement
的元素。这个元素简单地接受一个项目数组,并将数组映射到包含<li>
标签中的数组项值的 JSX 元素数组中。然后将结果列表项数组返回到<ul>
标签中。当 JSX 将其编译成 HTML 时,数组中的每个项目按顺序插入到<ul>
元素中。
HTML 表单
React 的最后一个关键概念是 HTML 表单。HTML 表单在 React 中的工作方式与其他 DOM 元素不同,因为 HTML 表单跟踪其自己的内部状态。如果我们只需要处理表单的默认行为,那么我们可以在 React 中直接使用它们,并且不会出现任何问题。然而,当我们希望让 JavaScript 处理表单提交并访问表单中的所有数据时,我们会遇到一个复杂的问题。这个问题是因为元素和 React 组件同时尝试跟踪表单的状态。
实现这一点的方法是使用受控组件。受控组件的目标是从表单元素中删除状态控制,并使 React 成为控制组件。这是通过为字段的值更改事件(onChange
)添加一个 React 事件监听器,并让 React 将其内部state
变量值设置为表单的值来实现的。然后,React 将字段的值设置为保存在state
变量中的值。React 读取input
字段中的任何更改,并强制input
字段采用发生在 React 组件中存储的数据的任何更改。以下片段中显示了这一点的示例:
class ControlledInput extends React.Component {
constructor(props) {
super(props);
this.state = {
value: ''};
}
handleChange(event) {
this.setState({
value: event.target.value}); }
render() {
return (
<div>
<input type="text" value={
this.state.value} onChange={
this.handleChange.bind(this)} />
<div>Value: {
this.state.value} </div>
</div>
);
}
}
片段 6.41:React 组件状态
在前面的片段中,我们创建了一个名为ControlledInput
的组件。该组件有一个名为 value 的状态变量,用于存储文本输入的值。我们创建了一个名为handleChange
的函数,简单地通过将值设置为从事件中读取的值来更新组件的状态。在渲染函数中,我们创建一个包含一个input
字段和另一个div
的 div。这个输入字段的值映射到this.state.value
,并且有一个调用handleChange
函数的事件监听器。第二个div
简单地镜像了this.state.value
的值。当我们在文本输入框内进行更改时,onChange
监听器被调用,组件的state.value
被设置为输入字段的当前值。每当this.state.value
被更改时,这种变化都会反映到input
字段上。组件的this.state.value
的值是绝对的,input
字段被强制镜像它。
活动 6:使用 React 构建前端
在练习 32中负责笔记应用的前端团队意外辞职了。您必须使用 React 构建此应用的前端。您的前端应该有两个视图,一个是主页视图,一个是编辑视图。为每个视图创建一个React
组件。主页
视图应该有一个按钮,可以切换到编辑
视图。编辑
视图应该有一个按钮,可以切换回主页
视图,一个包含笔记文本
的文本输入,一个调用 API 加载路由的加载按钮,以及一个调用 API 保存路由的保存按钮。已经为您提供了一个 Node.js 服务器。在activities/activity6/activity/src/index.js
中编写您的 React 代码。当您准备测试您的代码时,在启动服务器之前运行构建脚本(在package.json
中定义)。您可以参考练习 35中的index.html
文件,了解如何调用 API 路由的提示。
要构建一个可工作的 React 前端并将其与 Node.js Express 服务器集成,执行以下步骤:
-
打开
activity/activity6/activity
中的起始活动。运行npm install
以安装所需的依赖项。 -
在
src/index.js
文件中创建Home
和Editor
组件。 -
主页
视图应该显示应用程序名称,并有一个按钮,可以将应用状态更改为编辑
视图。 -
编辑
视图应该有一个返回主页的按钮,可以将应用状态更改为编辑
视图,一个由编辑视图
状态控制的文本输入,一个向服务器请求保存的笔记文本
的加载按钮,以及一个向服务器请求保存笔记文本的保存按钮。 -
在
App
组件中,使用app
状态来决定显示哪个视图(主页
或编辑
)。
代码
结果
图 6.10:主页视图
图 6.11:编辑视图
图 6.12:服务器视图
您已成功构建了一个可工作的 React 前端,并将其与 Node.js Express 服务器集成。
注意
此活动的解决方案可在第 293 页找到。
总结
在过去 10 多年中,JavaScript 生态系统已经大幅增长。在本章中,我们首先讨论了 JavaScript 生态系统。JavaScript 可用于构建完整的后端 Web 服务器和服务、命令行界面、移动应用程序和前端网站。在第二部分中,我们介绍了 Node.js。我们讨论了如何为浏览器外的 JavaScript 开发设置 Node.js,Node 包管理器,加载和创建模块,基本的 HTTP 服务器,流和管道,文件系统操作以及 Express 服务器。在最后一个主题中,我们介绍了用于前端 Web 开发的 React 框架。我们讨论了安装 React 以及 React 的基础知识和特定内容。
这本书到此结束。在本书中,您学习了 ES6 中的主要特性,并实现了这些特性来构建应用程序。然后,您处理了 JavaScript 浏览器事件,并创建了遵循 TDD 模式的程序。最后,您构建了后端框架 Node.js 和前端框架 React。现在,您应该具备将所学知识应用于实际工作中的工具。感谢您选择本高级 JavaScript 书籍。
文章评论