本章介绍了JavaScript函数。函数是JavaScript程序的基本构建块,也是几乎所有编程语言的共同特性。您可能已经熟悉函数的另外一些叫法,如子例程或过程。
函数是一个JavaScript代码块,只定义一次,但可以执行或调用任意次数。JavaScript函数是参数化的:一个函数定义可以包括一个标识符列表,称为参数,作为函数体的局部变量。函数调用为函数的参数提供值或参数。函数通常使用它们的参数值来计算一个返回值,该返回值成为函数调用表达式的值。除了参数之外,每次调用都有另一个值——调用上下文——即 this 关键字的值。
如果一个函数被分配给一个对象的属性,它被称为该对象的方法。当在对象上或通过对象调用函数时,该对象就是该函数的调用上下文或 this 值。为初始化新创建的对象而设计的函数称为构造函数。构造函数在 §6.2 中有描述,将在第9章中再次介绍。
在JavaScript中,函数是对象,它们可以被程序操作。例如,JavaScript可以将函数赋给变量,并将它们传递给其他函数。由于函数是对象,所以您可以在它们上设置属性,甚至调用它们上的方法。
JavaScript函数定义可以嵌套在其他函数中,并且它们可以访问定义它们的范围内的任何变量。这意味着JavaScript函数是闭包,它支持重要而强大的编程技术。
定义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章)。
函数声明由function关键字组成,后面跟着这些部分:
下面是一些函数声明的例子:
// 输出o的每个属性的名称和值。返回 undefined。
function printprops(o) {
for (let p in o) {
console.log(`${
p}: ${
o[p]}\n`);
}
}
// 计算笛卡尔点(x1,y1)和(x2,y2)之间的距离。
function distance(x1, y1, x2, y2) {
let dx = x2 - x1;
let dy = y2 - y1;
return Math.sqrt(dx * dx + dy * dy);
}
// 计算阶乘的递归函数(调用自己的函数)
// 回想一下,x !是x和所有小于它的正整数的乘积。
function factorial(x) {
if (x <= 1) return 1;
return x * factorial(x - 1);
}
关于函数声明,需要理解的一件重要事情是,函数的名称变成了一个变量,其值是函数本身。函数声明语句被“提升”到所包含的脚本、函数或块的顶部,以便可以从出现在定义之前的代码中调用以这种方式定义的函数。另一种说法是,在JavaScript代码块中声明的所有函数都将在该块中定义,并且它们将在JavaScript解释器开始执行该代码块中的任何代码之前定义。
我们描述的distance()和factorial()函数被设计为计算一个值,它们使用return将该值返回给调用者。return语句使函数停止执行,并将其表达式的值(如果有的话)返回给调用者。如果返回语句没有关联的表达式,则函数的返回值是undefined。
printprops()函数则不同:它的工作是输出对象属性的名称和值。不需要返回值,并且函数不包含返回语句。printprops()函数的调用值总是未定义的。如果一个函数不包含return语句,它只执行函数体中的每个语句,直到语句结束,然后将undefined的值返回给调用者。
在ES6之前,只允许在JavaScript文件或其他函数的顶层声明函数。虽然有些实现违反了规则,但在循环体、条件或其他块中定义函数在技术上是不合法的。但是,在ES6的严格模式中,允许在块中声明函数。然而,在块中定义的函数只存在于该块中,而在块之外不可见。
函数表达式看起来很像函数声明,但是它们出现在更大的表达式或语句的上下文中,而且名称是可选的。下面是一些示例函数表达式:
//这个函数表达式定义了一个对参数平方的函数。
//注意,我们给它分配了一个变量
const square = function(x) {
return x*x; };
// 函数表达式可以包含名称,这对递归很有用。
const f = function fact(x) {
if (x <= 1) return 1; else return x*fact(x-1); };
// 函数表达式也可以用作其他函数的参数:
[3,2,1].sort(function(a,b) {
return a-b; });
// 函数表达式有时被定义并立即调用:
let tensquared = (function(x) {
return x*x;}(10));
注意,对于定义为表达式的函数来说,函数名是可选的,我们前面展示的大多数函数表达式都省略了它。函数声明实际上是声明一个变量并给它分配一个函数对象。另一方面,函数表达式不声明变量:如果需要多次引用函数对象,则由您将新定义的函数对象分配给常量或变量。在函数表达式中使用const是一个很好的实践,这样您就不会因为分配新值而意外地覆盖函数。
对于需要引用自身的函数,比如factorial函数,允许使用名称。如果函数表达式包含名称,则该函数的本地函数作用域将包含该名称到函数对象的绑定。实际上,函数名变成了函数内的局部变量。大多数定义为表达式的函数不需要名称,这使得它们的定义更紧凑(尽管不像下面描述的箭头函数那样紧凑)。
用函数声明定义函数f()和在将函数创建为表达式后将函数赋值给变量f之间有一个重要的区别。当您使用声明方式时,函数对象将在包含它们的代码开始运行之前创建,定义将被提升,以便您可以从出现在定义语句上方的代码中调用这些函数。但是,对于定义为表达式的函数来说就不是这样了:在定义它们的表达式被实际求值之前,这些函数并不存在。此外,为了调用一个函数,您必须能够引用它,并且您不能引用定义为表达式的函数,直到它被赋值给一个变量,因此在定义了表达式的函数之前不能调用它们。
在ES6中,可以使用一种特别紧凑的语法定义函数,称为“箭头函数”。这种语法让人联想到数学符号,它使用=>“箭头”分隔函数参数和函数体。function 关键字没有使用,而且,由于箭头函数是表达式而不是语句,因此也不需要函数名。箭头函数的一般形式是用逗号分隔的圆括号中的参数列表,后面是=>箭头,后面是花括号中的函数体:
const sum = (x, y) => {
return x + y; };
但是箭头函数支持更紧凑的语法。如果函数体是一个单独的return语句,可以省略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 }; }; // 好:f()返回一个对象
const g = x => ({
value: x }); // 好:g()返回一个对象
const h = x => {
value: x }; // 不好:h()不返回任何东西
const i = x => {
v: x, w: x }; // 不好:语法错误
在这段代码的第三行中,函数h()确实是模棱两可的:您打算作为对象字面量的代码可以解析为一个标签语句,因此创建了一个返回undefined的函数。然而,在第4行,更复杂的对象字面量不是一个有效的语句,并且这个非法的代码会导致语法错误。
当你需要传递一个函数到另一个函数时,箭头函数简洁的语法使它们成为理想的对象,在数组的方法调用中经常可见箭头函数,如map(), filter()和reduce()(参见§7.8.1),例如:
// 复制一个删除null元素后的数组。
let filtered = [1,null,2,3].filter(x => x !== null); // filtered == [1,2,3]
//数字平方:
let squares = [1,2,3,4].map(x => x*x); // squares == [1,4,9,16]
箭头函数与以其他方式定义的函数有一个关键的区别:它们从定义它们的环境中继承this关键字的值,而不是像以其他方式定义的函数那样定义它们自己的调用上下文。这是箭头函数的一个重要而又非常有用的特性,我们将在本章后面再讲到它。箭头函数与其他函数的不同之处在于它们没有原型属性,这意味着它们不能被用作新类的构造函数(参见§9.2)。
在JavaScript中,函数可以嵌套在其他函数中。例如:
function hypotenuse(a, b) {
function square(x) {
return x*x; }
return Math.sqrt(square(a) + square(b));
}
嵌套函数的有趣之处在于它们的变量作用域规则:它们可以访问嵌套在其中的函数(或多个函数)的参数和变量。例如,在这里显示的代码中,内部函数square()可以读写外部函数hypotenuse()定义的参数a和b。这些嵌套函数的作用域规则非常重要,我们将在§8.6中再次讨论它们。
组成函数体的JavaScript代码不是在定义函数时执行,而是在调用函数时执行。JavaScript函数有五种调用方式:
函数可以作为函数调用,也可以通过调用表达式作为方法调用(§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中,函数调用时,在函数表达式之后和开括号之前,你可以插入 ?.,只有在函数不为空或未定义时才调用该函数。即表达式f?.(x)等价于(假设无副作用):(f !== null && f !== undefined) ? f(x) : undefined
关于这个条件调用语法的完整细节在§4.5.1中。
对于非严格模式下的函数调用,调用上下文(this值)是全局对象。但是,在严格模式下,调用上下文是未定义的。注意,使用箭头语法定义的函数的行为不同:它们总是继承在定义它们的地方生效的this值。
编写为作为函数(而不是方法)调用的函数通常根本不使用this关键字。但是,关键字可以用来确定严格模式是否有效:
// 定义并调用一个函数来确定我们是否处于严格模式。
const strict = (function() {
return !this; }());
递归函数和调用堆栈
递归函数就是调用自己的函数,就像本章开头的factorial()函数一样。有些算法,比如那些涉及基于树的数据结构的算法,可以用递归函数很好地实现。然而,在编写递归函数时,考虑内存约束是很重要的。当函数A调用函数B,然后函数B调用函数C时,JavaScript解释器需要跟踪这三个函数的执行上下文。当函数C完成时,解释器需要知道在哪里继续执行函数B,当函数B完成时,解释器需要知道在哪里继续执行函数A。你可以把这些执行上下文想象成一个堆栈。当一个函数调用另一个函数时,一个新的执行上下文被压入堆栈。当该函数返回时,它的执行上下文对象将从堆栈中弹出。如果一个函数递归地调用自己100次,那么堆栈将有100个对象被压入它,然后将这100个对象弹出。这个调用堆栈占用内存。在现代硬件上,通常可以编写多次调用自己的递归函数。但是,如果一个函数调用自己10,000次,它很可能会失败,并出现“超过了最大调用堆栈大小”这样的错误。
方法只不过是一个存储在对象属性中的JavaScript函数。如果你有一个函数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 = {
// 一个对象字面量
operand1: 1,
operand2: 1,
add() {
// 我们对这个函数使用了方法简写语法
// 注意使用this关键字引用包含的对象。
this.result = this.operand1 + this.operand2;
}
};
calculator.add(); // 计算1+1的方法调用。
calculator.result // => 2
大多数方法调用使用点表示法进行属性访问,但是使用方括号的属性访问表达式也会导致方法调用。下面是两种方法的调用,例如:
o["m"](x,y); // o.m(x,y) 另一种写法
a[0](z) // 也是一个方法调用(假设[0]是一个函数)。
方法调用也可能涉及更复杂的属性访问表达式:
customer.surname.toUpperCase(); // 调用 customer.surname上的方法
f().m(); // 在f()的返回值上调用方法m()
方法和this关键字是面向对象编程范例的核心。作为方法使用的任何函数都有效地传递一个隐式参数——调用它的对象。通常,方法对该对象执行某种类型的操作,方法调用语法是表达函数对对象操作这一事实的优雅方式。比较以下两行:
rect.setSize(width, height);
setRectSize(rect, width, height);
假设函数调用这两行代码可以执行相同的操作(假想的)对象rect,但方法调用语法在第一行更清楚地表明,对象rect是操作的主要焦点。
方法链式调用
当方法返回对象时,您可以使用一个方法调用的返回值作为后续调用的一部分。这将导致作为单个表达式的一系列(或“链”)方法调用。例如,当使用基于Promise的异步操作(见第13章)时,通常会编写这样的代码结构:// 依次运行三个异步操作,处理错误。 doStepOne().then(doStepTwo).then(doStepThree).catch(handleErrors);
当您编写一个本身没有返回值的方法时,请考虑让该方法返回this。如果你在整个API中始终这样做,你将启用一种称为方法链式调用的编程风格,其中只需定义一个对象,然后连续调用多个方法:
new Square().x(100).y(100).size(50).outline("red").fill("blue").draw();
注意,this是一个关键字,而不是变量或属性名。JavaScript语法不允许为它赋值。
this关键字的作用域不像变量那样,而且除了箭头函数外,嵌套函数不继承包含函数的这个值。如果一个嵌套函数被作为方法调用,那么它的this值就是调用它的对象。如果将嵌套函数(不是箭头函数)作为函数调用,则其this值将为全局对象(非严格模式)或未定义(严格模式)。假设在方法中定义并作为函数调用的嵌套函数可以使用this来获取方法的调用上下文,这是一个常见的错误。下面的代码演示了这个问题:
let o = {
// 一个对象 o.
m: function () {
// 对象的方法m.
let self = this; // 用一个变量保存this值.
this === o // => true: "this" 指向对象 o.
f(); // 现在调用辅助函数 f().
function f() {
// 一个嵌套函数 f
this === o // => false: "this" 是全局对象或undefined
self === o // => true: self 指向外部的 "this".
}
}
};
o.m(); // 调用对象o上的方法m.
在嵌套函数f()中,this关键字不等于对象o。这被广泛认为是JavaScript语言中的一个缺陷,注意这一点很重要。上面的代码演示了一种常见的解决方法。在方法m中,我们将this值赋给变量self,而在嵌套函数f中,我们可以使用self代替this来引用包含的对象。
在ES6和以后,另一个解决这个问题的方法是将嵌套函数f转换为一个箭头函数,它将正确继承这个值:
const f = () => {
this === o // true, 因为箭头函数继承 this
};