函数可以说是任何一门编程语言的核心概念。要能熟练掌握JavaScript,对于函数及其相关概念的学习是非常重要的一步。本篇从函数的基本知识、执行环境与作用域、闭包、this关键字等方面简单介绍JavaScript中的函数的使用。
我们通常通过如下两种方式定义函数:
function myFunc() { console.log("this is myFunc"); return; }
var myFunc = function () { }
与其他面相对象语言不同的是,JavaScript的函数没有规定返回值,实际上,我们可以在函数中返回任何值,甚至没有返回(没有显式return语句的函数返回undefined)。
在函数中,我们经常接触arguments对象,故名思议,它表示函数的参数。实际上,arguments对象是一个类数组对象,JavaScript通过它保存函数的所有参数。这也是JavaScript函数不在乎传进来多少个参数,也不在乎传进来的参数是什么类型的原因。看如下一个例子:
function testArgs(arg1, arg2) { console.log(arguments.length); } testArgs(1, 2); // 2 testArgs(1); //1 testArgs(1, 2, 3); //3
有人会问,使用arguments对象和直接使用函数声明的参数有什么区别。其实,本质上没有什么区别,函数声明的参数在函数的内部作用域中只是一个局部变量而已,它保存调用函数时传递的参数的值。
注意JavaScript中函数的传参都是按值传递,引用类型的变量也是按值传递。
JavaScript中的函数没有重载,但是通过arguments对象,我们可以简单实现JavaScript函数的重载功能。
function doAdd() { if(arguments.length == 1) { alert(arguments[0] + 10); } else if (arguments.length == 2) { alert(arguments[0] + arguments[1]); } } doAdd(10); //20 doAdd(30, 20); //50
在调用doAdd函数时,如果只传递一个参数,则将该数加10后返回结果,如果传递了两个参数,则将这两个参数相加返回结果。
在上一篇中,我们介绍了常用的JavaScript的数据类型,还有一种类型没有说,就是Function类型。之所以说Function类型是一种数据类型,是因为在JavaScript中,函数也是对象,是一等公民。由于函数类型在堆内存中进行实例化,函数名只是指向这个函数对象位置的指针而已,不会与某个具体的函数绑定。以下是一种显式的调用Function构造函数的方式定义函数的例子:
var sum = new Function("num1", "num2", "return num1 + num2");
在这个例子中,sum就是新定义的函数的名字,它与一般的变量没有实质的区别,它只保存新定义的函数的地址而已。从这个角度来理解为什么JavaScript函数没有重载就好理解多了。
既然函数是对象,函数名只是一个普通变量而已,那么我们就可以像使用普通变量一样使用函数。我们可以将函数作为参数传递给另一个函数,也可以将函数作为另一个函数的返回值返回。甚至我们可以给函数添加属性,当然不推荐这样做。
doAdd.add = function (a, b) { return a + b; }
关于函数,还有一点需要特别注意的是,函数声明与函数表达式的区别。
什么是函数声明呢?开篇的两种定义函数的第一种就是函数声明的方式。第二种就是函数表达式的方式。
这两种方式都定义了一个函数,具体有什么区别呢?JavaScript解析器存在一个叫做函数声明提升(function declaration hoisting)的过程,在代码开始执行之前,解析器通过函数声明提升读取并将函数声明添加到执行环境中,对代码求值时,JavaScript引擎在第一遍会声明函数并将放到源代码树的顶部。所以即使声明函数的代码在调用它的代码后面,JavaScript引擎也能把函数声明提升到顶部。
执行环境(execution context)是JavaScript中最为重要的一个概念。执行环境定义了变量或函数有权访问的其他数据,决定了他们各自的行为。每个执行环境都有一个与之关联的变量对象(variable object),环境中定义的所有变量和函数都保存在这个对象中。虽然我们编写的代码无法访问这个对象,但解析器在处理数据时会在后台使用到它。每个函数都有自己的执行环境。当执行流进入一个函数时,函数的环境就会被推入一个环境栈中。而在函数执行之后,栈将其环境弹出,把控制权返回给之前的执行环境。
JavaScript中的函数时通过词法来划分作用域的,而不是动态的划分作用域的。这就意味着它们在定义它们的作用域里运行,而不是在执行它们的作用域里运行。当定义了一个函数,当前的作用域链就保存起来,并且成为函数的内部状态的一部分。
当调用一个函数时,JavaScript解析器首先将作用域设置为定义函数的时候起作用的那个作用域链,接下来,它在作用域链的前端添加一个新的对象,叫做激活对象(activation object)。激活对象用一个名为arguments的属性来初始化,这个属性引用了函数的Arguments对象。函数的命名参数添加到激活对象的后面,用var语句声明的任何变量也都定义在这个对象中。因此,局部变量,函数的命名参数和Arguments对象都在函数内的作用域中。作用域链的用途是保证执行环境有权访问的变量和函数的有序访问。作用域链中的下一个变量对象来自包含(外部)环境,而再下一个变量对象则来自下一个包含环境。这样,一直持续到全局执行环境,全局执行环境的变量对象始终是作用域链的最后一个变量对象。
标识符解析是沿着作用域链一级一级的搜索标识符的过程,搜索过程始终从作用域的前端开始,然后逐级的向后回溯。
注意,尽管当一个函数定义了的时候,作用域链就固定了,但作用域中定义的属性还没有固定。某种程度上说作用域链是活的,函数在调用的时候,可以访问任何当前绑定的作用域,并修改其中的属性。
下面的例子形象的展示的作用域链的工作机制:
function compare(value1, value2){ if (value1 < value2){ return -1; } else if (value1 > value2){ return 1; } else { return 0; } } var result = compare(5, 10);
从图中可以清晰看出,作用域链本质上是一个指向变量对象的指针列表,它只引用但不实际包含变量对象。
无论什么时候在函数中访问一个变量是,就会从作用域链中搜索具有相应名字的变量。一般来说,在函数执行完毕后,局部激活对象就会被销毁,内存中仅保留全局作用域。但是,在有闭包存在的情况下,情况又有所不同。
闭包是指有权访问另一个函数作用域中的变量的函数。广义上说,任何函数都是闭包,是将要执行的代码代码和执行这些代码的作用域构成的一个综合体。
上面的作用域的例子中的compare函数实际上就是一个闭包,在compare函数内部,可以访问到全局对象(window)的属性。
再看一个闭包的例子:
function createComparisonFunction(propertyName) { return function(object1, object2){ var value1 = object1[propertyName]; var value2 = object2[propertyName]; if (value1 < value2){ return -1; } else if (value1 > value2){ return 1; } else { return 0; } }; } //创建函数 var compareNames = createComparisonFunction("name"); //调用函数 var result = compareNames({ name: "Nicholas" }, { name: "Greg" }); //解除对匿名函数的引用(以便释放内存) compareNames = null;
闭包虽然可以通过作用域链的方式访问其他函数作用域中的变量,但是它只能取得包含函数中任何一个变量的最后一个值。
function createFunctions(){ var result = new Array(); for (var i=0; i < 10; i++){ result[i] = function(){ return i; }; } return result; }
上面函数执行的结果result保存了十个函数,但每个函数的返回值都是10(i的最后一个值)。要解决这个问题,我们可以通过如下的方法:
function createFunctions(){ var result = new Array(); for (var i=0; i < 10; i++){ result[i] = function(num){ return function(){ return num; }; }(i); } return result; }
原理相信大家也都明白了。
在使用闭包的时候,还有一点需要注意,就是当涉及到一些dom操作时,要小心使用闭包,操作不当将导致内存泄露。
function assignHandler(){ var element = document.getElementById("someElement"); element.onclick = function(){ console.log(element.id); }; }
以上代码创建了一个作为 element 元素事件处理程序的闭包,而这个闭包则又创建了一个循环引用。由于匿名函数保存了一个对 assignHandler()的活动对象的引用,因此就会导致无法减少 element 的引用数。只要匿名函数存在,element 的引用数至少也是 1 ,因此它所占用的内存就永远不会被回收。不过,这个问题可以通过稍微改写一下代码来解决,如下所示。
function assignHandler(){ var element = document.getElementById("someElement"); var id = element.id; element.onclick = function(){ alert(id); }; element = null; }
在上面的代码中,通过把 element.id 的一个副本保存在一个变量中,并且在闭包中引用该变量消除了循环引用。但仅仅做到这一步,还是不能解决内存泄漏的问题。必须要记住:闭包会引用包含函数的整个活动对象,而其中包含着 element。即使闭包不直接引用 element,包含函数的活动对象中也仍然会保存一个引用。因此,有必要把 element 变量设置为 null。这样就能够解除对 DOM 对象的引用,顺利地减少其引用数,确保正常回收其占用的内存。
在使用函数的过程中,我们经常碰到this这个对象,在没有搞明白this原理之前,我们经常对this究竟代表什么对象感到疑惑。
function testThis() { console.log(this.name); } var name = "window"; var obj = { name: "object", func: testThis }; testThis(); // => window obj.func(); // => object
在上面这个例子中,由于函数名testThis只是一个指针,所以testThis和obj.func实际上指向同一个函数对象。为什么执行结果不一样呢?
其实要理解this关键字,主要记住一句话就可以了,this永远指向函数的调用者。如果函数在全局执行环境中被调用,那么this指向全局对象(window)。因此,this的取值是在运行时决定的,这点与作用域链不同。
在理解this关键字时,不要与作用域链混淆到一起,this是一个关键字,它指向函数的调用者,不在函数的激活对象中。这一点可以与arguments对比来看。
arguments对象有一个属性,arguments.callee,指向被调用的函数本身。但是,arguments是函数的活动对象的一部分。
说到this关键字,就不得不说说apply()和call()了。这两个函数都是函数的内部属性,都用于改变函数的调用者,即改变this的指向。
function sum(num1, num2){ return num1 + num2; } function callSum1(num1, num2){ return sum.apply(this, arguments); // 传入arguments 对象 } function callSum2(num1, num2){ return sum.apply(this, [num1, num2]); // 传入数组 } alert(callSum1(10,10)); //20 alert(callSum2(10,10)); //20 function callSum(num1, num2){ return sum.call(this, num1, num2); } alert(callSum(10,10)); //20
apply()和call()函数的功能相同,唯一的区别是传递参数的方式不同。apply()第一个参数是this的值,第二个参数是参数数组。call()函数的第一个参数也是this的值,但是传递给函数的参数都要直接放在call()的参数列表中。
本篇主要介绍了JavaScript函数的各方面的基础知识,其中核心就是函数的执行环境与作用域链。在此基础上,介绍了闭包的概念、使用方法,以及常见的问题。最后简单说明了函数中this的使用,以及如何改变函数的this值。其实了解原理只是第一步,关键是在开发过程中不断的运用,时刻有这样的意识,用的多了,就理解吸收了。