深入理解JavaScript函数
对于 JavaScript 来说,最核心的部分莫过于函数和对象,这篇文章就通过对函数的展开介绍,提供了一份全面的关于 JavaScript 函数的指南。
函数定义
到目前为止,定义普通函数总共有四种方式(加一种特殊方式):
- 函数声明
- 函数表达式
- 箭头函数
Function
构造函数Generator
函数
函数声明
对于函数声明语句,需要注意如下几点:
- 函数声明语句必须定义函数名称,函数的名称为函数内部的一个局部变量,指代该函数对象本身;
- 函数声明语句不能出现在循环、判断,或者
try/cache/finally
以及with
语句中; - 函数声明语句定义的函数,会被提前到外部脚本或者外部函数作用域的顶端,因此可以在定义之前使用。
1 |
|
大部分函数中会包含一条 return
语句,该语句用来停止函数的执行,如果一个函数不包含 return
语句或者 return
语句没有一个与之相关的表达式,则函数默认返回 undefined
。
函数表达式
与函数声明语句相比,函数表达式具有以下特点:
- 函数表达式可以省略函数名称;
- 函数表达式可以出现在任何地方;
- 函数表达式定义的函数,由于变量提升作用的存在,该表达式变量被提前,但是函数本身并未提前,因此无法在定义前调用。
1 |
|
箭头函数
使用箭头函数需要注意:
- 箭头函数没有自己的
this
,arguments
,super
或new.target
关键字绑定; - 函数体内的
this
对象,就是定义时所在的对象,而不是使用时所在的对象,因此箭头函数不适合作为方法; - 无法当作构造函数使用,也就是说,不可以使用
new
命令,否则会抛出一个错误; - 不可以使用
yield
命令,因此箭头函数不能用作Generator
函数。
1 |
|
Function 构造函数
使用 Function
定义函数时可以传入任意数量的字符串实参,最后一个实参就是函数体:
1 |
|
使用 Function
构造函数定义函数时只需要注意一个问题:由 Function
构造函数定义的函数只继承全局作用域。
1 |
|
Function()
构造函数允许 JavaScript 在运行时动态地创建并编译函数,每次调用都会解析函数体并创建新的函数对象,而使用函数声明语句和表达式只会解析一次,很明显使用构造函数的效率比较低,因此,通常应尽可能避免使用 Function
构造函数。
函数调用
在 JavaScript 中,函数调用总共有四种方式:
- 作为函数
- 作为方法
- 作为构造函数
- 通过
call()
和apply()
方法间接调用
其中作为函数调用是最基本的形式,只需注意以这种方式调用函数通常不使用 this
关键字,但是此时 this
可以用来判断当前是否为严格模式:
1 |
|
方法调用
方法指的是保存在对象属性中的 JavaScript 函数,使用方法调用有两种形式,与访问对象的属性访问方法一致:
- 使用“.”:
obj.f(argu)
; - 使用“[]”:
obj["f"](argu)
。
方法调用的参数和返回值处理和函数调用一致,但是方法调用有一个重要的特点:调用上下文(context),即函数体可以使用 this
引用该对象。
构造函数调用
如果函数和方法调用之前带有关键字 new
,它便构成构造函数调用。构造函数调用和函数调用以及方法调用在实参处理、调用上下文和返回值方便都有不同。
如果构造函数没有形参,则可以省略实参列表。构造函数创建的是一个新的对象,这个对象继承自构造函数的 prototype
属性,因此此时的调用上下文是生成的新对象而非构造函数。如果在构造函数中使用 return
语句并返回一个对象,那么这个新对象将作为调用结果,这一特性可用来实现私有属性、方法。
this
任何函数只要作为方法调用都会传入一个隐式实参——方法调用的母体对象。可以使用 this
关键字访问该母体对象的任意属性。this
关键字没有作用域的限制,嵌套的函数不会从调用它的函数中继承 this
,因此如果需要在嵌套的函数中使用 this
,需要先使用一个变量来保存外部的 this
。
需要注意的一点是,无论是否在严格模式下,在全局执行环境中(在任何函数体外部)this
都指向全局对象。在严格模式下,如果进入执行环境时没有设置 this
的值,this
会保持为 undefined
。
可以使用 call()
和 apply()
显式指定函数的调用上下文,即 this
的值。
1 |
|
使用 bind()
方法绑定函数的调用上下文,绑定后无论如何调用该函数,不会改变其调用上下文。
1 |
|
闭包
一般来说,只有在函数运行时,子函数才能访问父函数内定义的局部变量,但是利用闭包的特性,调用父函数中返回的子函数也能访问到父函数所有局部变量。闭包是由函数以及创建该函数的词法环境(作用域链)组合而成,这个环境包含了这个闭包创建时所能访问的所有局部变量。闭包常被用来实现私有变量和方法。
要理解闭包,首先需要理解嵌套函数的作用域规则。正常情况下,局部变量定义在 CPU 的栈中,因此函数返回后这些局部变量就不存在了。但是在 JavaScript 中,作用域链是以一个对象列表形式存在,而并非直接添加到栈中,因此只要有引用到这个对象的部分存在,该作用域就一直存在,否则就会被当作垃圾回收掉。
1 |
|
因此,只需要把私有变量方法放到父函数中,公共变量和方法放到嵌套函数中,即可实现变量方法的私有化:
1 |
|
需要注意的是,记住关联到闭包的作用域链都是“活动的”,嵌套的函数不会将作用域内的私有成员复制一份,如下面的例子所示:
1 |
|
函数参数
在 JavaScript 中,参数的传入是非常灵活的。当调用函数的时候传入的实参比函数声明时指定的形参个数少,则剩下的形参都会被设置为 undefined。
arguments 对象
在函数体内,标识符 arguments
指向实参对象的引用,是一个类数组对象,可以直接使用访问数组元素的方法访问对应位置的实参,也可以使用 length 属性来获取参数的个数。利用这个特性可以实现让函数操作任意数量的参数。
1 |
|
尽管 arguments
并非真正的数组,但是可以通过一定的方法把它转为真正的数组:
1 |
|
实参对象还有 callee
和 caller
两个属性,它们类似两个指针,callee
指向当前正在执行的函数,caller
指向当前正在执行的函数的函数,即调用栈。
callee
属性在递归中的应用:
1 |
|
Rest 参数
ES6 引入 rest
参数(形式为 ...变量名
),用于获取函数的多余参数,这样就不需要使用 arguments
对象了。rest
参数搭配的变量是一个数组,该变量将多余的参数放入数组中。需要注意的是,rest
参数只能作为函数的最后一个参数,否则会报错。同时,函数的 length
属性,不包括 rest
参数。
1 |
|
参数默认值
ES6 之前,想要为函数指定默认值只能采用如下的办法:
1 |
|
ES6 允许为函数的参数设置默认值,即直接写在参数定义的后面。
1 |
|
无论是上面说到的 rest
参数,还是参数默认值,都可以使用 ES6 的解构赋值。
1 |
|
函数的属性和方法
length 属性
函数的 length
属性不同于 arguments.length
,后者表示传入函数的实参个数,而前者表示函数定义时所需的实参个数,可以通过这个特性来判断传入函数的参数个数是否满足要求:
1 |
|
call、apply 和 bind
call()
和 apply()
的功能是一样的,可以将函数作为某个对象的方法调用,以此来改变调用上下文(context),即 this
的指向。它们的第一个参数是用来绑定上下文的对象,之后的参数是传入函数的实参,二者唯一的不同便是传入实参的形式,call()
直接传入实参,类似 call(o, 1, 2, 3)
,而 apply()
传入一个实参的数组或者类数组对象(arguments),比如 apply(o, [1, 2, 3])
。
1 |
|
apply()
对于任意参数的函数或者将一个函数的参数传给另一个函数的场景会非常好用:
1 |
|
bind()
是 ECMAScript5 中新增的方法,用于将函数绑定至某个对象,返回值是一个新的函数。
1 |
|
在 ECMAScript3 中可以通过已有的方法来模拟 bind()
方法:
1 |
|
其他
-
prototype
属性:指向原型对象,当函数作为构造函数时,新创建的对象会从原型对象继承属性; -
toString()
方法:以字符串的形式返回函数的完整源码。
参考资料
[1] 《JavaScript权威指南》第六版. 机械工业出版社,2015.
[2] 《JavaScript Guide》MDN web docs.
[3] 《ECMAScript 6 入门教程》阮一峰.