JavaScript大杂烩2 - 理解JavaScript的函数

JavaScript中的字面量
  书接上回,我们已经知道在JavaScript中存在轻量级的string,number,boolean与重量级的String,Number,Boolean,而且也知道了之间的区别。这里补充一点,直接使用字面量定义的变量都是属于前一种类型,例如:

var name = 'Frank';

  此外大多数的内置操作返回的也都是前一种类型,这是必须的。

function是第一等公民
  与别的语言不同,在JavaScript中,函数是作为数据类型存在的,所以函数具有数据的静态行为。而函数能够执行,所以具有代码的动态性。这两重特性的结合就使得函数是JavaScript的第一等公民。
  在JavaScript中定义一个函数很简单,比如如下的方式:

// 经典方式
function show(msg) {
alert(msg);
};
// 表达式方式
var show = function(msg) {
alert(msg);
};
// 使用new操作
var show = new Function('msg','alert(msg);');

  我相信,如非特殊,没人会选择第三种定义方式。第一种方式是最普通的函数定义方法,第二种方式成为函数表达式,体现了函数作为数据的一面,当然实际上上面的定义方式结果是一致的。

function的参数与返回值
  从上面函数的定义来说,形参列表中只需要写上参数的名字,这也符合动态语言的特点。如果函数有返回值的话,只需要在函数体中使用return语句返回一个值即可,与别的语言一致。

function的形参(parameter)与实参(argument)的访问
  对于别的语言来说,可能区分实参和形参的值没什么用处,但是在JavaScript中有时是非常重要的,因为JavaScript的函数调用时,实参个数不一定与形参一样多,也就是说有些参数可能会被省略(别的语言有Optional Parameter,JavaScript中直接是靠自己去判断的)。
  而且对于重载的问题,JavaScript也是完全靠形参和实参的个数由程序员自己去判断的,所以了解如何访问形参和实参就很重要了。
  相对来说,访问实参比访问形参更有意义,访问实参的方式是使用函数的隐含成员arguments,这是每个函数都有的内部成员,在函数内部可以直接访问。
  arguments对象是一个像数组一样方便访问(使用[]操作符),但是又不是数组的东东。它的常用用法是:
1. 使用[]操作符方便的拿到实参的值。
2. 使用arguments.length获得实参的个数。
3. 使用arguments.callee成员,可以拿到函数本身,这么做在大部分场合下是没什么意义的,但是在递归函数和匿名函数中作用还是很大的。而且arguments.callee.length可以获得形参的个数,这一点结合上面的第2点就可以实现函数重载。
  此外,有些函数需要知道是谁调用了自己,这个可以使用"函数名.caller"的方式获得到,不过注意,当该函数是在全局调用的时候返回的是undefined,也算是符合一般做法。当然了就像上面第3点说的那样,大多数情况下,获取函数自身的信息没什么意义,但是在递归或者匿名函数中,由于函数名字不再准确了,这时需要使用arguments.callee.caller去获得函数的调用者。
  说了这么多,看下面的用法:

function m(a, b)
{
alert(a);
alert(b);
alert(arguments.length);
alert(arguments[0]);
alert(arguments[1]);
alert(arguments.callee);
alert(arguments.callee.length);
alert(m.caller);
alert(arguments.callee.caller);
};
m(1);

  实际运行一下,你就会知道每个成员的作用。

function的调用与call,apply方法
  函数的直接调用太简单了,不需要啰嗦。不过与别的语言一样,JavaScript函数也存在一些特殊的调用方式,这就是call与apply方法。
  call与apply方法本质上是相同的,唯一的区别在于call方法从第二个参数起都作为被调用的函数的参数。而apply方法的第二个参数是一个数组,这个数组包含了所有的参数。
  先看一个例子,然后分析内部运行过程:

var func = new function() {
this.a = "func";
};
var myfunc = function(x, y) {
var a = "myfunc";
alert(this.a);
alert(x + y);
}; myfunc.call(func, "x", "y");
myfunc.apply(func, ["x", "y"]);

这段代码总共输出4次,2次"func",2次"xy"。

  简单来说,call/apply方法可将一个函数的对象上下文从初始的上下文(也就是函数内部的this指向的对象)改变为由第一个参数指定的新对象。如果没有提供第一个参数,那么全局对象window被用作this指向的对象。方法第一个参数后面的参数都是传递给当前方法的参数。
  对照上面的例子,细细品味例子中a值得变化吧。

function的作用域
  关于作用域大家应该并不陌生,函数的调用会在线程堆栈上创建的新区域,所有在函数体定义的参数和变量都作用在这个时间段内,当函数调用结束以后,函数的作用域就结束了,函数使用的参数和变量都会等待垃圾回收器释放。
  当然由于作用域的关系,函数外部的对象是无法访问这些内部变量的,除非是使用this暴露出去的变量,但是这么做并不是一种好的实践,到总结对象的时候我们会分析这种做法。
  上面说的是通常情况,有些情况是例外的,比如下面的闭包情况,在这种情况下,变量的作用域就被扩展了,就像越狱一样,需要等真正使用的地方结束以后才可能会被释放。
  除了这一点,还需要注意,不像C++这样的语言,你把几个语句包在{}之间就能开启一个新的作用域,JavaScript是没有这种块作用域的,所以在函数中定义的变量,不管是直接放在函数体下,还是放在函数的语句块中,比如if,while,for语句体中,作用域都是函数体的作用域。

  上面说完了普通的函数,下面再谈谈一些不普通的函数。
自执行函数
  在某些情况下,一些函数只需要执行一次,这时只需要定义一下,然后迅速执行即可。达到这种目的最简单的做法就是定义方法,然后调用一下,这么做有效但是似乎不够优雅,而且你也不能保证小伙伴们知道你的意图,刻意避免去再次调用这个方法,靠人还是不行的。于是各位达人们搞出了下列的各种写法:

// 正常调用一次的做法,这个方案的缺点是不能阻止别人调用这个方法
var func = function(msg) {
alert(msg);
};
func("auto!");
// 经典做法:使用函数的调用符号()去触发自执行
// 方式1:括号包在最外侧,很多人认为这个整体性比较好,于是推荐这种
(function(msg) {
alert(msg);
}("auto!"));
// 方式2:括号包在函数体外侧,这个像是正规的函数调用,个人喜欢这种风格
(function(msg) {
alert(msg);
})("auto!");
// 非典型性做法:使用其他各种运算符去触发自执行
// 方式3:使用[]触发
[function(msg) {
alert(msg);
}("auto!")];
// 方式4:使用一元操作符去触发,例如!,~,+,-,delete,void,new,var,typeof
! function(msg) {
alert(msg);
}("auto!");
// 方式5:使用多元操作符,例如,^,>,<,/,*,|,%,&
// 把下面例子中的逗号换成这些操作符的任何一个都可以触发
1, function(msg) {
alert(msg);
}("auto!");

  自执行函数就是这么多了,使用场景和方式都很简单,其实还有一个“立即调用”的说法,我不想去啰嗦了,分辨这两个概念我觉得是在无病呻吟,不说也罢。

  注意一点,上面的函数因为只执行一遍,所以大部分没有写函数名字,如果自执行需要递归的话(比如动画的效果),可以加上函数的名字,实现递归,看一个小例子:

// 一般是在初始化以后启动动画的循环
(function animloop() {
// 创建动画的推荐做法,不推荐使用setTimeout或者setInterval方法
window.requestAnimationFrame(animloop);
// 动画的逻辑,通常是更新窗口的内容,形成动画
self.tick();
})();

  上面的代码是经典的触发动画循环的代码。

匿名函数

  匿名函数其实就是没有名字的函数,上面的那些自执行的函数如果没有名字,那就是匿名函数了,概念很简单。

递归函数

  递归与循环算是算法界的难兄难弟了,大多时候,它们的实现是可以相互转化的,每次遇到它们,大家都会不由自主的去考虑效率的问题,普遍认为,由于递归存在多次方法调用的性能损失,所以用循环实现相关的功能性能要好一点,个人觉得在一定的递归次数内这个损失似乎可以忽略不计,毕竟在很多时候用递归去实现一个算法还是很方便的,如果用循环的话,实现起来会很啰嗦。

  看一下经典的斐波那契数列问题,问题就不详细说了,无非是算下面这个数列的某一个值:

1,1,2,3,5...

  这个数列的规律很明显,看一下在JavaScript中的实现:

function factorial(num) {
if(num <= 1) {
return 1;
}
else {
return num * factorial(num-1);
}
};

  这种写法在别的语言中并没什么问题,但是在JavaScript中就可能有问题了,因为函数也是一种数据,是可以去改的,看这段代码:

var anotherFactorial = factorial;
factorial = null;
alert(anoterFactorial(4));

  运行一下看看,你就会发现问题:递归调用时函数会发现factorial不存在了。

  解决这个问题也很简单,我们上一节已经讲过了,使用arguments.callee代替函数名:

function factorial(num) {
if(num <= 1) {
return 1;
}
else {
return num * arguments.callee(num-1);
}
};

  这下就没什么问题了,其实arguments.callee是唯一一个能实现匿名函数递归的方法

  其实对于当前的算法虽然够直接,但是效率并不高,因为每次递归计算factorial(n)的值得时候,有很多值其实已经重复算过很多遍了,如果能把这些值暂存起来,后面直接查询就行了,这是提高递归或者循环的常用技术手段,这里不展开说了。

  另外需要注意,递归是有一个潜在问题的,如果递归的层数太多的话,由于创建的调用堆栈太大,可能会引发Stack Overflow问题,这个是很显然了,使用递归的时候要考虑这个因素,不过就大多数非数学运算的操作来说,递归还是比较安全的。当然了如果这个递归可以很容易的转成循环来实现,那就不要犹豫,转成循环实现吧

闭包函数

  其实我想过用很多种描述去总结闭包函数,但是思虑良久,我还是觉得用“越狱”的说法最为贴切。

  所谓“闭包”,就是函数定义的变量作用域被人工延长了,不再局限于定义的函数中,就像*中的犯人,越狱以后活动空间被拓展了,不再局限于当前的牢房中了。

  所谓“闭包函数”,就是协助外层函数中定义的变量越狱的内层函数,也就是上面所说的人工方式。

  看一个简单的例子:

function generate(a) {
var n = parseInt(a);
return function() {
alert(n);
};
} var m1 = generate('10');
// ...
// 很久之后才执行
m1();

  体会一下在执行了定义m1那句话时,我们调用了generate方法,这个方法里面定义了变量n,按道理来说,generate方法调用结束以后,n的作用域也就结束了,但是由于n被我们返回的匿名函数使用了,而这个匿名函数返回给了m1,然后我们完全可能过了好一会儿才会去调用返回的m1,这样n的作用域就不能局限于在generate中了,它必须保持到匿名函数调用结束,这里就形成了一个闭包现象,而那个完成了这个高难度动作的匿名函数就是闭包函数,它其实就是一个使用了在外层函数变量的内层函数。

  闭包的含义还算是简单的,使用起来也比较灵活,功能很强大,闭包能让函数外面访问函数内部的数据,这是JavaScript面向对象编程的基石。但是让它臭名昭著的却是那个让人防不胜防的循环问题:

function generate() {
var callback = [];
for (var i = 0; i < 5; i++) {
callback.push(function () {
alert(i);
});
}
return callback;
}
var m1 = generate();
for (var i = 0; i < m1.length; i++) {
m1[i]();
}

  试着运行一下这个程序,看弹出的结果,是不是期望的0,1,2,3,4?不是的,是5,5,5,5,5。之所以出现这个问题,就在于在generate执行循环的时候,i的值已经从0增加到5了,而代码中的那些闭包函数还未执行,等到它们执行的时候,i早已经过递增变成5了,每个闭包函数都在这个时候使用i,结果当然都是弹出5。

  如何修复这个问题呢?既然我们已经了解了闭包的本质,那解决这个问题基本就是依靠创建新的函数作用域或者使用缓存等各种方式保存每次i的值了。看一些基本的方案:

// 1. 使用闭包函数传参的方式保护i每次循环的值
function generate() {
var callback = [];
for (var i = 0; i < 5; i++) {
(function (arg) {
var temp = arg;
callback.push(function () {
alert(temp);
});
})(i);
}
return callback;
} // 2. 使用闭包函数的变量保护i每次循环的值
function generate() {
var callback = [];
for (var i = 0; i < 5; i++) {
(function () {
var temp = i;
callback.push(function () {
alert(temp);
});
})();
}
return callback;
} // 3. 使用闭包函数的嵌套来保护i每次循环的值
function generate() {
var callback = [];
for (var i = 0; i < 5; i++) {
callback.push(function (arg) {
return function() {
alert(arg);
};
}(i));
}
return callback;
} // 4. 使用函数的构造函数来创建新的函数来保护i每次循环的值,每次创建新函数时都会创建新的拷贝
function generate() {
var callback = [];
for (var i = 0; i < 5; i++) {
callback.push(new Function("alert(" + i + ");"));
}
return callback;
} // 5. 创建全局的创建新函数的方法来保护i每次循环的值,原理同上
function generate() {
var callback = [];
for (var i = 0; i < 5; i++) {
callback.push(Function("alert(" + i + ");"));
}
return callback;
} // 6. 使用其他对象,比如函数自己,缓存i每次循环的值
function generate() {
var callback = [];
for (var i = 0; i < 5; i++) {
var func = function(){
alert(arguments.callee.i);
};
func.i = i; callback.push(func);
}
return callback;
}

  仔细体会一下每个实现中,使用的保护i的值的措施,对于最后一种使用对象缓存i值得方式,其实对象很多,因为在JavaScript中给对象添加成员很简单,所以可以找到很多对象充当这样的缓存,甚至包括各种HTML对象,这里就不展开说了。

  这个例子不是毫无意义的,它实际上是一个实际问题的抽象,在实际的编程中,我们通常会使用循环给一组具有类似功能的相同类型的按钮设置onclick函数,这个时候不经意间就会形成闭包,解决这类问题的方法就是上面列举的这些了,当然了,你也可以找到其他的方法来保护闭包引用的变量,因为道理是一样的。

上一篇:PHP的HashTable实现


下一篇:webpack的版本进化史