04 - JavaScript函数与Closure闭包

584 阅读18分钟

函数是进行模块化程序设计的基础,可以提高程序的可读性与易维护性。

  • 定义函数
  • 函数的调用
  • 常用内置函数
  • 特殊函数
  • 闭包closure
  • 回调函数的设计模式

函数是由事件驱动的或者当它被调用时执行的可重复使用的代码块,是实现一个特殊功能和作用的程序接口。

function functionName(parameters) {
    ...
}

保证函数功能的单一性(“单一功能原则Single Responsibility Principle”)很重要。函数过长会降低脚本代码的可读性,增加代码的复杂度及难度。

1. 定义函数

定义方式:

  • 声明
  • 表达式
  • 函数构造函数

1.1 声明

function functionName(parameters) {
    ...
}

ES5中,使用函数前,不需要先定义函数,因为es5中有Hoisting机制(函数声明提升function declaration hoisting),将declaration都提升至程序开头,故而myFunction(3, 4)也可以正常运行并得出结果:


regular function的调用,函数内部的this对象指向全局对象(browser中就是window对象):


1.2 表达式

var x = function(a, b) { 
    return a * b;
}


由此可见,变量x是个函数对象的实例。

函数表达式与其它表达式一样,在使用前必须先赋值,没有hoisting机制:


表达式定义的function调用,函数内部的this对象指向全局对象(browser中就是window对象):


1.3 函数构造器

var myFunction = new Function("a", "b", "return a * b;");


可见,函数构造器定义的function调用,函数内部的this对象指向全局对象(browser中就是window对象)


创建的是两个不同的Function对象实例。

2. 函数的调用

  • 作为一个全局函数被调用
  • 作为某个对象的方法被调用
  • 作为函数对象的方法被调用
  • 当作构造函数被用new调用

2.1 作为一个全局函数被调用


函数作为全局函数被调用是JavaScript常用方法,但是不是良好的编程习惯,因为全局变量、全局方法或函数容易造成命名冲突的bug。

2.2 作为某个对象的方法被调用

调用过程如下:

1. 程序扫描执行到对象的定义这行,在内存中为对象开辟存储空间


2. 然后扫描执行行16


3. 行16调用对象方法,于是去执行对象方法。可见,对象方法中的this指向对象本身,因为函数属于对象,对象是函数的所有者,当加入this对象后,this的值为对象本身。


2.3  作为函数对象的方法被调用

在JavaScript中,函数是对象,JavaScript函数有它的属性和方法,例如call()、apply()、bind()就是函数对象本身预定义的函数方法。三个方法可用于调用函数,三个方法的第一个参数必须是对象本身


有趣的现象,我们来探讨一下为什么会是三个true?


call()的参数第一个是一个对象,这个对象将代替Function类里原本的this对象,我们传入的是this,记住,这个this

myFunction
函数里指的是未来将要实例化这个函数的对象,当声明了myObject的时候,这个this指的就是myObject。除了第一个参数,后面所有的参数都是传给父函数本身使用的参数。

也就是说myFunction.call和myFunction.apply以及myFunction.bind都是实例化出来的新对象。call、apply和bind都是照着myObject的模版,根据参数调用myFunction实例化出来的新对象。

至于为什么window.myObject_call === window.myObject_apply是因为这里实例化出来的是常量数字180,存在常量池里。JavaScript如果找到已经存在的常量,JavaScript只会引用它,而不会新创建一个出来。所以它们指向同一块内存。


当我们把实例化出来的对象类型改为Object,就可以明确看出call、apply和bind是实例化新的对象,并且不是在原来myObject上直接改。


以上这个例子,就能说明一切。


此时,myObject只是声明里一个变量,没有在内存中开辟一块存储区域,所以在myfunction里面this对象指向的是全局对象。


此时,myObject是一个显示声明也赋值的Object对象,在内存中开辟里一块存储区域来保存sum值,所以在调用call、apply和bind函数,myFunction中上下文变成myObject的上下文,故而myFunction内this对象指向myObject。

2.4 当作构造函数被用new调用

如果函数调用前使用了new关键字,则是调用了构造函数。构造函数的调用会创建一个新的对象,新对象会继承构造函数的属性和方法

构造函数中this没有任何的值,this的值是在函数调用是实例化对象(new object)时创建的,指向新实例化的对象。




不用new调用构造函数时,regular function中this指向window对象。



3. 常用内置函数

  • eval()
  • isFinite()
  • isNaN()
  • parseInt()
  • parseFloat()
  • escape()
  • unescape()

4. JavaScript特殊函数

  • 嵌套函数
  • 递归函数
  • 内嵌函数

4.1 嵌套函数

嵌套函数是在函数内部定义一个函数,这样定义的优点在于可以使用内部函数轻松获取外部函数的参数以及函数的全局变量

function outerFunctionName(arg1, arg2){
    function innerFunctionName(){
      ...
    }
}


由此可见,变量inner和方法innerAdd()属于函数add作用域。



外界无法访问函数add()作用域里的变量inner和方法innerAdd(),那是属于function(local) scope。


函数add()没有将内部的innerAdd()的引用抛出来,所以外界没有调用到它。


必须要抛出引用才能调用它。


或者在函数add()里用IIFE立即执行函数调用innerAdd(),一旦调用函数add,就能执行innerAdd函数。

ES5中如此例,如果调用时没有显示赋予参数,那么默认为undefined,undefined+undefined=NaN。

嵌套函数这里需要考虑闭包问题:



嵌套函数在JavaScript中功能非常强大,但是使用嵌套函数会使程序可读性降低。

4.2 递归函数

递归函数需要两个必要条件:

  1. 一个结束递归的条件
  2. 一个递归调用语句(函数内部调用其自身)

function recursionFunctionName(arg1){
    recursionFunctionName(arg2);
}

递归过程如下:


实际计算顺序如下:


4.3 内嵌函数

由嵌套函数的例子可以,看出所有函数都能访问全局变量,且都能访问它们上一层的作用域。


内嵌函数innerAdd()可以访问父函数add内部的变量inner。

5. 闭包closure

闭包最大的用处有2个:

  1. 前面提到的嵌套函数inner function可以读取outer function内部的变量
  2. 让这些变量的值始终保持在内存中

什么是闭包?

闭包是一个拥有许多变量和绑定了这些变量的环境的表达式(通常是一个函数),因而这些变量也是该表达式的一部分。

闭包的原理

JavaScript允许使用内部函数,即函数定义和函数表达式位于另一个函数的函数体内。而且,这些内部函数可以访问它们所在的外部函数中声明的所有局部变量、参数和声明的其他内部函数。当其中一个这样的内部函数在包含它们的外部函数之外被调用时,就会形成闭包。这就是闭包的原理。



如上面这个例子,变量inner是定义在函数add()内的局部变量。若变量inner在函数add()调用完成以后不能再被访问,则在函数执行完成以后inner将被内存释放,因为它的作用域是函数add()局部。

但是由于函数add()返回了一个内部函数innerAdd()的引用给外界,且这个返回的函数引用了变量inner,导致变量inner可能会在函数add()执行完成以后还会被引用,所以变量inner所占用的资源不会被回收。这样函数add()就形成了一个闭包,变量inner的值就始终保持在内存中。



由上可见,如果不把内部函数的引用返回暴露给外界,则内部函数引用的局部变量inner在函数add()执行结束后,没有再被外界引用的可能,即会被内存释放、垃圾回收,函数add()就不会形成闭包来保持变量inner的值。

作用域链帮助彻底理解闭包

要彻底搞清楚其中的细节,必须从理解函数被调用的时候都会发生什么入手。有关如何创建作用域链以及作用域链有什么作用的细节,对彻底理解闭包至关重要。

函数被调用时,作用域链如何产生?

当某个函数被调用时,会创建一个执行环境(execution context)及相应的作用域链。然后,arguments和其它命名参数的值来初始化函数的活动对象(activation object)。但在作用域链中,outer function的活动对象始终处于第二位,outer function的outer function的活动对象处于第三位,......直至作为作用域链终点的全局执行环境

在函数执行过程中,为读取和写入变量的值,就需要在作用域链中查找变量。

如下面这个例子,先定义了add函数,然后又在全局作用域中调用了它。当调用add()时,会创建一个包含arguments、number1和number2的活动对象。全局Global执行环境的变量对象(包含变量y和函数add)在add()执行环境的作用域链中则处在第二位,add()的local执行环境的变量对象在第一位



后台的每一个执行环境都有一个表示变量的对象----变量对象

全局Global环境的变量对象始终存在,而像add()函数这样的局部local环境的变量对象,则只在函数执行过程中存在

所以上面这个例子,没有执行内部innerAdd()函数,所以只有全局执行环境Global的变量对象和函数add()被执行时产生的局部local环境的变量对象。

在创建add()函数时,会创建一个预先包含全局变量对象的作用域链,这个作用域链被保存在内部的[[Scope]]属性中。当调用add()函数时,会为函数创建一个执行环境,然后通过复制函数的[[Scope]]属性中的对象(即全局变量对象)构建起执行环境的作用域链。

此后,又有一个活动对象被创建并被推入执行环境作用域链的前端,即函数add()的局部local执行环境活动对象。所以,对于上面这个例子中的函数add()被执行时,其执行环境作用域链中包含两个变量对象:

  1. 本地活动对象(第一位)
  2. 全局变量对象(第二位)

显然,作用域链本质上是一个指向变量对象的指针列表,它只引用但不实际包含变量对象。无论什么时候,在函数中访问一个变量时,就会从作用域链中搜索具有相应名字的变量。一般来讲,当函数执行完毕后,局部活动对象就会被销毁,内存中仅保存全局作用域(全局执行环境的变量对象)

但是,闭包的情况又有所不同。

在另一个函数内部定义的函数会将outer function的活动对象添加到它的作用域链中。因此,在函数add()内定义的innerAdd()函数的作用域链中,实际上将会包含outer function的活动对象。

而下面这个例子,内部innerAdd()函数从outer function add()中被返回后(此处没有被调用),它的作用域链被初始化为包含outer function的活动对象和全局变量对象。这样,inner function就可以访问在outer function中定义的所有变量。下图中,innerAdd()函数[[Scope]]属性中可见,innerAdd()作用域链中有outer function 的Closure和Global变量对象。local变量对象是函数add()被执行时产生的。Global变量对象始终存在。

add(30, 30)只是调用了add()函数,但由于add()函数返回innerAdd()函数,所以innerAdd()函数在add()函数执行结束后,还有可能被调用,并且由于innerAdd()函数引用了add()函数内部变量,故而innerAdd()函数[[Scope]]里得有outer function add()的Closure来保持add()的内部变量,使其不被销毁。



更为重要的是,outer function在执行完毕后,其活动对象也不会被销毁,因为inner function的作用域链仍然在引用这个活动对象。

换句话说,当outer function add()函数返回后,其执行环境的作用域链会被销毁,但它的活动对象仍然会留在内存中,直到inner function被销毁后,outer function的活动对象才会被销毁。


程序运行到这一行,可见,目前作用域链只有window对象的Global全局活动对象。由Call Stack可见,add()函数还没有被执行。



可见,调用add()函数开始,add()被加入到Call Stack里。上面两张图展示add()函数被执行时,被创建出来的作用域链。


上图可见,函数add()执行完,Call Stack里弹出add,作用域链被只有一直存在的全局Global活动对象,可见

当函数执行完毕后,局部活动对象就会被销毁,内存中仅保存全局作用域(全局执行环境的变量对象)



接下来一步,由于add(30, 30)后的()调用innerAdd()函数,Call Stack里被加入innerAdd,当前Scope里可见到innerAdd的作用域链,可见即使函数add()执行完毕后,其局部活动对象就已经被摧毁了,由于闭包的原因,函数add()的内部变量number1和number2没有被销毁,innerAdd()仍然可以访问到。



再来一个例子:




一旦进入innerAdd()函数(Call Stack里innerAdd被加入,显示当前执行innerAdd,Scope里显示的是innerAdd的作用域链),可见函数innerAdd()有局部变量innerAddinner、同时有可以访问outer function add()局部变量number1和number2的闭包Closure活动对象,以及一直存在的全局Global活动对象。




当内部IIFE函数执行完毕,Call Stack里函数innerAdd()弹出,当前显示为函数add()的作用域链,可见其只有局部local活动对象以及一直存在的全局Global活动对象。


函数add()执行完毕,弹出Call Stack,局部活动对象被销毁,只剩全局Global活动对象。


由于闭包会携带outer(parent) function的作用域,因此会比其它函数占用更多的内存。过度使用闭包可能会导致内存占用过多,所以建议只在绝对必要时再考虑使用闭包。

虽然像Google V8等优化后的JavaScript引擎会尝试回收被闭包占用的内存,但还是要慎重使用闭包。

闭包与变量

作用域链的这种配置机制引出了一个值得注意的副作用,即闭包只能取得包含函数中任何变量的最后一个值。别忘了闭包所保存的是整个变量对象,而不是某个特殊的变量。


以上这个例子,开始执行createFunctions()函数,由下图可见,函数createFunctions()被压入Call Stack。


由上图可以看出,虽然var i是定义在for-loop中的,但是由于其只有全局作用域和函数局部作用域,所以变量var i属于函数createFunctions()。并且var定义的变量有hoisting提升机制,所以变量var i的声明被提升到了函数createFunctions()开头。于是,我们看到函数一开始运行,Scope里就有local variable i,只是没有被赋值。


运行完毕for-loop第一行,变量i被初始化为0。



下图第二轮for-loop开始,i被赋值为1,注意到上图第一轮创建出的array里的function里的i由0变成了当前i的值1。


第二轮for-loop结束后,结果如下图,


第三轮开始,array里之前被创建出来的function们里的i的值又都变为了当前i的值2。


为什么?

仔细观察可知,array里被创建出来的function里的[[Scope]]里都有闭包Closure [CreateFunctions] {i : 某个值}, 这个代表函数createFunctions()产生的闭包活动对象,在内存中是同一块引用,所以array里被创建出的function们都引用这保存变量i的同一个变量对象

解决办法有2个:

  1. 创建另一个匿名函数强制让闭包的行为符合预期
  2. for-loop里的变量i改用let定义,使其具有for-loop block scope
1. 创建另一个匿名函数强制让闭包的行为符合预期





仔细观察可得知,array里创建的function们,每个函数就会返回各自不同的索引值了。因为,这个例子没有直接把createFunctions()函数的闭包赋值给数组,而是定义了一个匿名函数,并将立即执行IIFE该匿名函数的结果赋值给数组。这里的匿名函数有一个参数j。在调用每个匿名函数时,传入变量i。由于函数参数是按值传递的,所以就会将变量i的当前值复制给参数j。而在这个匿名函数内部,又创建并返回了一个访问j的闭包Closure {j : 某个值}。这样一来,数组中的每一个函数都有自己j变量的一个副本,因此就可以返回各自不同的数值了。



如果给匿名函数个名字inner,可见Closure (inner) {j : 某个值}

2. for-loop里的变量i改用let定义,使其具有for-loop block scope


改为let定义后,变量i就没有hoisting提升机制了。


变量i是createFunctions()函数的变量,其具有block scope。


由于具有block scope的变量在block结束即销毁,但是此时由于创建出的每个函数引用这个变量,所以每次创建并返回了一个Block {i : 某个值}。下一轮for-loop开始时,又一个新的block scope variable i被创建出来了,跟上一轮的闭包Block {i : 某个值}里的i不是同一个。

关于this对象

在闭包中使用this对象可能导致一些问题。

-> this对象是在运行时基于函数的执行环境绑定的:

  • 在全局函数中,this等于window
  • 在函数被作为某个对象的方法调用时,this等于这个对象
  • 在构造函数中,用new调用这个函数时,this等于这个被创建的对象
  • 在全局函数被call、apply或者bind函数调用时,this等于第一个参数所指的对象
  • 匿名函数的执行环境具有全局性,因此其this对象通常指向window


匿名函数的执行环境具有全局性,因此其this对象通常指向window。但有时候由于编写闭包的方式不同,这一点可能不会那么明显。

下图这个例子,开始程序:


对象object的方法getNameFunc被调用,于是函数objFunc()被压入Call Stack。可见,当前this对象等于对象object,并且this对象拥有object对象的属性和方法。


对象object的方法getNameFunc返回一个匿名函数。


随后object.getNameFunc()后的()调用这个匿名函数。该匿名函数被压入Call Stack。可见匿名函数内部this对象等于window对象,因此this对象拥有全局变量name: "The window"


为什么匿名函数没有取得其包含作用域(或外部作用域)的this对象呢?

每个函数在被调用时都会自动取得2个特殊变量:thisarguments. 内部函数在搜索这两个变量时,只会搜索到其活动对象为止,而这个被返回的匿名函数是个全局函数,如下图所示:

其活动对象[[Scope]]为Global:



因此永远不可能直接访问外部函数中的这两个变量。

不过,把外部作用域中的this对象保存在一个闭包能够访问到的变量里就可以让闭包访问该对象了,如下所示:




在几种特殊情况下,this的值可能会意外地改变,如下面几个例子:


以上,作为对象的方法调用,故而this等于对象。



(window.object.getName = window.object.getNameFunc)()先执行了一条赋值语句,然后再调用赋值后的结果。因为这个赋值表达式的值是函数本身,所以this的值不能得到维持,结果就返回了"The Window"。

内存泄漏(有待整理。。。)





引用书籍:
  • 《JavaScript高级程序设计(第3版)》- 中国工信出版社
  • 《JavaScript从入门到项目实践(超值版)》- 清华大学出版社