点击标题下「异步社区」可快速关注
本文包括以下内容:
理解函数为何如此重要
函数为何是第一类对象
定义函数的方式
参数赋值之谜
在本文这一部分讨论JavaScript基础时,也许你会感到惊讶,我们的第一个论点是函数(function)而非对象(object)。当然,第3部分会用大量笔墨解释对象,但归根结底,你要理解一些基本事实,像普通人一样编写代码和像“忍者”一样编写代码的最大差别在于是否把JavaScript作为函数式语言(functional language)来理解。对这一点的认知水平决定了你编写的代码水平。
如果你正在阅读这本文,那么你应该不是一位初学者。对于后续内容,我们假设你已经足够了解面向对象基础(当然,我们会在以后章节详细讨论对象的高级概念),但真正理解JavaScript中的函数才是你能使用的唯一一件重要武器。函数是如此重要,所以本文及接下来两章将带领你彻底理解JavaScript中的函数。
JavaScript中最关键的概念是:函数是第一类对象(first-class objects),或者说它们被称作一等公民(first-class citizens)。函数与对象共存,函数也可以被视为其他任意类型的JavaScript对象。函数和那些更普通的JavaScript数据类型一样,它能被变量引用,能以字面量形式声明,甚至能被作为函数参数进行传递。本文一开始会介绍面向函数编程带来的差异,你会发现,在需要调用某函数的位置定义该函数,能让我们编写更紧凑、更易懂的代码。其次,我们还会探索如何把函数用作第一类对象来编写高性能函数。你能学到多种不同的函数定义方式,甚至包括一些新类型,例如箭头(arrow)函数,它能帮你编写更优雅的代码。最后,我们会学习函数形参和函数实参的区别,并重点关注ES6的新增特性,例如剩余参数和默认参数。
让我们通过了解函数式编程的优点来开始学习吧。
你知道吗?
回调函数在哪种情况下会同步调用,或者异步调用呢?
箭头函数和函数表达式的区别是什么?
你为什么需要在函数中使用默认参数?
1.1 函数式的不同点到底是什么
函数及函数式概念之所以如此重要,其原因之一在于函数是程序执行过程中的主要模块单元。除了全局JavaScript代码是在页面构建的阶段执行的,我们编写的所有的脚本代码都将在一个函数内执行。
由于我们的大多数代码会作为函数调用来执行,因此,我们在编写代码时,通用强大的构造器能赋予代码很大的灵活性和控制力。本文的大部分内容解释了如何利用函数作为第一类对象的特性获益。首先浏览一下对象中我们能使用的功能。JavaScript中对象有以下几种常用功能。
对象可通过字面量来创建{}。
对象可以赋值给变量、数组项,或其他对象的属性。
1var ninja = {}; ?--- 为变量赋值一个新对象
2ninjaArray.push({}); ?--- 向数组中增加一个新对象
3ninja.data = {}; ?--- 给某个对象的属性赋值为一个新对象
对象可以作为参数传递给函数。
1function hide(ninja){
2 ninja.visibility = false;
3}
4hide({}); ?--- 一个新创建的对象作为参数传递给函数
对象可以作为函数的返回值。
1function returnNewNinja() {
2 return {}; ?--- 从函数中返回了一个新对象
3}
对象能够具有动态创建和分配的属性。
1var ninja = {};
2ninja.name = "Hanzo"; ?--- 为对象分配一个新属性
其实,不同于很多其他编程语言,在JavaScript中,我们几乎能够用函数来实现同样的事。
1.1.1 函数是第一类对象
JavaScript中函数拥有对象的所有能力,也因此函数可被作为任意其他类型对象来对待。当我们说函数是第一类对象的时候,就是说函数也能够实现以下功能。
通过字面量创建。
1function ninjaFunction() {}
赋值给变量,数组项或其他对象的属性。
1var ninjaFunction = function() {}; ?--- 为变量赋值一个新函数
2ninjaArray.push(function(){}); ?--- 向数组中增加一个新函数
3ninja.data = function(){}; ?--- 给某个对象的属性赋值为一个新函数
作为函数的参数来传递。
1function call(ninjaFunction){
2 ninjaFunction();
3}
4call(function(){}); ?--- 一个新函数作为参数传递给函数
作为函数的返回值。
1function returnNewNinjaFunction() {
2 return function(){}; ?--- 返回一个新函数
3}
具有动态创建和分配的属性。
1var ninjaFunction = function(){};
2ninjaFunction.ninja = "Hanzo"; ?--- 为函数增加一个新属性
对象能做的任何一件事,函数也都能做。函数也是对象,唯一的特殊之处在于它是可调用的(invokable),即函数会被调用以便执行某项动作。
{JavaScript中的函数式编程!}
把函数作为第一类对象是函数式编程(functional programming)的第一步,函数式编程是一种编程风格,它通过书写函数式(而不是指定一系列执行步骤,就像那种更主流的命令式编程)代码来解决问题。函数式编程可以让代码更容易测试、扩展及模块化。不过这是一个很大的话题,因此本文仅对这个特性做了肯定。如果你对如何在JavacScript中利用函数式编程感兴趣,推荐阅读Luis Atencio著(由Manning出版社2016年出版)的《JavaScript函数式编程》,购买方式见www.manning.com/ books/functional-programming- in-JavaScript。
第一类对象的特点之一是,它能够作为参数传入函数。对于函数而言,这项特性也表明:如果我们将某个函数作为参数传入另一个函数,传入函数会在应用程序执行的未来某个时间点才执行。大家所知道的更一般的概念是回调函数(callback function)。下面我们来学习这个重要概念。
1.1.2 回调函数
每当我们建立了一个将在随后调用的函数时,无论是在事件处理阶段通过浏览器还是通过其他代码,我们都是在建立一个回调(callback)。这个术语源自于这样一个事实,即在执行过程中,我们建立的函数会被其他函数在稍后的某个合适时间点“再回来调用”。
有效运用JavaScript的关键在于回调函数,相信你已经在代码中使用了很多回调函数——不论是单击一次按钮、从服务端接收数据,还是UI动画的一部分。
本节我们会看一些实际使用回调函数的典型例子,例如处理事件、简单的排序集合。这部分内容会有点复杂,所以在深入学习之前,先透彻、完整地理解回调函数的概念,用最简单的形式来展现它。下面我们用一个简单例子来阐明这个概念,此例中的函数完全没什么实际用处,它的参数接收另一个函数的引用,并作为回调调用该函数:
1function useless(ninjaCallback) {
2 return ninjaCallback();
3}
这个函数可能没什么用,但它反映了函数的一种能力,即将函数作为另一个函数的参数,随后通过参数来调用该函数。
我们可以在清单1.1中测试一下这个名为useless的函数。
清单1.1 简单的回调函数例子
1var text = "Domo arigato!";
2report("Before defining functions");
3function useless(ninjaCallback) {
4 report("In useless function");
5 return ninjaCallback();
6} ?--- 函数定义,参数为一个回调函数,其函数体内会立即调用该回调函数
7function getText() {
8 report("In getText function");
9 return text;
10} ?--- 简单的函数定义,仅返回一个全局变量
11report("Before making all the calls");
12assert(useless(getText) === text,
13 "The useless function works! " text); ?--- 把gerText作为回调函数传入上面的useless函数
14report("After the calls have been made");
在这个代码清单中,我们使用自定义函数report(在本文附录B中定义)来输出代码执行过程中的信息,这样一来我们就能通过这些信息来跟踪程序的执行过程。我们还使用了第1章中的断言函数assert。该函数通常使用两个参数。第一个参数是用于断言的表达式。本例中,我们需要确定使用参数getText调用useless函数返回的值与变量text是否相等(useless(getText) === text)。若第一个参数的执行结果为true,断言通过;反之,断言失败。第二个参数是与断言相关联的信息,通常会根据通过/失败来输出到日志上。(附录B中概括地探讨了测试,以及我们对assert函数和report函数的简单实现)。
这段代码执行完毕后,执行结果如图1.1所示。可以看到,使用getText参数调用useless回调函数后,得到了期望的返回值。
图1.1 清单1.1中代码的执行结果
我们还可以看看这个简单的回调函数具体是如何执行的。如图1.2所示,getText函数作为参数传入了useless函数。从该图中可以看到,在useless函数体内,通过callback参数可以取得getText函数的引用。随后,回调函数callback()的调用让getText函数得到执行,而我们作为参数传入的getText函数则通过useless函数被回调。
图1.2 执行useless(getText)调用后的执行流。getText作为参数传入useless函数并调用。useless函数体内对传入函数进行调用,本例中触发了getText函数的执行(即我们对getText函数进行回调)。
完成这个过程是很容易的,原因就在于JavaScript的函数式本质让我们能把函数作为第一类对象。更进一步说,我们的代码可以写成如下形式:
1<pre class="代码无行号"><code>var text = 'Domo arigato!';
2function useless(ninjaCallback) {
3 return ninjaCallback();
4}
5assert(useless(<strong>function () { return text;}</strong>) === text, ?--- 直接以参数形式定义回调函数
6 "The useless function works! " text); </code></pre>
JavaScript的重要特征之一是可以在表达式出现的任意位置创建函数,除此之外这种方式能使代码更紧凑和易于理解(把函数定义放在函数使用处附近)。当一个函数不会在代码的多处位置被调用时,该特性可以避免用非必须的名字污染全局命名空间。
在回调函数的前述例子中,我们调用的是我们自己的回调。除此之外浏览器也会调用回调函数,回想一下第2章中的下述例子:
1document.body.addEventListener("mousemove", function() {
2
3 var second = document.getElementById("second")
4;
5 addMessage(second, "Event: mousemove"
6);
7});
上例同样是一个回调函数,作为mousemove事件的事件处理器,当事件发生时,会被浏览器调用。
{注意 }
本小节介绍的回调函数是其他代码会在随后的某个合适时间点“回过来调用”的函数。你已经学习了我们自己的代码调用回调(useless函数例子),也看到了当某事件发生时浏览器发起调用(mousemove例子)。注意这些很重要,不同于我们的例子,一些人认为回调会被异步调用,因此第一个例子不是一个真正的回调。这里之所以提到这些是以防万一你偶尔会遇见这类激烈的争论。
现在让我们看一个回调函数的用法,它能极大地简化集合的排序。
使用比较器排序
一般情况下只要我们拿到了一组数据集,就很可能需要对它进行排序。假如有一组随机序列的数字数组:0, 3, 2, 5, 7, 4, 8, 1。也许这个顺序没什么问题,但很可能早晚需要重新排列它。
通常来说,实现排序算法并不是编程任务中最微不足道的;我们需要为手中的工作选择最佳算法,实现它以适应当前的需要(使这些选项是按照特定顺序排列),并且需要小心仔细不能引入故障。除此之外,唯一特定于应用程序的任务是排列顺序。幸运的是,所有的JavaScript数组都能用sort方法。利用该方法可以只定义一个比较算法,比较算法用于指示按什么顺序排列。
这才是回调函数所要介入的!不同于让排序算法来决定哪个值在前哪个值在后,我们将会提供一个函数来执行比较。我们会让排序算法能够获取这个比较函数作为回调,使算法在其需要比较的时候,每次都能够调用回调。该回调函数的期望返回值为:如果传入值的顺序需要被调换,返回正数;不需要调换,返回负数;两个值相等,返回0。对于排序上述数组,我们对比较值做减法就能得到我们所需要的值。
1<pre class="代码无行号"><code>var values = [0, 3, 2, 5, 7, 4, 8, 1];
2values.sort<strong>(function(value1, value2) {</strong>
3 return value1 - value2;
4<strong>}</strong>);</code></pre>
没有必要思考排序算法的底层细节(甚至是选择了什么算法)。JavaScript引擎每次需要比较两个值的时候都会调用我们提供的回调函数。
函数式方式让我们能把函数作为一个单独实体来创建,正像我们对待其他类型一样,创建它、作为参数传入一个方法并将它作为一个参数来接收。函数就这样显示了它一等公民的地位。
1.2 函数作为对象的乐趣
本节我们会考察函数和其他对象类型的相似之处。也许让你感到惊讶的相似之处在于我们可以给函数添加属性:
1var ninja = {};
2ninja.name = "hitsuke"; ?--- 创建新对象并为其分配一个新属性
3var wieldSword = function(){};
4wieldSword.swordType = "katana"; ?--- 创建新函数并为其分配一个新属性
我们再来看看这种特性所能做的更有趣的事:
在集合中存储函数使我们轻易管理相关联的函数。例如,某些特定情况下必须调用的回调函数。
记忆让函数能记住上次计算得到的值,从而提高后续调用的性能。
让我们行动起来吧。
1.2.1 存储函数
某些例子中(例如,我们需要管理某个事件发生后需要调用的回调函数集合),我们会存储元素唯一的函数集合。当我们向这样的集合中添加函数时,会面临两个问题:哪个函数对于这个集合来说是一个新函数,从而需要被加入到该集合中?又是哪个函数已经存在于集合中,从而不需要再次加入到集合中?一般来说,管理回调函数集合时,我们并不希望存在重复函数,否则一个事件会导致同一个回调函数被多次调用。
一种显著有效的简单方法是把所有函数存入一个数组,通过循环该数组来检查重复函数。令人遗憾的是,这种方法的性能较差,尤其作为一个“忍者”要把事情干得漂亮而不仅是做到能用。我们可以使用函数的属性,用适当的复杂度来实现它,如清单1.2所示。
清单1.2 存储唯一函数集合
1var store = {
2 nextId: 1, ?--- 跟踪下一个要被复制的函数
3 cache: {}, ?--- 使用一个对象作为缓存,我们可以在其中存储函数
4 add: function(fn) {
5 if (!fn.id) {
6 fn.id = this.nextId ;
7 this.cache[fn.id] = fn;
8 return true;
9 }
10 } ?--- 仅当函数唯一时,将该函数加入缓存
11};
12function ninja(){}
13assert(store.add(ninja),
14 "Function was safely added.");
15assert(!store.add(ninja),
16 "But it was only added once."); ?--- 测试上面代码按预期工作
在这个清单中,我们创建了一个对象赋值给变量store,这个变量中存储的是唯一的函数集合。这个对象有两个数据属性:其一是下一个可用的id,另外一个缓存着已经保存的函数。函数通过add()方法添加到缓存中。
1add: function(fn) {
2 if (!fn.id) {
3 fn.id = this.nextId ;
4 this.cache[fn.id] = fn;
5 return true;
6 }
7...
在add函数内,我们首先检查该函数是否已经存在id属性。如果当前的函数已经有id属性,我们则假设该函数已经被处理过了,从而忽略该函数,否则为该函数分配一个id(同时增加nextId)属性,并将该函数作为一个属性增加到cache上,id作为属性名。紧接着该函数的返回值为true,从而可得知调用了add()后,函数是什么时候被添加到存储中的。
在浏览器中运行该程序后,页面显示:测试程序尝试两次添加ninja()函数,而该函数只被添加一次到存储中,如图1.3所示。第9章展示了用于操作合集的更好技术,它利用了ES6的新的对象类型集合(Set)。
图1.3 给函数附加一个属性后,我们就能够引用该属性。本例通过这种方式可以确保该ninja函数仅被添加到函数中一次
另外一种有用的技巧是当使用函数属性时,可以通过该属性修改函数自身。这个技术可以用于记忆前一个计算得到的值,为之后计算节省时间。
1.2.2 自记忆函数
如同前面所提到的,记忆化(memoization)是一种构建函数的处理过程,能够记住上次计算结果。在这个果壳里,当函数计算得到结果时就将该结果按照参数存储起来。采用这种方式时,如果另外一个调用也使用相同的参数,我们则可以直接返回上次存储的结果而不是再计算一遍。像这样避免既重复又复杂的计算可以显著地提高性能。对于动画中的计算、搜索不经常变化的数据或任何耗时的数学计算来说,记忆化这种方式是十分有用的。
看看下面的这个例子,它使用了一个简单的(也的确是效率不高的)算法来计算素数。尽管这是一个复杂计算的简单例子,但它经常被应用到大计算量的场景中(例如可以引申到通过字符串生成MD5算法),这里不便展示。
从外表来说,这个函数和任何普通函数一样,但在内部我们会构建一个结果缓存,它会保存函数每次计算得到的结果,如清单1.3所示。
清单1.3 计算先前得到的值
1function isPrime(value) {
2 if (!isPrime.answers) {
3 isPrime.answers = {};
4 } ?--- 创建缓存
5 if (isPrime.answers[value] !== undefined) {
6 return isPrime.answers[value];
7 } ?--- 检查缓存的值
8 var prime = value !== 0 && value !== 1; // 1 is not a prime
9 for (var i = 2; i < value; i ) {
10 if (value % i === 0) {
11 prime = false;
12 break;
13 }
14 }
15 return isPrime.answers[value] = prime; ?--- 存储计算的值
16}
17assert(isPrime(5), "5 is prime!");
18assert(isPrime.answers[5], "The answer was cached!"); ?--- 测试该函数是否正常工作
在isPrime函数中,首先通过检查它的answers属性来确认是否已经创建了一个缓存,如果没有创建,则新建一个:
1if (!isPrime.answers) {
2 isPrime.answers = {};
3}
只有第一次函数调用才会创建这个初始空对象,之后这个缓存就已经存在了。然后我们会检查参数中传的值是否已经存储到缓存中:
1if (isPrime.answers[value] !== undefined) {
2 return isPrime.answers[value];
3}
这个缓存会针对参数中的值value来存储该值是否为素数(true或false)。如果我们在缓存中找到该值,函数会直接返回。
1return isPrime.answers[value] = prime;
这个缓存是函数自身的一个属性,所以只要该函数还存在,缓存也就存在。
最后的测试结果可以看到记忆函数生效了。
1assert(isPrime(5), "5 is prime!");
2assert(isPrime.answers[5], "The answer was cached!");
这个方法具有两个优点。
由于函数调用时会寻找之前调用所得到的值,所以用户最终会乐于看到所获得的性能收益。
它几乎是无缝地发生在后台,最终用户和页面作者都不需要执行任何特殊请求,也不需要做任何额外初始化,就能顺利进行工作。
当然这种方法并不是像玫瑰和提琴一样完美,还是要权衡利弊。
任何类型的缓存都必然会为性能牺牲内存。
纯粹主义者会认为缓存逻辑不应该和业务逻辑混合,函数或方法只需要把一件事做好。但不必担心,在第8章你会了解到如何解决这类问题。
对于这类问题很难做负载测试或估计算法复杂度,因为结果依赖于函数之前的输入。
现在你看到了函数作为第一类公民的一些实例,接下来看看不同的函数定义的方式。
1.3 函数定义
JavaScript函数通常由函数字面量(function literal)来创建函数值,就像数字字面量创建一个数字值一样。要记住这一点,作为第一类对象,函数是可以用在编程语言中的值,就像例句字符串或数字的值。无论你是否意识到了这一点,你一直都是这样做的。
JavaScript提供了几种定义函数的方式,可以分为4类。
函数定义(function declarations)和函数表达式(function expressions)——最常用,在定义函数上却有微妙不同的的两种方式。人们通常不会独立地看待它们,但正如你将看到的,意识到两者的不同能帮我们理解函数何时能够被调用。
1function myFun(){ return 1;}
箭头函数(通常被叫做lambda函数)——ES6新增的JavaScript标准,能让我们以尽量简洁的语法定义函数。
1myArg => myArg*2
函数构造函数—— 一种不常使用的函数定义方式,能让我们以字符串形式动态构造一个函数,这样得到的函数是动态生成的。这个例子动态地创建了一个函数,其参数为a和b,返回值为两个数的和。
1new Function('a', 'b', 'return a b')
生成器函数——ES6新增功能,能让我们创建不同于普通函数的函数,在应用程序执行过程中,这种函数能够退出再重新进入,在这些再进入之间保留函数内变量的值。我们可以定义生成器版本的函数声明、函数表达式、函数构造函数。
1function* myGen(){ yield 1; }
理解这几种方式的不同很重要,因为函数创建的方式很大程度地影响了函数可被调用的时间、函数的行为以及函数可以在哪个对象上被调用。
这一节中,我们将会探索函数定义、函数表达式和箭头函数。你将学到它们的语法和它们的工作方式,我们也将会在本文中多次回顾它们的细节。另一方面,生成器函数则有一点独特,它不同于普通函数。在第6章我们会再来学习它们的细节。
剩下的JavaScript特性——函数构造函数我们将全部跳过。尽管它具有某些有趣的应用场景,尤其是在动态创建和执行代码时,但我们依然认为它是JavaScript语言的边缘功能。如果你想知道更多关于函数构造函数的信息,请访问http://mng.bz/ZN8e。
让我们先用最简单、最传统的方式定义函数吧:函数声明和函数表达式。
1.3.1 函数声明和函数表达式
JavaScript中定义函数最常用的方式是函数声明和函数表达式。这两种技术非常相似,有时甚至难以区分,但在后续章节中你将看到,它们之间还是存在着微妙的差别。
函数声明
JavaScript定义函数的最基本方式是函数声明(见图1.4)。正如你所见,每个函数声明以强制性的function开头,其后紧接着强制性的函数名,以及括号和括号内一列以逗号分隔的可选参数名。函数体是一列可以为空的表达式,这些表达式必须包含在花括号内。除了这种形式以外,每个函数声明还必须包含一个条件:作为一个单独的JavaScript语句,函数声明必须独立(但也能够被包含在其他函数或代码块中,在下一小节中你将会准确理解其含义)。