函数是这样的一段JavaScript代码,它只定义一次,但可能被执行或调用任意次。JavaScript函数是参数化的:函数的定义会包括一个称为形参(parameter
)的标识符列表,这些参数在函数体中像局部变量一样工作。函数调用会为形参提供实参的值。函数使用它们实参的值来计算返回值,称为该函数调用表达式的值。除了实参之外,每次调用还会拥有另外一个值——本次调用的上下文——就是this
关键字的值。
如果函数挂载在一个对象上,作为对象的一个属性,就称他为对象的方法。当通过这个对象来调用函数时,该对象就是此次调用的上下文(context
),也就是该函数的this
的值。
在JavaScript里,函数即对象,程序可以随意操控它们。比如,JavaScript可以把函数赋值给变量,或者作为参数传递给其他函数。因为函数就是对象,所以可以给它们设置属性,甚至调用它们的方法。
JavaScript的函数可以嵌套在其他函数中定义,这样它们就可以访问它们被定义时所处的作用域中的任何变量。这意味着JavaScript函数构成了一个闭包(closure
),它给JavaScript带来了非常强劲的编程能力。
一、函数定义
函数使用function
关键字来定义,它可以用在函数定义表达式或者函数声明语句离。在两种形式中,函数定义都从function
关键字开始,其后跟随这些组成部分:
函数名称标识符
函数名称是函数声明语句必须的部分。它的用途就像变量的名字,新定义的函数对象会赋值给这个变量。对函数定义表达式来说,这个名字是可选的:如果存在,改名字只存在于函数体中,并指代该函数对象本身。
一对圆括号
其中包含由0个或者多个用逗号隔开的标识符组成的列表。这些标识符是函数的参数名称,它们就像函数体中的局部变量一样。
一对花括号
其中包含0条或多条JavaScript语句。这些语句构成了函数体:一旦调用函数,就会执行这些语句。
1 | function printprops(o) { |
注意:以表达式方式定义的函数,函数的名称是可选的。一条函数声明语句实际上声明了一个变量,并把一个函数对象赋值给它。相对而言,定义函数表达式时并没有声明一个变量。如果一个函数定义表达式包含名称,函数的局部作用域会包含一个绑定到函数对象的名称。实际上,函数的名称将会成为函数内部的一个局部变量。
函数命名
任何合法的JavaScript标识符都可以用作一个函数的名称。命名时要尽量选择描述性强而又简洁的函数名。通常函数名的第一个字符为小写,这是一种编程约定。当函数名包含多个单词时,一种约定是将单词以下划线分隔,就像like_this()
。还有另外一种约定,就是除了第一个单词之外的单词首字母使用大写字母,就像likeThis()
。有些函数是用作内部函数或私有函数(不是作为公用API的一部分),这种函数名通常以一条下划线为前缀。
函数声明语句“被提前”到外部脚本或外部函数作用域的顶部,所以以这种方式声明的函数,可以被在它定义之前出现的代码所调用。不过,以表达式定义的函数就另当别论了,为了调用一个函数,必须要能引用它,而要使用一个以表达式方式定义的函数之前,必须把它赋值给一个变量。变量的声明提前了,但是变量赋值是不会提前的,所以,以表达式方式定义的函数在定义之前无法调用。
大多数函数都会包含一条return
语句,return
语句会导致函数停止执行,并返回它的表达式的值给调用者。如果return
语句没有一个与之相关的表达式,则它返回undefined值。如果一个函数不包含return
语句,那它就只执行函数体中的每条语句,并返回undefined
给调用者。
二、嵌套函数
在JavaScript里,函数可以嵌套在其他函数里
1 | function hypotenuse(a, b) { |
嵌套函数的有趣之处在于它的变量作用域规则:它们可以访问嵌套它们(或多重嵌套)的函数的参数和变量。这些作用于规则对内嵌函数非常重要。
上文提到,函数声明语句并非真正的语句,ES规范只是允许它们作为顶级语句。它们可以出现在全局代码里,或者内嵌在其他函数中,但它们不能出现在循环、条件判断,或者try/catch/finally
以及with
语句中。注意,此限制仅适用于以语句声明形式定义的函数。函数定义表达式可以出现在JavaScript代码的任何地方。
三、函数调用
构成函数主体的JavaScript代码在定义之时并不会执行,只有调用该函数时,它们才会执行。有4种方式来调用JavaScript函数
作为函数
作为方法
作为构造函数
通过它们的
call()
和apply()
方法直接调用
1. 函数调用
使用调用表达式可以进行普通的函数调用也可进行方法调用。一个调用表达式由多个函数表达式组成,每个函数表达式都是由一个函数对象和左圆括号、参数列表和右圆括号组成,参数列表是由逗号分隔的零个或多个参数表达式组成。如果函数表达式是一个属性访问表达式,即该函数是一个对象的属性或属性中的一个元素,那么它就是一个方法调用表达式。
1 | printprops({x: 1}); |
在一个调用中,每个参数表达式(圆括号之间的部分)都会计算出一个值,计算的结果作为参数传递给另外一个函数。这些值作为实参传递给声明函数时定义的形参。在函数体中存在一个形参的引用,指向当前传入的实参列表,通过它可以获得参数的值。
在ES3和非严格模式的ES5中,函数的调用上下文(this)是全局对象,然而在严格模式下,调用上下文则是undefined。
以函数形式调用的函数通常不使用this关键字,不过,this可以用来判断当前是否是严格模式。
1 | // 定义并调用一个函数来确定当前脚本运行时是否为严格模式 |
2. 方法调用
一个方法无非是个保存在一个对象的属性里的JavaScript函数。如果有一个函数f和一个对象o,则可以用下面的代码给o
定义一个名为m()
的方法
1 | o.m = f; |
对方法调用的参数和返回值的处理,和上面所描述的普通函数调用完全一致。但是,方法调用和函数调用有一个重要的区别,即调用上下文。属性访问表达式由两部分组成:一个对象(o
)和属性名称(m
),在像这样的方法调用表达式里,对象o
称为调用上下文,函数体可以使用关键字this
引用该对象
1 | var calculator = { |
大多数方法调用使用点符号来访问属性,使用方括号也可以进行属性访问操作
1 | o['m'](x, y); |
方法调用可能包括更复杂的属性访问表达式
1 | customer.surname.toUpperCase(); |
方法和this
关键字是面向对象编程范例的核心。任何函数只要作为方法调用实际上都会传入一个隐式的实参——这个实参是一个对象,方法调用的母体就是这个对象。通常来讲,基于那个对象的方法可以执行多种操作,方法调用的语法已经很清晰的表明了函数将基于一个对象进行操作。比较下面两行代码。
1 | rect.setSize(width, height); |
我们假设这两行代码的功能完全一样,它们都作用于一个假定的对象rect
。可以看出,第一行的方法调用语法非常清晰地表明这个函数执行的载体是rect
对象,函数中的所有操作都将基于这个对象。
需要注意的是,this
是一个关键字,不是变量,也不是属性名。JavaScript的语法不允许给this
赋值。
和变量不同,关键字this
没有作用域的限制,嵌套的函数不会从调用它的函数中继承this
。如果嵌套函数作为方法调用,其this
指向调用它的对象。如果嵌套函数作为函数调用,其this
值不是全局对象(非严格模式下)就是undefined
(严格模式下)。很多人误以为调用嵌套函数时this
会指向调用外层函数的上下文。如果你想访问这个外部函数的this
值,需要将this
的值保存在一个变量里,这个变量和内部函数都同在一个作用域内。通常使用self
变量来保存this
。
1 | var o = { |
3. 方法链
当方法的返回值是一个对象,这个对象还可以再调用它的方法。这种方法调用序列中(通常称为“链”或“级联”)每次的调用结果都是另外一个表达式的组成部分。比如基于jQuery库,我们常常会这样写
1 | // 找到所有的header,取得它们id的映射,转换为数组并对它们进行排序 |
当方法不需要返回值时,最好直接返回this
。如果在设计的API中一直采用这种方式,使用API就可以进行“链式调用”风格的变成,在这种编程风格中,只要指定一次要调用的对象即可,余下的方法都可以基于此进行调用。
4. 构造函数
如果函数或者方法调用之前带有关键字new
,他就构成构造函数调用。构造函数调用和普通的函数调用以及方法调用在实参处理、调用上下文和返回值方面都有不同。
如果构造函数调用在圆括号内包含一组实参列表,先计算这些实参表达式,然后传入函数内,这和函数调用和方法调用是一致的。但如果构造函数没有形参,JavaScript调用的语法是允许省略实参列表和圆括号的。凡是没有形参的构造函数调用都可以省略圆括号
1 | // 两行代码是等价的 |
构造函数调用创建一个新的空对象,这个对象继承自构造函数的prototype
属性。构造函数试图初始化这个新创建的对象,并将这个对象用作其调用上下文,因此构造函数可以使用this
关键字来引用这个新创建的对象。注意,尽管构造函数看起来像一个方法调用,它依然会使用这个新对象作为调用上下文。也就是说,在表达式new o.m()
中,调用上下文并不是o
,而是m
。
构造函数通常不使用return
关键字,它们通常初始化新对象,当构造函数的函数体执行完毕时,它会显式返回。在这种情况下,构造函数调用表达式的计算结果就是这个新对象的值。然而如果构造函数显式地使用return
语句返回一个对象,那么调用表达式的值就是这个对象。如果构造函数使用return
语句但没有指定返回值,或者返回一个原始值,那么这时将忽略返回值,同时使用这个新对象作为调用结果。
5. 间接调用
JavaScript中的函数也是对象,和其他JavaScript对象没什么两样,函数对象也可以包含方法。其中的call()
和apply()
可以用来间接地调用函数,两个方法都允许显式地调用所需的this
值。也就是说,任何函数可以作为任意对象的方法来调用,哪怕这个函数不是那对对象的方法。两个方法都可以指定调用的实参。call()
方法使用它自有的实参列表作为函数的实参,apply()
方法则要求以数组的形式传入参数。
四、函数的实参和形参
JavaScript中的函数定义并未指定函数形参的类型,函数调用也未对传入的实参值作任何类型检查。实际上,JavaScript函数调用甚至不检查传入形参的个数。
1. 可选形参
当调用函数的时候传入的实参比函数声明时指定的形参个数要少,剩下的形参都将设置为undefined
值。因此在调用函数时形参是否可选以及是否可以省略应当保持较好的适应性。为了做到这一点,应当给省略的参数赋一个合理的默认值
1 | function getPropertyNames(o, /* optional */ a) { |
如果在第一行代码中不使用if
语句,可以使用“||
”运算符
1 | a = a || []; |
需要注意的是,当用这种可选实参来实现函数时,需要将可选实参放在实参列表的最后。那些调用你的函数的程序员是没法省略第一个实参并传入第二个实参的,它必须将undefined
作为第一个实参显式传入。同样注意在函数定义中使用注释/*optional*/
来强调形参是可以选的。
2. 可变长的实参列表:实参对象
当调用函数的时候传入的实参个数超过函数定义时的形参个数时,没有办法直接获得未命名的引用。参数对象解决了这个问题。在函数体内,标识符arguments
是指向实参对象的引用,实参对象是一个类数组对象,这样可以通过数字下标就能访问传入函数的实参值,而不用非要通过名字来得到实参。
实参对象在很多地方都非常有用,下面的例子展示了使用它来验证实参的个数,从而调用正确的逻辑。
1 | function f(x, y, z) { |
需要注意的是,通常不必像这样检查实参个数。大多数情况下JavaScript的默认行为是可以满足需要的。省略的实参都将是undefined
,多出的参数会自动省略。
实参对象有一个重要的用处,就是让函数可以操作任意数量的实参。下面的函数就可以接收任意数量的实参,并返回传入实参的最大值(内置函数Max.max()
的功能与之类似)
1 | function max(/* ... */) { |
类似这种函数可以接收任意个数的实参,这种函数也成为“不定实参函数”(varargs function
)。注意,不定实参函数的实参个数不能为零,arguments[]
对象最适合的应用场景是在这样一类函数中,这类函数包含固定个数的命名和必须参数,以及随后个数不定的可选实参。
数组对象包含一个非同寻常的特性。在非严格模式下,当一个函数包含若干形参,实参对象的数组元素是函数形参所对应实参的别名,实参对象中以数字索引,并且形参名称可以认为是相同变量的不同命名。通过实参名字来修改实参值的话,通过arguments[]
数组也可以获取到更改后的值。
1 | function f(x) { |
如果实参对象是一个普通数组的话,第二条console.log(x)
语句的结果绝对不会是null
,在这个例子中,arguments[0]
和x
指代同一个值,修改其中一个的值会影响到另一个。
在严格模式下还有一点(和非严格模式下相比的)不同,在非严格模式中,函数里的arguments
仅仅是一个标识符,在严格模式中,它变成了一个保留字。严格模式中的函数无法使用arguments
作为形参名或局部变量名,也不能给arguments
赋值。
callee和caller属性
除了数组元素,实参对象还定义了callee
和caller
属性,在ES5严格模式中,对这两个属性的读写操作都会产生一个类型错误。而在非严格模式下,ES标准规范规定callee
属性指代当前正在执行的函数。caller
是非标准的,但大多数浏览器都实现了这个属性,它指代调用当前正在执行的函数的函数。通过caller
属性可以访问调用栈。callee
属性在某些时候会非常有用,比如在匿名函数中通过callee
来递归地调用自身。
1 | var factorial = function(x) { |
3. 将对象属性用作实参
当一个函数包含超过三个形参时,对于程序员来说,要记住调用函数中实参的正确顺序实在让人头疼。最好通过名值对的形式来传入参数,这样参数的顺序就无关紧要了。为了实现这种风格的方法调用,定义函数的时候,传入的实参都写入一个单独的对象之中,在调用的时候传入一个对象,对象中的名值对是真正需要的实参数据。
1 | function arraycopy(from, from_start, to, to_start, length) { |
五、作为值的函数
函数定义和调用是JavaScript的词法特性,函数不仅是一种语法,也是值,也就是说,可以将函数赋值给变量,存储在对象的属性或数组的元素中,作为参数传入另外一个函数。
1 | function square(x) { return x * x; }; |
考虑一下Array.sort()
方法,这个方法可以接收一个函数作为参数,用来处理具体的排序操作。这个函数的作用非常简单,对于任意两个值都返回一个值,以指定它们在排序后的数组中的先后顺序。
1. 自定义函数属性
JavaScript中的函数并不是原始值,而是一种特殊的对象,也就是说,函数可以拥有属性。当函数需要一个“静态”变量来在调用时保持某个值不变,最方便的方式就是给函数定义属性,而不是定义全局变量,显然定义全局变量会让命名空间变得更加杂乱无章。
比如,我们想写一个返回一个唯一整数的函数,不管在哪里调用函数都会返回这个整数。而函数不能两次返回同一个值,可以把这些信息存放到全局变量中,但这并不是必须的,因为这个信息仅仅是函数本身用到的。最好将这个信息保存到函数对象的一个属性中
1 | uniqueInteger.counter = 0; |
下面这个函数factorial()
使用了自身的属性来缓存上一次的计算结果
1 | function factorial(n) { |
六、作为命名空间的函数
函数作用域:在函数中声明的变量在整个函数体内都是可见的(包括在嵌套的函数中),在函数的外部是不可见的。不在任何函数内声明的变量是全局变量,在整个JavaScript程序中都是可见的。在JavaScript中是无法声明只在一个代码块内可见的变量的,基于这个原因,我们常常简单的定一个函数用作临时的命名空间,在这个命名空间内定义的变量都不会污染到全局命名空间。
比如,假设我们写了一段JavaScript模块代码,这段代码将要用在不同的JavaScript程序中。和大多数代码一样,假定这段代码定义了一个用以存储中间计算结果的变量。这样问题就来了,当模块代码放到不同的程序中运行时,我们无法得知这个变量是否已经创建了,如果已经存在这个变量,那么将会和代码发生冲突。解决办法当然是将代码放入一个函数内,然后调用这个函数。这样全局变量就变成了函数内的局部变量
1 | function mymodule() { |
这段代码仅仅定义了一个单独的全局变量,名叫“mymodule
”的函数,这样还是太麻烦,可以直接定义一个匿名函数,并在单个表达式中调用它
1 | (function() { |
最外层的圆括号是习惯用法,尽管有些时候没有必要也不应当省略,这里定义的函数会立即调用。
下文示例中定义一个返回extend()
函数的匿名函数,代码检测是否出现了一个众所周知的IE bug,如果出现了这个bug,就返回一个带补丁的函数版本。此外,这个匿名函数命名空间用来隐藏一组属性名。
1 | // 定义一个扩展函数,用来将第二个以及后续参数复制至第一个参数 |
七、闭包
和其他大多数现代编程语言一样,JavaScript也采用词法作用域(lexical scoping
),也就是说,函数的执行依赖于变量作用域,这个作用域是在函数定义时决定的,而不是函数调用时决定的。为了实现这种词法作用域,JavaScript函数对象的内部状态不仅包含函数的代码逻辑,还必须引用当前的作用域链。函数对象可以通过作用域链相互关联起来,函数体内部的变量都可以保存在函数作用域内,这种特性在计算机科学文献中称为 “闭包”。
从技术的角度讲,所有的JavaScript函数都是闭包:它们都是对象,它们都关联到作用域链。定义大多数函数时的作用域链在调用函数时依然有效,但这并不影响闭包。当调用函数时闭包所指向的作用域链和定义函数时的作用域链不是同一个作用域链时,事件就变得非常微妙。当一个函数嵌套了另外一个函数,外部函数将嵌套的函数对象作为返回值返回的时候往往会发生这种事情。有很多强大的编程技术都利用到了这类嵌套的函数必报,以至于这种编程模式在JavaScript中非常常见。
理解闭包首先要了解嵌套函数的词法作用域规则,看一下这段代码
1 | var scope = "global scope"; // 全局变量 |
checkscope()
函数声明了一个局部变量,并定义了一个函数f()
,函数f()
返回了这个变量的值,最后将函数f()
的执行结果返回。我们应当非常清楚为什么调用checkscope()
会返回“local scope
”。现在我们对这段代码做一点改动,看看会返回什么
1 | var scope = "global scope"; // 全局变量 |
在这段代码中,我们将函数内的一对圆括号移动到了checkscope()
之后,checkscope()
现在仅仅返回函数内嵌套的一个函数对象,而不是直接返回结果。在定义函数的作用域外面,调用这个嵌套的函数(包含最后一行代码的最后一对圆括号)会发生什么事情呢?
回想一下词法作用域的基本规则:JavaScript函数的执行用到了作用域链,这个作用域链是函数定义的时候创建的。嵌套的函数定义在这个作用域链里,其中的变量scope
一定是局部变量,不管在何时何地执行函数f()
,这种绑定在执行f()
时依然有效。因此最后一行代码返回"local scope"
,而不是"global scope"
。简而言之,闭包的这个恶性强大到让人吃惊:它们可以捕捉到局部变量(和参数),并一直保存下来,看起来像这些变量绑定到了在其中定义它们的外部函数。
实现闭包
如果我们理解了词法作用域的规则,我们就能很容易地理解闭包:函数定义时的作用域链到函数执行时依然有效。我们将作用域链描述为一个对象列表,不是绑定的栈。每次调用JavaScript函数的时候,都会为之创建一个新的对象用来保存局部变量,把这个对象添加至作用域链中。当函数返回的时候,就从作用域链中将这个绑定变量的对象删除。如果不存在嵌套的函数,也没有其他引用指向这个绑定对象,它就会被当作垃圾回收掉。如果定义了嵌套的函数,每个嵌套的函数都各自对应一个作用域链,并且这个作用域链指向一个变量绑定对象。但如果这些嵌套的函数对象在外部函数中保存下来,那么它们也会和所指向的变量绑定对象一样当作垃圾回收。但是如果这个函数定义了嵌套的函数,并将它作为返回值返回或者存储在某处的属性里,这时就会有一个外部引用指向这个嵌套的函数。它就不会被当作垃圾回收,并且它所指向的变量绑定对象也不会被当作垃圾回收。
在前文定义的uniqueInteger()
函数中,这个函数使用自身的一个属性来保存每次返回的值,以便每次调用都能跟踪上次的返回值。但这种做法有一个问题,就是恶意代码可能将计数器重置或者把一个非整数赋值给它,导致uniqueInteger()
函数不一定能产生“唯一”的“整数”。而闭包可以捕捉到单个函数调用的局部变量,并将这些局部变量用作私有状态。我们可以利用闭包这样重写uniqueInteger()
函数
1 | var uniqueInteger = (function() { // 定义函数并立即调用 |
粗略来看,第一行代码看起来像将函数赋值给一个变量uniqueInteger
,实际上,这段代码定义了一个立即调用的函数(函数的开始带有左圆括号),因此是这个函数的返回值赋值给变量uniqueInteger
。现在,我们来看函数体,这个函数返回另外一个函数,这是一个嵌套的函数,我们将它赋值给变量uniqueInteger
,嵌套的函数是可以访问作用域内的变量的,而且可以访问外部函数中定义的counter
变量。当外部函数返回之后,其他任何代码都无法访问counter
变量,只有内部的函数才能访问到它。
像counter
一样的私有变量不是只能用在一个单独的闭包内,在同一个外部函数中定义的多个嵌套函数也可以访问它,这多个嵌套函数都共享一个作用域链。
1 | function counter() { |
counter()
函数返回了一个“计数器”对象,这个对象包含两个方法:count()
返回下一个整数,reset()
将计数器重置为内部状态。首先要理解,这两个方法都可以访问私有变量n
。再者,每次调用counter()
都会创建爱你一个新的作用域链和一个新的私有变量。因此,如果调用counter()
两次,则会得到两个计数器对象,而且彼此包含不同的私有变量,调用其中一个计数器对象的count()
或reset()
不会影响到另外一个对象。
从技术角度看,其实可以将这个闭包合并为属性存取器方法getter
和setter
。下面这段代码所示的counter()
函数,这里私有状态的实现是利用了闭包,而不是利用普通的对象属性来实现
1 | function counter(n) { // 函数参数n是一个私有变量 |
需要注意的是,这个版本的counter()
函数并未生命局部变量,而只是使用参数n
来保存私有状态,属性存取器方法可以访问n
。这样的话,调用counter()
的函数就可以指定私有变量的初始值了。
下文示例是使用闭包技术来共享私有状态的通用做法。这个例子定义了addPrivateProperty()
函数,这个函数定义了一个私有变量,以及两个嵌套的函数用来获取和设置这个私有变量的值。它将这些嵌套函数添加为所指定对象的方法
1 | // 这个函数给对象o增加了属性存取器方法 |
我们已经给出了很多例子,在同一个作用域链中定义两个闭包,这两个闭包共享同样的私有变量或变量。这是一种非常重要的技术,但还是要特别小心那些不希望共享的变量往往不经意间共享给了其他的闭包,了解这一点也很重要,看看下面的代码
1 | // 这个函数返回一个总是返回v的函数 |
这段代码利用循环创建了很多个闭包,当写类似这种代码的时候往往会犯一个错误:那就是试图将循环代码移入定义这个闭包的函数之内,看一下这段代码
1 | // 返回一个函数组成的数组,它们的返回值是0~9 |
上面这段代码创建了10个闭包,并将它们存储到一个数组中。这些必报都是在同一个函数调用中定义的,因此它们可以共享变量i
。当constfuncs()
返回时,变量i
的值是10,所有的闭包够共享这一个值,因此,数组中的函数的返回值都是同一个值,这不是我们想要的结果。关联到闭包的作用域链都是“活动的”,记住这一点非常重要。嵌套的函数不会将作用域内的私有成员复制一份,也不会对所绑定的变量生成静态快照(static snapshot
)。
书写闭包的时候还需注意一件事情,this
是JavaScript的关键字,而不是变量。正如之前讨论的,每个函数调用都包含一个this
值,如果闭包在外部函数里是无法访问this
的,除非外部函数将this
转存为一个变量
1 | var self = this; // 将this保存至一个变量中 |
绑定arguments
的问题与之类似,arguments
并不是一个关键字,但在调用每个函数时都会自动声明它,由于闭包具有自己所绑定的arguments
,因此闭包内无法直接访问外部函数的参数数组,除非外部函数将参数数组保存到另外一个变量中
1 | var outerArguments = arguments; // 保存起来以便嵌套的函数能使用它 |
八、函数属性、方法和构造函数
我们看到在JavaScript程序中,函数是值。对函数执行typeof
运算符会返回字符串“function
”,但是函数是JavaScript中特殊的对象。因为函数也是对象,它们也可以拥有属性和方法,就像普通的对象可以拥有属性和方法一样。甚至可以用Function()
构造函数来创建新的对象。
1. length属性
在函数体里,arguments.length
表示传入函数的实参的个数。而函数本身的length
属性则由不同含义。函数的length
属性是只读属性,它代表函数实参的数量,这里的参数指的是“形参”而非“实参”,也就是在函数定义时给出的实参个数,通常也是在函数调用时期望传入函数的实参个数。
下面的代码定义了一个名叫check()
的函数,从另外一个函数给它传入arguments
数组,它比较arguments.length
(实际传入的实参个数)和arguments.callee.length
(期望传入的实参个数)来判断所传入的实参个数是否正确。如果个数不正确,则抛出异常。check()
函数之后定义一个测试函数f()
,用来展示check()
的用法
1 | // 这个函数使用arguments.callee,因此它不能在严格模式下工作 |
2. prototype属性
每一个函数都包含一个prototype
属性,这个属性是指向一个对象的引用,这个对象称作“原型对象”(prototype object
)。每一个函数都包含不同的原型对象,当将函数用作构造函数的时候,新创建的对象会从原型对象上继承属性。
3. call()方法和apply()方法
我们可以将call()
和apply()
看作是某个对象的方法,通过调用方法的形式来间接调用函数。call()
和apply()
的第一个实参是要调用函数的母对象,它是调用上下文,在函数体内通过this
来获得对它的引用。要想以对象o
的方法来调用函数f()
,可以这样使用call()
和apply()
1 | f.call(o); |
每行代码和下面代码的功能类似(假设对象o
中预先不存在名为m
的属性)
1 | o.m = f; // 将f存储为o的临时方法 |
在ES5的严格模式中,call()
和apply()
的第一个实参都会变为this
的值,哪怕传入的实参是原始值甚至是null
或undefined
。在ES3和非严格模式中,传入的null
和undefined
都会被全局对象替代,而其他原始值则会被相应的包装对象(wrapper object
)所替代。
对于call()
来说,第一个调用上下文实参之后的所有实参就是要传入待调用函数的值。比如,以对象o
的方法的形式调用函数f()
,并传入两个参数,可以使用这样的代码
1 | f.call(o, 1, 2); |
apply()
方法和call()
类似,但传入实参的形式和call()
有所不同,它的实参都放入一个数组当中
1 | f.apply(o, [1, 2]); |
如果一个函数的实参可以是任意数量,给apply()
传入的参数数组可以是任意长度的。比如,为了找出数组中最大的数值元素,调用Math.max()
方法的时候可以给apply()
传入一个包含任意元素的数组
1 | var biggest = Math.max.apply(Math, array_of_numbers); |
需要注意的是,传入apply()
的参数数组可以是类数组对象也可以是真实数组。实际上,可以将当前函数的arguments
数组直接传入(另一个函数的)apply()
来调用另一个函数,参考如下代码
1 | // 将对象o中名为m()的方法替换为另一个方法 |
trace()
函数接收两个参数,一个对象和一个方法名,它将指定的方法替换为一个新方法,这个新方法是“包裹”原始方法的另一个泛函数。这种动态修改已有方法的做法有时称作“monkey-patching
”。
4. bind()方法
bind()
是在ES5中新增的方法,但在ES3中可以轻易模拟bind()
。从名字就可以看出,这个方法的主要作用就是将函数绑定至某个对象。当在函数f()
上调用bind()
方法并传入一个对象o
作为参数,这个方法将返回一个新的函数。(以函数调用的方式)调用新的函数将会把原始的函数f()
当作o
的方法来调用。传入新函数的任何实参都将传入原始函数,比如
1 | function f(y) { |
可以通过如下代码轻易地实现这种绑定
1 | // 返回一个函数,通过调用它来调用o中的方法f(),传递它所有的实参 |
ES5中的bind()
方法不仅仅是将函数绑定至一个对象,它还附带一些其他应用:除了第一个实参之外,传入bind()
的实参也会绑定至this
,这个附带的应用是一种常见的函数式编程技术,有时也被称为“柯里化”(currying
)。参照下面这个例子中的bind()
方法的实现
1 | var sum = function(x, y) { |
我们可以绑定this
的值并在ES3中实现这个附带的应用,下面代码就模拟实现标准的bind()
方法。注意我们将这个方法另存为Function.prototype.bind
,以便所有的函数对象都继承它
1 | if (!Function.prototype.bind) { |
我们注意到,bind()
方法返回的函数是一个闭包,在这个闭包的外部函数中声明了self
和boundArgs
变量,这两个变量在闭包里用到。尽管定义闭包的内部函数已经从外部函数中返回,而且调用这个闭包逻辑的时刻要在外部函数返回之后(在闭包中照样可以正确访问这两个变量)。
ES5定义的bind()
方法也有一些特性是上述ES3代码无法模拟的。首先,真正的bind()
方法返回的是一个函数对象,这个函数对象的length
属性是绑定函数的形参个数减去绑定实参的个数(length
的值不能小于0)。再者,ES5的bind()
方法可以顺带用作构造函数。如果bind()
返回的函数用作构造函数,将忽略传入bind()
的this
,原始函数就会以构造函数的形式调用,其实参也已经绑定。由bind()
方法所返回的函数并不包含prototype
属性(普通函数固有的prototype
属性是不能删除的),并且将这些绑定的函数用作构造函数时所创建的对象从原始的未绑定的构造函数中继承prototype
。同样,在使用instanceof
运算符时,绑定构造函数和未绑定构造函数并无两样。
5. toString()方法
和所有的JavaScript对象一样,函数也有toString()
方法,ES规范规定这个方法返回一个字符串,这个字符串和函数声明语句的语法相关。实际上,大多数的toString()
方法的实现都返回函数的完整源码。内置函数往往返回一个类似“[native code]
”的字符串作为函数体。
6. Function()构造函数
不管是通过函数定义语句还是函数直接量表达式,函数的定义都要使用function
关键字。但函数还是可以通过Function()
构造函数来定义
1 | var f = new Function('x', 'y', 'return x * y;'); |
这行代码创建一个新的函数,这个函数和通过下面代码定义的函数几乎等价
1 | var f = function(x, y) { return x * y; }; |
Function()
构造函数可以传入任意数量的字符串实参,最后一个实参所表示的文本就是函数体:它可以包含任意的JavaScript语句,每两条语句之间用分号分隔。传入构造函数的其他所有的实参字符串是指定函数的形参名字的字符串。如果定义的函数不包含任何参数,只需给构造函数简单地传入一个字符串——函数体——即可。
注意,Function()
构造函数并不需要通过传入实参以指定函数名。就像函数直接量一样,Function()
构造函数创建一个匿名函数。
关于Function()
构造函数有几点需要特别注意:
Function()
构造函数允许JavaScript在运行时动态地创建并编译函数。每次调用
Function()
构造函数都会解析函数体,并创建新的函数对象。如果是在一个循环或者多次调用的函数中执行这个构造函数,执行效率会受影响。相比之下,循环中的嵌套函数和函数定义表达式则不会每次执行时都重新编译。最后一点,也是关于
Function()
构造函数非常重要的一点,就是它所创建的函数并不是使用词法作用域,相反,函数体代码的编译总是会在顶层函数执行,正如下面代码所示
1 | var scope = 'global'; |
我们可以将Function()
构造函数认为是在全局作用域中执行的eval()
,eval()
可以在自己的私有作用域内定义新变量和函数,Function()
构造函数在实际编程过程中很少会用到。
7. 可调用的对象
我们在前面提到的“类数组对象”并不是真正的数组,但大部分场景下可以将其当作数组来对待。对于函数也存在类似的情况。“可调用对象”(callable object
)是一个对象,可以在函数调用表达式中调用这个对象。所有的函数都是可调用的,但并非所有的可调用对象都是函数。
截至目前,可调用对象在两个JavaScript实现中不能算作函数:
1) IE8及之前的版本实现了客户端方法(
window.alert()
和document.getElementById()
),使用了可调用的宿主对象,而不是内置函数对象。IE中的这些方法在其他浏览器中也都存在,但它们本质上不是Function
对象。IE9将它们实现为真正的函数,因此这类可调用的对象越来越罕见。2)
RegExp
对象,可以直接调用RegExp
对象,这比调用它的exec()
方法更快捷一些。在JavaScript中这是一个彻头彻尾的非标准特性,代码最好不要对可调用的RegExp
对象由太多依赖,这个特性在不久的将来可能会废弃并删除。
如果想检测一个对象是否是真正的函数对象(并且具有函数方法),可以检测它的class
属性
1 | function isFunction(x) { |
九、函数式编程
和Lisp、Haskell不同,JavaScript并非函数式编程语言,但在JavaScript中可以像操控对象一样操控函数,也就是说可以在JavaScript中应用函数式编程技术。ES5中的数组方法(诸如map()
和reduce()
)就可以非常适合用于函数式编程风格。
1. 使用函数处理数组
假设有一个数组,数组元素都是数字,我们想要计算这些元素的平均值和标准差。若使用非函数式编程风格的话,代码会是这样
1 | var data = [1, 1, 3, 5, 5]; // 待处理数组 |
可以使用数组方法map()
和reduce()
来实现同样的计算,这种实现极其简洁
1 | // 首先定义两个简单的函数 |
如果我们基于ES3来如何实现呢?因为ES3中并不包含这些数组方法,如果不存在内置方法的话我们可以自定义map()
和reduce()
函数
1 | // 对于每个数组元素调用f(),并返回一个结果数组 |
使用定义的map()
和reduce()
函数,计算平均值和标准差的代码看起来像这样
1 | var data = [1, 1, 3, 5, 5]; |
2. 高阶函数
所谓高阶函数(higher-order function
)就是操作函数的函数,它接收一个或多个函数作为参数,并返回一个新函数
1 | // 这个高阶函数返回一个新的函数,这个新函数将它的实参传入f() |
上面的not()
函数就是一个高阶函数,因为它接收一个函数作为参数,并返回一个新函数。另外一个例子,来看下面的mapper()
函数,它也是接收一个函数作为参数,并返回一个新函数,这个新函数将一个数组映射到另一个使用这个函数的数组上。这个函数使用了之前定义的map()
函数,但要首先理解这两个函数有哪些不同,这一点至关重要
1 | // 所返回的函数的参数应当是一个实参数组,并对每个数组元素执行函数f() |
这里是一个更常见的例子,它接收两个函数f()
和g()
,并返回一个新的函数用以计算f(g())
1 | // 返回一个新的可以计算f(g(...))的函数 |
3. 不完全函数
函数f()
的bind()
方法返回一个新函数,给新函数传入特定的上下文和一组指定的参数,然后调用函数f()
。我们说它把函数“绑定至”对象并传入一部分参数。bind()
方法只是将实参放在(完整实参列表的)左侧,也就是说bind()
的实参都是放在传入原始函数的实参列表开始的位置,但有时我们期望将传入bind()
的实参放在(完整实参列表的)右侧
1 | // 实现一个工具函数将类数组对象(或对象)转换为真正的数组 |
利用这种不完全函数的编程技巧,可以编写一些有意思的代码,利用已有的函数来定义新的函数
1 | var increment = partialLeft(sum, 1); |
当将不完全调用和其他高阶函数整合在一起的时候,事情就变得格外有趣了。比如,这里的例子定义了not()
函数,它用到了刚才提到的不完全调用
1 | var not = partialLeft(compose, function(x) { |
我们也可以使用不完全调用的组合来重新组织求平均数和标准差的代码,这种编码风格是非常纯粹的函数式编程
1 | var data = [1, 1, 3, 3, 5]; // 待处理的数据 |
4. 记忆
在函数式编程中,采用缓存的方式记录结果的技巧称为“记忆”(memorization
)。下面的代码展示了一个高阶函数,memorize()
接收一个函数作为实参,并返回带有记忆能力的函数。
需要注意的是,记忆只是一种编程技巧,本质上是牺牲算法的空间复杂度以换取更优的时间复杂度,在客户端JavaScript中代码的执行时间复杂度往往成为瓶颈,因此在大多数场景下,这种牺牲空间换取时间的做法以提升程序执行效率的做法是非常可取的。
1 | // 返回f()的带有记忆功能的版本 |
memorize()
函数创建一个新的对象,这个对象被当作缓存(的宿主)并赋值给一个局部变量,因此对于返回的函数来说它是私有的(在闭包中)。所返回的函数将它的实参数组转换成字符串,并将字符串用作缓存对象的属性名。如果在缓存中存在这个值,则直接返回它。
否则,就调用既定的函数对实参进行计算,将计算结果换存起来并返回,下面的代码展示了如何使用memorize()
1 | // 返回两个整数的最大公约数 |