第 8 章 函数

本章介绍了 JavaScript 函数。函数是 JavaScript 程序的基本构建块,也是几乎所有编程语言的共同特性。你可能已经了解函数的概念,如子例程或过程。

函数是一个 JavaScript 代码块,只定义一次,但可以执行或调用任意次数。JavaScript 函数是参数化的:一个函数定义可以包含一个标识符列表,称为参数,作为函数体的局部变量。函数调用为函数的参数提供值或实参。函数通常使用它们的实参值来计算一个返回值,该返回值成为函数调用表达式的值。除了参数之外,每次调用都有另一个值——调用上下文——即 this 关键字的值。

如果函数挂载在一个对象上作为其属性,它就被称为方法。当该方法在对象中被调用或通过对象调用时,该对象就是该方法函数的调用上下文或 this 值。用于初始化新创建的对象的函数称为构造函数。构造函数在 §6.2 中有介绍,我们将在第9章中再次谈到它。

在 JavaScript 中,函数是对象,它们可以被程序操作。例如,JavaScript 可以将函数赋给变量,并将它们传递给其他函数。由于函数是对象,所以您可以给它们设置属性,甚至调用它们的方法。

JavaScript 函数可以嵌套在其他函数中定义,并且它们可以访问定义它们所处的作用域内任何变量。这意味着 JavaScript 函数是闭包,支持闭包是非常重要的,它是非常强大的编程技巧。

8.1 函数定义

定义 JavaScript 函数最直接的方法是使用 function 关键字,它既可以用作声明又可以用作表达式。ES6 定义了一种不使用 function 关键字的重要新方法来定义的函数:“箭头函数”,它具有特别简洁语法,并且在将一个函数作为参数传递给另一个函数的场景中非常实用。接下来的小节将介绍这三种定义函数的方法。注意,关于函数定义语法包含的函数参数相关内容将在 §8.3 中介绍。

在对象字面量和类定义中,有一种方便的快捷语法来定义方法。这种简写语法在 §6.10.5 中有介绍,相当于通过对象字面量语法将函数定义表达式用最基本的属性名:属性值的方式赋值给对象的属性。在另一种特殊情况下,可以在对象字面量中使用关键字 get 和 set 来定义特殊的属性 getter 和 setter 方法。这个函数定义语法在 §6.10.6 中介绍过。

注意,函数也可以用 Function() 构造函数来定义,这是 §8.7.7 的主题。此外,JavaScript 还定义了一些特殊类型的函数。function* 定义函数生成器(见第12章)和 async function 定义异步函数(见第13章)。

8.1.1 函数声明

函数声明由 function 关键字组成,后面跟着这些组件:

  • 函数名称标识符。名称是函数声明的必要部分:它用作变量的名称,并将新定义的函数对象赋值给该变量。
  • 一对圆括号,其中包含由0个或者多个用逗号分隔的标识符组成的列表。这些标识符是函数的参数名,它们的行为类似于函数体中的局部变量。
  • 一对花括号,其中包含由0条或者多条 JavaScript 语句。这些语句构成了函数体:每当函数调用时,就会执行这些语句。

下面是一些函数声明的例子:

// Print the name and value of each property of o.  Return undefined.
function printprops(o) {
    for(let p in o) {
        console.log(`${p}: ${o[p]}\n`);
    }
}

// Compute the distance between Cartesian points (x1,y1) and (x2,y2).
function distance(x1, y1, x2, y2) {
    let dx = x2 - x1;
    let dy = y2 - y1;
    return Math.sqrt(dx*dx + dy*dy);
}

// A recursive function (one that calls itself) that computes factorials
// Recall that x! is the product of x and all positive integers less than it.
function factorial(x) {
    if (x <= 1) return 1;
    return x * factorial(x-1);
}

重中之重要理解函数声明是函数名变成一个变量,这个变量的值是函数本身。函数声明语句“被提前”到脚本、函数、块之前,因此这种方式定义的函数可以在它定义之前被调用。另一种说法是所有声明在 Javascript 代码块中的函数,在块内始终是有定义的,它们定义在 JavaScript 解释器开始解释执行块内任何语句之前。

distance() 和 factorial() 函数计算一个值,它们用 retrun 来将这个值返回给调用者。return 语句导致函数停止执行,并返回它的表达式的值(如果有的话)给调用者。如果 return 语句没有一个与之相关的表达式,则函数返回 undefined 值。

printprops() 函数不同:它负责输出对象属性的名称和值。没有返回值的必要,并且该函数也不包含一个 return 语句。 调用 printprops() 函数的返回值永远是 undefined。如果一个函数不包含一个 return 语句,它仅仅执行函数体内每一条语句直到结束,并返回 undefined 给调用者。

在 ES6 之前,函数只允许在 JavaScript 文件顶层或者其他函数中声明。然而一些实现违反规约,在循环体条件体或者其他块中定义函数。在 ES6 的严格模式下,函数允许在块内进行声明。一个定义在块内的函数只存在于该块内,块外是不可见的。

8.1.2 函数表达式

函数表达式看起来很像函数声明,但是它出现在一个它的上层表达式或语句的上下文中,并且函数名称是可选项。

下面是一些函数表达式的例子:

// This function expression defines a function that squares its argument.
// Note that we assign it to a variable
const square = function(x) { return x*x; };

// Function expressions can include names, which is useful for recursion.
const f = function fact(x) { if (x <= 1) return 1; else return x*fact(x-1); };

// Function expressions can also be used as arguments to other functions:
[3,2,1].sort(function(a,b) { return a-b; });

// Function expressions are sometimes defined and immediately invoked:
let tensquared = (function(x) {return x*x;}(10));

注意函数名称在函数表达式中是可选项,在大部分函数表达式中我们省略了它。函数声明实际上声明了一个变量并且将函数对象赋值给它。按照这个角度来看,函数表达式没有声明一个变量:可以根据它是否会多次调用由你自己决定是将新定义的函数对象赋值给一个常量还是变量。用 const 定义函数表达式是一个非常好的做法,你不会因为意外赋值而重写了你的函数。

可以给函数一个名称,就像 factorial 函数,它需要调用它自己。如果一个函数表达式包含一个名称,那这个函数的局部函数作用域内会包含一个属性名为该函数名的对象,其值绑定的是该函数。实际上,函数名变成这个函数的一个局部变量。大多数函数表达式不需要函数名称,这让它们的定义更简洁(但是并没有下面要讲的箭头函数简洁)。

在函数声明和函数表达式之间有一个非常重要的不同。当你用函数声明,该函数对象创建于该函数所在作用域的代码开始执行之前,也就是声明提前,所以你可以在函数定义之前调用他们。如果用函数表达式来定义一个函数,这样使用就是不对的:该函数不会存在,直到函数定义表达式真正被计算。因为,想要执行一个函数,你必须可以引用它,而一个函数表达式定义的函数一直到该函数赋值给一个变量后才能被引用,所以要使用函数表达式需要在函数被调用之前定义。

8.1.3 箭头函数

在 ES6 之后,你可以用一个特别简洁的语法来定义函数,被称为“箭头函数”。这个语法联想到数学符号,用一个 => "箭头"来分隔函数的参数和函数体。function 关键字未使用,并且,由于箭头函数是表达式而不是声明语句,也不需要一个函数名称。一般箭头函数用圆括号包含一个逗号分隔的参数列表,接一个 => 箭头,后面是花括号包含的函数体。

const sum = (x, y) => { return x + y; };

但是箭头函数支持更加简洁的语法。如果函数体只有一个简单的 return 语句,你可以省略 return 关键字,分号和花括号都一起省略,将函数体写成一个计算返回值的表达式。

const sum = (x, y) => x + y;

而且,如果一个箭头函数只有一个参数,你可以省略参数列表的圆括号。

const polynomial = x => x*x + 2*x + 3;

注意,如果箭头函数没有参数,必须写一对空圆括号。

const constantFunc = () => 42;

注意,当写一个箭头函数时,函数参数和箭头之间不能换行。否则,可能会直接在赋值后中止,就像 const polynomial = x,因为它本身是一个语法上合法的赋值语句。

此外,如果箭头函数体是一个单一的 return 语句,而且他返回的是一个对象字面量,那必须将对象字面量用圆括号包起来,避免将对象字面量的大括号误解成函数体的大括号。

const f = x => { return { value: x }; };  // Good: f() returns an object
const g = x => ({ value: x });            // Good: g() returns an object
const h = x => { value: x };              // Bad: h() returns nothing
const i = x => { v: x, w: x };            // Bad: Syntax Error

这段代码的第三行,函数 h() 就有歧义:这段代码原本返回对象字面量被转化为一个标签语句,所以一个返回 undefined 的函数被创建。第四行,结构更复杂的对象字面量不是一个合法的语句,这段代码会抛出一个语法异常。

简洁的箭头函数可以完美的传递一个函数给另外一个函数,比如一些数组的常规操作方法 map(),filter() 和 reduce()(见 §7.8.1),例如:

// Make a copy of an array with null elements removed.
let filtered = [1,null,2,3].filter(x => x !== null); // filtered == [1,2,3]
// Square some numbers:
let squares = [1,2,3,4].map(x => x*x);               // squares == [1,4,9,16]

箭头函数不同于用关键字定义的函数:箭头函数从定义它们的环境继承 this 关键字,而不是像其他定义方式那样定义自己的调用上下文。这是箭头函数一个重要且特别实用的特性,我们会在这一章的后面再次提到它。箭头函数也不同于其他函数,它们没有有原型属性。这意味着它不能被当作一个构造函数去创建一个类(见 §9.2)。

8.1.4 嵌套函数

在 JavaScript 中,函数可以嵌套在其他函数内。例如:

function hypotenuse(a, b) {
    function square(x) { return x*x; }
    return Math.sqrt(square(a) + square(b));
}

嵌套函数的有趣之处在于它的变量作用域规则:它们可以访问嵌套它们(或多重嵌套)的函数的参数和变量。例如,在上面的代码里,内部函数 square() 可以读写外部函数 hypotenuse() 定义的参数 a 和 b。这些作用域规则对嵌套函数非常重要,我们会在 §8.6 再深入了解它们。

8.2 函数调用

构成函数主体的 JavaScript 代码在定义之时并不会执行,只有调用该函数时,它们才会执行。JavaScript 函数可以以五种方式被调用。

  • 作为函数
  • 作为方法
  • 作为构造函数
  • 通过它们的 call() 和 apply() 方法间接调用
  • 隐式调用,不同于普通函数调,通过 JavaScript 语言特性调用函数。

8.2.1 函数调用

函数或方法通过调用表达式(§4.5)被调用。调用表达式由以下部分组成,计算函数对象的函数表达式,一个开放圆括号,逗号分隔的零个或多个实参表达式列表,一个闭合圆括号。如果函数表达式是属性访问表达式(函数是一个对象的属性或者一个数组的元素)那么它是一个方法调用表达式。这种情况会通过下面的例子说明,接下来这个代码包含了一些常规的函数调用表达式:

printprops({x: 1});
let total = distance(0,0,2,1) + distance(2,1,3,5);
let probability = factorial(5)/factorial(13);

在调用中,每个实参表达式(圆括号内的)执行计算,返回值作为函数的实参。这些值传给函数定义的参数。在函数体内,参数的引用指向对应实参的值。

对于常规的函数调用,函数返回值变成函数调用表达式的值。如果因解释器执行到函数结尾而返回,返回值就是 undefined。如果函数返回是因为解释器执行一个 return 语句,那么返回值是 return 后面的表达式的计算结果,如果 return 语句没有值也返回 undefined。

条件调用

在 ES2020 中你可以通过在函数表达式和圆括号之间插入 ?. 符号,使函数只有在不为 null 和 undefined 时候再调用。表达式 f?.(x) 等价(假设没有副作用)于:

(f !== null && f !== undefined) ? f(x) : undefined

详细的条件执行语法描述在 §4.5.1。

函数调用在非严格模式下,调用上下文( this )是全局对象。然而在严格模式下,调用上下文是 undefined。注意箭头语法定义的函数行为是不同的:实际上它们总是继承它们定义位置的 this 值。

以函数形式调用的函数通常不使用 this 关键字。不过,this 关键字可以用来判断当前是否是严格模式。

// Define and invoke a function to determine if we're in strict mode.
const strict = (function() { return !this; }());

递归函数和栈

递归函数就像本章开始的 factorial() 函数,它调用它自己。某些算法(如涉及基于树的数据结构)可以使用递归函数特别优雅地实现。在写递归函数时,考虑内存分配是很重要的。当函数 A 调用函数 B,然后函数 B 又调用函数 C 时,Javascript 编译器需要知道在哪里重新执行函数 B,当函数 B 执行完成后它需要知道在哪里执行函数 A。你可以将执行上下文想象成一个栈。当一个函数调用另外一个函数时,一个新的执行上下文被压入栈中。当被调用函数返回,它的执行上下文对象从栈中弹出。如果一个函数递归调用100次,那么会有100个对象被压入栈中,然后这100个对象再依次从栈中弹出。这种调用非常耗内存。以现代的硬件递归调用100次通常没什么问题。但是如果一个函数递归上千次,它可能会失败并报错“Maximum call-stack size exceeded.”。

8.2.2 方法调用

方法只不过是对象属性函数。如果有一个函数 f 和一个对象 o,可以用下面的代码给对象 o 定义一个名为 m 的方法:

o.m = f;

给对象 o 定义了方法 m(),用这种方式调用它:

o.m();

或者 m() 需要两个实参,可以这样调用它:

o.m(x, y);

例子中的代码是一个调用表达式:它包含一个函数表达式 o.m 和两个实参表达式 x 和 y。函数表达式本身是一个属性访问表达式,这意味着该函数被当作一个方法调用,而不是一个普通的函数。

对方法调用的参数和返回值的处理,和上面所描述的普通函数调用完全一致。但是,方法调用和函数调用有一个重要的区别,即:调用上下文。属性访问表达式由两部分组成:一个对象(本例中的 o)和属性名称(m)。在像这样的方法调用表达式里,对象 o 成为调用上下文,函数体可以使用关键字this引用该对象。下面是一个具体的例子:

let calculator = { // An object literal
    operand1: 1,
    operand2: 1,
    add() {        // We're using method shorthand syntax for this function
        // Note the use of the this keyword to refer to the containing object.
        this.result = this.operand1 + this.operand2;
    }
};
calculator.add();  // A method invocation to compute 1+1.
calculator.result  // => 2

大多数方法调用使用点符号来访问属性,使用方括号(属性访问表达式)也可以进行属性访问操作。下面两个例子都是函数调用:

o["m"](x,y);   // Another way to write o.m(x,y).
a[0](z)        // Also a method invocation (assuming a[0] is a function).

方法调用可能包括更复杂的属性访问表达式:

customer.surname.toUpperCase(); // Invoke method on customer.surname
f().m();                        // Invoke method m() on return value of f()

方法和 this 关键字是面向对象编程范式的核心。任何函数只要作为方法调用实际上都会传入一个隐式的实参对象,就是调用这个方法对象本身。通常来讲,方法执行就是对象的某种操作,方法调用的语法也清晰的表达了它是操作对象的函数,比较下面两行代码:

rect.setSize(width, height);
setRectSize(rect, width, height);

我们假设这两行代码的功能完全一样,它们都作用于一个假定的对象 rect。可以看出,第一行的方法调用语法非常清晰地表明这个函数执行的载体是 rect 对象,函数中的所有操作都将基于这个对象。

方法链

当方法返回一个对象,这个对象还可以再调用它的方法。这种方法调用序列中(或“链”)每次的调用结果都是另外一个表达式的组成部分。比如,基于 Promise 的异步操作(参见第13章),我们常常会这样写代码:

// Run three asynchronous operations in sequence, handling errors.
doStepOne().then(doStepTwo).then(doStepThree).catch(handleErrors);

当方法并不需要返回值时,最好直接返回 this。如果在设计的 API 中一直采用这种方式,使用 API 就可以用方法链 1 风格的编程,在这种编程风格中,只要指定一次要调用的对象即可,余下的方法都可以基于此进行调用:

new Square().x(100).y(100).size(50).outline("red").fill("blue").draw();

需要注意的是,this 是一个关键字,不是变量,也不是属性名。JavaScript 的语法不允许给 this 赋值。

关键字 this 没有变量作用域的限制,除了箭头函数,嵌套函数不会从包含它的函数中继承 this。如果嵌套函数作为方法调用,其 this 的值指向调用它的对象。如果嵌套的函数作为函数调用(不包含箭头函数),其 this 值不是全局对象(非严格模式下)就是undefined(严格模式下)。很多人误以为在一个方法中的函数声明并以函数调用的方式去执行可以用 this 来获取方法的执行上下文。下面这个例子说明了这个问题:

let o = {                 // An object o.
    m: function() {       // Method m of the object.
        let self = this;  // Save the "this" value in a variable.
        this === o        // => true: "this" is the object o.
        f();              // Now call the helper function f().

        function f() {    // A nested function f
            this === o    // => false: "this" is global or undefined
            self === o    // => true: self is the outer "this" value.
        }
    }
};
o.m();                    // Invoke the method m on the object o.

嵌套函数 f() 中,this 关键之不等于对象 o。这被广泛的认为是 JavaScript 的一个缺陷,了解这一点是很是很重要的。上面的代码演示了一种常用的解决方案。在方法 m 中,将 this 值赋值给一个变量 self,在签到函数 f 中,可以用 self 代替 this 来引用包含它的对象。

在 ES6 之后,有另外一种解决方案来解决这个问题,将嵌套函数 f 转换成箭头函数,它会正确的继承 this 值:

const f = () => {
    this === o  // true, since arrow functions inherit this
};

函数表达式不像函数声明,声明提前,所以为了让这种解决方案定义的函数可以被调用,需要将 f 函数定义表达式放在方法 m 中,这样它才可以在它被调用时存在。

另外还可以用嵌套函数的 bind() 方法,在指定对象上隐式调用一个新的函数:

const f = (function() {
    this === o  // true, since we bound this function to the outer this
}).bind(this);

关于 bind() 方法将在 §8.7.5 中讲解。

8.2.3 构造函数调用

如果函数或者方法调用之前带有关键字 new,它就构成构造函数调用(构造函数调用在 §4.6 和 §6.2.2 节有简单介绍,第9章会对构造函数做更详细的讨论)。构造函数调用和普通的函数调用以及方法调用在实参处理、调用上下文和返回值方面都有不同。

如果构造函数调用在圆括号内包含一组实参列表,先计算这些实参表达式,然后传入函数内,这和函数调用和方法调用是一致的。但如果构造函数没有形参,JavaScript 构造函数调用的语法是允许省略实参列表和圆括号的。凡是没有形参的构造函数调用都可以省略圆括号,比如,下面这两行代码就是等价的:

o = new Object();
o = new Object;

构造函数调用创建一个新的空对象,这个对象继承自构造函数的 prototype 属性。构造函数试图初始化这个新创建的对象,并将这个对象用做其调用上下文,因此构造函数内可以使用 this 关键字来引用这个新创建的对象。注意,尽管构造函数看起来像一个方法调用,它依然会使用这个新对象作为调用上下文。也就是说,在表达式 new o.m() 中,调用上下文并不是 o。

构造函数通常不使用 return 关键字,它们通常初始化新对象,当构造函数的函数体执行完毕时,它会隐式返回。在这种情况下,构造函数调用表达式的计算结果就是这个新对象的值。然而如果构造函数显式地使用 return 语句返回一个对象,那么调用表达式的值就是这个对象。如果构造函数使用 return 语句但没有指定返回值,或者返回一个原始值,那么这时将忽略返回值,同时使用这个新对象作为调用结果。

8.2.4 间接调用

JavaScript 中的函数也是对象,和其他 JavaScript 对象没什么两样,函数对象也可以包含方法。其中的两个方法 call() 和 apply() 可以用来间接地调用函数。两个方法都允许显式指定调用所需的 this 值,也就是说,任何函数可以作为任何对象的方法来调用,哪怕这个函数不是那个对象的方法。两个方法都可以指定调用的实参。call() 方法使用它自有的实参列表作为函数的实参,apply() 方法则要求以数组的形式传入实参。§8.7.4 节会有关于 call() 和apply () 方法的详细讨论。

8.2.5 函数隐式调用

有各种各样的 JavaScript 语言特性,它们看起来不像函数调用但是却能调用函数。额外小心编写函数时可能会隐式调用,因为在隐式函数调用中 bug、副作用和性能问题都比普通的函数更难诊断和修复。

可能引起函数隐式调用的语言特性包括:

如果一个对象定义了 getter 或者 setter 方法,获取或者设置它的属性值可能调用这些方法。见 §6.10.6 有更多相关描述。

当对象用作一个字符串文本时(例如对象和一个字符串连接),它的 toString() 方法会被调用。同样的,对象用作一个数值型文本时,它的 valueOf() 方法被调用。详见 §3.9.3。

当循环可迭代对象的元素时会产生很多方法调用。第12章介绍了迭代器在函数调用级别如何工作,并演示如何编写方法来定义自己的可迭代类型。

可以伪装在模板字面量中 。在 §14.5 中演示如何在模板字符串中调用函数。

Proxy 对象(在 §14.7 中描述)的行为完全由函数控制。它的任何一个操作都会导致函数调用。

8.3 函数的实参和形参

JavaScript 中的函数定义并未指定函数形参的类型,函数调用也未对传入的实参值做任何类型检查。实际上,JavaScript 函数调用甚至不检查传入形参的个数。下面几节将会讨论当调用函数时的实参个数和声明的形参个数不匹配时出现的状况,同样介绍了如何显式测试函数实参的类型,以避免非法的实参传入函数。

8.3.1 可选形参和默认值

当调用函数的时候传入的实参比函数声明时指定的形参个数要少,剩下的形参都将设置为 undefined 值。所以一些参数设置成可选的是非常实用的。看下面这个例子:

// Append the names of the enumerable properties of object o to the
// array a, and return a.  If a is omitted, create and return a new array.
function getPropertyNames(o, a) {
    if (a === undefined) a = [];  // If undefined, use a new array
    for(let property in o) a.push(property);
    return a;
}

// getPropertyNames() can be invoked with one or two arguments:
let o = {x: 1}, p = {y: 2, z: 3};  // Two objects for testing
let a = getPropertyNames(o); // a == ["x"]; get o's properties in a new array
getPropertyNames(p, a);      // a == ["x","y","z"]; add p's properties to it

第一行代码中可以使用 || 运算符来代替一个 if 语句,这是一种习惯用法:

a = a || [];

回忆一下,§4.10.2 介绍了“||”运算符,如果第一个实参是真值的话就返回第一个实参;否则返回第二个实参。在这个场景下,如果作为第二个实参传入任意对象,那么函数就会使用这个对象。如果省略掉第二个实参(或者传递 null 以及其他任何假值),那么就新创建一个空数组,并赋值给 a。

需要注意的是,当用这种可选实参来实现函数时,需要将可选实参放在实参列表的最后。那些调用你的函数的程序员是没办法省略第一个实参并传入第二个实参,他们必须显地的将 undefined 传入作为第一个实参 。

在 ES6 之后,可以直接在函数的参数列表中为每个函数参数定义默认值。直接在参数名后面接一个等号再接一个默认值(用于没有实参提供给参数时参数的值):

// Append the names of the enumerable properties of object o to the
// array a, and return a.  If a is omitted, create and return a new array.
function getPropertyNames(o, a = []) {
    for(let property in o) a.push(property);
    return a;
}

默认参数表达式只有在函数调用时进行计算,而不是在它定义时,所以每一次 getPropertyNames() 函数只传一个实参调用时,一个新的空数组被创建并传给参数。2 最容易理解的就是参数默认值是常量(或者字面量表达式 [] 和 {})。但这并不是必须的:举个例子,你可以用变量或者函数调用,计算一个默认参数的值。一个很有趣的情况是,对于有多个参数的函数,可以用前面的参数值来定义后面的参数默认值。

// This function returns an object representing a rectangle's dimensions.
// If only width is supplied, make it twice as high as it is wide.
const rectangle = (width, height=width*2) => ({width, height});
rectangle(1)  // => { width: 1, height: 2 }

这段代码描述了箭头函数中的参数默认值。方法函数和其他形式的函数定义也是如此。

8.3.2 剩余参数和可变长实参列表

调用函数时允许传入的实参比函数声明时指定的形参个数少。剩余参数允许相反的情况:它允许我们在调用函数时,传入比型参多任意个数的实参。下面是一个可以传入一个或多个数值型实参的例子,并且返回其中最大的数:

function max(first=-Infinity, ...rest) {
    let maxValue = first; // Start by assuming the first arg is biggest
    // Then loop through the rest of the arguments, looking for bigger
    for(let n of rest) {
        if (n > maxValue) {
            maxValue = n;
        }
    }
    // Return the biggest
    return maxValue;
}

max(1, 10, 100, 2, 3, 1000, 4, 5, 6)  // => 1000

剩余参数由三个 . 开始,必须是函数声明的最后一个参数。调用有剩余参数的函数时,传递的实参先赋值给非剩余参数,然后其余所有的实参(也就是“剩余”实参)存储在一个数组中变成剩余参数的值,最后一点非常重要:在一个函数体中,剩余参数的值总是一个数组。这个数组可能是空的,但是剩余参数永远不会是 undefined。(因此,从不会给剩余参数设置默认值,并且这也是不合法的。)

类似这种函数可以接收任意个数的实参,这种函数也称为“不定实参函数”,这个术语源自古老的C语言。

不要混淆 ... 定义函数的剩余参数和 ... 展开运算符,将在 §8.3.4 描述展开运算符在函数调用中的应用。

8.3.3 实参对象

剩余参数是在 ES6 中加入的概念。在这之前,不定实参函数是用 Arguments 对象实现的:在函数体中,标识符 Arguments 是指向实参对象的引用。Arguments 对象是一个类数组对象(参照 §7.9),这样可以通过数字下标就能访问传入函数的实参值,而不用非要通过名字来得到实参。下面的 max() 函数就是以前用 Arguments 对象代替剩余参数的例子:

function max(x) {
    let maxValue = -Infinity;
    // Loop through the arguments, looking for, and remembering, the biggest.
    for(let i = 0; i < arguments.length; i++) {
        if (arguments[i] > maxValue) maxValue = arguments[i];
    }
    // Return the biggest
    return maxValue;
}

max(1, 10, 100, 2, 3, 1000, 4, 5, 6)  // => 1000

Arguments 对象可追溯到 JavaScript 的最早时代,并带有一些奇怪的历史包袱,这使得它效率低下且难以优化,尤其是不在严格模式下。可能还会遇到一些代码使用 Arguments 对象,但是在编写新代码时要避免使用,可以用 ... 剩余函数来替代。Arguments 对象还有部分令人遗憾的遗产,在严格模式下,arguments 被视为保留字,不能声明具有该名称的局部变量来定义函数的参数。

8.3.4 函数调用时的展开运算符

当需要单个值时,... 展开运算符用来拆包,或者说将元素从数组(或者其他的任何和迭代对象,例如字符串)中“展开”到上下文。我们已经在 §7.1.2 见到了展开运算符在数组字面量上的使用。展开运算符可以在函数调用中以同样方式使用:

let numbers = [5, 2, 10, -1, 9, 100, 1];
Math.min(...numbers)  // => -1

注意 ... 不是一个真正的运算符,因为它不能通过计算来提供一个值。它是一个可以用在数组字面量和函数调用中的特殊的 JavaScript 语法。

在函数定义和函数调用中使用相同的 ... 语法时,和展开运算符有着相仿的效果。在 §8.3.2 中我们看到函数定义使用 ... 将复数个函数实参合并到一个数组中。剩余参数和展开运算符经常一同使用,就像下面这个函数:

// This function takes a function and returns a wrapped version
function timed(f) {
    return function(...args) {  // Collect args into a rest parameter array
        console.log(`Entering function ${f.name}`);
        let startTime = Date.now();
        try {
            // Pass all of our arguments to the wrapped function
            return f(...args);  // Spread the args back out again
        }
        finally {
            // Before we return the wrapped return value, print elapsed time.
            console.log(`Exiting ${f.name} after ${Date.now()-startTime}ms`);
        }
    };
}

// Compute the sum of the numbers between 1 and n by brute force
function benchmark(n) {
    let sum = 0;
    for(let i = 1; i <= n; i++) sum += i;
    return sum;
}

// Now invoke the timed version of that test function
timed(benchmark)(1000000) // => 500000500000; this is the sum of the numbers

8.3.5 实参解构到形参中

用实参列表调用函数时,实参的值最终赋值给函数定义的参数。函数调用初始化阶段非常像变量赋值。所以我们不必惊讶于可以将解构赋值(见 §3.10.3)用于函数。

如果一个函数的参数带有方括号,就说明函数要给每一个方括号传一个数组。在一个调用进程中,数组实参会被拆包传递给对应的参数。例如,假设我们将 2D 矢量表示为两个数字的数组,其中第一个元素是 X 坐标,第二个元素是 Y 坐标。用这个简单的数据结构,编写下面这个函数计算两个矢量的和:

function vectorAdd(v1, v2) {
    return [v1[0] + v2[0], v1[1] + v2[1]];
}
vectorAdd([1,2], [3,4])  // => [4,6]

如果下面这种方式解构这两个矢量实参,这段代码将更容易理解:

function vectorAdd([x1,y1], [x2,y2]) { // Unpack 2 arguments into 4 parameters
    return [x1 + x2, y1 + y2];
}
vectorAdd([1,2], [3,4])  // => [4,6]

同样,如果定义一个函数时需要对象实参,你能对这个对象进行参数解构。再次实用矢量的例子,这一次,我们用 x 和 y 参数包装成对象来描述矢量:

// Multiply the vector {x,y} by a scalar value
function vectorMultiply({x, y}, scalar) {
    return { x: x*scalar, y: y*scalar };
}
vectorMultiply({x: 1, y: 2}, 2)  // => {x: 2, y: 4}

这个例子将一个简单的对象实参解构成两个参数是很简单的,因为参数的名字和我们在对象中使用的属性名是匹配的。当您需要将同一个名称的属性解构为具有不同名称的参数时,语法更加冗长和难懂。下面是一个矢量加法的例子,基于对象矢量的实现:

function vectorAdd(
    {x: x1, y: y1}, // Unpack 1st object into x1 and y1 params
    {x: x2, y: y2}  // Unpack 2nd object into x2 and y2 params
)
{
    return { x: x1 + x2, y: y1 + y2 };
}
vectorAdd({x: 1, y: 2}, {x: 3, y: 4})  // => {x: 4, y: 6}

像 {x:x1, y:y1} 的解构语法棘手的是记住哪一个是属性名哪一个是参数名。牢记解构赋值和解构函数调用的规则,声明的变量或参数在对象字面量中的位置固定。属性名总是在冒号的左边,参数(或变量)名在右边。

可以使用解构参数定义参数默认值。下面是适用于 2D 或 3D 矢量的矢量乘法:

// Multiply the vector {x,y} or {x,y,z} by a scalar value
function vectorMultiply({x, y, z=0}, scalar) {
    return { x: x*scalar, y: y*scalar, z: z*scalar };
}
vectorMultiply({x: 1, y: 2}, 2)  // => {x: 2, y: 4, z: 0}

一些语言(像 Python)允许函数的调用者以 name=value 型式指定实参,这在有很多可选实参或者参数列表长到难以记住正确的顺序时是非常方便的。JavaScript 不允许直接这样做,但可以通过解构对象实参到函数参数中。构思一个函数将指定数量的元素从一个数组复制到另一个数组中,可以随意地为每个数组指定起始偏移量。如下有五个可传入参数,其中一些有默认值,并且调用者很难记住参数的顺序来传递实参,可以像这样定义和调用 arraycopy() 方法:

function arraycopy({from, to=from, n=from.length, fromIndex=0, toIndex=0}) {
    let valuesToCopy = from.slice(fromIndex, fromIndex + n);
    to.splice(toIndex, 0, ...valuesToCopy);
    return to;
}
let a = [1,2,3,4,5], b = [9,8,7,6,5];
arraycopy({from: a, n: 3, to: b, toIndex: 4}) // => [9,8,7,6,1,2,3,5]

当解构一个数组,在其被拆包时,可以定义一个剩余参数将其余值放在数组中。 在方括号中的剩余参数和真正的函数中的剩余参数是完全不同的:

// This function expects an array argument. The first two elements of that
// array are unpacked into the x and y parameters. Any remaining elements
// are stored in the coords array. And any arguments after the first array
// are packed into the rest array.
function f([x, y, ...coords], ...rest) {
    return [x+y, ...rest, ...coords];  // Note: spread operator here
}
f([1, 2, 3, 4], 5, 6)   // => [3, 5, 6, 3, 4]

在 ES2018,也可以用剩余参数解构对象。剩余参数是一个没有解构的属性的对象。对象剩余参数经常与对象展开运算符连用,这是 ES2018 的新特性:

// Multiply the vector {x,y} or {x,y,z} by a scalar value, retain other props
function vectorMultiply({x, y, z=0, ...props}, scalar) {
    return { x: x*scalar, y: y*scalar, z: z*scalar, ...props };
}
vectorMultiply({x: 1, y: 2, w: -1}, 2)  // => {x: 2, y: 4, z: 0, w: -1}

最后,请记住,除了可以解构实参对象和数组,也可以解构数组对象,对象有数组属性,并且对象还有对象的属性。构思一个将圆表示为具有 x、y、半径和颜色属性的对象的图形代码,颜色属性是一个数组由 RGB 组成。你可以定义一个函数,该函数希望将单个圆对象传递给它,但其解构为六个单独的参数:

function drawCircle({x, y, radius, color: [r, g, b]}) {
    // Not yet implemented
}

如果函数实参解构比这更复杂,代码会变得更难读,而不是更简单。有时,显示地对对象属性访问和数组索引会让代码更清晰。

8.3.6 实参类型

JavaScript 方法的形参并未声明类型,在形参传入函数体之前也未做任何类型检查。可以采用语义化的单词来给函数实参命名,并在函数注释给每一个实参详细描述,以此使代码自文本化。

§3.9 已经提到,JavaScript 在必要时会进行类型转换。因此如果函数期 望接收一个字符串实参,而调用函数时传入其他类型的值,所传入的值会在函数体内将其用做字符串的地方转换为字符串类型。所有的原始类型都可以转换为字符串,所有的对象都包含 toString() 方法(尽管不一定有用),所以这种场景下是不会有任何错误的。

然而事情不总是这样,回头看一下刚才提到的 arraycopy() 方法。这个方法期望获得一个或两个实参,并且这些实参的类型错误会导致函数执行失败。除非所写的私有函数只会被附近的代码调用,你应当添加类似的实参类型检查逻辑。因为宁愿程序在传入非法值时报错,也不愿非法值导致程序在执行时报错,相比而言,逻辑执行时的报错消息不甚清晰且更难处理。下面这个例子中的函数就做了这种类型检查:

// Return the sum of the elements an iterable object a.
// The elements of a must all be numbers.
function sum(a) {
    let total = 0;
    for(let element of a) { // Throws TypeError if a is not iterable
        if (typeof element !== "number") {
            throw new TypeError("sum(): elements must be numbers");
        }
        total += element;
    }
    return total;
}
sum([1,2,3])    // => 6
sum(1, 2, 3);   // !TypeError: 1 is not iterable
sum([1,2,"3"]); // !TypeError: element 2 is not a number

8.4 函数作为值

函数可以定义,也可以调用,这是函数最重要的特性。函数定义和调用是 JavaScript 的词法特性,对于其他大多数编程语言来说亦是如此。然而在 JavaScript 中,函数不仅是一种语法,也是值,也就是说,可以将函数赋值给变量,存储在对象的属性或数组的元素中,作为参数传入另外一个函数等。3

To understand how functions can be JavaScript data as well as JavaScript syntax, consider this function definition:

为了便于理解 JavaScript 中的函数是如何用做 Javascript 数据以及 JavaScript 语法的,来看一下这样一个函数定义:

function square(x) { return x*x; }

这个定义创建一个新的函数对象,并将其赋值给变量 square。函数的名字实际上是无形的,它(square)仅仅是变量的名称,这个变量是函数对象的引用。函数还可以赋值给其他的变量,并且仍可以正常工作:

let s = square;  // Now s refers to the same function that square does
square(4)        // => 16
s(4)             // => 16

除了可以将函数赋值给变量,同样可以将函数赋值给对象的属性。当函数作为对象的属性调用时,函数就称为“方法”:

let o = {square: function(x) { return x*x; }}; // An object literal
let y = o.square(16);                          // y == 256

函数甚至不需要带名字,就像把它们赋值给数组元素:

let a = [x => x*x, 20]; // An array literal
a[0](a[1])              // => 400

上面的例子看起来很奇怪,但的确是合法的函数调用表达式!

举一个例子来说明将函数当作值来对待的益处,考虑下 Array.sort() 方法。这个方法用来对数组元素进行排序。因为排序的规则有很多(基于数值大小、字母表顺序、日期大小、从小到大、从大到小等),sort() 方法可以接收一个函数作为参数,用来处理具体的排序操作。这个函数的作用非常简单:对于任意两个值都返回一个值,以指定它们在排序后的数组中的先后顺序。这个函数参数使得 Array.sort() 具有更完美的通用性和无限可扩展性,它可以对任何类型的数据进行任意排序。§7.8.6 有示例代码。

例 8-1 展示了将函数用做值时的一些例子,这段代码可能会难读一些,但注释解释了代码的具体含义:

例 8-1:用函数做值

// We define some simple functions here
function add(x,y) { return x + y; }
function subtract(x,y) { return x - y; }
function multiply(x,y) { return x * y; }
function divide(x,y) { return x / y; }

// Here's a function that takes one of the preceding functions
// as an argument and invokes it on two operands
function operate(operator, operand1, operand2) {
    return operator(operand1, operand2);
}

// We could invoke this function like this to compute the value (2+3) + (4*5):
let i = operate(add, operate(add, 2, 3), operate(multiply, 4, 5));

// For the sake of the example, we implement the simple functions again,
// this time within an object literal;
const operators = {
    add:      (x,y) => x+y,
    subtract: (x,y) => x-y,
    multiply: (x,y) => x*y,
    divide:   (x,y) => x/y,
    pow:      Math.pow  // This works for predefined functions too
};

// This function takes the name of an operator, looks up that operator
// in the object, and then invokes it on the supplied operands. Note
// the syntax used to invoke the operator function.
function operate2(operation, operand1, operand2) {
    if (typeof operators[operation] === "function") {
        return operators[operation](operand1, operand2);
    }
    else throw "unknown operator";
}

operate2("add", "hello", operate2("add", " ", "world")) // => "hello world"
operate2("pow", 10, 2)  // => 100

8.4.1 自定义函数属性

JavaScript 中的函数并不是原始值,而是一种特殊的对象,也就是说,函数可以拥有属性。当函数需要一个“静态”变量来在调用时保持某个值不变,最方便的方式就是给函数定义属性,而不是定义全局变量,显然定义全局变量会让命名空间变得更加杂乱无章。比如,假设你想写一个返回一个唯一整数的函数,不管在哪里调用函数都会返回这个整数。而函数不能两次返回同一个值,为了做到这一点,函数必须能够跟踪它每次返回的值,而且这些值的信息需要在不同的函数调过程中持久化。可以将这些信息存放到全局变量中,但这并不是必需的,因为这个信息仅仅是函数本身用到的。最好将这个信息保存到函数对象的一个属性中,下面这个例子就实现了这样一个函数,每次调用函数都会返回一个唯一的整数:

// Initialize the counter property of the function object.
// Function declarations are hoisted so we really can
// do this assignment before the function declaration.
uniqueInteger.counter = 0;

// This function returns a different integer each time it is called.
// It uses a property of itself to remember the next value to be returned.
function uniqueInteger() {
    return uniqueInteger.counter++;  // Return and increment counter property
}
uniqueInteger()  // => 0
uniqueInteger()  // => 1

来看另外一个例子,下面这个函数 factorial() 使用了自身的属性(将自身当做数组来对待)来缓存上一次的计算结果:

// Compute factorials and cache results as properties of the function itself.
function factorial(n) {
    if (Number.isInteger(n) && n > 0) {           // Positive integers only
        if (!(n in factorial)) {                  // If no cached result
            factorial[n] = n * factorial(n-1);    // Compute and cache it
        }
        return factorial[n];                      // Return the cached result
    } else {
        return NaN;                               // If input was bad
    }
}
factorial[1] = 1;  // Initialize the cache to hold this base case.
factorial(6)  // => 720
factorial[5]  // => 120; the call above caches this value

8.5 函数作为命名空间

变量声明在函数内对于函数体外是不可见的。因此,有时定义函数作为临时命名空间非常有用,您可以在其中定义变量而不弄乱全局命名空间。

比如,假设你写了一段 JavaScript 模块代码,这段代码将要用在不同的 JavaScript 程序中(对于客户端 JavaScript 来讲通常是用在各种各样的网页中)。和大多数代码一样,假定这段代码定义了一个用以存储中间计算结果的变量。这样问题就来了,当模块代码放到不同的程序中运行时,你无法得知这个变量是否已经创建了,如果已经存在这个变量,那么将会和代码发生冲突。解决办法当然是将代码放入一个函数内,然后调用这个函数。这样全局变量就变成了函数内的局部变量:

function chunkNamespace() {
    // Chunk of code goes here
    // Any variables defined in the chunk are local to this function
    // instead of cluttering up the global namespace.
}
chunkNamespace();  // But don't forget to invoke the function!

这段代码仅仅定义了一个单独的全局变量:名为 chunkNamespace 的函数。如果还是太麻烦,可以用一个单独的表达式定义一个匿名函数并调用它:

(function() {  // chunkNamespace() function rewritten as an unnamed expression.
    // Chunk of code goes here
}());          // End the function literal and invoke it now.

这种定义匿名函数并立即在单个表达式中调用它的写法非常常见,并给它起了个名字“匿名调用函数表达式”。注意上面代码的圆括号的用法,function 之前的左圆括号是必需的,因为如果不写这个左圆括号,JavaScript 解释器会试图将关键字 function 解析为函数声明语句。使用圆括号 JavaScript 解释器才会正确地将其解析为函数定义表达式。使用前导括号也有助于人类阅读时区分函数定义是立即执行还是供以后使用。

函数用作命名空间很常用,在命名空间函数中定义一个或多个函数使用其中的变量,然后将他们作为函数命名空间的返回值。这样的函数称为闭包,它们是下一节的主题。

8.6 闭包

和其他大多数现代编程语言一样,JavaScript 也采用词法作用域。也就是说,函数的执行依赖于变量作用域,这个作用域是在函数定义时决定的,而不是函数调用时决定的。为了实现这种词法作用域,JavaScript 函数对象的内部状态不仅包含函数的代码逻辑,还必须包括对函数定义出现的作用域的引用。将函数对象可和作用域相互关联起来(一对变量的绑定),函数体内部的变量都可以保存在函数作用域内,这种特性在计算机科学文献中称为闭包。

从技术的角度讲,所有的 JavaScript 函数都是闭包,但是大多数函数调用和定义在同一个作用域内,通常不会注意这里有涉及到闭包。当调用函数不和其定义处于同一作用域内时,事情就变得非常微妙。当一个函数嵌套了另外一个函数,外部函数将嵌套的函数对象作为返回值返回的时候往往会发生这种事情。有很多强大的编程技术都利用到了这类嵌套的函数闭包,以至于这种编程模式在 JavaScript 中非常常见。当你第一次碰到闭包时可能会觉得非常让人费解,一旦你理解掌握了闭包之后,就能非常自如地使用它了,了解这一点至关重要。

理解闭包首先要了解嵌套函数的词法作用域规则。看一下这段代码:

let scope = "global scope";          // A global variable
function checkscope() {
    let scope = "local scope";       // A local variable
    function f() { return scope; }   // Return the value in scope here
    return f();
}
checkscope()                         // => "local scope"

checkscope() 函数声明了一个局部变量,然后定义并执行了一个函数 f() ,函数 f() 返回了这个变量的值,最后将函数 f() 的执行结果返回。你应当非常清楚为什么调用 checkscope() 会返回 local scope。现在我们对这段代码做一点改动。你知道这段代码返回什么吗?

let scope = "global scope";          // A global variable
function checkscope() {
    let scope = "local scope";       // A local variable
    function f() { return scope; }   // Return the value in scope here
    return f;
}
let s = checkscope()();              // What does this return?

在这段代码中,我们将函数内的一对圆括号移动到了 checkscope() 之后。checkscope() 现在仅仅返回函数内嵌套的一个函数对象,而不是直接返回结果。在定义函数的作用域外面,调用这个嵌套的函数(包含最后一行代码的最后一对圆括号)会发生什么事情呢?

回想一下词法作用域的基本规则:JavaScript 函数的执行用到了作用域,这个作用域是函数定义的时候创建的。嵌套的函数 f() 定义在变量 scope 绑定的值是“local scope”的作用域里,这个绑定无论 f 函数在何处调用都依然有效。因此最后一行代码返回“local scope”,而不是“global scope”。简言之,闭包的这个特性强大到让人吃惊:它们可以捕捉到它们的外部函数所绑定的局部变量(和参数)。

在 §8.4.1 中定义了 uniqueInteger() 函数,这个函数使用自身的一个属性来保存每次返回的值,以便每次调用都能跟踪上次的返回值。但这种做法有一个问题,就是恶意代码可能将计数器重置或者把一个非整数赋值给它,导致 uniquenterger() 函数不一定能产生“唯一”的“整数”。而闭包可以捕捉到单个函数调用的局部变量,并将这些局部变量用做私有状态。下面是如何用立即调用函数表达式重写 uniqueInteger() 来定义命名空间和闭包来保持其状态私有化:

let uniqueInteger = (function() {  // Define and invoke
    let counter = 0;               // Private state of function below
    return function() { return counter++; };
}());
uniqueInteger()  // => 0
uniqueInteger()  // => 1

你需要仔细阅读这段代码才能理解其含义。粗略来看,第一行代码看起来像将函数赋值给一个变量 uniqueInteger,实际上,这段代码定义了一个立即调用的函数(函数的开始带有左圆括号),因此是这个函数的返回值赋值给变量 uniqueInteger。现在,我们来看函数体,这个函数的返回值是另外一个函数。这是一个嵌套的函数,我们将它赋值给变量 uniqueInteger。嵌套的函数是可以访问作用域内的变量的,而且可以访问外部函数中定义的 counter 变量。当外部函数返回之后,其他任何代码都无法访问 counter 变量:只有内部的函数才能访问到它。

像 counter 一样的私有变量不是只能用在一个单独的闭包内,在同一个外部函数内定义的多个嵌套函数也可以访问它,这多个嵌套函数都共享一个作用域,看一下这段代码:

function counter() {
    let n = 0;
    return {
        count: function() { return n++; },
        reset: function() { n = 0; }
    };
}

let c = counter(), d = counter();   // Create two counters
c.count()                           // => 0
d.count()                           // => 0: they count independently
c.reset();                          // reset() and count() methods share state
c.count()                           // => 0: because we reset c
d.count()                           // => 1: d was not reset

counter() 函数返回了一个“计数器”对象,这个对象包含两个方法:count() 返回下一个整数,reset() 重置内部状态。首先要理解,这两个方法都可以访问私有变量n。再者,每次调用 counter() 都会创建一个新的作用域链和一个新的私有变量。因此,如果调用 counter() 两次,则会得到两个计数器对象,而且彼此包含不同的私有变量,调用其中一个计数器对象的 count() 或 reset() 不会影响到另外一个对象。

从技术角度看,其实可以将这个闭包合并为属性存取器方法 getter 和 setter。下面这段代码所示的 counter() 函数的版本是 §6.10.6 中代码的变种,所不同的是,这里私有状态的实现是利用了闭包,而不是利用普通的对象属性来实现:

function counter(n) {  // Function argument n is the private variable
    return {
        // Property getter method returns and increments private counter var.
        get count() { return n++; },
        // Property setter doesn't allow the value of n to decrease
        set count(m) {
            if (m > n) n = m;
            else throw Error("count can only be set to a larger value");
        }
    };
}

let c = counter(1000);
c.count            // => 1000
c.count            // => 1001
c.count = 2000;
c.count            // => 2000
c.count = 2000;    // !Error: count can only be set to a larger value

需要注意的是,这个版本的 counter() 函数并未声明局部变量,而只是使用参数 n 来保存私有状态并与属性存取器方法共享。这样的话,调用 counter() 的函数就可以指定私有变量的初始值了。

例 8-2是这种使用闭包技术来共享的私有状态的通用做法。这个例子定义了 addPrivateProperty() 函数,这个函数定义了一个私有变量,以及两个嵌套的函数用来获取和设置这个私有变量的值。它将这些嵌套函数添加为所指定对象的方法:

例 8-2:利用闭包实现的私有属性存取器方法

// This function adds property accessor methods for a property with
// the specified name to the object o. The methods are named get<name>
// and set<name>. If a predicate function is supplied, the setter
// method uses it to test its argument for validity before storing it.
// If the predicate returns false, the setter method throws an exception.
//
// The unusual thing about this function is that the property value
// that is manipulated by the getter and setter methods is not stored in
// the object o. Instead, the value is stored only in a local variable
// in this function. The getter and setter methods are also defined
// locally to this function and therefore have access to this local variable.
// This means that the value is private to the two accessor methods, and it
// cannot be set or modified except through the setter method.
function addPrivateProperty(o, name, predicate) {
    let value;  // This is the property value

    // The getter method simply returns the value.
    o[`get${name}`] = function() { return value; };

    // The setter method stores the value or throws an exception if
    // the predicate rejects the value.
    o[`set${name}`] = function(v) {
        if (predicate && !predicate(v)) {
            throw new TypeError(`set${name}: invalid value ${v}`);
        } else {
            value = v;
        }
    };
}

// The following code demonstrates the addPrivateProperty() method.
let o = {};  // Here is an empty object

// Add property accessor methods getName and setName()
// Ensure that only string values are allowed
addPrivateProperty(o, "Name", x => typeof x === "string");

o.setName("Frank");       // Set the property value
o.getName()               // => "Frank"
o.setName(0);             // !TypeError: try to set a value of the wrong type

我们已经看到了很多例子,在同一个作用域中定义两个闭包,这两个闭包共享同样的私有变量或变量。这是一种非常重要的技术,但还是要特别小心那些不希望共享的变量往往不经意间共享给了其他的闭包,了解这一点也很重要。看一下下面这段代码:

// This function returns a function that always returns v
function constfunc(v) { return () => v; }

// Create an array of constant functions:
let funcs = [];
for(var i = 0; i < 10; i++) funcs[i] = constfunc(i);

// The function at array element 5 returns the value 5.
funcs[5]()    // => 5

这段代码利用循环创建了很多个闭包,当写类似这种代码的时候往往会犯一个错误:那就是试图将循环代码移入定义这个闭包的函数之内,看一下这段代码:

// Return an array of functions that return the values 0-9
function constfuncs() {
    let funcs = [];
    for(var i = 0; i < 10; i++) {
        funcs[i] = () => i;
    }
    return funcs;
}

let funcs = constfuncs();
funcs[5]()    // => 10; Why doesn't this return 5?

上面这段代码创建了10个闭包,并将它们存储到一个数组中。这些闭包都是在同一个函数调用中定义的,因此它们可以共享变量 i。当 constfuncs() 返回时,变量 i 的值是10,所有的闭包都共享这一个值,因此,数组中的函数的返回值都是同一个值,这不是我们想要的结果。关联到闭包的作用域都是“活动的”,记住这一点非常重要。嵌套的函数不会将作用域内的私有成员复制一份,也不会对所绑定的变量生成静态快照。从根本上讲,这里的问题是,使用 var 声明的变量,它的定义贯穿整个函数。我们的 for 循环使用 var i 声明循环变量,因此变量 i 在整个函数中都有定义,而不是更狭义地作用于循环的主体。该代码演示了 ES6 之前的常见 Bug 类别,但在 ES6 中引入块级变量作用域解决了这个问题。如果我们只是用 let 或 const 替换 var, 那么问题就消失了。由于 let 和 const 是块级作用域,因此循环的每个迭代都定义了一个独立于所有其他迭代的作用域,并且每个作用域都有其自己的独立绑定 i。

书写闭包的时候还需注意一件事情,this 是 JavaScript 的关键字,而不是变量。正如之前讨论的,箭头函数从包含它们的函数中继承 this 值,但是用 function 关键字定义的函数不是。所以如果写一个闭包需要使用包含它的函数的 this 值,要在闭包返回之前使用箭头函数或者用调用 bind(),或者将 this 值赋值给一个变量,这样你的闭包会继承它:

const self = this;  // Make the this value available to nested functions

8.7 函数属性、方法、和构造函数

我们看到在 JavaScript 程序中,函数是值。对函数执行 typeof 运算会返回字符串“function”,但是函数是 JavaScript 中特殊的对象。因为函数也是对象,它们也可以拥有属性和方法,就像普通的对象可以拥有属性和方法一样。甚至可以用 Function() 构造函数来创建新的函数对象。接下来几节就会着重介绍函数 length、name 和 prototype 属性;call()、 apply()、 bind() 和 toString() 方法;以及 Function() 构造函数。

8.7.1 length 属性

函数的只读属性 length 指定函数参数个数--声明在其参数列表中的参数个数,大多数函数期望的实参个数。如果一个函数有一个剩余函数,它的参数个数不被计算入 length 属性。(经测试,可选参数也不计算 length。)

8.7.2 name 属性

如果使用名称定义函数,只读属性 name 指定函数定义时用的名称,或未命名函数表达式在首次创建时分配给的变量或属性的名称。此属性在编写调试或错误消息时很有用。

8.7.3 prototype 属性

所有函数都包含一个 prototype 属性,这个属性是指向一个对象的引用,这个对象称做原型对象。每一个函数都包含不同的原型对象。当将函数用作构造函数的时候,新创建的对象会从原型对象上继承属性。§6.2.3 讨论了原型和 prototype 属性,在第9章里会有进一步讨论。

8.7.4 call() 和 apply() 方法

我们可以将 call() 和 apply () 看做是某个对象的方法,通过调用方法的形式来间接调用(见 §8.2.4)函数。call() 和 apply() 的第一个实参是要调用函数的母对象,它是调用上下文,在函数体内变成 this 关键字的值。要想以对象 o 的方法来调用函数 f()(没有实参传递),可以这样使用 call() 和 apply():

f.call(o);
f.apply(o);

每行代码和下面代码的功能类似(假设对象 o 中预先不存在名为 m 的属性):

o.m = f;     // Make f a temporary method of o.
o.m();       // Invoke it, passing no arguments.
delete o.m;  // Remove the temporary method.

不要忘了,箭头函数从它定义的位置的上下文继承 this 值。这不能被 call() 和 apply() 方法重写。如果通过箭头函数调用它俩任何一个方法,第一个实参实际上都被忽略。

对于 call() 来说,除了第一个作为调用上下文实参,之后的所有实参就是要传入待调用函数的值(并且,这部分实参对于箭头函数来说不被忽略)。比如,以对象 o 的方法的形式调用函数 f(),并传入两个数,可以使用这样的代码:

f.call(o, 1, 2);

apply() 方法和 call() 类似,但传入实参的形式和 call() 有所不同,它的实参都放入一个数组中:

f.apply(o, [1,2]);

如果一个函数的实参可以是任意数量,用 apply() 方法允许你传入的参数数组可以是任意长度的。在 ES6 之后,我们可以用展开运算符,但是在 ES5 的代码中你可以看到这种情况是用 apply() 来替代。比如,不用展开运算符找出数组中最大的数值元素,调用 Math.max() 方法的时候可以给 apply() 传入一个包含任意个元素的数组:

let biggest = Math.max.apply(Math, arrayOfNumbers);

下面定义的 trace() 与 §8.3.4 中定义的 timed() 函数类似,但是它对方法有效而不是函数。它使用 apply() 方法而不是展开运算符,通过这样做,它能够调用具有相同参数和与被包装方法相同的 this 值的包装方法。

// Replace the method named m of the object o with a version that logs
// messages before and after invoking the original method.
function trace(o, m) {
    let original = o[m];         // Remember original method in the closure.
    o[m] = function(...args) {   // Now define the new method.
        console.log(new Date(), "Entering:", m);      // Log message.
        let result = original.apply(this, args);      // Invoke original.
        console.log(new Date(), "Exiting:", m);       // Log message.
        return result;                                // Return result.
    };
}

8.7.5 bind() 方法

The primary purpose of bind() is to bind a function to an object. When you invoke the bind() method on a function f and pass an object o, the method returns a new function. Invoking the new function (as a function) invokes the original function f as a method of o. Any arguments you pass to the new function are passed to the original function. For example:

bind() 方法的主要作用就是将函数绑定至某个对象。当在函数 f() 上调用 bind() 方法并传入一个对象 o 作为参数,这个方法将返回一个新的函数。(以函数调用的方式)调用新的函数将会把原始的函数 f() 当做 o 的方法来调用。传入新函数的任何实参都将传入原始函数,比如:

function f(y) { return this.x + y; } // This function needs to be bound
let o = { x: 1 };                    // An object we'll bind to
let g = f.bind(o);                   // Calling g(x) invokes f() on o
g(2)                                 // => 3
let p = { x: 10, g };                // Invoke g() as a method of this object
p.g(2)                               // => 3: g is still bound to o, not p.

箭头函数从它们定义的上下文中继承 this 值,并且其不可被 bind() 方法重写,所以如果上面的代码用箭头函数定义函数 f(),这个绑定不会生效。调用 bind() 方法的最常用场景是让不带箭头的函数的行为像箭头函数一样,所以实际上绑定箭头函数的 this 局限性并不是一个问题。

但是 bind() 方法不仅仅是将函数绑定至一个对象。它还附带一些其他应用:除了第一个实参之外,传入 bind() 的实参也会绑定至 this 值。这个附带的应用在箭头函数上也同样生效。是一种常见的函数式编程技术,有时也被称为“柯里化”。参照下面这个例子中的 bind() 方法的实现:

let sum = (x,y) => x + y;      // Return the sum of 2 args
let succ = sum.bind(null, 1);  // Bind the first argument to 1
succ(2)  // => 3: x is bound to 1, and we pass 2 for the y argument

function f(y,z) { return this.x + y + z; }
let g = f.bind({x: 1}, 2);     // Bind this and y
g(3)     // => 6: this.x is bound to 1, y is bound to 2 and z is 3

bind() 返回函数的名称属性是调用 bind() 的函数的名称属性前面加上前缀为单词"bound"。

8.7.6 toString() 方法

和所有的 JavaScript 对象一样,函数也有 toString() 方法,ECMAScript 规范规定这个方法返回一个字符串,这个字符串和函数声明语句的语法相关。实际上,大多数(非全部)的 toString() 方法的实现都返回函数的完整源码。内置函数往往返回一个类似”[native code]”的字符串作为函数体。

8.7.7 Function() 构造函数

因为函数是对象,有一个 Function() 构造函数可以用来创建新的函数:

const f = new Function("x", "y", "return x*y;");

这一行代码创建一个新的函数,这个函数和通过下面代码定义的函数几乎等价:

const f = function(x, y) { return x*y; };

Function() 构造函数可以传入任意数量的字符串实参,最后一个实参所表示的文本就是函数体;它可以包含任意的 JavaScript 语句,每两条语句之间用分号分隔。传入构造函数的其他所有的实参字符串是指定函数的形参名字的字符串。如果定义的函数不包含任何参数,只须给构造函数简单地传入一个字符串——函数体——即可。

注意,Function() 构造函数并不需要通过传入实参以指定函数名。就像函数字面量一样,Function() 构造函数创建一个匿名函数。

关于 Function() 构造函数有几点需要特别注意:

Function() 构造函数允许 JavaScript 在运行时动态地创建并编译函数。

每次调用 Function() 构造函数都会解析函数体,并创建新的函数对象。如果是在一个循环或者多次调用的函数中执行这个构造函数,执行效率会受影响。相比之下,循环中的嵌套函数和函数定义表达式则不会每次执行时都重新编译。

最后一点,也是关于 Function() 构造函数非常重要的一点,就是它所创建的函数并不是使用词法作用域,相反,函数体代码的编译类似顶层函数,如下面代码所示:

let scope = "global";
function constructFunction() {
    let scope = "local";
    return new Function("return scope");  // Doesn't capture local scope!
}
// This line returns "global" because the function returned by the
// Function() constructor does not use the local scope.
constructFunction()()  // => "global"

我们可以将 Function() 构造函数认为是在全局作用域中执行的 eval()(见 §4.12.2),eval() 可以在自己的私有作用域内定义新变量和函数,Function() 构造函数在实际编程过程中很少会用到。

8.8 函数式编程

和 Lisp、Haskell 不同,JavaScript 并非函数式编程语言,但在 JavaScript 中可以像操控对象一样操控函数,也就是说可以在 JavaScript 中应用函数式编程技术。数组方法诸如 map() 和 reduce() 就可以非常适合用于函数式编程风格。接下来的几节将会着重介绍 JavaScript 中的函数式编程技术。函数式编程旨在扩展对 JavaScript 函数功能功能的探索,而不是为了良好的编程风格。

8.8.1 用函数处理数组

假设有一个数组,数组元素都是数字,我们想要计算这些元素的平均值和标准差。若使用非函数式编程风格的话,代码会是这样:

let data = [1,1,3,5,5];  // This is our array of numbers

// The mean is the sum of the elements divided by the number of elements
let total = 0;
for(let i = 0; i < data.length; i++) total += data[i];
let mean = total/data.length;  // mean == 3; The mean of our data is 3

// To compute the standard deviation, we first sum the squares of
// the deviation of each element from the mean.
total = 0;
for(let i = 0; i < data.length; i++) {
    let deviation = data[i] - mean;
    total += deviation * deviation;
}
let stddev = Math.sqrt(total/(data.length-1));  // stddev == 2

可以使用数组方法 map() 和 reduce() 来实现同样的计算,这种实现极其简洁(参照 §7.8.1 来查看这些方法):

// First, define two simple functions
const sum = (x,y) => x+y;
const square = x => x*x;

// Then use those functions with Array methods to compute mean and stddev
let data = [1,1,3,5,5];
let mean = data.reduce(sum)/data.length;  // mean == 3
let deviations = data.map(x => x-mean);
let stddev = Math.sqrt(deviations.map(square).reduce(sum)/(data.length-1));
stddev  // => 2

这个新版本的代码看起来跟第一版有很大不同,但是它仍然调用对象的方法,所以它还是面向对象编程。接下来用函数版本的 map() 和 reduce() 方法:

const map = function(a, ...args) { return a.map(...args); };
const reduce = function(a, ...args) { return a.reduce(...args); };

用这两个函数定义了 map() 和 reduce(),我们计算平均值和标准差变成这样:

const sum = (x,y) => x+y;
const square = x => x*x;

let data = [1,1,3,5,5];
let mean = reduce(data, sum)/data.length;
let deviations = map(data, x => x-mean);
let stddev = Math.sqrt(reduce(map(deviations, square), sum)/(data.length-1));
stddev  // => 2

8.8.2 高阶函数

所谓高阶函数就是操作函数的函数,它接收一个或多个函数作为参数,并返回一个新函数。来看这个例子:

// This higher-order function returns a new function that passes its
// arguments to f and returns the logical negation of f's return value;
function not(f) {
    return function(...args) {             // Return a new function
        let result = f.apply(this, args);  // that calls f
        return !result;                    // and negates its result.
    };
}

const even = x => x % 2 === 0; // A function to determine if a number is even
const odd = not(even);         // A new function that does the opposite
[1,1,3,5,5].every(odd)         // => true: every element of the array is odd

上面的 not() 函数就是一个高阶函数,因为它接收一个函数作为参数,并返回一个新函数。另外一个例子,来看下面的 mapper() 函数,它也是接收一个函数作为实参,并返回一个新函数,这个新函数将一个数组映射到另一个使用这个函数的数组上。这个函数使用了之前定义的 map() 函数,但要首先理解这两个函数有哪里不 同,理解这一点至关重要:

// Return a function that expects an array argument and applies f to
// each element, returning the array of return values.
// Contrast this with the map() function from earlier.
function mapper(f) {
    return a => map(a, f);
}

const increment = x => x+1;
const incrementAll = mapper(increment);
incrementAll([1,2,3])  // => [2,3,4]

这里是一个更常见的例子,它接收两个函数 f() 和 g(),并返回一个新的函数用以计算 f(g()):

// Return a new function that computes f(g(...)).
// The returned function h passes all of its arguments to g, then passes
// the return value of g to f, then returns the return value of f.
// Both f and g are invoked with the same this value as h was invoked with.
function compose(f, g) {
    return function(...args) {
        // We use call for f because we're passing a single value and
        // apply for g because we're passing an array of values.
        return f.call(this, g.apply(this, args));
    };
}

const sum = (x,y) => x+y;
const square = x => x*x;
compose(square, sum)(2,3)  // => 25; the square of the sum

本章后续几节中定义了 partial() 和 memoize() 函数,这两个函数是非常重要的高阶函数。

8.8.3 局部应用函数

函数 f()(见 §8.7.5)的 bind() 方法返回一个新函数,给新函数传入特定的上下文和一组指定的参数,然后调用函数 f()。我们说它把函数“绑定至”对象并传入一部分参数。bind() 方法只是将实参放在(完整实参列表的)左侧,也就是说传入 bind() 的实参都是放在传入原始函数的实参列表开始的位置,但有时我们期望将传入 bind() 的实参放在(完整实参列表的)右侧:

// The arguments to this function are passed on the left
function partialLeft(f, ...outerArgs) {
    return function(...innerArgs) { // Return this function
        let args = [...outerArgs, ...innerArgs]; // Build the argument list
        return f.apply(this, args);              // Then invoke f with it
    };
}

// The arguments to this function are passed on the right
function partialRight(f, ...outerArgs) {
    return function(...innerArgs) {  // Return this function
        let args = [...innerArgs, ...outerArgs]; // Build the argument list
        return f.apply(this, args);              // Then invoke f with it
    };
}

// The arguments to this function serve as a template. Undefined values
// in the argument list are filled in with values from the inner set.
function partial(f, ...outerArgs) {
    return function(...innerArgs) {
        let args = [...outerArgs]; // local copy of outer args template
        let innerIndex=0;          // which inner arg is next
        // Loop through the args, filling in undefined values from inner args
        for(let i = 0; i < args.length; i++) {
            if (args[i] === undefined) args[i] = innerArgs[innerIndex++];
        }
        // Now append any remaining inner arguments
        args.push(...innerArgs.slice(innerIndex));
        return f.apply(this, args);
    };
}

// Here is a function with three arguments
const f = function(x,y,z) { return x * (y - z); };
// Notice how these three partial applications differ
partialLeft(f, 2)(3,4)         // => -2: Bind first argument: 2 * (3 - 4)
partialRight(f, 2)(3,4)        // =>  6: Bind last argument: 3 * (4 - 2)
partial(f, undefined, 2)(3,4)  // => -6: Bind middle argument: 3 * (2 - 4)

利用这种不完全函数的编程技巧,可以编写一些有意思的代码,利用已有的函数来定义新的函数,参照下面这个例子:

const increment = partialLeft(sum, 1);
const cuberoot = partialRight(Math.pow, 1/3);
cuberoot(increment(26))  // => 3

当将不完全调用和其他高阶函数整合在一起的时候,事情就变得格外有趣了。比如,这里的例子定义了 not() 函数,它用到了刚才提到的不完全调用:

const not = partialLeft(compose, x => !x);
const even = x => x % 2 === 0;
const odd = not(even);
const isNumber = not(isNaN);
odd(3) && isNumber(2)  // => true

我们也可以使用不完全调用的组合来重新组织求平均数和标准差的代码,这种编码风格是非常纯粹的函数式编程:

// sum() and square() functions are defined above. Here are some more:
const product = (x,y) => x*y;
const neg = partial(product, -1);
const sqrt = partial(Math.pow, undefined, .5);
const reciprocal = partial(Math.pow, undefined, neg(1));

// Now compute the mean and standard deviation.
let data = [1,1,3,5,5];   // Our data
let mean = product(reduce(data, sum), reciprocal(data.length));
let stddev = sqrt(product(reduce(map(data,
                                     compose(square,
                                             partial(sum, neg(mean)))),
                                 sum),
                          reciprocal(sum(data.length,neg(1)))));
[mean, stddev]  // => [3, 2]

注意,这段代码计算平均值和标准差完全是函数调用;没有涉及运算符,并且括号的数量增长如此之大让 JavaScript 开始看起来像 Lisp 代码。同样,这不是我提倡的 JavaScript 编程风格,但它是一个有趣的练习,看看 JavaScript 代码的功能有多深。

8.8.4 记忆(Memoization)

在 §8.4.1 中定义了一个阶乘函数,它可以将上次的计算结果缓存起来。在函数式编程当中,这种缓存技巧叫做“记忆”(memorization)。下面的代码展示了一个高阶函数,memorize() 接收一个函数作为实参,并返回带有记忆能力的函数:

// Return a memoized version of f.
// It only works if arguments to f all have distinct string representations.
function memoize(f) {
    const cache = new Map();  // Value cache stored in the closure.

    return function(...args) {
        // Create a string version of the arguments to use as a cache key.
        let key = args.length + args.join("+");
        if (cache.has(key)) {
            return cache.get(key);
        } else {
            let result = f.apply(this, args);
            cache.set(key, result);
            return result;
        }
    };
}

memorize() 函数创建一个新的对象,这个对象被当做缓存(的宿主)并赋值给一个局部变量,因此对于返回的函数来说它是私有的(在闭包中)。所返回的函数将它的实参数组转换成字符串,并将字符串用做缓存对象的属性名。如果在缓存中存在这个值,则直接返回它。否则,就调用既定的函数对实参进行计算,将计算结果缓存起来并返回,下面的代码展示了如何使用 memorize():

// Return the Greatest Common Divisor of two integers using the Euclidian
// algorithm: http://en.wikipedia.org/wiki/Euclidean_algorithm
function gcd(a,b) {  // Type checking for a and b has been omitted
    if (a < b) {           // Ensure that a >= b when we start
        [a, b] = [b, a];   // Destructuring assignment to swap variables
    }
    while(b !== 0) {       // This is Euclid's algorithm for GCD
        [a, b] = [b, a%b];
    }
    return a;
}

const gcdmemo = memoize(gcd);
gcdmemo(85, 187)  // => 17

// Note that when we write a recursive function that we will be memoizing,
// we typically want to recurse to the memoized version, not the original.
const factorial = memoize(function(n) {
    return (n <= 1) ? 1 : n * factorial(n-1);
});
factorial(5)      // => 120: also caches values for 4, 3, 2 and 1.

8.9 总结

本章关键点总结如下:

  • 可以用函数关键字和 ES6 => 箭头函数来定义函数。
  • 可以以方法和构造函数的方式调用函数。
  • 一些 ES6 特性,允许参数设定默认值,可以用剩余参数将多个参数搜集到一个数组中,可以解构对象和数组实参到函数参数中。
  • 可以用 ... 展开运算符传递数组元素或者其他可迭代对象到函数调用。
  • 封闭函数内部定义并返回的函数保留对其词法作用域的访问,因此可以读取和写入外部函数内定义的变量。用这种方式使用的函数称为闭包,这是一种值得理解的技术。
  • 函数是可由 JavaScript 操作的对象,这使 JavaScript 支持函数式编程。

  1. 这个术语最初是由 Martin Fowler 提出的,参见http://martinfowler.com/dslwip/MethodChaining.html。 

  2. 这看起来不足为奇,但如果你对 Python 很熟悉,你会发现 Python 中的函数是程序的一 部分,但无法被程序操作。 

  3. 这似乎并不是一个特别有趣的点,除非你熟悉更多的静态语言,其中函数是程序的一部分,但不能由程序操作。