深入浅出JavaScript函数 v 0.5
本文的观点是建立在《JavaScript权威指南 6th Ed》 《JavaScript高级编程 3th Ed》 《JavaScript精粹 2th Ed》之上,
笔者将尽所能将概念、原理叙述清楚,由于笔者水平有限,理解不当之处在所难免,请读者不吝交流。
目录
在本文中,你将学到的概念:“调用”属性、匿名函数、函数声明提升、实参对象、扩充类型功能、嵌套函数、方法链、闭包、模块模式、记忆等
(这些概念均来自以上提到的三本书,这里列出来的目的是给读者提供索引。)
引用《JavaScript精粹》的一句话:JavaScript设计的最出色的的就是它的函数的实现,它几乎接近完美。
什么是函数?
函数是一组语句,是JavaScript的基础模块单元,用于代码复用、信息隐藏和组合调用。
函数默认的返回值是undefined值。
函数用于指定对象的行为(作为对象的方法)。
函数就是对象!
对象是“key/value”对的集合并拥有一个连接到原型对象的“指针”。(对象字面量产生的对象连接到Object.prototype,
函数对象连接到Function.prototype)【关于原型对象的描述,我将在以后的博文中分享给大家,到时候会在此给出链接】。
每个函数在创建的时候会附加两个隐藏属性:函数的上下文(this)和实现函数行为的代码(“调用”属性JavaScript函数调用的时候,就是调用了此函数的“调用”属性,这是函数与众不同的地方,可以被调用)
由于函数是对象,所以函数可以出现在对象能出现的任何位置(保存在变量、数组、对象中),还可以作为参数传递给其他函数,也可以作为其他函数的返回值。
而且,函数也可以拥有方法。
函数字面量(函数表达式)
函数对象通过函数字面量来创建:
1 // 创建一个名为 add 的变量,并用来把两个数字相加的函数赋值给它。 2 var add = function /*optional name*/ (a,b) { 3 return a + b; 4 } ; //注意结尾的分号
函数字面量可以出现在任何允许表达式出现的地方,也可以定义在其他函数中。
函数字面量包括四个部分:
- 第一部分是保留字 function。
- 第二部分是函数名,可选。主要用于函数递归(很好理解,函数得用它的名字递归调用自己吧),还能用来被调试器和开发工具来识别函数。如果没有给函数命名,则称为匿名函数。
- 第三部分是包围在圆括号中的一组参数,多个参数用逗号隔开(称为参数表达式)。参数的名称被定义为函数的变量,不像普通变量被初始化为undefined,而是在函数调用的时候初始化为实参的值。
- 第四部分是包围在花括号中的一组语句,是函数的主题,在函数被调用的时候执行。
tips: 函数字面量(函数表达式) 和函数声明的区别:
JavaScript解析器会率先读取函数声明,并使其在执行任何代码之前可用(函数声明提升);至于函数表达式,则必须等到解析器执行到它所在的代码行,才会真正的被解释执行。
举例说明:
alert (add (2,3)); //5 function add(a,b) { return a+b; } // 函数声明提升 //=====为了方便,笔者写在了一起,在测试的时候,可不要在一个作用域中执行哟=============== alert (add (2,3)); //error var add = function (a,b) { return a+b; }; //函数字面量,注意结尾的分号哟(细节很重要)。
另外,函数不能(有些浏览器可以,但是不建议)再if while for 等代码块里声明(特指函数声明哟),但是函数字面量可以出现在以上代码块中。
最后,函数字面量如果有函数名的话,则函数名在外部不可用。
举例说明:
var add = function add1 (a,b) { return a+b; }; add (2,3); // 返回5 add1(3,4); //ReferenceError: add1 is not defined
函数调用
调用一个函数时,除了声明时定义的形式参数,每个函数还接收两个附加的参数:this和arguments。
this的值取决于调用的模式(不同的模式,this的初始化也不一样)。JavaScript中共有4中调用模式:
- 方法调用模式
- 函数调用模式
- 构造器调用模式(构造函数调用)
- call()和apply()间接调用模式
调用运算符是跟在任何一个产生函数值的表达式之后的一对圆括号(多么凝练的语句啊)。
圆括号内可包含零个或多个用逗号隔开的表达式。每个表达式产生一个参数值。每个参数值被赋予函数声明时定义的形式参数。
JavaScript不对函数的参数个数和参数类型进行检查。
当实参和形参个数不匹配时,不会导致运行时错误。实参如果过多,超出的参数将会被忽略。如果过少,缺失的值将会被替换为undefined。
方法调用模式
当一个函数被保存为对象的一个属性时,它就是该对象的一个方法。当一个方法被调用时,this被绑定到该对象。
可以通过点表达式或者下标表达式来调用一个方法。
// 创建myObject 对象, 它有一个value 属性和一个increment 方法 // increment 方法接收一个可选的参数,如果参数不是一个数字,则默认使用数字1 var myObject = { value : 0, increment : fucntion (inc) { this.value += typeof inc ===‘number‘ ? inc : 1; } }; myObject.increment(‘joke‘); // 1 推荐使用 myObject["increment"](3); // 4
函数调用模式
当一个函数不是一个对象的属性时,它就是被当做一个函数来调用的:
var sum = add (5,6); // sum 的值为11
此模式下,this值被绑定到全局对象(在大部分浏览器中该对象是window对象)
构造器调用模式
如果在一个函数前面带上new 关键字来调用, 那么背地里将会创建一个连接到该函数的prototype 成员的新对象。
同时,this 会被绑定到那个新对象上。
// 创建一个名为Person 的构造函数,它构造一个带有name 和age 的对象 var Person = function (name,age) { this.name = name; this.age = age; }; // 给Person的所有实例(就是原型) 提供名为getName() 和getAge() 的方法 Person.prototype.getName = function (){ return this.name; }; Person.prototype.getAge = function () { return this.age; }; // 构造一个Person 实例 ,并测试 var tony = new Person (‘tony‘,23); console.log(‘name: ‘+tony.getName()+‘ \nage: ‘+tony.getAge());
一个函数,如果创建的目的就是希望结合new 前缀来调用,那么它就被称为构造器函数
构造器函数按照约定(仅仅是约定哟,但是约定优于配置的思想很重要),首字母都应该大写,这样可以避免调用时丢失new或者new普通函数等错误。
间接调用模式
JavaScript是一门函数式的面向对象编程语言,函数既然是对象,那么函数可以拥有方法。
其中两个方法 call() 和 apply() 方法可以用来间接的调用函数。两个方法的第一个参数可以绑定函数的this值。
它们的语法如下:
call([thisObj[,arg1[, arg2[, [,.argN]]]]]) // thisObj 是this要绑定的对象,后面是逗号分隔开的参数 apply([thisObj,[arglist]]) //thisObj 是this要绑定的对象,后面是以列表形式的参数。
举例说明二者的用法:
// 声明一个函数 var add = function (a,b) { return a+b; }; // 构造一个含有两个数字的数组 var arr = [5,6]; //===通过apply和call将它们相加=== var sum0 = add.apply(null, arr); var sum1 = add.call(null,arr[0],arr[1]); console.log(sum0); console.log(sum1);
函数的参数与返回值
在调用函数的时候,除了隐藏的this参数之外,还有arguments参数,它是一个类似于数组的对象。
arguments 拥有一个 length 属性,但它没有任何数组的方法。
函数可以通过此参数访问所有传递给函数的参数。
// 定义一个sum函数,它将所有参数进行相加,并返回相加之和 var sum = function (/*可是没形参的哟*/) { var sum = 0; for (var i = 0; i< arguments.length; ++i) { sum += arguments[i]; } return sum; }; console.log(sum(1,2,3,4,5,6,7));
看上面的例子,如果我们不对传入的参数进行类型检测,当传入的参数不都是Number类型,那么结果将不可预料。
因此,我们要改造sum函数使其能够检测到错误情况。
// 定义一个sum函数,它将所有参数进行相加,并返回相加之和 var sum = function (/*可是没形参的哟*/) { var sum = 0; for (var i = 0; i< arguments.length; ++i) { if( typeof arguments[i] !== ‘number‘ ){ throw { name: ‘TypeError‘, message: ‘sum needs numbers‘ }; } sum += arguments[i]; } return sum; }; console.log(sum(1,2,3,4,5,6,7)); try { console.log(sum(1,2,‘three‘,‘4‘)); } catch (e) { console.log(e.name+‘ : ‘+e.message); // TypeError : sum needs numbers }
一个函数被调用时,从第一句开始执行,并在遇到关闭函数体} 时结束。
但是 return 语句可用来使函数提前返回。 一个函数总是会返回一个值。如果没有指定返回值,则返回undefined。
如果函数调用时前面加上了 new 前缀 ,且返回值不是一个对象,则返回 this (该新对象)。
扩充类型的功能
JavaScript 允许给语言的基本类型扩充功能。 通过给类型的prototype添加方法,可以扩充该类型对象的功能。
这样的方式适用于函数、数组、字符串、数字、正则表达式和布尔值。
举例说明:
// 通过给Function.prototype 添加方法来使得该方法对所有函数可用 if (! Function.prototype.hasOwnProperty(‘method‘)){ // 检测Function原型是否已存在method属性 Function.prototype.method = function (/*String*/name, /*function*/func) { //首先应该检查参数是否合乎标准 if (typeof name !==‘string‘ || typeof func !== ‘function‘){ // 抛出异常。这里不再赘述 } //其次还要检查name是否已经存在于原型中。 if (! this.prototype[name]){ //抛出异常。不再赘述。 } this.prototype[name] = func; return this; }; }
上例中比较完整的给出了如何扩充类型功能的方法,
通过给Function.prototype 增加一个method 方法,下次给对象添加方法的时候就不用再键入prototype。
首先判断method是否已经存在于原型中,然后将method方法注册到原型。在函数中先检查参数是否合法,再检查name函数已经存在于原型中。
下面就用method 方法注册一个方法到Number原型中(Number 注册method方法和Function类似)。
Number.method(‘integer‘, function () { return Math[this < 0 ? ‘ ceil ‘ : ‘floor‘] (this); });
递归函数
递归函数就是会直接或间接地调用自身的一种函数,它将一个问题分解成一组相似的子问题,每一个都用一个寻常解(明显解)去解决。
递归函数可以非常高效的操作树形结构。
使用递归函数计算一个数的阶乘(参见JS 高级程序设计 第三版 p177):
function factorial (num) { if (num <=1){ return 1; }else { return num* factorial(num -1 ); } }
虽然这个函数表面看起来没什么问题,但下面的代码却能导致它出错。
var anotherFactorial = factorial; factorial = null; alert(anotherFactorial (5)); // 出错!
在将factorial设置为null后,再执行anotherFactorial (5) 时,由于必须执行factorial () ,而factorial 以不再是函数,所以导致错误。
那么怎样写出健壮的递归函数呢?
方法一: 还记得arguments对象吧,该对象有一个属性callee指向正在执行的函数的指针,因此可以用它来实现对函数的递归调用。
function factorial (num) { if (num <=1){ return 1; }else { return num* arguments.callee(num -1 ); } }
方法二(推荐):在严格模式(什么是严格模式?)下,不能通过脚本访问arguments.callee ,访问该属性会导致错误,
不过可以使用函数字面量(命名函数表达式:只不过是没有省略函数名的函数表达式。)来达到相同的结果。
var factorial = (function fact (num) { // 函数名 fact 在外部访问是undefined if (num <=1){ return 1; } else { return num * fact (num -1); } });
闭包
在说闭包之前,简单说说JavaScript 作用域的问题,JavaScript不支持块级作用域。
if (true) { var a = 1; console.log(a); // 1 } console.log(a); //1
JavaScript 确实有函数作用域,这意味着定义在函数中的参数和变量在函数外部是不可见的,而在一个函数内部任何位置定义的变量,在该函数内部任何地方都可见。
由于JS缺少块级作用域,所以最好的做法是在函数体的顶部声明所有可能要用到的变量。
作用域的好处是内部函数可以访问定义它们的外部函数的参数和变量(除了this和arguments:将this和arguments赋值给其他变量,可以间接访问到。)
更美好的是,内部函数拥有比他外部函数更长的声明周期。
var myObj = ( function () { var value = 0; return { increment: function (inc) { value += typeof inc === ‘number‘ ? inc : 1; }, getValue: function () { return value; } }; } () );
最外部的匿名函数中定义变量value,并返回拥有两个方法的对象,这些方法继续享有访问value变量的特权。该对象不能被非法修改value的值,只能通过两个方法来修改。
两个方法(函数)可以访问它被创建时所处的上下文环境,这就被称为闭包。闭包是指有权访问另一个函数作用域中的变量的函数。
理解内部函数(嵌套函数)能访问外部函数的实际变量,而无须复制是很重要的。
下面引用《JS 精粹 》 p38页的例子来说明:
// 糟糕的例子 // 构造一个函数,用错误的方式给一个数组中的节点设置事件处理程序。 // 当点击一个节点时,按照预期,应该弹出一个对话框显示节点的序号, // 但它总是会显示节点的数目 var add_the_handlers = function (nodes) { var i; for (i = 0; i < nodes.length; ++i){ nodes[i].onclick = function (e) { alert(i); }; } }; //每一个事件处理函数,都弹出一个对话框显示节点的数目 nodes.length
该函数的本意是想传递给每个事件处理器一个唯一的值(i),但它未能达到目的,因为事件处理器绑定了变量i本身,而不是函数在构造时的变量i的值。(理解了吗? 不理解的话看下一个例子)
// 改良后的例子 // 构造一个函数, 用正确的方式给一个数组中的节点设置事件处理程序, // 点击一个节点,将会弹出一个对话框显示节点的序号。 var add_the_handlers = function (nodes) { var i; var helper = function (i) {
return function (e){ alert(i);
}; }; for(i = 0; i < nodes.length; ++ i){ nodes[i].onclick = helper(i); } };
避免在循环中创建函数,先在循环之外创建一个辅助函数,让这个辅助函数再返回一个绑定了当前i值的函数。
模块模式
可以使用函数和闭包来构造模块。模块是一个提供接口却隐藏状态与实现的函数或对象。
通过使用函数产生模块,几乎可以完全摒弃全局变来那个的使用。
- 一个定义了私有变量和函数的函数;
- 利用闭包创建可以访问私有变量和函数的特权函数;
- 最后返回这个特权函数,或者把它们保存到一个可以访问到的地方(变量,数组,或者对象中)。
使用模块模式可以摒弃全局变量的使用,促进了信息隐藏和其他优秀的设计实践。对于应用程序的封装,或者构造其他单例对象,模块模式非常有效。
模块模式可以用来产生安全对象,假定想要构造一个用来产生序列号的对象:
var serial_maker = function () { // 返回一个用来产生唯一字符串的对象 // 唯一字符串由两部分组成:前缀+序列号 // 该对象包含一个设置前缀的方法和一个设置序列号的方法 // 还有一个得到字符串的方法 var prefix = ‘‘; var seq = 0; return { setPrefix: function (pre) { this.prefix = pre; }, setSeq: function (seq) { this.seq = seq; }, gensym: function () { return this.prefix+this.seq; } }; }; var seqer = serial_maker (); seqer.setPrefix("QAZ"); seqer.setSeq(10011); console.log(seqer.gensym());
级联(方法链)
有一些方法没有返回值,如果我们让这些方法返回this而不是undefined,就可以启用级联。
在一个级联中,可以单独在一条语句中依次调用同一个对象的很多方法。
举个Ajax类库的例子。
// 某Ajax类库的级联调用 getElement (‘myBoxDiv‘) .move (100,200) .width (100) .height (200)l;
级联技术可以产生出极富表现力的接口。 // 我觉得级联在某些场合比较好用,但不要滥用。
记忆
函数可以将先前操作的结果记录在某个对象里,从而避免无所谓的重复运算,这种优化被称为记忆。JavaScript的对象和数组要实现这种优化非常方便。
比如用递归函数来计算Fibonacci 数列:
var fibonacci = (function fib (n) { return n < 2 ? n : fib (n-1) + fib (n-2); }); for (var i = 0; i <= 10; ++i){ console.log (fibonacci (i)+ ‘\n‘); }
但是这样它做了很多无谓的工作,如果我们让该函数具备记忆功能,就可以显著的减少运算量。
var fibonacci = function () { var memo = [0,1]; var fib = function (n) { if (typeof memo[n] !== ‘number‘){ memo [n] = fib (n-1) + fib (n-2); } return memo[n]; }; return fib; }();
for (var i = 0; i <= 10; ++i){ console.log (fibonacci (i)+ ‘\n‘); }
函数拾遗
函数没有重载
ECMAScript 函数不能像传统意义上那样实现重载,而其他语言中,可以为一个函数编写两个定义。只要这两个定义(接受的参数的类型和数量)不同即可。
ECMAScript函数没有签名,因为其参数是由包含零个或多个值的“数组”来表示的。而没有函数签名,真正的重载是不能做到的。
模仿块级作用域
匿名函数可以模仿块级作用域,用块级作用域(通常称为私有作用域) 的匿名函数的语法如下:
(function () { // 这里是块级作用域 })();
什么是严格模式?
ECMAScript 5 引入了严格模式概念,严格模式是为JavaScript 定义了一种不同的解析和执行模型。在严格模式下,ECMAScript 3 中一些不确定的行为将得到处理,而对某些不安全的操作也会抛出错误。
在整个脚本中启用严格模式,可以在顶部添加如下代码:
"use strict";
当然也可以在某一函数中启用严格模式
function doSomething () { "use strict"; // 函数体 }