javascript--3基本概念 函数



变量类型

  在说函数之前,先来说说变量类型。

1、变量:变量在本质上就是命名的内存空间。

2、变量的数据类型:就是指变量可以存储的值的数据类型,比如Number类型、Boolean类型、Object类型等,在ECMAScript中,变量的数据类型是动态的,可以在运行时改变变量的数据类型。

3、变量类型:是指变量本身的类型,在ECMAScript中,变量类型就只有两种:值类型和引用类型。当变量的数据类型是简单数据类型时,变量类型就是值类型,当变量的数据类型是对象类型时,变量类型就是引用类型。在不引起歧义的情况下,也可以称变量的数据类型为变量类型。

  那么,值类型和引用类型有什么区别呢?最主要的一个,就是当变量类型为值类型时,变量存储的就是变量值本身,而当变量类型为引用类型时,变量存储的并不是变量值,而只是一个指向变量值的指针,访问引用类型的变量值时,首先是取到这个指针,然后是根据这个指针去获取变量值。如果将一个引用类型的变量值赋给另一个变量,最终结果是这两个变量同时指向了一个变量值,修改其中一个会同时修改到另一个:


var a = {
    name:'linjisong',
    age:29
};
var b = a;//将引用类型的变量a赋给变量b,a、b同时指向了a开始指向的那个对象
b.name = 'oulinhai';//修改b指向的对象,也就是修改了a指向的对象
console.info(a.name);//oulinhai
b = {//将变量重新赋值,但是b原来指向的对象没有变更,也就是a指向的对象没有变化
    name:'hujinxing',
    age:23
};
console.info(a.name);//oulinhai

  好了,关于变量类型先说到这,如果再继续到内存存储数据结构的话,就怕沉得下去浮不上来。

函数

  如果说对象是房间,那么函数就是有魔幻效应的房间了。函数首先是对象,然后这个函数对象还具有很多魔幻功能……

1、函数

(1)函数是对象

  函数也是一种对象,而用于创建函数对象实例的函数就是内置的Function()函数(创建对象实例需要函数,而函数又是一种对象实例,是不是让你有了先有鸡还是先有蛋的困惑?别钻牛角尖了,只要鸡能生蛋,蛋能孵鸡就行了,谁先谁后还是留给哲学家吧),但是函数这种对象,又和一般的对象有着极大的不同,以至于对函数对象实例使用typeof时返回的不是object而是function了。

(2)函数名是指向函数对象的引用类型变量


function fn(p){
    console.info(p);
}

console.info(fn);//fn(p),可以将fn作为一般变量来访问
var b = fn;
b('function');//function,可以对b使用函数调用,说明b指向的对象(也就是原来fn指向的对象)是一个函数

注:关于函数名,在ES5的严格模式下,已经不允许使用eval和arguments了,当然,参数名也不能用这两个了(我想除非你是专业黑客,否则也不会使用这些作为标识符来使用吧)。

2、函数创建

(1)作为一种对象,函数也有和普通对象类似的创建方式,使用new调用构造函数Function(),它可以接受任意数量的参数,最后一个参数作为函数体,而前面的所有参数都作为函数的形式参数,前面的形式参数还可以使用逗号隔开作为一个参数传入,一般形式为:


var fn = new Function(p1, p2, ..., pn, body);
//或者
var fn = Function(p1, p2, ..., pn, body);
//或者
var fn = new Function("p1, p2, ..., pn", q1, q2, ..., qn, body);
//或者
var fn = Function("p1, p2, ..., pn", q1, q2, ..., qn, body);

例如:

var add = new Function('a','b','return a + b;');
console.info(add(2,1));//3
var subtract = Function('a','b','return a - b;');
console.info(subtract(2,1));//1
var sum = new Function('a,b','c','return a + b + c;');
console.info(sum(1,2,3));//6
这种方式创建函数,会解析两次代码,一次正常解析,一次解析函数体,效率会影响,但是比较适合函数体需要动态编译的情况。

(2)由于函数对象本身的特殊性,我们还可以使用关键字function来创建函数:


function add(a, b){
    return a + b;
}
console.info(add(2,1));//3
var subtract = function(a, b){
    return a - b;   
};
console.info(subtract(2,1));//1

从上可以看到,使用function关键字创建函数也有两种方式:函数声明和函数表达式。这两种方式都能实现我们想要的效果,那他们之间有什么区别呢?这就是我们下面要讲的。

3、函数声明和函数表达式

(1)从形式上区分,在ECMA-262的规范中,可以看到:

函数声明:  function Identifier       (参数列表(可选)){函数体}
函数表达式:function Identifier(可选)(参数列表(可选)){函数体}
除了函数表达式的标识符(函数名)是可选的之外没有任何区别,但我们也可以从中得知:没有函数名的一定是函数表达式。当然,有函数名的,我们就只能从上下文来判断了。

(2)从上下文区分,这个说起来简单,就是:只允许表达式出现的上下文中的一定是函数表达式,只允许声明出现的上下文的一定是函数声明。举一些例子:


function fn(){};//函数声明
//function fn(){}(); // 异常,函数声明不能直接调用
var fn = function fn(){};//函数表达式
(function fn(){});//函数表达式,在分组操作符内
+function fn(){console.info(1);}();//1,函数表达式,出现在操作符+之后,因此可以直接调用,这里,也可以使用其它的操作符,比如new
new function fn(){console.info(2);}();//2,函数表达式,new操作符之后
(function(){
    function fn(){};//函数声明   
});

(3)区别:我们为什么要花这么大力气来区分函数声明和函数表达式呢?自然就是因为它们的不同点了,他们之间最大的不同,就是声明会提升,关于声明提升,在前面基础语法的那一篇文章中,曾经对全局作用域中的声明提升做过讨论,我们把那里的结论复习一下:

A、引擎在解析时,首先会解析函数声明,然后解析变量声明(解析时不会覆盖类型),最后再执行代码;

B、解析函数声明时,会同时解析类型(函数),但不会执行,解析变量声明时,只解析变量,不会初始化。

在那里也举了一些例子来演示(回忆一下),不过没有同名称的声明例子,这里补充一下:


1 console.info(typeof fn);//function,声明提升,以函数为准
2 var fn = '';
3 function fn(){   
4 }
5 console.info(typeof fn);//string,由于已经执行了代码,这里fn的类型变为string
6 try{
7     fn();//已经是string类型,不能调用了,抛出类型异常
8 }catch(e){
9     console.info(e);//TypeError
10 }
11 fn = function(){console.info('fn');};//如果想调用fn,只能再使用函数表达式赋值给fn
12 fn();//fn,可以调用
13
14 console.info(typeof gn);//function
15 function gn(){   
16 }
17 var gn = '';
18 console.info(typeof gn);//string

可以看出:不管变量声明是在前还是在后,在声明提升时都是以函数声明优先,但是在声明提升之后,由于要执行变量初始化,而函数声明不再有初始化(函数类型在提升时已经解析),因此后面输出时就成为String类型了。

上面第3行定义了一个函数,然后第7行马上调用,结果竟然不行!你该明白保持全局命名空间清洁的重要性了吧,要不然,你可能会遇到“我在代码中明明定义了一个函数却不能调用”这种鬼事情,反过来,如果你想确保你定义的函数可用,最好就是使用函数表达式来定义,当然,这样做你需要冒着破坏别人代码的风险。

还有一个问题,这里我们怎么确定变量类型是在初始化时候而不是在变量声明提升时候改变的呢?看下面的代码:

console.info(typeof fn);//function
function fn(){
}
var fn;
console.info(typeof fn);//function
可以看到,声明提升后类型为function,并且由于没有初始化代码,最后的类型没有改变。

  关于函数声明和函数表达式,还有一点需要注意的,看下面的代码:


if(true){
    function fn(){
        return 1;   
    }   
}else{
    function fn(){
        return 2;
    }   
}
console.info(fn());// 在Firefox输出1,在Opera输出2,在Opera中声明提升,后面的声明会覆盖前面的同级别声明

if(true){
    gn = function(){
        return 1;   
    };
}else{
    gn = function(){
        return 2;
    };   
}
console.info(gn());// 1,所有浏览器输出都是1

  在ECMAScript规范中,命名函数表达式的标识符属于内部作用域,而函数声明的标识符属于定义作用域。


var sum = function fn(){
    var total = 0,
        l = arguments.length;
    for(; l; l--)
    {
        total += arguments[l-1];
    }
    console.info(typeof fn);
    return total;
}
console.info(sum(1,2,3,4));//function,10
console.info(fn(1,2,3,4));//ReferenceError

  上面是一个命名函数表达式在FireFox中的运行结果,在函数作用域内可以访问这个名称,但是在全局作用域中访问出现引用异常。不过命名函数表达式在IE9之前的IE浏览器中会被同时作为函数声明和函数表达式来解析,并且会创建两个对象,好在IE9已经修正。

  除了全局作用域,还有一种函数作用域,在函数作用域中,参与到声明提升竞争的还有函数的参数。首先要明确的是,函数作用域在函数定义时不存在的,只有在函数实际调用才有函数作用域。


// 参数与内部变量,参数优先
function fn(inner){
    console.info(inner);// param
    console.info(other);// undefined
    var inner = 'inner';
    var other = 'other';
    console.info(inner);// inner
    console.info(other);// other
}
fn('param');

// 参数与内部函数,内部函数优先
function gn(inner){
    console.info(inner);// inner()函数
    console.info(inner());// undefined
    function inner(){
        return other;
    }
    var other = 'other';
    console.info(inner);// inner()函数
    console.info(inner());// other
}
gn('param');

通过上面的输出结果,我们得出优先级:内部函数声明 > 函数参数 > 内部变量声明。

  这里面的一个过程是:首先内部函数声明提升,并将函数名的类型设置为函数类型,然后解析函数参数,将传入的实际参数值赋给形式参数,最后再内部变量声明提升,只提升声明,不初始化,如果有重名,同优先级的后面覆盖前面的,不同优先级的不覆盖(已经解析了优先级高的,就不再解析优先级低的)。
  说明一下,这只是我根据输出结果的推断,至于后台实现,也有可能步骤完全相反,并且每一步都覆盖前一步的结果,甚至是从中间开始,然后做一个优先级标志确定是否需要覆盖,当然,从效率上来看,应该是我推断的过程会更好。另外,全局作用域其实就是函数作用域的一个简化版,没有函数参数。

  这里就不再举综合的例子了,建议将这篇文章和前面的基础语法那一篇一起阅读,可能效果会更好。关于优先级与覆盖,也引出下面要说的一个问题。

4、函数重载

  函数是对象,函数名是指向函数对象的引用类型变量,这使得我们不可能像一般面向对象语言中那样实现重载:


1 function fn(a){
2     return a;
3 }
4 function fn(a,b){
5     return a + b;
6 }
7
8 console.info(fn(1));  // NaN
9 console.info(fn(1,2));// 3

不要奇怪第8行为什么输出NaN,因为函数名只是一个变量而已,两次函数声明会依次解析,这个变量最终指向的函数就是第二个函数,而第8行只传入1个参数,在函数内部b就自动赋值为undefined,然后与1相加,结果就是NaN。换成函数表达式,也许就好理解多了,只是赋值了两次而已,自然后面的赋值会覆盖前面的:

var fn = function (a){ return a; }
fn = function (a,b){ return a + b;}
那么,在ECMAScript中,怎么实现重载呢?回想一下简单数据类型包装对象(Boolean、Number、String),既可以作为构造函数创建对象,也可以作为转换函数转换数据类型,这是一个典型的重载。这个重载其实在前一篇文章中我们曾经讨论过:

(1)根据函数的作用来重载,这种方式的一般格式为:


function fn(){
    if(this instanceof fn)
    {
        // 功能1
    }else
    {
        // 功能2
    }
}

这种方式虽然可行,但是很明显作用也是有限的,比如就只能重载两次,并且只能重载包含构造函数的这种情形。当然,你可以结合apply()或者call()甚至ES5中新增的bind()来动态绑定函数内部的this值来扩展重载,但这已经有了根据函数内部属性重载的意思了。
(2)根据函数内部属性来重载


function fn(){
    var length = arguments.length;
    if(0 == length)//将字面量放到左边是从Java中带过来的习惯,因为如果将比较操作符写成了赋值操作符(0=length)的话,编译器会提示我错误。如果你不习惯这种方式,请原谅我   
   {
        return 0;
    }else if(1 == length)
    {
        return +arguments[0];
    }else{
        return (+arguments[0])+(+arguments[1]);
    }
}

console.info(fn());//0
console.info(fn(1));//1
console.info(fn(true));//1
console.info(fn(1,2));//3
console.info(fn('1','2'));//3

这里就是利用函数内部属性arguments来实现重载的。当然,在内部重载的方式可以多种多样,你还可以结合typeof、instanceof等操作符来实现你想要的功能。至于内部属性arguments具体是什么?这就是下面要讲的。
5、函数内部属性arguments

  简单一点说,函数内部属性,就是只能在函数体内访问的属性,由于函数体只有在函数被调用的时候才会去执行,因此函数内部属性也只有在函数调用时才会去解析,每次调用都会有相应的解析,因此具有动态特性。这种属性有:this和arguments,这里先看arguments,在下一篇文章中再说this。

(1)在函数定义中的参数列表称为形式参数,而在函数调用时候实际传入的参数称为实际参数。一般的类C语言,要求在函数调用时实际参数要和形式参数一致,但是在ECMAScript中,这两者之间没有任何限制,你可以在定义的时候有2个形式参数,在调用的时候传入2个实际参数,但你也可以传入3个实际参数,还可以只传入1个实际参数,甚至你什么参数都不传也可以。这种特性,正是利用函数内部属性来实现重载的基础。

(2)形式参数甚至可以取相同的名称,只是在实际传入时会取后面的值作为形式参数的值(这种情况下可以使用arguments来访问前面的实际参数):


function gn(a,a){
    console.info(a);
    console.info(arguments[0]);
    console.info(arguments[1]);
}
gn(1,2);//2,1,2
gn(1);//undefined,1,undefined

这其实也可以用本文前面关于声明提升的结论来解释:同优先级的后面的覆盖前面的,并且函数参数解析时同时解析值。当然,这样一来,安全性就很成问题了,因此在ES5的严格模式下,重名的形式参数被禁止了。

(3)实际参数的值由形式参数来接受,但如果实际参数和形式参数不一致怎么办呢?答案就是使用arguments来存储,事实上,即便实际参数和形式参数一致,也存在arguments对象,并且保持着和已经接受了实际参数的形式参数之间的同步。将这句话细化一下来理解:

arguments是一个类数组对象,可以像访问数组元素那样通过方括号和索引来访问arguments元素,如arguments[0]、arugments[1]。
arguments是一个类数组对象,除了继承自Object的属性和方法(有些方法被重写了)外,还有自己本身的一些属性,如length、callee、caller,这里length表示实际参数的个数(形式参数的个数?那就是函数属性length了),callee表示当前函数对象,而caller只是为了和函数属性caller区分而定义的,其值为undefined。
arguments是一个类数组对象,但并不是真正的数组对象,不能直接对arguments调用数组对象的方法,如果要调用,可以先使用Array.prototype.slice.call(arguments)先转换为数组对象。
arguments保存着函数被调用时传入的实际参数,第0个元素保存第一个实际参数,第1个元素保存第二个实际参数,依次类推。
arguments保存实际参数值,而形式参数也保存实际参数值,这两者之间有一个同步关系,修改一个,另一个也会随之修改。
arguments和形式参数之间的同步,只有当形式参数实际接收了实际参数时才存在,对于没有接收实际参数的形式参数,不存在这种同步关系。
arguments对象虽然很强大,但是从性能上来说也存有一定的损耗,所以如果不是必要,就不要使用,建议还是优先使用形式参数。

fn(0,-1);
function fn(para1,para2,para3,para4){
    console.info(fn.length);//4,形式参数个数
    console.info(arguments.length);//2,实际参数个数
    console.info(arguments.callee === fn);//true,callee对象指向fn本身
    console.info(arguments.caller);//undefined
    console.info(arguments.constructor);//Object(),而不是Array()
    try{
        arguments.sort();//类数组毕竟不是数组,不能直接调用数组方法,抛出异常
    }catch(e){
        console.info(e);//TypeError
    }
    var arr = Array.prototype.slice.call(arguments);//先转换为数组
    console.info(arr.sort());//[-1,0],已经排好序了
   
    console.info(para1);//0
    arguments[0] = 1;
    console.info(para1);//1,修改arguments[0],会同步修改形式参数para1
   
    console.info(arguments[1]);//-1
    para2 = 2;
    console.info(arguments[1]);//2,修改形式参数para2,会同步修改arguments[1]
   
    console.info(para3);//undefined,未传入实际参数的形式参数为undefined
    arguments[2] = 3;
    console.info(arguments[2]);//3
    console.info(para3);//undefined,未接受实际参数的形式参数没有同步关系
   
    console.info(arguments[3]);//undefined,未传入实际参数,值为undefined
    para4 = 4;
    console.info(para4);//4
    console.info(arguments[3]);//undefined,为传入实际参数,不会同步
}

经过测试,arguments和形式参数之间的同步是双向的,但是《JavaScript高级程序设计(第3版)》中第66页说是单向的:修改形式参数不会改变arguments。这可能是原书另一个Bug,也可能是FireFox对规范做了扩展。不过,这也让我们知道,即便经典如此,也还是存有Bug的可能,一切当以实际运行为准。

结合arguments及其属性callee,可以实现在函数内部调用自身时与函数名解耦,这样即便函数赋给了另一个变量,而函数名(别忘了,也是一个变量)另外被赋值,也能够保证运行正确。典型的例子有求阶乘函数、斐波那契数列等。

//求阶乘
function factorial(num){
    if(num <= 1)
    {
        return 1;
    }else{
        return num * factorial(num - 1);   
    }
}
var fn = factorial;
factorial = null;
try{
    fn(2);//由于函数内部递归调用了factorial,而factorial已经赋值为null了,所以抛出异常
}catch(e){
    console.info(e);//TypeError
}

//斐波那契数列
function fibonacci(num){
    if(1 == num || 2 == num){
        return 1;   
    }else{
        return arguments.callee(num - 1) + arguments.callee(num - 2);   
    }
}
var gn = fibonacci;
fibonacci = null;
console.info(gn(9));//34,使用arguments.callee,实现了函数对象和函数名的解耦,可以正常执行

递归的算法非常简洁,但因为要维护运行栈,效率不是很好。关于递归的优化,也有很多非常酣畅漓淋的算法,这里就不深入了。

  需要注意的是,arguments.callee在ES5的严格模式下已经被禁止使用了,这时候可以使用命名的函数表达式来实现同样的效果:


//斐波那契数列
var fibonacci = (function f(num){
    return num <= 2 ? 1 : (f(num - 1) + f(num - 2));
});
var gn = fibonacci;
fibonacci = null;
console.info(gn(9));//34,使用命名函数表达式实现了函数对象和函数名的解耦,可以正常执行


 接着看函数——这个具有魔幻色彩的对象。在上篇文章中说函数内部属性时,还遗留了一个this内部属性没有解释,不过在说this之前,我想先说一说执行环境和作用域的概念。

6、执行环境和作用域

(1)执行环境(execution context):所有的JavaScript代码都运行在一个执行环境中,当控制权转移至JavaScript的可执行代码时,就进入了一个执行环境。活动的执行环境从逻辑上形成了一个栈,全局执行环境永远是这个栈的栈底元素,栈顶元素就是当前正在运行的执行环境。每一个函数都有自己的执行环境,当执行流进入一个函数时,会将这个函数的执行环境压入栈顶,函数执行完之后再将这个执行环境弹出,控制权返回给之前的执行环境。

(2)变量对象(variable object):每一个执行环境都有一个与之对应的变量对象,执行环境中定义的所有变量和函数就是保存在这个变量对象中。这个变量对象是后台实现中的一个对象,我们无法在代码中访问,但是这有助于我们理解执行环境和作用域相关概念。

(3)作用域链(scope chain):当代码在一个执行环境中运行时,会创建由变量对象组成的一个作用域链。这个链的前端,就是当前代码所在环境的变量对象,链的最末端,就是全局环境的变量对象。在一个执行环境中解析标识符时,会在当前执行环境相应的变量对象中搜索,找到就返回,没有找到就沿着作用域链一级一级往上搜索直至全局环境的变量对象,如果一直未找到,就抛出引用异常。

(4)活动对象(activation object):如果一个执行环境是函数执行环境,也将变量对象称为活动对象。活动对象在最开始只包含一个变量,即arguments对象(这个对象在全局环境的变量对象中不存在)。 

  这四个概念虽然有些抽象,但还是比较自然的,可以结合《JavaScript高级程序设计(第3版)》中的一个例子来细细体会一下:


// 进入到全局作用域,创建全局变量对象
var color = "blue";

function changeColor(){
    // 进入到changeColor作用域,创建changeColor相应变量对象
    var anotherColor = "red";

    function swapColors(color1, color2){
        // 进入到swapColors作用域,创建swapColors相应变量对象
        var tempColor = anotherColor;
        anotherColor = color;
        color = tempColor;
        /*
         * swapColors作用域内可以访问的对象有:
         * 全局变量对象的color,changeColor
         * changeColor函数相应变量对象的anotherColor、swapColors
         * swapColors函数相应变量对象的tempColor
         */
    }      
    swapColors('white');
    /*
     * changeColor作用域内可以访问的对象有:
     * 全局变量对象的color,changeColor
     * changeColor函数相应变量对象的anotherColor、swapColors
     */
}

changeColor();
/*
* 全局作用域内可以访问的对象有:
* 全局变量对象的color,changeColor
*/

这里的整个过程是:

(1)进入全局环境,创建全局变量对象,将全局环境压入栈顶(这里也是栈底)。根据前面的关于声明提升的结论,这里创建全局变量对象可能的一个过程是,先创建全局变量对象,然后处理函数声明设置属性changeColor为相应函数,再处理变量声明设置属性color为undefined。

(2)执行全局环境中的代码。先执行color变量初始化,赋值为'blue',再调用changeColor()函数。

(3)调用changeColor()函数,进入到changeColor函数执行环境,创建这个环境相应的变量对象(也就是活动对象),将这个环境压入栈顶。创建活动对象可能的一个过程是,先创建活动对象,处理内部函数声明设置属性swapColors为相应函数,处理函数参数创建活动对象的属性arguments对象,处理内部变量声明设置属性anotherColor为undefined。

(4)执行changeColor()函数代码。先执行anotherColor初始化为'red',再调用swapColors()函数。

(5)调用swapColors()函数,进入到swapColors函数执行环境,创建相应的变量对象(活动对象),将swapColors执行环境压入栈顶。这里创建活动对象可能的一个过程是,先创建活动对象,处理函数参数,将形式参数作为活动对象的属性并赋值为undefined,创建活动对象的属性arguments对象,并根据实际参数初始化形式参数和arguments对应的值和属性(将属性color1和arguments[0]初始化为'white',由于没有第二个实际参数,所以color2的值为undefined,而arguments的长度只为1了),处理完函数参数之后,再处理函数内部变量声明,将tempColor作为活动对象的属性并赋值为undefined。

(6)执行swapColors()函数代码。先给tempColor初始化赋值,然后实现值交换功能(这里color和anotherColor的值都是沿着作用域链才读取到的)。

(7)swapColors()函数代码执行完之后,返回undefined,将相应的执行环境弹出栈并销毁(注意,这里会销毁执行环境,但是执行环境相应的活动对象并不一定会被销毁),当前执行环境恢复成changeColor()函数的执行环境。随着swapColor()函数执行完并返回,changeColor()也就执行完了,同样返回undefined,并将changeColor()函数的执行环境弹出栈并销毁,当前执行环境恢复成全局环境。整个处理过程结束,全局环境直至页面退出再销毁。

  作用域链也解释了为什么函数可以在内部递归调用自身:函数名是函数定义所在执行环境相应变量对象的一个属性,然后在函数内部执行环境中,就可以沿着作用域链向外上溯一层访问函数名指向的函数对象了。如果在函数内部将函数名指向了一个新函数,递归调用时就会不正确了:


function fn(num){
    if(1 == num){
        return 1;
    }else{
        fn = function(){
            return 0;
        };
        return num * fn(num - 1);
    }
}
console.info(fn(5));//0

  关于作用域和声明提升,再看一个例子:


1 var name = 'linjisong';
2 function fn(){
3     console.info(name);//undefined
4     var name = 'oulinhai';
5     console.info(name);//oulinhai
6 }
7 fn();
8 console.info(name);//linjisong

  这里最不直观的可能是第3行输出undefined,因为在全局中已经定义过name了,不过按照上面解析的步骤去解析一次,就可以得出正确的结果了。另外强调一下,在ECMAScript中只有全局执行环境和函数执行环境,相应的也只有全局作用域和函数作用域,没有块作用域——虽然有块语句。


function fn(){
    var fnScope = 'a';
   
    {
        var blockScope = 'b';   
        blockScope += fnScope;
    }   
    console.info(blockScope);//没有块作用域,所以可以在整个函数作用域内访问blockScope
    console.info(fnScope);
}
fn();//ba,a

console.info(blockScope);//ReferenceError,函数作用域外,不能访问内部定义的变量
console.info(fnScope);//ReferenceError

  对于作用域链,还可以使用with、try-catch语句的catch块来延长:

使用with(obj){}语句时,将obj对象添加到当前作用域链的最前端。
使用try{}catch(error){}语句时,将error对象添加到当前作用域链的最前端。
  插了一段较为抽象的概念,希望不至于影响整个阅读的流畅,事实上,我在这里还悄悄的绕过了一个称为“闭包”的概念,关于函数与闭包,在下篇文章中再详细叙述。

7、函数内部对象与this

  对于面向对象语言的使用者来说,this实在是再熟悉不过了,不就是指向构造函数新创建的对象吗!不过,在ECMAScript中,且别掉以轻心,事情没有那么简单,虽然在使用new操作符调用函数的情况下,this也的确是指向新创建的对象,但这只是指定this对象值的一种方式而已,还有更多的方式可以指定this对象的值,换句话说,this是动态的,是可以由我们自己自由指定的。

(1)全局环境中的this

  在全局环境中,this指向全局对象本身,在浏览器中也就是window,这里也可以把全局环境中的this理解为全局执行环境相应的变量对象,在全局环境中定义的变量和函数都是这个变量对象的属性:


var vo = 'a';
vo2 = 'b';
function fn(){
    return 'fn';
}
console.info(this === window);//true
console.info(this.vo);//a
console.info(this.vo2);//b
console.info(this.fn());//fn

  如果在自定义函数中要引用全局对象,虽然可以直接使用window,但更好的方式则是将全局对象作为参数传入函数,这是在JS库中非常通用的一种方式:

(function(global){
    console.info(global === window);//在内部可以使用global代替window了
})(this);
  这种方式兼容性更好(ECMAScript的实现中全局对象未必都是window),在压缩时,也可以将global简化为g,而不用使用window了。

(2)函数内部属性this

  在函数环境中,this是一个内部属性对象,可以理解成函数对应的活动对象的一个属性,而这个内部属性的值是动态的。那this值是怎么动态确定的呢?

使用new调用时,函数也称为构造函数,这个时候函数内部的this被指定为新创建的对象。

function fn(){
    var name = 'oulinhai';//函数对应的活动对象的属性
    this.name = 'linjisong';//当使用new调用函数时,将this指定为新创建对象,也就是给新创建对象添加属性
}
var person = new fn();
console.info(person.name);//linjisong

var arr = [fn];
console.info(arr[0]());//undefined

  需要注意区分一下函数执行环境中定义的属性(也即活动对象的属性)和this对象的属性,在使用数组元素方式调用函数时,函数内部this指向数组本身,因此上例最后输出undefined。

作为一般函数调用时,this指向全局对象。
作为对象的方法调用时,this指向调用这个方法的对象。
  看下面的例子:


var name = 'oulinhai';
var person = {
    name:'linjisong',
    getName:function(){
        return this.name;   
    }
};
console.info(person.getName());//linjisong
var getName = person.getName;
console.info(getName());//oulinhai

  这里函数对象本身是匿名的,是作为person对象的一个属性,当作为对象属性调用时,this指向了对象,当把这个函数赋给另一个函数然后调用时,是作为一般函数调用的,this指向了全局对象。这个例子充分说明了“函数作为对象的方法调用时内部属性this指向这个调用对象,函数作为一般函数调用时内部属性this指向全局对象”,也说明了this的指定是动态的,是在调用时指定的,而不管函数是单独定义的还是作为对象方法定义的。也正是因为函数作为对象的方法调用时this指向这个调用对象,所以在函数内部返回this时才能够延续调用对象的下一个方法——也就是链式操作(jQuery的一大特色)。

使用apply()、call()或bind()调用函数时,this指向第一个参数对象。如果没有传入参数或传入的是null和undefined,this指向全局对象(在ES5的严格模式下会设为null)。如果传入的第一个参数是一个简单类型,会将this设置为相应的简单类型包装对象。

var name = 'linjisong';
function fn(){
    return this.name;
}
var person = {
    name:'oulinhai',
    getName:fn   
};
var person2 = {name:'hujinxing'};
var person3 = {name:'huanglanxue'};
console.info(fn());//linjisong,一般函数调用,内部属性this指向全局对象,因此this.name返回linjisong
console.info(person.getName());//oulinhai,作为对象方法调用,this指向这个对象,因此这里返回person.name
console.info(fn.apply(person2));//hujinxing,使用apply、call或bind调用函数,执行传入的第一个参数对象,因此返回person2.name
console.info(fn.call(person2));//hujinxing
var newFn = fn.bind(person3);//ES5中新增方法,会创建一个新函数实例返回,内部this值被指定为传入的参数对象
console.info(newFn());//huanglanxue

上面示例中列出的都是一些常见情况,没有列出第一个参数为null或undefined的情况,有兴趣的朋友可以自行测试。关于this值的确定,在原书中还有一个例子:


var name = 'The Window';
var object = {
    name : 'My Object',
    getName:function(){
        return this.name;   
    },
    getNameFunc:function(){
        return function(){
            return this.name;   
        }   
    }
};

console.info(object.getName());//My Object
console.info((object.getName)());//My Object
console.info((object.getName = object.getName)());//The Window
console.info(object.getNameFunc()());//The Window

第1个是正常输出,第2个(object.getName)与object.getName的效果是相同的,而第3个(object.getName=object.getName)最终返回的是函数对象本身,也就是说第3个会作为一般函数来调用,第4个则先是调用getNameFunc这个方法,返回一个函数,然后再调用这个函数,也是作为一般函数来调用。

8、函数属性和方法

  函数是一个对象,因此也可以有自己的属性和方法。不过函数属性和方法与函数内部属性很容易混淆,既然容易混淆,就把它们放一起对照着看,就好比一对双胞胎,不对照着看,不熟悉的人是区分不了的。

  先从概念上来区分一下:

(1)函数内部属性:可以理解为函数相应的活动对象的属性,是只能从函数体内部访问的属性,函数每一次被调用,都会被重新指定,具有动态性。

(2)函数属性和方法:这是函数作为对象所具有的特性,只要函数一定义,函数对象就被创建,相应的属性和方法就可以访问,并且除非你在代码中明确赋为另一个值,否则它们的值不会改变,因而具有静态性。有一个例外属性caller,表示调用当前函数的函数,也是在函数被调用时动态指定,在《JavaScript高级程序设计(第3版)》中也因此将caller属性和函数内部属性arguments、this一起讲解,事实上,在ES5的严格模式下,不能对具有动态特性的函数属性caller赋值。

  光从概念上区分是非常抽象的,也不是那么容易理解,再把这些属性列在一起比较一下(没有列入一些非标准的属性,如name):

类别 名称 继承性 说明 备注
函数内部属性 this - 函数据以执行的环境对象 和一般面向对象语言有很大区别
arguments -
表示函数实际参数的类数组对象

arguments本身也有自己的属性:length、callee和caller

1、length属性表示实际接收到的参数个数

2、callee属性指向函数对象本身,即有:

  fn.arguments.callee === fn

3、caller属性主要和函数的caller相区分,值永远都是undefined

函数属性 caller 否 调用当前函数的函数 虽然函数一定义就可访问,但是不在函数体内访问时永远为null,在函数体内访问时返回调用当前函数的函数,在全局作用域中调用函数也会返回null
length 否 函数形式参数的长度 就是定义函数时命名的参数个数
prototype 否 函数原型对象 原型对象是ECMAScript实现继承的基础
constructor 是 继承自Object,表示创建函数实例的函数,也就是Function() 值永远是Function,也就是内置的函数Function()
函数方法 apply 否 调用函数自身,以(类)数组方式接受参数
这三个方法主要作用是动态绑定函数内部属性this

1、apply和call在绑定之后会马上执行

2、bind在绑定之后可以在需要的时候再调用执行

call 否 调用函数自身,以列举方式接受参数
bind 否 绑定函数作用域,ES5中新增
toLocalString 覆盖
覆盖了Object类型中的方法,返回函数体

不同浏览器实现返回可能不同,可能返回原始代码,也可能返回去掉注释后的代码

toString 覆盖
valueOf 覆盖
hasOwnProperty 是 直接继承自Object类型的方法,用法同Object
propertyIsEnumerable 是
isPropertyOf 是
  函数属性和方法,除了从Object继承而来的属性和方法,也包括函数本身特有的属性和方法,用的最多的方法自然就是上一小节说的apply()、call(),这两个方法都是用来设置函数内部属性this从而扩展函数作用域的,只不过apply()扩展函数作用域时是以(类)数组方式接受函数的参数,而call()扩展函数作用域时需要将函数参数一一列举出来传递,看下面的例子:


function sum(){
  var total = 0,
       l = arguments.length ;

  for(; l; l--){
       total += arguments[l-1];
  }   
  return total;
}

console.info(sum.apply(null,[1,2,3,4]));//10
console.info(sum.call(null,1,2,3,4));//10

不过需要强调的是:apply和call的主要作用还是在于扩展函数作用域。apply和call在扩展作用域时会马上调用函数,这使得应用中有了很大限制,因此在ES5中新增加了一个bind()函数,这个函数也用于扩展作用域,但是可以不用马上执行函数,它返回一个函数实例,将传入给它的第一个参数作为原函数的作用域。它的一个可能的实现如下:


function bind(scope){
    var that = this;
    return function(){
        that.apply(scope, arguments);   
    }
}
Function.prototype.bind = bind;

这里涉及了一个闭包的概念,明天再继续。




9、作为值的函数

  在一般的编程语言中,如果要将函数作为值来使用,需要使用类似函数指针或者代理的方式来实现,但是在ECMAScript中,函数是一种对象,拥有一般对象具有的所有特征,除了函数可以有自己的属性和方法外,还可以做为一个引用类型的值去使用,实际上我们前面的例子中已经有过将函数作为一个对象属性的值,又比如函数也可以作为另一个函数的参数或者返回值,异步处理中的回调函数就是一个典型的用法。


var name = 'linjisong';
var person = {name:'oulinhai'};
function getName(){
    return this.name;
}
function sum(){
    var total = 0,
        l = arguments.length;
    for(; l; l--)
    {
        total += arguments[l-1];
    }   
    return total;
}

// 定义调用函数的函数,使用函数作为形式参数
function callFn(fn,arguments,scope){
    arguments = arguments || [];
    scope = scope || window;
    return fn.apply(scope, arguments);
}
// 调用callFn,使用函数作为实际参数
console.info(callFn(getName));//linjisong
console.info(callFn(getName,'',person));//oulinhai
console.info(callFn(sum,[1,2,3,4]));//10

再看一个使用函数作为返回值的典型例子,这个例子出自于原书第5章:


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 data = [{name: "Zachary", age: 28}, {name: "Nicholas", age: 29}];

data.sort(createComparisonFunction("name"));
console.info(data[0].name);  //Nicholas

data.sort(createComparisonFunction("age"));
console.info(data[0].name);  //Zachary

10、闭包(Closure)
  闭包是指有权访问另一个函数作用域中的变量的函数。对象是带函数的数据,而闭包是带数据的函数。

  首先闭包是一个函数,然后闭包是一个带有数据的函数,那么,带有的是什么数据呢?我们往上看看函数作为返回值的例子,返回的是一个匿名函数,而随着这个匿名函数被返回,外层的createComparisonFunction()函数代码也就执行完成,按照前面的结论,外层函数的执行环境会被弹出栈并销毁,但是接下来的排序中可以看到在返回的匿名函数中依旧可以访问处于createComparisonFunction()作用域中的propertyName,这说明尽管createComparisonFunction()对应的执行环境已经被销毁,但是这个执行环境相对应的活动对象并没有被销毁,而是作为返回的匿名函数的作用域链中的一个对象了,换句话说,返回的匿名函数构成的闭包带有的数据就是:外层函数相应的活动对象。由于活动对象的属性(也就是外层函数中定义的变量、函数和形式参数)会随着外层函数的代码执行而变化,因此最终返回的匿名函数构成的闭包带有的数据是外层函数代码执行完成之后的活动对象,也就是最终状态。

  希望好好理解一下上面这段话,反复理解一下。虽然我已经尽我所能描述的更易于理解一些,但是闭包的概念还是有些抽象,下面看一个例子,这个例子来自原书第7章:


function createFunctions(){
    var result = new Array();
    for (var i=0; i < 10; i++){
        result[i] = function(){
            return i;
        };
    }
    return result;
}

var funcs = createFunctions();
for (var i=0,l=funcs.length; i < l; i++){
    console.info(funcs[i]());//每一个函数都输出10
}

  这里由于闭包带有的数据是createFunctions相应的活动对象的最终状态,而在createFunctions()代码执行完成之后,活动对象的属性i已经变成10,因此在下面的调用中每一个返回的函数都输出10了,要处理这种问题,可以采用匿名函数作用域来保存状态:


function createFunctions(){
    var result = new Array();
    for (var i=0; i < 10; i++){
        result[i] = (function(num){
            return function(){
                return num;
            };
        })(i);
    }
    return result;
}

  将每一个状态都使用一个立即调用的匿名函数来保存(保存在匿名函数相应的活动对象中),然后在最终返回的函数被调用时,就可以通过闭包带有的数据(相应的匿名函数活动对象中的数据)来正确访问了,输出结果变成0,1,...9。当然,这样做,就创建了10个闭包,在性能上会有较大影响,因此建议不要滥用闭包,另外,由于闭包会保存其它执行环境的活动对象作为自身作用域链中的一环,这也可能会造成内存泄露。尽管闭包存在效率和内存的隐患,但是闭包的功能是在太强大,下面就来看看闭包的应用——首先让我们回到昨天所说的函数绑定方法bind()。

(1)函数绑定与柯里化(currying)

A、再看this,先看一个例子(原书第22章):


<button id='my-btn' title='Button'>Hello</button>
<script type="text/javascript">
    var handler = {
        title:'Event',
        handleClick:function(event){
            console.info(this.title);
        }   
    };
    var btn = document.getElementById('my-btn');//获取页面按钮
    btn.onclick = handler.handleClick;//给页面按钮添加事件处理函数
</script>

如果你去点击“Hello”按钮,控制台打印的是什么呢?竟然是Button,而不是期望中的Event,原因就是这里在点击按钮的时候,处理函数内部属性this指向了按钮对象。可以使用闭包来解决这个问题:

btn.onclick = function(event){
        handler.handleClick(event);//形成一个闭包,调用函数的就是对象handler了,函数内部属性this指向handler对象,因此会输出Event
}
B、上面的解决方案并不优雅,在ES5中新增了函数绑定方法bind(),我们使用这个方法来改写一下:


if(!Function.prototype.bind){//bind为ES5中新增,为了保证运行正常,在不支持的浏览器上添加这个方法
        Function.prototype.bind = function(scope){
            var that = this;//调用bind()方法的函数对象
            return function(){
                that.apply(scope, arguments);//使用apply方法,指定that函数对象的内部属性this
            };
        };   
}
btn.onclick = handler.handleClick.bind(handler);//使用bind()方法时只需要使用一条语句即可

这里添加的bind()方法中,主要技术也是创建一个闭包,保存绑定时的参数作为函数实际调用时的内部属性this。如果你不确定是浏览器本身就支持bind()还是我们这里的bind()起了作用,你可以把特性检测的条件判断去掉,然后换个方法名称试试。
C、上面对函数使用bind()方法时,只使用了第一个参数,如果调用bind()时传入多个参数并且将第2个参数开始作为函数实际调用时的参数,那我们就可以给函数绑定默认参数了。


if(!Function.prototype.bind){
        Function.prototype.bind = function(scope){
            var that = this;//调用bind()方法的函数对象
            var args = Array.prototype.slice.call(arguments,1);//从第2个参数开始组成的参数数组
            return function(){
                var innerArgs = Array.prototype.slice.apply(arguments);
                that.apply(scope, args.concat(innerArgs));//使用apply方法,指定that函数对象的内部属性this,并且填充绑定时传入的参数
            };
        };   
}

D、柯里化:在上面绑定时,第一个参数都是用来设置函数调用时的内部属性this,如果把所有绑定时的参数都作为预填的参数,则称之为函数柯里化。


if(!Function.prototype.curry){
        Function.prototype.curry = function(){
            var that = this;//调用curry()方法的函数对象
            var args = Array.prototype.slice.call(arguments);//预填参数数组
            return function(){
                var innerArgs = Array.prototype.slice.apply(arguments);//实际调用时参数数组
                that.apply(this, args.concat(innerArgs));//使用apply方法,并且加入预填的参数
            };
        };   
}

(2)利用闭包缓存

  还记得前面使用递归实现斐波那契数列的函数吗?使用闭包缓存来改写一下:


var fibonacci = (function(){//使用闭包缓存,递归
    var cache = [];
    function f(n){
        if(1 == n || 2 == n){
            return 1;
        }else{
            cache[n] = cache[n] || (f(n-1) + f(n-2));
            return     cache[n];
        }
    }
    return f;
})();

var f2 = function(n){//不使用闭包缓存,直接递归
    if(1 == n || 2 == n){
        return 1;   
    }else{
        return f2(n-1) + f2(n-2);   
    }
};

下面是测试代码以及我机器上的运行结果:


var test = function(n){
     var start = new Date().getTime();
     console.info(fibonacci(n));
     console.info(new Date().getTime() - start);

     start = new Date().getTime();
     console.info(f2(n));
     console.info(new Date().getTime() - start);   
};
test(10);//55,2,55,2
test(20);//6765,1,6765,7
test(30);//832040,2,832040,643

可以看到,n值越大,使用缓存计算的优势越明显。作为练习,你可以尝试自己修改一下计算阶乘的函数。

(3)模仿块级作用域

  在ECMAScript中,有语句块,但是却没有相应的块级作用域,但我们可以使用闭包来模仿块级作用域,一般格式为:

(function(){
//这里是块语句   
})();
上面这种模式也称为立即调用的函数表达式,这种模式已经非常流行了,特别是由于jQuery源码使用这种方式而大规模普及起来。
  闭包还有很多有趣的应用,比如模仿私有变量和私有函数、模块模式等,这里先不讨论了,在深入理解对象之后再看这些内容。

猜你喜欢

转载自zhyp29.iteye.com/blog/2304005