函数是 JavaScript 程序的一个基本组成部分,也是几乎所有编程语言共有的特性。这篇文章将学习 JavaScript 的函数。
函数简介
函数是一个 JavaScript 代码块,定义之后,可以被执行或调用任意多次:
- JavaScript 函数是参数化的,即函数定义可以包含一组标识符,称为参数或形参(parameter)
- 这些形参类似函数体内定义的局部变量,函数调用会为这些形参提供值或者称为实参(argument)
- 函数返回值会成为函数调用表达式的值。
- 除了实参,每个调用还有另外一个值,即调用上下文(invocation context),也就是 this 关键字的值
如果把函数赋值给一个对象的属性,则可以称其为该对象的方法。如果函数是在一个对象上被调用或通过一个对象被调用,这个对象就是函数的调用上下文或 this 值。
JavaScript 中的函数是对象。因为函数是对象,所以可以在函数上设置属性,甚至调用函数的方法。
JavaScript 函数可以嵌套定义在其他函数里,内嵌的函数可以访问定义在函数作用域的任何变量。这意味着JavaScript 函数是闭包(closure),基于闭包可以实现重要且强大的编程技巧。
定义函数
在 JavaScript 中定义函数最直观的方式就是使用 function 关键字,这个关键字可以用作声明或表达式。ES6定义了一种新的方式,可以不通过 function 关键字定义函数,即 箭头函数
。
函数声明
函数声明由 function 关键字后跟如下组件构成:
- 命名函数的标识符:这个作为函数名的标识符对于函数声明是必需的,它作为一个变量名使用,新定义的函数对象会赋值给这个变量
- 一对圆括号:中间包含逗号分隔的零或多个标识符。这些标识符是函数的参数名,它们就像是函数体内的局部变量
- 一对花括号:其中包含零或多个 JavaScript 语句。这些语句构成函数体,会在函数被调用时执行
1 | function square() { |
这里最关键的是,理解函数的名字变成了一个变量,这个变量的值就是函数本身。函数声明语句会被 提升
到包含脚本、函数或代码块的顶部,因此调用以这种方式定义的函数时,调用代码可以出现在函数定义代码之前。
return 语句导致函数停止执行并将其表达式(如果有)的值返回给调用者。如果 return 语句没有关联的表达式,则函数的返回值是 undefined。
函数表达式
函数表达式看上去很像函数声明,但是出现在复杂表达式或者语句的上下文中,而且函数名是可选的:
1 | const square = function(x) { return x * x; }; |
函数名对定义为表达式的函数而言是可选的(如果需要引用自身,也可以带函数名),前面看到的多数函数表达式都没有名字。函数声明实际上会声明一个变量,然后把函数对象赋值给它。而函数表达式不会声明变量,至于要把新定义的函数赋值给一个常量还是变量都取决于你。
如果函数表达式包含名字,则该函数的局部作用域中也会包含一个该名字与函数对象的绑定。实际上,函数名就变成了函数体内的一个局部变量。
在使用声明形式时,先创建好函数对象,然后再运行包含它们的代码,而且函数的定义会被提升到顶部,因此在定义函数的语句之前就可以调用它们。但对于定义为表达式的函数就不一样了,这些函数在定义它们的表达式实际被求值以前是不存在的。因此定义为表达式的函数不能在它们的定义之前调用。
箭头函数
在 ES6 中,我们可以使用一种特别简洁的语法来定义函数,叫作 箭头函数”
。它使用 箭头
分隔函数的参数和函数体。箭头函数的一般形式是圆括号中逗号分隔的参数列表,后跟箭头 =>,再跟包含在花括号中的函数体:
1 | const sum = (x, y) => { return x + y; }; |
如果函数体只有一个 return 语句,那么可以省略 return 关键字、语句末尾的分号以及花括号,将函数体写成一个表达式,它的值将被返回:
1 | const sum = (x, y) => x + y; |
如果箭头函数只有一个参数,也可以省略包围参数列表的圆括号:
1 | const polynomial = x => x * x + 2 * x + 3; |
但是如果没有参数,箭头函数一定要把空圆括号写出来:
1 | const func = () => 42; |
在写箭头函数时,不能在函数参数和箭头之间放换行符,否则会出现类似于 const func = x
,这本身就是合法的赋值语句。另外,如果箭头函数的函数体是一个 return 语句,但要返回的表达式是对象字面量,那必须把这个对象字面量放在一对圆括号中,以避免解释器分不清花括号到底是函数体的花括号,还是对象字面量的花括号
箭头函数的简洁语法让它们非常适合作为值传给其他函数:
1 | > [1, null, 2, 3].filter(x => x != null) |
它们从定义自己的环境继承 this 关键字的值,而不是像以其他方式定义的函数那样定义自己的调用上下文。箭头函数与其他函数还有一个区别,就是它们没有 prototype 属性。这意味着箭头函数不能作为新类的构造函数。
嵌套函数
在 JavaScript 中,函数可以嵌套在其他函数中。关于嵌套函数,最重要的是理解它们的变量作用域规则:它们可以访问包含自己的函数(或更外层函数)的参数和变量。下文将详细介绍。
调用函数
构成函数体的 JavaScript 代码不在定义函数的时候执行,而在调用函数的时候执行。
函数调用
函数是通过调用表达式来进行调用的,此时函数可以是常规函数或者方法。调用表达式包括求值为函数对象的函数表达式,后跟一对圆括号,圆括号中是逗号分隔的零或多个参数表达式列表。如果函数表达式是属性访问表达式,即函数是对象的属性或数组的元素,那么它是一个方法调用表达式。
在一次调用中,每个(位于括号中的)实参表达式都会被求值,求值结果会变成函数的实参。在函数体内,对形参的引用会求值为对应的实参值。
在 ES2020中,可以在函数表达式后面、左圆括号前面插入 ?.
,从而只在函数不是 null 或 undefined 时调用函数。因此 f?.(x)
等价于
1 | (f !== null && f !== undefined) ? f(x) : undefined |
对于非严格模式下的函数调用,调用上下文(this值)是全局对象。但在严格模式下,调用上下文是 undefined。要注意的是,使用箭头语法定义的函数又有不同:它们总是继承自身定义所在环境的 this 值。
方法调用
方法其实就是 JavaScript 的函数,只不过它保存为对象的属性而已。
1 | > let o = {} |
当函数表达式本身是个属性访问表达式,这意味着函数在这里是作为方法而非常规函数被调用的。方法调用的参数和返回值与常规函数调用的处理方式完全一样。但方法调用与函数调用有一个重要的区别:调用上下文。属性访问表达式由两部分构成:对象(这里的 o)和属性名(m)。在像这样的方法调用表达式中,对象 o 会成为调用上下文,而函数体可以通过关键字 this 引用这个对象。
多数方法调用使用点号进行属性访问,但使用方括号的属性访问表达式也可以实现方法调用。
方法和 this 关键字是面向对象编程范式的核心。任何用作方法的函数实际上都会隐式收到一个参数,即调用它的对象。通常,方法会在对象上执行某些操作,而方法调用语法是表达函数操作对象这一事实的直观方式。
如果方法返回对象,那么基于这个方法调用的返回值还可以继续调用其他方法。这样就会得到表现为一个表达式的一系列方法调用(或方法调用链)。
1 | doStepOne().then(doStepTwo).then(doFinalAction).catch(handleError); |
如果你写的方法没有自己的返回值,可以考虑让它返回 this。如果能在自己的 API 中统一这么做,那就可以支持一种被称为方法调用链(method channing)的编程风格。这样只要给对象命名,之后就可以连续调用这个对象的方法:
1 | new Square().x(100).y(100).color("red").show(); |
注意,this 是个关键字,不是变量也不是属性名。JavaScript 语法不允许给 this 赋值。this 关键字不具有变量那样的作用域机制,除了箭头函数,嵌套函数不会继承包含函数的 this 值。这里有一个常见的错误,就是对于定义在方法中的嵌套函数,如果将其当作函数来调用,以为可以使用 this 获得这个方法的调用上下文:
- 如果嵌套函数被当作方法来调用,那它的 this 值就是调用它的对象
- 如果嵌套函数(不是箭头函数)被当作函数来调用,则它的 this 值要么是全局对象(非严格模式),要么是 undefined(严格模式)。
1 | let o = { |
上面代码也展示了如何在嵌套行数中访问得到 o 对象,即通过在外层函数中额外定义 self 变量。在 ES6 及之后的版本中,解决这个问题的另一个技巧是把嵌套函数 f 转换为箭头函数,因为箭头函数可以继承 this 值。
1 | const f = () => console.log(this === o); // => true |
还有一个技巧是调用嵌套函数的 bind() 方法,来定义一个在指定对象上被隐式调用的新函数:
1 | const f = (function() { |
构造函数调用
如果函数或方法调用前面加了一个关键字 new,那它就是构造函数调用。构造函数调用与常规函数和方法调用的区别在于参数处理、调用上下文和返回值。
- 假如没有参数列表,构造函数调用时其实也可以省略空圆括号
- 构造函数调用会创建一个新的空对象,这个对象继承构造函数的 prototype 属性指定的对象
在构造函数中可以通过 this 关键字引用这个新创建的对象。即使构造函数调用看起来像方法调用,构造函数的调用上下文也是新创建的对象,而不是方法调用所对应的对象。即在 new o.m
o 不会用作调用上下文。
- 构造函数正常情况下不使用 return 关键字,而是初始化新对象并在到达函数体末尾时隐式返回这个对象。此时,这个新对象就是构造函数调用表达式的值
- 但是,如果构造函数显式使用了 return 语句返回某个对象,那该对象就会变成调用表达式的值
- 如果构造函数使用 return 但没有返回值,或者返回的是一个原始值,则这个返回值会被忽略,仍然以新创建的对象作为调用表达式的值
间接调用
JavaScript 函数是对象,与其他 JavaScript 对象一样,JavaScript 函数也有方法。其中有两个方法 call()
和 apply()
,可以用来间接调用函数。这两个方法允许我们指定调用时的 this 值,这意味着可以将任意函数作为任意对象的方法来调用,即使这个函数实际上并不是该对象的方法。
隐式函数调用
有一些 JavaScript 语言特性看起来不像函数调用,但实际上会导致某些函数被调用。
- 如果对象有获取方法或设置方法,则查询或设置其属性值可能会调用这些方法
- 当对象在字符串上下文中使用时(比如当拼接对象与字符串时),会调用对象的 toString() 方法。类似地,当对象用于数值上下文时,则会调用它的 valueOf() 方法
- 在遍历可迭代对象的元素时,也会涉及一系列方法调用
- 标签模板字面量是一种伪装的函数调用
- 代理对象的行为完全由函数控制。这些对象上的几乎任何操作都会导致一个函数被调用
函数实参与形参
JavaScript 函数定义不会指定函数形参的类型,函数调用也不对传入的实参进行任何类型检查。事实上,JavaScript 函数调用连传入实参的个数都不检查。
- 当调用函数时传入的实参少于声明的形参时,额外的形参会获得默认值,通常是 undefined(因此可选参数一定要放到参数列表的后面,这样调用时才可以省略)
- 在 ES6 及更高的版本中,可以在函数形参列表中直接为每个参数定义默认值。语法是在形参名后面加上等于号和默认值,这样在没有给该形参传值时就会使用这个默认值
1 | function getPropertyNames(o, a = []) { |
- 函数的形参默认值表达式会在函数调用时求值,不会在定义时求值。因此每次调用
getPropertyNames()
函数时如果只传一个参数,都创建并传入一个新的空数组 - 可以使用变量或函数调用计算形参的默认值。即如果函数有多个形参,则可以使用前面参数的值来定义后面参数的默认值
- 形参默认值也可以在箭头函数中使用
1 | > const rectangle = (width, height = width * 2) => ({width, height}) |
剩余形参与可变长度实参列表
剩余形参(rest parameter)是一种允许你将不定数量的参数表示为一个数组的函数参数。这对于处理未知数量的参数非常有用,特别是在创建可变参数的函数时。
1 | function max(first=-Infinity, ...rest) { |
- 剩余形参前面有 3 个点,而且必须是函数声明中最后一个参数
- 在调用有剩余形参的函数时,传入的实参首先会赋值到非剩余形参,然后所有剩余的实参(也是剩余参数)会保存在一个数组中赋值给剩余形参
- 函数体内,剩余形参的值始终是数组。数组有可能为空,但剩余形参永远不可能是 undefined(所以不要给剩余形参定义默认值,这样既没用,也不合法)
一定要分清在函数定义中用于定义剩余形参的 ...
和扩展操作符 ...
,后者可以在函数调用中使用。
Arguments 对象
剩余形参是 ES6 引入 JavaScript 的。在 ES6 之前,变长函数是基于 Arguments 对象实现的。在任何函数体内,标识符 arguments 引用该次调用的 Arguments 对象。Arguments 对象是一个类数组对象。它允许通过数值而非名字取得传给函数的参数值。
1 | function max() { |
Arguments 对象可以追溯到 JavaScript 诞生之初,它效率低且难以优化,但在新写的代码中应该避免使用它。
在函数调用中使用扩展操作符
扩展操作符 ...
用于展开或 扩展
数组(或任何可迭代对象,如字符串)的元素。该操作符可以出现在函数调用中。
从求值并产生一个值的角度说,…并不是真正的操作符。应该说,它是一种可以针对数组字面量或函数调用使用的特殊 JavaScript 语法。
剩余形参和扩展操作符经常同时出现。
把函数实参解构为形参
调用函数时如果传入一个实参列表,则所有参数值都会被赋给函数定义时声明的形参。函数调用的这个初始化阶段非常类似变量赋值。因此在函数调用时可以进行解构赋值。
- 如果我们定义了一个函数,它的形参名包含在方括号中,那说明这个函数期待对每对方括号都传入一个数组值。作为调用过程的一部分,我们传入的数组实参会被解构赋值为单独的命名形参。
- 类似地,如果定义的函数需要一个对象实参,也可以把传入的对象解构赋值给形参
- 在解构赋值中也可以为形参定义默认值
- 解构数组时,可以为被展开数组中的额外元素定义一个剩余形参
- 在 ES2018 中,解构对象时也可以使用剩余形参。此时剩余形参的值是一个对象,包含所有未被解构的属性。对象剩余形参经常与对象扩展操作一起使用,
1 | function vectorAdd([x1, y1], [x2, y2]) { |
1 | function vectorMultiply({x, y, z=0, ...props}, scalar) { |
参数类型
JavaScript 方法的参数没有预定义的类型,在调用传参时也没有类型检查。可以用描述性强的名字作为函数参数,同时通过在注释中解释函数的参数来解决这个问题。
有时候需要函数检查参数的类型,对函数来说,在发现传入的值不对时立即失败,一定好过先执行逻辑再以出错告终,而且前者比后者更清晰。
函数作为值
在 JavaScript 中,函数不仅是语法,也是值。这意味着可以把函数赋值给变量、保存为对象的属性或者数组的元素、作为参数传给其他函数等等。
之前说过,函数声明就是创建一个新函数对象并将其赋值给一个变量,而该变量名就是函数名字。我们可以将该函数对象赋值给其他变量,之后再通过新的变量名进行函数调用。
1 | > function square(x) { return x*x; } |
除了变量,也可以把函数赋值给对象的属性。如前所述,这时候把函数称作 方法
。函数甚至可以没有名字,比如可以把匿名函数作为一个数组元素:
1 | > let a = [x => x*x, 20]; |
函数在 JavaScript 中并不是原始值,而是一种特殊的对象。这意味着函数也可以有属性。
- 如果一个函数需要一个
静态
变量,且这个变量的值需要在函数每次调用时都能访问到,则通常把这个变量定义为函数自身的一个属性
1 | uniqInteger.count = 0; |
- 如下代码则更加灵活,它使用
[]
语法来给函数本身添加属性,此时函数本身有点类似于数组了。可以通过该方式来缓存某些计算结果
1 | > function f(x) { return f[x]; } |
函数作为命名空间
在函数体内声明的变量在函数外部不可见。为此,有时候可以把函数用作临时的命名空间,这样可以保证在其中定义的变量不会污染全局命名空间。
例如:
1 | (function() { |
在一个表达式中定义并调用匿名函数的技术非常常用,因此甚至有了别称,叫 立即调用函数表达式
(immediately invoked function expression)。
注意,function 外围的圆括号是必须的。因为如果没有它,JavaScript 解释器会把 function 关键字作为函数声明语句来解析。有了这对括号,解释器会把它正确地识别为函数定义表达式。
闭包
与多数现代编程语言一样,JavaScript 使用词法作用域(lexical scoping)。这意味着函数执行时使用的是定义函数时生效的变量作用域,而不是调用函数时生效的变量作用域。为了实现词法作用域,JavaScript 函数对象的内部状态不仅要包括函数代码,还要包括对函数定义所在作用域的引用。这种函数对象与作用域(即一组变量绑定)组合起来解析函数变量的机制,在计算机科学文献中被称作闭包(closure)。
严格来讲,所有JavaScript函数都是闭包。但由于多数函数调用与函数定义都在同一作用域内,所以闭包的存在无关紧要。闭包真正值得关注的时候,是定义函数与调用函数的作用域不同的时候。最常见的情形就是一个函数返回了在它内部定义的嵌套函数。很多强大的编程技术都是建立在这种嵌套函数闭包之上的,因此嵌套函数闭包在 JavaScript 程序中也变得比较常见。
1 | let scope = "global scope"; |
JavaScript 函数是使用定义它们的作用域来执行的。在定义嵌套函数 f()
的作用域中,变量 scope 绑定的值是 local scope
,该绑定在 f 执行时仍然有效,无论它在哪里执行。
这正是闭包惊人且强大的本质:它们会捕获自身定义所在外部函数的局部变量(及参数)绑定。闭包可以捕获一次函数调用的局部变量,可以将这些变量作为私有状态。
1 | > let uniqCounter = (function() { let counter = 0; return function() { return counter++; }; })() |
类似 counter 这样的私有变量并非只能由一个闭包独享。同一个外部函数中完全可以定义两个或更多嵌套函数,而它们共享相同的作用域。
1 | function counter() { |
每次调用 counter() 都会创建一个新作用域(与之前调用创建的作用域相互独立),作用中域中创建的一个新私有变量。因此如果调用两次 counter(),就会得到拥有两个不同私有变量的计数器对象。在一个计数器上调用count() 或 reset() 不会影响另一个计数器。
有一点需要指出的是,可以将这种闭包技术与属性获取方法和设置方法组合使用。下面的例子使用自己的参数n 保存供属性访问器方法共享的私有状态:
1 | function count(n) { |
与闭包关联的作用域是 活的
,嵌套函数不会创建作用域的私有副本或截取变量绑定的静态快照。再来看一个例子:
1 | function funcs1() { |
这里很容易犯错:
- 通过 var 声明的变量在整个函数作用域内都有定义。代码中的 for 循环使用
var i
声明循环变量,因此变量 i 的作用域是整个函数体,因此所有闭包都捕获的是同一个 i 变量。当调用闭包时,它们都访问的是同一个变量 i 的最新值,即 10 - 通过 let 声明的变量只在声明它们的块作用域内有效。因此,每次迭代都会创建一个新的、与其他循环不同的独立作用域,每个闭包捕获的都是迭代时所创建的 i 变量,该变量的值不会被后续迭代改变(因为每次迭代都会创建一个新的 i 值)
在写闭包的时候,要注意:this 是 JavaScript 关键字,不是变量。如前所述,箭头函数继承包含它们的函数中的 this 值,但使用 function 定义的函数并非如此。因此如果你要写的闭包需要使用其包含函数的 this 值,那么可以通过以下任意方式来解决:
- 使用箭头函数
- 调用 bind()
- 把外部的 this 值赋给你的闭包将继承的变量
1 | const self = this; |
函数属性、方法与构造函数
对函数使用 typeof 操作符会返回字符串 function
,但函数实际上是一种特殊的 JavaScript 对象。由于函数是对象,因此它们也有属性和方法,甚至还有一个 Function() 构造函数可以用来创建新函数对象。
1 | > typeof (function() {}) |
length 属性
函数有一个只读的 length 属性,表示函数在参数列表中声明的形参个数。如果函数有剩余形参,则这个剩余形参不包含在 length 属性内。
1 | > let a = function(x, y) {} |
name 属性
函数有一个只读的 name 属性,表示定义函数时使用的名字。如果是未命名的函数,表示在第一次创建这个函数时赋给该函数的变量名或属性名。
1 | > let b = function() {} |
prototype 属性
除了箭头函数,所有函数都有一个 prototype 属性,这个属性引用一个被称为原型对象的对象。每个函数都有自己的原型对象。当函数被作为构造函数使用时,新创建的对象从这个原型对象继承属性。
call() 和 apply() 方法
call()
和 apply()
允许间接调用一个函数,就像这个函数是某个其他对象的方法一样:
- call() 和 apply() 的第一个参数都是要在其上调用这个函数的对象。也就是函数的调用上下文,在函数体内它会变成 this 关键字的值
- 箭头函数从定义它的上下文中继承 this 值。这个 this 值不能通过 call() 和 apply() 方法重写。如果对箭头函数调用这两个方法,那第一个参数实际上会被忽略
- 除了作为调用上下文传给 call() 的第一参数,后续的所有参数都会传给被调用的函数
- apply() 方法与 call() 方法类似,只不过要传给函数的参数需要以数组的形式提供
1 | > function test() { console.log(this.m) } |
如下是一个更复杂的例子:
1 | function trace(o, m) { |
1 | # node trace.js |
bind
bind() 方法的主要目的是把函数绑定到对象。如果在函数 f 上调用 bind()
方法并传入对象 o,则这个方法会返回一个新函数。如果作为函数来调用这个新函数,就会像 f 是 o 的方法一样调用原始函数。传给这个新函数的所有参数都会传给原始函数。
1 | > function f(y) { return this.x + y; } |
箭头函数从定义它们的环境中继承this值,且这个值不能被 bind() 覆盖。因此 bind() 对箭头函数是不起作用的。
事实上,除了把函数绑定到对象,bind() 方法还会做其他事:
- 第一个参数之后传给bind() 的参数也会随着this值一起被绑定,从而可以实现
部分应用
。部分应用是函数式编程中的一个常用技术,有时候也被称为柯里化(currying)
1 | > let sum = (x, y) => x + y; |
toString() 方法
与所有 JavaScript 对象一样,函数也有 toString() 方法。ECMAScript 规范要求这个方法返回一个符合函数声明语句的字符串。
- 很多实现都是返回函数的源代码
- 内置函数返回的字符串中通常包含
[native code]
1 | > sum.toString() |
Function() 构造函数
因为函数是对象,所以就有一个 Function()
构造函数可以用来创建新函数:
1 | > const f0 = new Function("x", "y", "return x * y"); |
- Function() 构造函数可以接收任意多个字符串参数,其中最后一个参数是函数体的文本
- 这个函数体文本中可以包含任意 JavaScript 语句,相互以分号分隔
- 传给这个构造函数的其他字符串都用于指定新函数的参数名
- Function() 构造函数不接收任何指定新函数名字的参数
要理解Function()构造函数,需要理解以下几点:
- Function() 函数允许在运行时动态创建和编译 JavaScript 函数
- Function() 构造函数每次被调用时都会解析函数体并创建一个新函数对象。如果在循环中或者被频繁调用的函数中出现了对它的调用,可能会影响程序性能。相对而言,出现在循环中的嵌套函数和函数表达式不会每次都被重新编译
- 最后,也是关于Function()非常重要的一点,就是它创建的函数不使用词法作用域,而是始终编译为如同顶级函数一样
函数式编程
JavaScript 可以把函数作为对象来操作意味着可以在 JavaScript 中使用函数式编程技巧。像 map() 和 reduce() 这样的数组方法就特别适合函数式编程风格。
使用函数处理数组
如下使用函数式编程风格,计算数组元素的平均值和标准差:
1 | const sum = (x, y) => x + y; |
如果想把里面的一些面向对象编程风格也去除,可以再重新定义 map 和 reduce 方法。
高阶函数
高阶函数就是操作函数的函数,它接收一个或多个函数作为参数并返回一个新函数。
1 | function mapper(f) { |
再看一个示例:
1 | function compose(f, g) { |
函数的部分应用
bind 方法可以返回一个新的函数,这个新函数在指定的上下文中以指定的参数调用 f。我们称之为 把函数绑定到一个对象上并应用了部分的参数
。bind()
左侧部分应用参数,即传给 bind()
的参数会放在传给原始函数参数列表的开头。但是也有可能在右侧部分应用参数。
1 | function partialLeft(f, ...outerArgs) { |
函数记忆
如下展示了高阶函数 memorize,它可以接受一个函数参数,并返回这个函数的记忆版本(即可以缓存之前的计算结果):
1 | function memorize(f) { |