《JavaScript模式》函数

原文:https://github.com/TooBug/javascript.patterns/blob/master/chapter4.markdown

熟练运用函数是JavaScript程序员的必备技能,因为在JavaScript中函数实在是太常用了。它能够完成各种类型的任务,而在其他语言中则需要很多特殊的语法支持才能拥有这种能力。

本章将会介绍在JavaScript中定义函数的几种方式,包括函数表达式和函数声明以及局部作用域和变量声明提前的工作原理。然后会介绍一些有用的模式,帮助你设计API(为你的函数提供更好的接口)、搭建代码架构(使用尽可能少的全局对象)、并优化性能(避免不必要的操作)。

现在让我们来一起揭秘JavaScript函数,首先从一些背景知识开始说起。

背景知识

JavaScript的函数具有两个主要特性,正是这两个特性让它们与众不同。第一个特性是,函数是一等对象(first-class object),第二个是函数提供作用域支持。

函数是对象,那么:

  • 可以在程序执行时动态创建函数
  • 可以将函数赋值给变量,可以将函数的引用拷贝至另一个变量,可以扩充函数,除了某些特殊场景外均可被删除
  • 可以将函数作为参数传入另一个函数,也可以被当作返回值返回
  • 函数可以包含自己的属性和方法

有可能会有这样的情况:一个函数A,它也是一个对象,拥有属性和方法,其中某个属性是另一个函数B,B可以接受函数作为参数,假设这个函数参数为C,当执行B的时候,返回另一个函数D。乍一看这里有一大堆相互关联的函数,但当你开始习惯函数的许多用法时,你会惊叹原来函数是如此灵活、强大且富有表现力。通常说来,一说到JavaScript的函数,我们首先认为它是一个对象,具有一个可以“执行”的特性,也就是说我们可以“调用”这个函数。

我们通过new Function()构造函数来创建一个函数,这时可以明显看出函数是对象:

// 反模式,仅用于演示
var add = new Function('a, b', 'return a + b');
add(1, 2); // 返回 3

在这段代码中,毫无疑问add()是一个对象,因为它是由构造函数创建的。这里并不推荐使用Function()构造函数来创建函数(和eval()一样糟糕),因为程序逻辑代码是以字符串的形式传入构造器的。这样的代码可读性差,写起来也很费劲,你还要对代码中的引号做转义处理,并需要特别关注为了保持可读性而保留的空格和缩进。

函数的第二个重要特性是它能提供作用域支持。在JavaScript中没有块级作用域(译注:在JavaScript1.7中提供了块级作用域部分特性的支持,可以通过let来声明块级作用域内的“局部变量”),也就是说不能通过花括号来创建作用域,JavaScript中只有函数作用域(译注:这里只针对函数而言,此外JavaScript还有全局作用域)。在函数内所有通过var声明的变量都是局部变量,在函数外部是不可见的。刚才所说的花括号无法提供作用域支持的意思是说,如果在if条件句、forwhile循环体内用var定义了变量,这个变量并不是属于if语句或forwhile)循环的局部变量,而是属于它所在的函数。如果不在任何函数内部,它会成为全局变量。在第二章里提到我们要减少对全局命名空间的污染,那么使用函数则是控制变量作用域的最佳选择。

术语

首先我们先简单讨论下与创建函数相关的术语,因为精确无歧义的术语约定非常重要。

看下这个代码片段:

// 具名函数表达式
var add = function add(a, b) {
	return a + b;
};

这段代码描述了一个函数,这种描述称为“具名函数表达式”。

如果省略掉函数表达式中的名字(比如下面的示例代码),这时它是“匿名函数表达式”,通常我们称之为“匿名函数”,比如:

// 匿名函数表达式,又称匿名函数
var add = function (a, b) {
	return a + b;
};

因此“函数表达式”是一个更广义的概念,“具名函数表达式”是函数表达式的一种特殊形式,仅仅当需要给函数定义一个可选的名字时使用。

当省略第二个add,它就成了没有名字的函数表达式,这不会对函数定义和调用语法造成任何影响。带名字和不带名字唯一的区别是函数对象的name属性是否为空字符串。name属性属于语言的扩展(未在ECMA标准中定义),但很多环境都实现了。如果不省略第二个add,那么add.name是”add”,name属性在用像Firebug之类的调试工具进行调试的过程中非常有用,它也可以让函数递归调用自身,如果是其他情况,则可以省略它。

最后来看一下“函数声明”,函数声明的语法和其他语言中的语法非常类似:

function foo() {
	// 函数体
}

从语法上来看,具名函数表达式和函数声明非常像,特别是当不需要将函数表达式赋值给一个变量的时候(在本章后面所讲到的回调模式中有类似的例子)。多数情况下,函数声明和具名函数表达式在外观上没有多少不同,只是它们在函数执行时对上下文的影响有所区别,下一小节会讲到。

两种语法的一个区别是末尾的分号。函数声明末尾不需要分号,而函数表达式末尾是需要分号的。推荐你始终不要丢掉函数表达式末尾的分号,即便JavaScript可以进行分号补全,也不要冒险这样做。

另外我们经常看到“函数字面量”。它用来表示函数表达式或具名函数表达式。由于这个术语是有歧义的,所以最好不要用它。

声明 vs 表达式:命名与提前

那么,到底应该用哪个呢?函数声明还是函数表达式?在不能使用函数声明语法的场景下,就只能使用函数表达式了,将函数作为参数传递、在对象字面量中定义方法都是这样的例子:

// 作为参数传递给callMe的函数表达式
callMe(function () {
	// 我是匿名函数表达式,也叫匿名函数
});

// 这是一个具名函数表达式
callMe(function me() {
	// 我是具名函数表达式,我的名字是“me”
});

// 另一个函数表达式
var myobject = {
	say: function () {
		// 我是函数表达式
	}
};

函数声明只能出现在“程序代码”中,也就是说在别的函数体内或在全局。这个定义不能赋值给变量或属性,同样不能作为函数调用的参数。(译注:注意这里说的是函数声明的语句,而不是通过声明语句定义出来的函数本身。任何函数都是可以被赋值给变量和属性的,也可以被作为参数传递。)下面这个例子是函数声明的合法用法,这里所有的函数foo()bar()local()都使用函数声明来定义:

// 全局作用域
function foo() {}

function local() {
	// 本地作用域
	function bar() {}
	return bar;
}

函数的name属性

选择用哪种模式定义函数时的另一个考虑是只读属性name的可用性。尽管标准规范中并未定义,但很多运行环境都实现了name属性,在函数声明和具名函数表达式中是有name属性的。在匿名函数表达式中,则不一定有定义,这个是和实现相关的,在IE中是无定义的,在Firefox和Safari中是有定义的,但是值为空字符串。

function foo() {} // 函数声明
var bar = function () {}; // 匿名函数表达式
var baz = function baz() {}; // 具名函数表达式

foo.name; // "foo"
bar.name; // ""
baz.name; // "baz"

在Firebug或其他工具中调试程序时name属性非常有用,它可以用来显示当前正在执行的函数。同样可以通过name属性来递归地调用函数自身。如果你对这些场景不感兴趣,那么请尽可能地使用匿名函数表达式,这样会更简单、且冗余代码更少。

相对函数声明而言,函数表达式的语法更能说明函数是一种和其它对象类似的对象,而不是语言中某种特别的组成部分。

我们可以将一个带名字的函数表达式赋值给变量,变量名和函数名不同,这在技术上是可行的。比如:var foo = function bar(){};。然而,这种用法的行为在浏览器中的兼容性不好(特别是IE中),因此并不推荐大家使用这种模式。

声明提前

通过前面的讲解,你可能以为函数声明和具名函数表达式是完全等价的。事实上并不是这样,主要区别在于“声明提前”的行为。

术语“提前”并未在ECMAScript中定义,但是它是一种很好地描述这种行为的方法。

我们知道,不管在函数内何处声明变量,变量都会自动提前至函数体的顶部。对于函数来说亦是如此,因为他们也是一种对象,赋值给了变量。需要注意的是,函数声明定义的函数不仅能让声明提前,还能让定义提前,看一下这段示例代码:

// 反模式,仅用于演示

// 全局函数
function foo() {
	alert('global foo');
}
function bar() {
	alert('global bar');
}

function hoistMe() {

	console.log(typeof foo); // "function"
	console.log(typeof bar); // "undefined"

	foo(); // "local foo"
	bar(); // TypeError: bar is not a function

	// 函数声明:
	// 变量foo和它的定义实现都被提前了

	function foo() {
		alert('local foo');
	}

	// 函数表达式:
	// 只有变量bar被提前,它的定义实现没有被提前
	var bar = function () {
		alert('local bar');
	};
}
hoistMe();

在这段代码中,和普通的变量一样,hoistMe()函数中的foobar被“搬运”到了顶部,覆盖了全局的foo()bar()。不同之处在于,本地的foo()的位置并不在前面,但它的定义却被提前到了顶部并能正常工作,而bar()的定义并未提前,只有声明提前了。因此当程序执行到bar()定义的位置之前,它的值都不是函数,而是undefined(在此期间全局的bar()都是被本地覆盖的)。

到目前为止我们介绍了必要的背景知识和函数定义相关的术语,下面开始介绍一些JavaScript所提供的函数相关的模式,我们从回调模式开始。再次强调JavaScript函数的两个特性,掌握这两点至关重要:

  • 函数是对象
  • 函数提供本地变量作用域

回调模式

函数是对象,也就意味着函数可以当作参数传入另外一个函数中。给函数writeCode()传入一个函数参数introduceBugs(),在某个时刻writeCode()执行了(或调用了)introduceBugs(),在这种情况下,我们称introduceBugs()是一个“回调函数”,简称“回调”:

function writeCode(callback) {
	// 做点什么……
	callback();
	// ……
}

function introduceBugs() {
	// ……
}

writeCode(introduceBugs);

注意introduceBugs()作为参数传入writeCode()时,函数后面是不带括号的。括号的意思是执行函数,而这里我们希望传入一个引用,让writeCode()在合适的时机执行它(调用它)。

回调的例子

我们从一个例子开始,首先介绍无回调的情况,然后再进行修改。假设你有一个通用的函数,用来完成某种复杂的逻辑并返回一大段数据。假设这个通用函数叫findNodes(),用来对DOM树进行遍历,并返回页面节点:

var findNodes = function () {
	var i = 100000, // 大量耗时的循环
		nodes = [], // 存储结果
		found; // 标示下找到的节点
	while (i) {
		i -= 1;
		// 这里是复杂的逻辑……
		nodes.push(found);
	}
	return nodes;
};

保持这个函数的功能的通用性,让它只返回DOM节点组成的数组,而不对节点进行操作是一个很好的思想。可以将操作节点的逻辑放入另外一个函数中,比如hide()函数,这个函数用来隐藏页面中的节点元素:

var hide = function (nodes) {
	var i = 0, max = nodes.length;
	for (; i < max; i += 1) {
		nodes[i].style.display = "none";
	}
};

// 执行函数
hide(findNodes());

这个实现的效率并不高,因为它将findNodes()所返回的节点数组重新遍历了一遍。更高效的办法是在findNodes()中选择元素的时候就直接应用hide()操作,这样就能避免第二次的遍历,从而提高效率。但如果将hide()的逻辑写死在findNodes()的函数体内,findNodes()就变得不再通用了,因为修改逻辑和遍历逻辑耦合在一起了。这时候如果使用回调模式,就可以将隐藏节点的逻辑写入回调函数,将其传入findNodes()中适时执行:

// 重构后的findNodes()接受一个回调函数
var findNodes = function (callback) {
	var i = 100000,
		nodes = [],
		found;
	
	// 检查回调函数是否可以执行
	if (typeof callback !== "function") {
		callback = false;
	}
	while (i) {
		i -= 1;

		// 这里是复杂的逻辑……

		// 回调:
		if (callback) {
			callback(found);
		}

		nodes.push(found);
	}
	return nodes;
};

这里的实现比较直接,findNodes()多作了一个额外工作,就是检查回调函数是否存在,如果存在的话就执行它。回调函数是可选的,因此修改后的findNodes()仍然可以和之前一样使用,是可以兼容旧代码和旧API的。

这时hide()的实现就非常简单了,因为它不用对元素列表做任何遍历了:

// 回调函数
var hide = function (node) {
	node.style.display = "none";
};

// 找到节点并隐藏它们
findNodes(hide);

回调函数可以是事先定义好的,像上面的代码一样,也可以是一个在调用函数时创建的匿名函数,比如这段代码,我们利用同样的通用函数findNodes()来完成显示元素的操作:

// 传入匿名回调函数
findNodes(function (node) {
	node.style.display = "block";
});

回调和作用域

在上一个例子中,执行回调函数的写法是:

callback(parameters);

尽管这种写法很简单,而且可以适用于大多数的情况,但还有一些场景,回调函数不是匿名函数或者全局函数,而是对象的方法,如果这种情况下回调函数中使用了this指向它所属的对象,则回调逻辑就可能不是我们期望的那样。

假设回调函数是paint(),它是myapp的一个方法:

var myapp = {};
myapp.color = "green";
myapp.paint = function (node) {
	node.style.color = this.color;
};

函数findNodes()大致如下:

var findNodes = function (callback) {
	// ...
	if (typeof callback === "function") {
		callback(found);
	}
	// ...
};

当你调用findNodes(myapp.paint)时,运行结果和我们期望的不一致,因为this.color未定义。这时候this指向的是全局对象,因为findNodes()是全局函数。如果findNodes()是dom对象的方法(类似dom.findNodes()),那么回调函数内的this指向该dom,而不是myapp

解决办法是,除了传入回调函数,还需将回调函数所属的对象当作参数传进去:

findNodes(myapp.paint, myapp);

同样需要修改findNodes()的逻辑,增加对传入的对象的绑定:

var findNodes = function (callback, callback_obj) {
	//...
	if (typeof callback === "function") {
		callback.call(callback_obj, found);
	}
	// ...
};

在后续的章节会对call()apply()有更详细的讲述。

其实还有一种替代写法,就是将函数名称以字符串传入findNodes(),这样就不必再写一次对象了,也就是说:

findNodes(myapp.paint, myapp);

可以写成:

findNodes("paint", myapp);

findNodes()中的逻辑则需要修改为:

var findNodes = function (callback, callback_obj) {

	if (typeof callback === "string") {
		callback = callback_obj[callback];
	}

	//...
	if (typeof callback === "function") {
		callback.call(callback_obj, found);
	}
	// ...
};

异步事件监听

JavaScript中的回调模式已经是我们的家常便饭了,比如,如果你给网页中的元素绑定事件,则需要提供回调函数的引用,以便事件发生时能调用到它。这里有一个简单的例子,我们将console.log()作为回调函数绑定到了document的点击事件上:

document.addEventListener("click", console.log, false);

客户端浏览器中的大多数编程都是事件驱动的,当网页下载完成,则触发load事件,当用户和页面产生交互时也会触发多种事件,比如clickkeypressmouseovermousemove等等。JavaScript天生适合事件驱动编程,因为回调模式能够让程序“异步”执行,换句话说,就是让程序不按顺序执行。

“不要打电话给我,我会打给你”,这是好莱坞很有名的一句台词,可能很多人会对同一个角色说这句话,而电影中的主角不可能同时应答这些人的电话呼叫。在JavaScript的异步事件模型中也是同样的道理,不同的是,电影中是留下电话号码,JavaScript中是提供一个在适当的时机被调用的回调函数。有时甚至可以提供比实际需要更多的回调函数,因为可能某个特定的事件永远不会发生。比如,假设用户一直不点击“购买”,那么你之前写的用来验证信用卡号格式的函数就永远不会被调用执行。(译注:这段话有点不好翻译,前面的比喻看不懂。后面有两个方面的意思,一方面指回调函数并不一定会被执行,如果事件不发生,那么回调函数就永远不会被执行;另一方面指可以通过多个事件来绑定同一个回调函数,因为你无法确定用户会触发哪一个事件,比如到底是键盘操作还是鼠标操作。)

延时

另外一个最常用的回调模式是在调用延时函数的时候。延时函数是浏览器window对象的方法,共有两个:setTimeout()setInterval()。这两个方法的参数都是回调函数。

var thePlotThickens = function () {
	console.log('500ms later...');
};
setTimeout(thePlotThickens, 500);

再次提醒,函数名thePlotThickens是作为变量传入setTimeout的,它不带括号,如果带括号的话就被立即执行了,而这里只是用到这个函数的引用,以便在setTimeout()的逻辑中调用它。也可以传入字符串"thePlotThickens()",但这是一种反模式,和eval()一样不推荐使用。

类库中的回调

回调模式非常简单,但又很强大,可以信手拈来灵活运用,因此这种模式在类库的设计中也非常得宠。类库的代码要尽可能保持通用和可复用,而回调模式则可帮助库的作者达成这个目标。你不必预料并实现你所想到的所有情形,这会让类库变得臃肿,而且大多数用户并不需要这些多余的特性支持。相反,你将精力放在核心功能的实现上,提供回调的入口作为“钩子”,可以让类库的方法变得可扩展、可定制。

返回函数

函数是对象,因此可以作为返回值。也就是说,函数不一定非要返回一坨数据,函数也可以返回另外一个函数,或者可以根据输入的不同按需创造另外一个函数。

这里有一个简单的例子:一个函数完成了某种功能,可能是一次性初始化,然后做了一些对返回值的操作,而这个返回值恰巧是另一个函数:

var setup = function () {
	alert(1);
	return function () {
		alert(2);
	};
};

// 使用setup()函数
var my = setup(); // alerts 1
my(); // alerts 2

因为setup()包裹了返回的函数,因此它创建了一个闭包,我们可以用这个闭包来存储一些私有数据,这些私有数据可以通过返回的函数进行操作,但在函数外部不能直接读取到这些私有数据。比如这个例子中提供了一个计数器,每次调用这个函数时,计数器都会加一:

var setup = function () {
	var count = 0;
	return function () {
		return (count += 1);
	};
};

// 使用
var next = setup();
next(); // 返回 1
next(); // 2
next(); // 3

重定义函数

函数可以被动态定义,也可以被赋值给变量。如果将你定义的函数赋值给已经存在的函数变量的话,则新函数会覆盖旧函数。这样做的结果是,旧函数的引用被丢弃掉,变量中所存储的引用值替换成了新的函数。这样看起来这个变量指代的函数逻辑就发生了变化,或者说函数进行了“重新定义”或“重写”。听起来很麻烦,但实际上并不复杂,来看一个例子:

var scareMe = function () {
	alert("Boo!");
	scareMe = function () {
		alert("Double boo!");
	};
};
// 使用重定义函数
scareMe(); // Boo!
scareMe(); // Double boo!

当函数中包含一些初始化操作,并希望这些初始化操作只执行一次,那么这种模式是非常合适的,因为我们要避免重复执行不需要的代码。在这个场景中,函数执行一次后就被重写为另外一个函数了。

使用这种模式可以帮助提高应用的执行效率,因为重新定义的函数执行的代码量更少。

这种模式的另外一个名字是“函数的懒惰定义”,因为直到函数执行一次后才重新定义,可以说它是“某个时间点之后才存在”,简称“懒惰定义”。

这种模式有一个明显的缺陷,就是之前给原函数添加的功能在重定义之后都丢失了。同时,如果这个函数被重定义为不同的名字,被赋值给不同的变量,或者是作为对象的方法使用,那么重定义的部分并不会生效,原来的函数依然会被执行。

让我们来看一个例子,scareMe()函数在这里作为一等对象来使用:

  1. 给他增加了一个属性
  2. 函数对象被赋值给一个新变量
  3. 函数还被作为方法来调用

看一下这段代码:

// 1. 添加一个新属性
scareMe.property = "properly";

// 2. 被赋值给一个不同名的变量
var prank = scareMe;

// 3. 作为方法使用
var spooky = {
	boo: scareMe
};

// 使用新名字调用
prank(); // "Boo!"
prank(); // "Boo!"
console.log(prank.property); // "properly"

// 作为方法调用
spooky.boo(); // "Boo!"
spooky.boo(); // "Boo!"
console.log(spooky.boo.property);	// "properly"

// 使用重定义函数
scareMe(); // Double boo!
scareMe(); // Double boo!
console.log(scareMe.property); // undefined

从结果来看,当重定义函数被赋值给一个新的变量的时候,这段使用重定义函数的代码的执行结果与我们期望的结果可能并不一样。每当prank()被调用的时候,它都弹出“Boo!”。同时它也重写了scareMe()函数,但是prank()自己仍然能够使用之前的定义,包括属性property。在这个函数被作为spooky对象的boo()方法调用的时候,结果也一样。所有的这些调用,在第一次的时候就已经修改了全局的scareMe()的指向,所以当它最终被调用的时候,它的函数体已经被修改为弹出“Double boo”,也就不能获取到新添加的属性scareMe.property

即时函数

即时函数是一种语法模式,它会使函数在定义后立即执行。看这个例子:

(function () {
	alert('watch out!');
}());

这种模式本质上只是一个在创建后就被执行的函数表达式(具名或者匿名)。“即时函数”这种说法并没有在ECMAScript标准中被定义,但它作为一个名词,有助于我们的描述和讨论。

这种模式由以下几个部分组成:

  • 使用函数表达式定义一个函数。(不能使用函数声明。)
  • 在最后加入一对括号,这会使函数立即被执行。
  • 把整个函数包裹到一对括号中(只在没有将函数赋值给变量时需要)。

下面这种语法也很常见(注意右括号的位置),但是JSLint倾向于第一种:

(function () {
	alert('watch out!');
})();

这种模式很有用,它为我们提供一个作用域的沙箱,可以在执行一些初始化代码的时候使用。设想这样的场景:当页面加载的时候,你需要运行一些代码,比如绑定事件、创建对象等等。所有的这些代码都只需要运行一次,所以没有必要创建一个带有名字的函数。但是这些代码需要一些临时变量,而这些变量在初始化完之后又不会再次被用到。显然,把这些变量作为全局变量声明是不合适的。正因为如此,我们才需要即时函数。它可以把你所有的代码包裹到一个作用域里面,而不会暴露任何变量到全局作用域中:

(function () {

	var days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
		today = new Date(),
		msg = 'Today is ' + days[today.getDay()] + ', ' + today.getDate();

	alert(msg);

}()); // "Today is Fri, 13"

如果这段代码没有被包裹到立即执行函数中,那么变量daystodaymsg都会是全局变量,而这些变量仅仅是因为初始化而遗留下来的垃圾,没有任何用处。

即时函数的参数

即时函数也可以接受参数,看这个例子:

// 打印出:
// I met Joe Black on Fri Aug 13 2010 23:26:59 GMT-0800 (PST)

(function (who, when) {

	console.log("I met " + who + " on " + when);

}("Joe Black", new Date()));

通常我们会把全局对象当作一个参数传给即时函数,以保证在函数内部也可以访问到全局对象,而不是使用window对象,这样可以使得代码在非浏览器环境中使用时更具可移植性。

值得注意的是,一般情况下尽量不要给即时函数传入太多的参数,否则会有一件麻烦的事情,就是你在阅读代码的时候需要频繁地上下滚动代码。

即时函数的返回值

和其它的函数一样,即时函数也可以返回值,并且这些返回值也可以被赋值给变量:

var result = (function () {
	return 2 + 2;
}());

如果省略括号的话也可以达到同样的目的,因为如果需要将返回值赋给变量,那么第一对括号就不是必需的。省略括号的代码是这样子:

var result = function () {
	return 2 + 2;
}();

这种写法更简洁,但是同时也容易造成误解。如果有人在阅读代码的时候忽略了最后的一对括号,那么他会以为result指向了一个函数。而事实上result是指向这个函数运行后的返回值,在这个例子中是4。

还有一种写法也可以得到同样的结果:

var result = (function () {
	return 2 + 2;
})();

前面的例子中,即时函数返回的是一个基本类型的数值。但事实上,一个即时函数可以返回任意类型的值,甚至返回一个函数都可以。你可以利用即时函数的作用域来存储一些私有的数据,这些数据只能在返回的内层函数中被访问。

在下面的例子中,即时函数的返回值是一个函数,这个函数会简单地返回res的值,并且这个值被赋给了变量getResult。而res是一个预先计算好的变量,它被存储在即时函数的闭包中:

var getResult = (function () {
	var res = 2 + 2;
	return function () {
		return res;
	};
}());

在定义一个对象属性的时候也可以使用即时函数。设想一下这样的场景:你需要定义一个对象的属性,这个属性在对象的生命周期中都不会改变,但是在定义之前,你需要做一些计算来得到它的值。这种情况下你就可以使用即时函数来包裹那些额外的计算工作,然后将它的返回值作为对象属性的值。下面是一个例子:

var o = {
	message: (function () {
		var who = "me",
			what = "call";
		return what + " " + who;
	}()),
	getMsg: function () {
		return this.message;
	}
};

// 使用对象
o.getMsg(); // "call me"
o.message; // "call me"

在这个例子中,o.message是一个字符串,而不是一个函数,但是它需要一个函数在脚本载入后通过计算得到这个属性值。

好处和用法

即时函数应用很广泛。它可以帮助我们做一些不想留下全局变量的工作。所有定义的变量都只是即时函数的本地变量,你完全不用担心临时变量会污染全局对象。

即时函数还有一些名字,比如“自调用函数”或者“自执行函数”,因为这些函数会在被定义后立即执行自己。

这种模式也经常被用到书签代码中,因为书签代码有可能会运行在任何一个页面中,所以需要非常苛刻地保持全局命名空间干净。

这种模式也可以让你包裹一些独立的特性到一个封闭的模块中。设想你的页面是静态的,在没有JavaScript的时候工作正常,然后,本着渐进增强的精神,你给页面加入了一点增强代码。这时候,你就可以把你的代码(也可以叫“模块”或者“特性”)放到一个即时函数中并且保证页面在有没有它的时候都可以正常工作。然后你就可以加入更多的增强特性,或者对它们进行移除、进行独立测试或者允许用户禁用等等。

你可以使用下面的模板定义一段函数代码,我们叫它module1:

// 在module1.js中定义module1
(function () {
	
	// 所有module 1的代码……
	
}());

你可以套用这个模板来编写其它的模块,然后在发布到线上的时候,再决定在这个时间节点上哪些特性是稳定可用的,然后使用发布脚本将它们打包上线。

对象即时初始化

还有另外一种可以避免污染全局作用域的方法,和前面描述的即时函数相似,叫做“对象即时初始化”模式。这种模式使用一个带有init()方法的对象来实现,这个方法在对象被创建后立即执行。初始化的工作由init()函数来完成。

下面是一个对象即时初始化模式的例子:

({
	// 这里可以定义一些设置项,比如常量
	maxwidth: 600,
	maxheight: 400,
	
	// 你也可以定义一些方法
	gimmeMax: function () {
		return this.maxwidth + "x" + this.maxheight;
	},

	// 初始化
	init: function () {
		console.log(this.gimmeMax());
		// 更多的初始化任务……
	}
}).init();

在语法上,当你使用这种模式的时候就像在使用对象字面量创建一个普通对象一样。不同之处在于,需要将对象字面量用括号括起来,这样能让JavaScript引擎知道这是一个对象字面量,而不是一个代码块(if或者for循环之类)。在括号后面,紧接着就执行了init()方法。

你也可以将对象字面量和init()调用一起写到括号里面。简单地说,下面两种语法都是有效的:

({...}).init();
({...}.init());

这种模式的好处和即时函数模式是一样的:在做一些一次性的初始化工作的时候保护全局作用域不被污染。从语法上看,这种模式似乎比即时函数要复杂一些,但是如果你的初始化工作比较复杂(这种情况很常见),它会给整个初始化工作一个比较清晰的结构。比如,一些私有的辅助性函数可以被很轻易地看出来,因为它们是这个临时对象的属性,但是如果是在即时函数模式中,它们很可能只是一些散落的函数。

这种模式的一个弊端是,JavaScript压缩工具可能不能像压缩一段包裹在函数中的代码一样有效地压缩这种模式的代码。这些私有的属性和方法不被会重命名为一些更短的名字,因为从压缩工具的角度来看,保证压缩的可靠性更重要。在写作本书的时候,Google出品的Closure Compiler的“advanced”模式是唯一会重命名立即初始化的对象的属性的压缩工具。一个压缩后的样例是这样:

({d:600,c:400,a:function(){return this.d+"x"+this.c},b:function(){console.log(this.a())}}).b();

这种模式主要用于一些一次性的工作,并且在init()方法执行完后就无法再次访问到这个对象。如果希望在这些工作完成后保持对对象的引用,只需要简单地在init()的末尾加上return this;即可。

条件初始化

条件初始化(也叫条件加载)是一种优化模式。当你知道某种条件在整个程序生命周期中都不会变化的时候,那么对这个条件的探测只做一次就很有意义。浏览器探测(或者特征检测)是一个典型的例子。

举例说明,当你探测到XMLHttpRequest被作为一个本地对象支持时,就知道浏览器不会在程序执行过程中改变这一情况,也不会出现突然需要去处理ActiveX对象的情况。当环境不发生变化的时候,你的代码就没有必要在需要在每次初始化XHR对象时探测一遍(并且得到同样的结果)。

另外一些可以从条件初始化中获益的场景是获得一个DOM元素的computed styles或者是绑定事件处理函数。大部分程序员在他们的编程生涯中都编写过事件绑定和取消绑定相关的组件,像下面的例子:

// 优化前的代码
var utils = {
	addListener: function (el, type, fn) {
		if (typeof window.addEventListener === 'function') {
			el.addEventListener(type, fn, false);
		} else if (typeof document.attachEvent === 'function') { // IE
			el.attachEvent('on' + type, fn);
		} else { // 老的浏览器
			el['on' + type] = fn;
		}
	},
	removeListener: function (el, type, fn) {
		// 和上面很类似的代码……
	}
};

这段代码的问题就是效率不高。每当你执行utils.addListener()或者utils.removeListener()时,同样的检查都会被重复执行。

如果使用条件初始化,那么浏览器探测的工作只需要在初始化代码的时候执行一次。在初始化的时候,代码探测一次环境,然后重新定义这个函数在剩下来的程序生命周期中应该怎样工作。下面是一个例子,看看如何达到这个目的:

// 优化后的代码

// 接口
var utils = {
	addListener: null,
	removeListener: null
};

// 实现
if (typeof window.addEventListener === 'function') {
	utils.addListener = function (el, type, fn) {
		el.addEventListener(type, fn, false);
	};
	utils.removeListener = function (el, type, fn) {
		el.removeEventListener(type, fn, false);
	};
} else if (typeof document.attachEvent === 'function') { // IE
	utils.addListener = function (el, type, fn) {
		el.attachEvent('on' + type, fn);
	};
	utils.removeListener = function (el, type, fn) {
		el.detachEvent('on' + type, fn);
	};
} else { // older browsers
	utils.addListener = function (el, type, fn) {
		el['on' + type] = fn;
	};
	utils.removeListener = function (el, type, fn) {
		el['on' + type] = null;
	};
}

说到这里,要特别提醒一下关于浏览器探测的事情。当你使用这个模式的时候,不要对浏览器特性过度假设。举个例子,如果你探测到浏览器不支持window.addEventListener时,不要假设这个浏览器是IE,也不要认为它不支持原生的XMLHttpRequest,虽然这个结论在整个浏览器历史上的某个时间点是正确的。当然,也有一些情况是可以放心地做一些特性假设的,比如.addEventListener.removeEventListerner,但是通常来讲,浏览器的特性在发生变化时都是独立的。最好的策略就是分别探测每个特性,然后使用条件初始化,使这种探测只做一次。

函数属性——记忆模式(Memoization)

函数也是对象,所以它们可以有属性。事实上,函数也确实本来就有一些属性。比如,对一个函数来说,不管是用什么语法创建的,它会自动拥有一个length属性来标识这个函数期待接受的参数个数:

function func(a, b, c) {}
console.log(func.length); // 3

任何时候都可以给函数添加自定义属性。添加自定义属性的一个有用场景是缓存函数的执行结果(返回值),这样下次同样的函数被调用的时候就不需要再做一次那些可能很复杂的计算。缓存一个函数的运行结果也就是为大家所熟知的记忆模式。

在下面的例子中,myFunc函数创建了一个cache属性,可以通过myFunc.cache访问到。这个cache属性是一个对象(hash表),传给函数的参数会作为对象的key,函数执行结果会作为对象的值。函数的执行结果可以是任何的复杂数据结构:

var myFunc = function (param) {
	if (!myFunc.cache[param]) {
		var result = {};
		// ……复杂的计算……
		myFunc.cache[param] = result;
	}
	return myFunc.cache[param];
};

// 缓存
myFunc.cache = {};

上面的代码假设函数只接受一个参数param,并且这个参数是原始类型(比如字符串)。如果你有更多更复杂的参数,则通常需要对它们进行序列化。比如,你需要将arguments对象序列化为JSON字符串,然后使用JSON字符串作为cache对象的key:

var myFunc = function () {

	var cachekey = JSON.stringify(Array.prototype.slice.call(arguments)),
		result;

	if (!myFunc.cache[cachekey]) {
		result = {};
		// ……复杂的计算……
		myFunc.cache[cachekey] = result;
	}
	return myFunc.cache[cachekey];
};

// 缓存
myFunc.cache = {};

需要注意的是,在序列化的过程中,对象的“标识”将会丢失。如果你有两个不同的对象,却碰巧有相同的属性,那么他们会共享同样的缓存内容。

前面代码中的函数名还可以使用arguments.callee来替代,这样就不用将函数名硬编码。不过尽管现阶段这个办法可行,但是仍然需要注意,arguments.callee在ECMAScript5的严格模式中是不被允许的:

var myFunc = function (param) {

	var f = arguments.callee,
		result;

	if (!f.cache[param]) {
		result = {};
		// ……复杂的计算……
		f.cache[param] = result;
	}
	return f.cache[param];
};

// 缓存
myFunc.cache = {};

配置对象

配置对象模式是一种为自己的代码提供更简洁的API的方法,如果你正在写一个即将被其它程序调用的类库之类的代码的时候就特别有用。

软件在开发和维护过程中需要不断改变是一个不争的事实。这样的事情总是以一些有限的需求开始,但是随着开发的进行,越来越多的功能会不断被加进来。

设想一下你正在写一个名为addPerson()的函数,它接受一个姓和一个名,然后在列表中加入一个人:

function addPerson(first, last) {...}

然后你意识到,生日也必须要存储,此外,性别和地址也作为可选项存储。所以你修改了函数,添加了一些新的参数(还得非常小心地将可选参数放到最后):

function addPerson(first, last, dob, gender, address) {...}

这个时候,函数已经显得有点长了。然后,你又被告知需要添加一个用户名,并且不是可选的。现在这个函数的调用者需要将所有的可选参数传进来,并且得非常小心地保证不弄混参数的顺序:

addPerson("Bruce", "Wayne", new Date(), null, null, "batman");

这样传一大串的参数真的很不方便。一个更好的办法就是将它们替换成一个参数对象,我们叫它conf,是“configuration”(配置)的缩写:

addPerson(conf);

然后这个函数的使用者就可以这样:

var conf = {
	username: "batman",
	first: "Bruce",
	last: "Wayne"
};
addPerson(conf);

配置对象模式的好处是:

  • 不需要记住参数的顺序
  • 可以很安全地跳过可选参数
  • 拥有更好的可读性和可维护性
  • 更容易添加和移除参数

配置对象模式的坏处是:

  • 需要记住参数的名字
  • 参数名字不能被压缩

在实践中,这个模式对创建DOM元素的函数或者是给元素设定CSS样式的函数会非常实用,因为元素和CSS样式可能会有很多属性,但是大部分属性是可选的。

柯里化 (Curry)

在本章剩下的部分,我们将讨论一下关于柯里化和部分应用的话题。但是在我们开始这个话题之前,先看一下什么是函数应用。

函数应用

在一些纯粹的函数式编程语言中,对函数的描述不是被调用(called或者invoked),而是被应用(applied)。在JavaScript中也有同样的东西——我们可以使用Function.prototype.apply()来应用一个函数,因为在JavaScript中,函数实际上是对象,并且他们拥有方法。

下面是一个函数应用的例子:

// 定义函数
var sayHi = function (who) {
	return "Hello" + (who ? ", " + who : "") + "!";
};

// 调用函数
sayHi(); // "Hello"
sayHi('world'); // "Hello, world!"

// 应用函数
sayHi.apply(null, ["hello"]); // "Hello, hello!"

从上面的例子中可以看出来,调用一个函数和应用一个函数有相同的结果。apply()接受两个参数:第一个是在函数内部绑定到this上的对象,第二个是一个参数数组,参数数组会在函数内部变成一个类似数组的arguments对象。如果第一个参数为null,那么this将指向全局对象,这正是当你调用一个函数(且这个函数不是某个对象的方法)时发生的事情。

当一个函数是一个对象的方法时,我们不再像前面的例子一样传入null。(译注:主要是为了保证方法中的this绑定到一个有效的对象而不是全局对象。)在下面的例子中,对象被作为第一个参数传给apply()

var alien = {
	sayHi: function (who) {
		return "Hello" + (who ? ", " + who : "") + "!";
	}
};

alien.sayHi('world'); // "Hello, world!"
sayHi.apply(alien, ["humans"]); // "Hello, humans!"

在这个例子中,sayHi()中的this指向alien。而在上一个例子中,this是指向的全局对象。(译注:这个例子的代码有误,最后一行的sayHi并不能访问到aliensayHi方法,需要使用alien.sayHi.apply(alien, ["humans"])才可正确运行。另外,在sayHi中也没有出现this。)

正如上面两个例子所展现出来的一样,我们将所谓的函数调用当作函数应用的一种语法糖来理解也没有什么太大的问题。

需要注意的是,除了apply()之外,Function.prototype对象还有一个call()方法,但是它仍然只是apply()的一种语法糖。(译注:这两个方法的区别在于,apply()只接受两个参数,第二个参数为需要传给函数的参数数组,而call()则接受任意多个参数,从第二个开始将参数依次传给函数。)不过有种情况下使用这个语法糖会更好:当你的函数只接受一个参数的时候,你可以省去为唯一的一个元素创建数组的工作:

// 第二种更高效,因为节省了一个数组
sayHi.apply(alien, ["humans"]); // "Hello, humans!"
sayHi.call(alien, "humans"); // "Hello, humans!"

部分应用

现在我们知道了,调用一个函数实际上就是给它应用一堆参数,那是否能够只传一部分参数而不传全部呢?这实际上跟我们手工处理数学函数非常类似。

假设已经有了一个add()函数,它的工作是把xy两个数加到一起。下面的代码片段展示了当x为5、y为4时的计算步骤:

// 并不是合法的JavaScript代码,仅用于演示

// 假设有一个add()函数
function add(x, y) {
	return x + y;
}

// 给定参数
add(5, 4);

// 第一步 传入一个参数
function add(5, y) {
	return 5 + y;
}

// 第二步 传入另一个参数
function add(5, 4) {
	return 5 + 4;
}

在这个代码片段中,第一步和第二步并不是有效的JavaScript代码,但是它展示了我们手工计算的过程。首先获得第一个参数的值,然后在函数中将未知的x值替换为5。然后重复这个过程,直到替换掉所有的参数。

第一步是一个所谓的部分应用的例子:我们只应用了第一个参数。当你执行一个部分应用的时候并不能获得结果(或者是解决方案),取而代之的是另一个函数。

下面的代码片段展示了一个虚拟的partialApply()方法的用法:

var add = function (x, y) {
	return x + y;
};

// 完整应用
add.apply(null, [5, 4]); // 9

// 部分应用
var newadd = add.partialApply(null, [5]);
// 为新函数传入一个参数
newadd.apply(null, [4]); // 9

正如你所看到的一样,部分应用给了我们另一个函数,这个函数可以在稍后调用的时候接受其它的参数。这实际上跟add(5)(4)是等价的,因为add(5)返回了一个函数,这个函数可以使用(4)来调用。我们又一次看到,熟悉的add(5, 4)也差不多是add(5)(4)的一种语法糖。

现在,让我们回到地球:并不存在这样的一个partialApply()函数,并且函数的默认表现也不会像上面的例子中那样。但是你完全可以自己去写,因为JavaScript的动态特性完全可以做到这样。

让函数理解并且处理部分应用的过程,叫柯里化(Currying)。

柯里化(Currying)

柯里化这个名字来自数学家Haskell Curry。(Haskell编程语言也是因他而得名。)柯里化是一个变换函数的过程。柯里化的另外一个名字也叫schönfinkelisation,来自另一位数学家——Moses Schönfinkelisation——这种变换的最初发明者。

所以我们怎样对一个函数进行柯里化呢?其它的函数式编程语言也许已经原生提供了支持并且所有的函数已经默认柯里化了。在JavaScript中我们可以修改一下add()函数使它柯里化,然后支持部分应用。

来看一个例子:

// 柯里化过的add()方法,可以接受部分参数
function add(x, y) {
	var oldx = x, oldy = y;
	if (typeof oldy === "undefined") { // 部分应用
		return function (newy) {
			return oldx + newy;
		};
	}
	// 完整应用
	return x + y;
}

// 测试
typeof add(5); // "function"
add(3)(4); // 7

// 创建并保存函数
var add2000 = add(2000);
add2000(10); // 2010

在这段代码中,第一次调用add()时,在返回的内层函数那里创建了一个闭包。这个闭包将原来的xy的值存储到了oldxoldy中。当内层函数执行的时候,oldx会被使用。如果没有部分应用,即xy都传了值,那么这个函数会简单地将他们相加。这个add()函数的实现显得有些冗余,仅仅是为了更好地说明问题。下面的代码片段中展示了一个更简洁的版本,没有oldxoldy,因为原始的x已经被存储到了闭包中,此外我们复用了y作为本地变量,而不用像之前那样新定义一个变量newy

// 柯里化过的add()方法,可以接受部分参数
function add(x, y) {
	if (typeof y === "undefined") { // 部分应用
		return function (y) {
			return x + y;
		};
	}
	// 完整应用
	return x + y;
}

在这些例子中,add()函数自己处理了部分应用。有没有可能用一种更为通用的方式来做同样的事情呢?换句话说,我们能不能对任意一个函数进行处理,得到一个新函数,使它可以处理部分参数?下面的代码片段展示了一个通用函数的例子,我们叫它schonfinkelize(),它正是用来做这个的。我们使用schonfinkelize()这个名字,一部分原因是它比较难发音,另一部分原因是它听起来比较像动词(使用“curry”则不是那么明确),而我们刚好需要一个动词来表明这是一个函数转换的过程。

这是一个通用的柯里化函数:

function schonfinkelize(fn) {
	var slice = Array.prototype.slice,
	stored_args = slice.call(arguments, 1);
	return function () {
		var new_args = slice.call(arguments),
		args = stored_args.concat(new_args);
		return fn.apply(null, args);
	};
}

这个schonfinkelize()可能显得比较复杂了,只是因为在JavaScript中arguments不是一个真的数组。从Array.prototype中借用slice()方法帮助我们将arguments转换成数组,以便能更好地对它进行操作。当schonfinkelize()第一次被调用的时候,它使用slice变量存储了对slice()方法的引用,同时也存储了调用时的除去第一个之外的参数(stored_args),因为第一个参数是要被柯里化的函数。schonfinkelize()返回了一个函数,当这个返回的函数被调用的时候,它可以(通过闭包)访问到已经存储的参数stored_argsslice。新的函数只需要合并老的部分应用的参数(stored_args)和新的参数(new_args),然后将它们应用到原来的函数fn(也可以在闭包中访问到)即可。

现在有了通用的柯里化函数,就可以做一些测试了:

// 普通函数
function add(x, y) {
	return x + y;
}

// 柯里化得到新函数
var newadd = schonfinkelize(add, 5);
newadd(4); // 9

// 另一种选择 直接调用新函数
schonfinkelize(add, 6)(7); // 13

用来做函数转换的schonfinkelize()并不局限于单个参数或者单步的柯里化。这里有些更多用法的例子:

// 普通函数
function add(a, b, c, d, e) {
	return a + b + c + d + e;
}

// 参数个数可以随意分割
schonfinkelize(add, 1, 2, 3)(5, 5); // 16

// 两步柯里化
var addOne = schonfinkelize(add, 1);
addOne(10, 10, 10, 10); // 41
var addSix = schonfinkelize(addOne, 2, 3);
addSix(5, 5); // 16

什么时候使用柯里化

当你发现自己在调用同样的函数并且传入的参数大部分都相同的时候,就是考虑柯里化的理想场景了。你可以通过传入一部分的参数动态地创建一个新的函数。这个新函数会存储那些重复的参数(所以你不需要再每次都传入),然后再在调用原始函数的时候将整个参数列表补全。

小结

在JavaScript中,对开发者在函数这个话题的理解和运用的要求是比较苛刻的。在本章中,主要讨论了有关函数的一些背景知识和术语。介绍了JavaScript函数中两个重要的特性,也就是:

  1. 函数是一等对象,他们可以被作为值传递,也可以拥有属性和方法。
  2. 函数拥有本地作用域,而大括号不产生块级作用域。另外需要注意的是,变量的声明会被提前到本地作用域顶部。

创建一个函数的语法有:

  1. 具名函数表达式
  2. 匿名函数表达式(和上一种一样,但是没有名字),也就是为大家熟知的“匿名函数”
  3. 函数声明,与其它语言的函数语法相似

在介绍完背景和函数的语法后,介绍了一些有用的模式,按分类列出:

  1. API模式,它们帮助我们为函数给出更干净的接口,包括:
    • 回调模式
      传入一个函数作为参数
      
    • 配置对象
       帮助保持函数的参数数量可控
      
    • 返回函数
       函数的返回值是另一个函数
      
    • 柯里化
       新函数在已有函数的基础上再加上一部分参数构成
      
  2. 初始化模式,这些模式帮助我们用一种干净的、结构化的方法来做一些初始化工作(在web页面和应用中非常常见),通过一些临时变量来保证不污染全局命名空间。这些模式包括:
    • 即时函数
       当它们被定义后立即执行
      
    • 对象即时初始化
       初始化工作被放入一个匿名对象,这个对象提供一个可以立即被执行的方法
      
    • 条件初始化
       使分支代码只在初始化的时候执行一次,而不是在整个程序生命周期中反复执行
      
  3. 性能模式,这些模式帮助提高代码的执行速度,包括:
    • 记忆模式
       利用函数的属性,使已经计算过的值不用再次计算
      
    • 自定义函数
       重写自身的函数体,使第二次及后续的调用做更少的工作

关注我

我的微信公众号:前端开发博客,在后台回复以下关键字可以获取资源。

  • 回复「小抄」,领取Vue、JavaScript 和 WebComponent 小抄 PDF
  • 回复「Vue脑图」获取 Vue 相关脑图
  • 回复「思维图」获取 JavaScript 相关思维图
  • 回复「简历」获取简历制作建议
  • 回复「简历模板」获取精选的简历模板
  • 回复「加群」进入500人前端精英群
  • 回复「电子书」下载我整理的大量前端资源,含面试、Vue实战项目、CSS和JavaScript电子书等。
  • 回复「知识点」下载高清JavaScript知识点图谱

每日分享有用的前端开发知识,加我微信:caibaojian89 交流