前端--关于javascript函数

yunjian 9年前

来自: http://www.cnblogs.com/jinjilin/p/5164233.html

终于可以说说函数了,函数是javascript设计最出色的地方,可以说它是所有概念中最重要的一个,因为围绕函数而阐述的周边概念涵盖了javascript的方方面面,所以理解了函数可以说对javascript有了大半部分的理解,在此我只能怀着谨慎与谦逊的态度小心翼翼的总结出自己所理解的函数。

  • 函数是什么

要理解javascript函数首先得弄明白它是什么,这个问题并不是很容易就让人接受的,尤其是对有过C语言或者java等编程经验的人来说,因为会受到函数声明写法的诱惑,认知更倾向于把它理解为一种编程方式,一种可以重复调用执行的具名语句块。而在javascript中,函数是一种数据类型不是一种编程方式,它是对象类型的实例,它就是对象,它就是一种值,它和{x:3}这种对象直接量的地位是一样的。这种对象的定义有它自己特殊的写法,解释器在进行词法分析的时候会对此进行判断,它的可重复执行的语句块只是这个对象的一个隐藏属性,而要调用这个隐藏属性的执行只需要用到该对象加()运算符即可。

  • 函数定义

函数定义有三中方法:定义表达式、函数声明语句、Function构造函数。

定义表达式从字面上就可以看出它是一个表达式,塔仅仅返回一个函数对象值,主要用在给变量赋值等需要用到值的地方,它的写法和函数声明语句是差不多的,都是function关键字+标识符+()+{},只是这个标识符可以省略,省略标识符的函数也叫做匿名函数,不省略的话就只是在函数体内可以访问的一个指向函数本身的局部变量,一般情况下是省略的。

而函数声明语句就相当于一条赋值语句,可以看作是把变量声明与函数对象值的赋值合并在一起的写法,所以function后面的标识符是不能省略的,并且这个标识符在函数体外面可以访问到。但函数声明语句毕竟是有别于一般的函数赋值语句的,最重要的一条区别就是变量声明提前。函数声明语句在程序执行到函数声明之前时是可以调用该函数的,例如fun1();function fun1(){};这样在声明fun1之前调用fun1是不会报错的。函数赋值语句则不行,函数赋值语句要在给变量赋值之后才能调用该变量,否则会报错,例如fun2(); var fun2=function (){};这样提前调用就会报错。除此之外声明语句和定义表达式还有一个不同就是他俩可以出现的地方:函数声明语句是不能够出现在for循环、if/else、try/catch等语句中的,而定义表达式则可以出现在程序的任何地方。

第三种Function构造函数定义函数的方法在实际编程中是不经常使用的,它允许javascript在运行时动态地创建并编译函数,并且它动态创建的函数中this是指向全局对象的。

  • 函数调用

所谓函数调用就是执行函数对象中隐藏属性指向的代码块,这需要用到一个运算符--(),语法规则就是函数对象+()。

虽然函数调用的语法是唯一的即函数对象+(),但函数调用的模式或者调用场景却可以有些不同,在javascript中一共有四种调用模式:函数调用、方法调用、构造函数调用、间接调用。这四种调用模式的差别就是函数体中初始化this的取值不同。(this不是变量也不是属性名,它是一个关键字,不能给它赋值)

函数调用很常见也很简单,就是函数对象+()调用,函数体中的this关键字取值初始化为全局对象,要注意的是这里也包括嵌套函数的调用,不要想当然的认为嵌套函数的函数体中this的值会初始化为父函数对象。

方法调用比函数调用多了一步对象属性的取值过程,因为只有当函数被保存为一个对象的属性时才叫做方法,函数体中this关键字取值初始化为函数属性所属的对象。如果一个对象有多个方法,并且每个方法的方法体最后又都返回this,这样就可以启用级联,一个方法可以调用另一个方法形成方法链。

构造函数调用就是在函数调用前加一个new关键字,构造函数总是会返回一个对象并且函数体中this关键字取值初始化为这个返回的对象。这里要注意的是如果是一个方法构造函数,例如 new o.fun(),那么函数体里的this值仍然会被初始化为新建的对象而不是调用对象o。

借用调用也叫做间接调用,这需要用到函数对象从Function.prototype继承的方法:call、apply、bind,它们可以根据需要指定方法体内this的值。其中apply和call是立即执行原始方法的,只是他俩传入的参数形式有所不同,例如f.call(o,1,2,3),f.apply(o,[1,2,3]),f中this的值为o传入的参数是1,2,3;注意apply传参数的时候不仅仅可以是真实的数组也可以是类数组,比如arguments或者getElementsByTagName。bind方法并不是立即执行的而是返回一个代理函数,例如var a = f.bind(o),这个代理函数a可以在需要的时候再被调用,在调用的时候所有传递给a的参数都将传入原始函数调用执行(这就是为什么它是一个代理函数,它甚至连prototype属性都没有)其中的this初始化为先前绑定的对象o。

  • 函数参数

在定义函数的时候可以在括号里写入形式参数,在函数调用时传入的实参会初始化相应的形参。这种提前定义的形参其实没什么卵用,如果非要说有用的话那就是告诉调用者这个函数应该至少传入几个参数,因为在javascript中函数调用的时候是不会检查函数签名的,不论如何传递实参函数体都会执行,而且不管多少个参数都总是会传入到函数体内。其中的一个原因就是每次调用时都会为函数体传入两个隐形参数:this和arguments,this的取值前面已经说过了,arguments是一个类数组对象,它的属性包括了调用者传入的所有参数。要访问传入的实参值只需要通过arguments加下标就可以,例如arguments[2],这里要注意的是如果定义了形参,那么形参和相应arguments的属性的关系是别名的关系,就好像引用同一个对象一样,如果改变了形参值,相应的arguments值也会改变,即便这个值是原始类型。要查看调用者传入了几个参数只需要调用arguments.length属性,这个属性表示了实参个数,arguments除了具有length属性,还有callee和caller属性,callee属性指向当前运行的函数对象,caller指向调用当前正在执行的函数的函数,只是caller还并不是官方标准。

  • 函数属性

函数作为一个对象它也是有默认属性的:length和prototype。函数的length属性表示当初定义式写的形参个数,prototype是原型对象,用作构造函数时构造的对象继承这个prototype属性值,通过为prototype添加新方法可以拓展内置类型或者原始类型的功能。同时也可以为函数自定义属性,比如随便写一个function a(){a.coun++;},a.count=0,这里就比较有意思了,因为每调用一次a(),a.count就加1,在每次调用中共享的都是同一个a.count,为什么比较有意思呢,因为函数每次调用的时候执行的都是该函数的副本,感觉不应该共享a.count,既然事实上是共享的了,那就说明函数对象a只有一个是不会被复制的,副本指的是代码块的副本,并且在每一个代码块副本的执行过程中都可以访问到那同一个函数对象的属性,这是理解作用域和闭包的一个点。

  • 函数作用域

在一段程序中某个地方用到的变量名并不总是如你所愿可以取到值或者取到你所预测的值,因为在程序中一个变量名字的可用范围和生命周期是有限的,这个有效的范围与生命周期就是作用域。根据这种特点可以把变量划分为全局变量和局部变量,全局变量就是变量名在从程序开始到结束这整个代码范围内都是有效的,局部变量只是变量名在整个程序代码的某一段范围内是有效的。其他的程序设计语言在一般情况下一个{}括起来的代码块就可以划出一个局部作用域范围,在{}中声明的任何变量都只在{}里有效,在{}外面生命周期就停止了,这有个学名叫块级作用域。然而在javascript中并不支持块级作用域,也就是说javascript不支持这种划分局部作用域的模式,javascript有自己特有的划分局部作用域的模式,可以说javascript只有这一种划分作用域的模式,就算全局变量也是这种划分模式的应用。应该知道的是在javascript运行任何代码之前也就是在javascript解释器启动运行的时候(在前端中就是页面加载的时候),它会创建一个叫做全局对象的对象,这个对象就相当于javascript的类库,里面有编程时常用的内置方法和属性,比如Array、Date、parseInt、Math等,同时自己写的所有源代码也是这个全局对象的一个隐藏属性,就好像函数对象的函数体语句块是函数的一个隐藏属性一样,所以可以把这个全局对象看作是一个函数对象,整个javascript源代码的执行就是调用这个全局函数对象,在这个全局函数对象的函数体中声明的变量在源代码中任何地方都是可见的,是全局变量。同样如果要创建一个局部变量,只需要创建一个函数对象,在这个函数对象的函数体中声明的变量在函数体执行时只在该函数体内部可见,函数执行结束后在函数体外面访问不到声明的变量,所以可以说函数体语句块是javascript划分作用域的唯一模式---即函数作用域模式。这种函数作用域比前面的块级作用域要复杂,块级作用域的实现是依靠对内存栈的操作,而函数作用域则是函数体内通过遍历查找函数对象的某个隐藏属性来实现的。这里要解释的是在函数对象被定义的时候javascript引擎还为其添加了一些仅供解释器存取的内部属性,姑且就叫它[scope]属性吧,这个隐藏属性的值是一个对象,这个对象类似于一个数组,下标以0开始,每个“数组“元素都是一个对象,比方说表达式scope[2]返回一个对象,scope[n]返回的对象按键值对的形式存储函数体可以用到的部分变量的名字和值,因此可以说这个scope对象是一个按顺序排列的一系列对象的集合,集合中的每个子对象存储了可用的变量名和变量值,所以这个集合就是常说的作用域链。当在函数体内遇到变量名字的时候,解释器就会按顺序遍历这个集合也就是scope对象(在函数属性中提到过函数体副本可以访问函数对象的属性)来查找同名的对象属性,找到了就取相应的值找不到就报错,这个过程叫标识符的解析。

在标识符解析过程之前还存在两个步骤:scope对象的初始化和“执行环境”对象的初始化。scope对象的初始化过程有一个好听的名字叫词法作用域,词法作用域讲的是通过阅读源代码看定义变量时的环境就能知道一个变量的作用域,换句话就是说在定义函数的时候scope对象就会被初始化,例如定义一个全局函数function globalFun(){}(这个函数定义在全局环境中),当解释器扫过这句话的时候会把“全局对象”(这个全局对象代表着所有全局范围内定义的变量)赋值给globalFun对象的scope[0]。当这个函数被调用执行函数体之前有一个“执行环境”对象初始化的过程,之前说过函数在每次调用的时候执行的都是函数体的副本,解释器会为每一个副本创建一个唯一的“执行环境”对象,所以如果多次调用会创建多个“执行环境”对象。每个“执行环境”对象的初始化分为两步:1.复制函数对象的scope属性值到“执行环境”对象;2.创建一个名字叫做“活动对象”的对象,把这个“活动对象”的属性名初始化为形参、局部变量名,而属性值是在不同阶段分开赋值的(暂不详述);并且这个“活动对象”放到这个“执行环境”对象的最前面,函数执行过程中函数体内标识符解析过程用到的作用域链都在这个“执行环境”对象里面。

  • 闭包

不少和我一样的初学者觉得会觉得闭包很难不好理解,我认为主要的原因是这个名字起的太难听了--!

其实只要明白了闭包产生的原因闭包就很好理解了,产生闭包的原因有两个:词法作用域和垃圾回收机制。

前面讲了在全局环境下函数作用域链的创建,而当在一个函数体环境下声明一个嵌套函数时情况会有所不同,嵌套函数的scope属性的会被初始化为引用父函数的“执行环境”对象,所以当嵌套函数被调用的时候它的执行环境对象里包含了父函数的局部变量。javascript中的垃圾回收机制是一种自动的回收,当内存中的对象不存在被引用或者变量不在被使用的时候他们会被js引擎销毁以释放内存,所以嵌套的函数被父函数当作结果返回的时候,如果嵌套函数用到了父函数的局部变量,那么在父函数结束调用的时候它的执行环境对象不会被销毁,它会在嵌套函数的scope属性值中继续存在,所以在调用嵌套函数的时候仍然可以访问到父函数的局部变量。到这我仍的然不知道什么是闭包,有的地方说这个嵌套函数就是闭包,也有的地方说嵌套函数的方法块是闭包,还有的地方说这种变量可以保存到作用域中的特性是闭包,不管咋样反正闭包就是这么个事情。有一点要注意的是闭包可能会产生性能问题,因为父函数的“执行环境”对象没有释放会占据更多的内存空间,并且在进行标识符解析的过程中父函数的变量在嵌套函数中“执行环境”的第二层对象,所以在遍历的时候会总是多遍历当前嵌套函数的活动对象一次而产生性能的损耗。

  • 柯里化

柯里化是一种对闭包的应用,它的作用是把多参数函数转换为一系列单参数函数并进行调用。一个最简单极端的例子,实现一个求两个数的和的函数,没有柯里化的函数定义是function add(x+y){return x+y;},这个函数在调用的时候要一次性传入两个参数,add(1,2);而柯里化的写法是function add(x){return function(y){return x+y}},这个函数在调用的时候要分次传入一个参数,add(1)(2);这种写法通用性很差,一个通用性比较好的柯里化方法应该是一个无参函数,并且要每次调用都要保存住传入的参数并与原来传入的参数进行合并,所以可以写一个拓展方法Function.prototype.curry(){var  slice=Array.prototype.slice;var args=slice.call(arguments;var that=this;return function(){return that.apply(null,args.concat(slice.apply(arguments)));});}。好在EMCAScript5标准之后不用这么麻烦,5标准有一个bind方法,前面说过这个方法属于函数的借用调用,bind不直接执行而是返回一个初始化了this的函数,其实bind不仅可以初始化this,还可以初始化其他的参数,所以bind方法第一个参数用来初始化this,可以为null,其后的参数都是初始化函数参数的,因此那个add函数可以这么调用add.bind(null,1)(3);