22.1 高级函数【JavaScript高级程序设计第三版】
函数是JavaScript 中最有趣的部分之一。它们本质上是十分简单和过程化的,但也可以是非常复杂和动态的。一些额外的功能可以通过使用闭包来实现。此外,由于所有的函数都是对象,所以使用函数指针非常简单。这些令JavaScript 函数不仅有趣而且强大。以下几节描绘了几种在JavaScript 中使用函数的高级方法。
22.1.1 安全的类型检测
JavaScript 内置的类型检测机制并非完全可靠。事实上,发生错误否定及错误肯定的情况也不在少数。比如说typeof 操作符吧,由于它有一些无法预知的行为,经常会导致检测数据类型时得到不靠谱的结果。Safari(直至第4 版)在对正则表达式应用typeof 操作符时会返回"function",因此很难确定某个值到底是不是函数。
再比如,instanceof 操作符在存在多个全局作用域(像一个页面包含多个frame)的情况下,也是问题多多。一个经典的例子(第5 章也提到过)就是像下面这样将对象标识为数组。
var isArray = value instanceof Array;
以上代码要返回true,value 必须是一个数组,而且还必须与Array 构造函数在同个全局作用域中。(别忘了,Array 是window 的属性。)如果value 是在另个frame 中定义的数组,那么以上代码就会返回false。
在检测某个对象到底是原生对象还是开发人员自定义的对象的时候,也会有问题。出现这个问题的原因是浏览器开始原生支持JSON 对象了。因为很多人一直在使用Douglas Crockford 的JSON 库,而该库定义了一个全局JSON 对象。于是开发人员很难确定页面中的JSON 对象到底是不是原生的。
解决上述问题的办法都一样。大家知道,在任何值上调用Object 原生的toString()方法,都会返回一个[object NativeConstructorName]格式的字符串。每个类在内部都有一个[[Class]]属性,这个属性中就指定了上述字符串中的构造函数名。举个例子吧。
alert(Object.prototype.toString.call(value)); //"[object Array]"
由于原生数组的构造函数名与全局作用域无关,因此使用toString()就能保证返回一致的值。利用这一点,可以创建如下函数:
function isArray(value) { return Object.prototype.toString.call(value) == "[object Array]"; }
同样,也可以基于这一思路来测试某个值是不是原生函数或正则表达式:
function isFunction(value) { return Object.prototype.toString.call(value) == "[object Function]"; } function isRegExp(value) { return Object.prototype.toString.call(value) == "[object RegExp]"; }
不过要注意,对于在IE 中以COM 对象形式实现的任何函数,isFunction()都将返回false(因为它们并非原生的JavaScript 函数,请参考第10 章中更详细的介绍)。
这一技巧也广泛应用于检测原生JSON 对象。Object 的toString()方法不能检测非原生构造函数的构造函数名。因此,开发人员定义的任何构造函数都将返回[object Object]。有些JavaScript 库会包含与下面类似的代码。
var isNativeJSON = window.JSON && Object.prototype.toString.call(JSON) == "[object JSON]";
在Web 开发中能够区分原生与非原生JavaScript 对象非常重要。只有这样才能确切知道某个对象到底有哪些功能。这个技巧可以对任何对象给出正确的结论。
请注意,Object.prototpye.toString()本身也可能会被修改。本节讨论的技巧假设Object.prototpye.toString()是未被修改过的原生版本。
22.1.2 作用域安全的构造函数
第6 章讲述了用于自定义对象的构造函数的定义和用法。你应该还记得,构造函数其实就是一个使用new 操作符调用的函数。当使用new 调用时,构造函数内用到的this 对象会指向新创建的对象实例,如下面的例子所示:
function Person(name, age, job) { this.name = name; this.age = age; this.job = job; } var person = new Person("Nicholas", 29, "Software Engineer");
上面这个例子中,Person 构造函数使用this 对象给三个属性赋值:name、age 和job。当和new操作符连用时,则会创建一个新的Person 对象,同时会给它分配这些属性。问题出在当没有使用new操作符来调用该构造函数的情况上。由于该this 对象是在运行时绑定的,所以直接调用Person(),this 会映射到全局对象window 上,导致错误对象属性的意外增加。例如:
var person = Person("Nicholas", 29, "Software Engineer"); alert(window.name); //"Nicholas" alert(window.age); //29 alert(window.job); //"Software Engineer"
运行一下
这里,原本针对Person 实例的三个属性被加到window 对象上,因为构造函数是作为普通函数调用的,忽略了new 操作符。这个问题是由this 对象的晚绑定造成的,在这里this 被解析成了window对象。由于window 的name 属性是用于识别链接目标和frame 的,所以这里对该属性的偶然覆盖可能会导致该页面上出现其他错误。这个问题的解决方法就是创建一个作用域安全的构造函数。
作用域安全的构造函数在进行任何更改前,首先确认this 对象是正确类型的实例。如果不是,那么会创建新的实例并返回。请看以下例子:
function Person(name, age, job) { if (this instanceof Person) { this.name = name; this.age = age; this.job = job; } else { return new Person(name, age, job); } } var person1 = Person("Nicholas", 29, "Software Engineer"); alert(window.name); //"" alert(person1.name); //"Nicholas" var person2 = new Person("Shelby", 34, "Ergonomist"); alert(person2.name); //"Shelby"
这段代码中的Person 构造函数添加了一个检查并确保this 对象是Person 实例的if 语句,它表示要么使用new 操作符,要么在现有的Person 实例环境中调用构造函数。任何一种情况下,对象初始化都能正常进行。如果this 并非Person 的实例,那么会再次使用new 操作符调用构造函数并返回结果。最后的结果是,调用Person 构造函数时无论是否使用new 操作符,都会返回一个Person 的新实例,这就避免了在全局对象上意外设置属性。关于作用域安全的构造函数的贴心提示。实现这个模式后,你就锁定了可以调用构造函数的环境。
如果你使用构造函数窃取模式的继承且不使用原型链,那么这个继承很可能被破坏。这里有个例子:
function Polygon(sides) { if (this instanceof Polygon) { this.sides = sides; this.getArea = function() { return 0; }; } else { return new Polygon(sides); } } function Rectangle(width, height) { Polygon.call(this, 2); this.width = width; this.height = height; this.getArea = function() { return this.width * this.height; }; } var rect = new Rectangle(5, 10); alert(rect.sides); //undefined
在这段代码中,Polygon 构造函数是作用域安全的,然而Rectangle 构造函数则不是。新创建一个Rectangle 实例之后,这个实例应该通过Polygon.call()来继承Polygon 的sides 属性。但是,由于Polygon 构造函数是作用域安全的,this 对象并非Polygon 的实例,所以会创建并返回一个新的Polygon 对象。Rectangle 构造函数中的this 对象并没有得到增长,同时Polygon.call()返回的值也没有用到,所以Rectangle 实例中就不会有sides 属性。
如果构造函数窃取结合使用原型链或者寄生组合则可以解决这个问题。考虑以下例子:
function Polygon(sides) { if (this instanceof Polygon) { this.sides = sides; this.getArea = function() { return 0; }; } else { return new Polygon(sides); } } function Rectangle(width, height) { Polygon.call(this, 2); this.width = width; this.height = height; this.getArea = function() { return this.width * this.height; }; } Rectangle.prototype = new Polygon(); var rect = new Rectangle(5, 10); alert(rect.sides); //2
上面这段重写的代码中,一个Rectangle 实例也同时是一个Polygon 实例,所以Polygon.call()会照原意执行,最终为Rectangle 实例添加了sides 属性。
多个程序员在同一个页面上写JavaScript 代码的环境中,作用域安全构造函数就很有用了。届时,对全局对象意外的更改可能会导致一些常常难以追踪的错误。除非你单纯基于构造函数窃取来实现继承,推荐作用域安全的构造函数作为最佳实践。
22.1.3 惰性载入函数
因为浏览器之间行为的差异,多数JavaScript 代码包含了大量的if 语句,将执行引导到正确的代码中。看看下面来自上一章的createXHR()函数。
function createXHR() { if (typeof XMLHttpRequest != "undefined") { return new XMLHttpRequest(); } else if (typeof ActiveXObject != "undefined") { if (typeof arguments.callee.activeXString != "string") { var versions = ["MSXML2.XMLHttp.6.0", "MSXML2.XMLHttp.3.0", "MSXML2.XMLHttp"], i, len; for (i = 0, len = versions.length; i < len; i++) { try { new ActiveXObject(versions[i]); arguments.callee.activeXString = versions[i]; break; } catch(ex) { //跳过 } } } return new ActiveXObject(arguments.callee.activeXString); } else { throw new Error("No XHR object available."); } }
每次调用createXHR()的时候,它都要对浏览器所支持的能力仔细检查。首先检查内置的XHR,然后测试有没有基于ActiveX 的XHR,最后如果都没有发现的话就抛出一个错误。每次调用该函数都是这样,即使每次调用时分支的结果都不变:如果浏览器支持内置XHR,那么它就一直支持了,那么这种测试就变得没必要了。即使只有一个if 语句的代码,也肯定要比没有if 语句的慢,所以如果if 语句不必每次执行,那么代码可以运行地更快一些。解决方案就是称之为惰性载入的技巧。
惰性载入表示函数执行的分支仅会发生一次。有两种实现惰性载入的方式,第一种就是在函数被调用时再处理函数。在第一次调用的过程中,该函数会被覆盖为另外一个按合适方式执行的函数,这样任何对原函数的调用都不用再经过执行的分支了。例如,可以用下面的方式使用惰性载入重写createXHR()。
function createXHR() { if (typeof XMLHttpRequest != "undefined") { createXHR = function() { return new XMLHttpRequest(); }; } else if (typeof ActiveXObject != "undefined") { createXHR = function() { if (typeof arguments.callee.activeXString != "string") { var versions = ["MSXML2.XMLHttp.6.0", "MSXML2.XMLHttp.3.0", "MSXML2.XMLHttp"], i, len; for (i = 0, len = versions.length; i < len; i++) { try { new ActiveXObject(versions[i]); arguments.callee.activeXString = versions[i]; break; } catch(ex) { //skip } } } return new ActiveXObject(arguments.callee.activeXString); }; } else { createXHR = function() { throw new Error("No XHR object available."); }; } return createXHR(); }
运行一下
在这个惰性载入的createXHR()中,if 语句的每一个分支都会为createXHR 变量赋值,有效覆盖了原有的函数。最后一步便是调用新赋的函数。下一次调用createXHR()的时候,就会直接调用被分配的函数,这样就不用再次执行if 语句了。
第二种实现惰性载入的方式是在声明函数时就指定适当的函数。这样,第一次调用函数时就不会损失性能了,而在代码首次加载时会损失一点性能。以下就是按照这一思路重写前面例子的结果。
var createXHR = (function() { if (typeof XMLHttpRequest != "undefined") { return function() { return new XMLHttpRequest(); }; } else if (typeof ActiveXObject != "undefined") { return function() { if (typeof arguments.callee.activeXString != "string") { var versions = ["MSXML2.XMLHttp.6.0", "MSXML2.XMLHttp.3.0", "MSXML2.XMLHttp"], i, len; for (i = 0, len = versions.length; i < len; i++) { try { new ActiveXObject(versions[i]); arguments.callee.activeXString = versions[i]; break; } catch(ex) { //skip } } } return new ActiveXObject(arguments.callee.activeXString); }; } else { return function() { throw new Error("No XHR object available."); }; } })();
运行一下
这个例子中使用的技巧是创建一个匿名、自执行的函数,用以确定应该使用哪一个函数实现。实际的逻辑都一样。不一样的地方就是第一行代码(使用var 定义函数)、新增了自执行的匿名函数,另外每个分支都返回正确的函数定义,以便立即将其赋值给createXHR()。
惰性载入函数的优点是只在执行分支代码时牺牲一点儿性能。至于哪种方式更合适,就要看你的具体需求而定了。不过这两种方式都能避免执行不必要的代码。
22.1.4 函数绑定
另一个日益流行的高级技巧叫做函数绑定。函数绑定要创建一个函数,可以在特定的this 环境中以指定参数调用另一个函数。该技巧常常和回调函数与事件处理程序一起使用,以便在将函数作为变量传递的同时保留代码执行环境。请看以下例子:
var handler = { message: "Event handled", handleClick: function(event) { alert(this.message); } }; var btn = document.getElementById("my-btn"); EventUtil.addHandler(btn, "click", handler.handleClick);
在上面这个例子中,创建了一个叫做handler 的对象。handler.handleClick()方法被分配为一个DOM 按钮的事件处理程序。当按下该按钮时,就调用该函数,显示一个警告框。虽然貌似警告框应该显示Event handled , 然而实际上显示的是undefiend 。这个问题在于没有保存handler.handleClick()的环境,所以this 对象最后是指向了DOM按钮而非handler(在IE8 中,this 指向window。)可以如下面例子所示,使用一个闭包来修正这个问题。
var handler = { message: "Event handled", handleClick: function(event) { alert(this.message); } }; var btn = document.getElementById("my-btn"); EventUtil.addHandler(btn, "click", function(event) { handler.handleClick(event); });
这个解决方案在onclick 事件处理程序内使用了一个闭包直接调用handler.handleClick()。
当然,这是特定于这段代码的解决方案。创建多个闭包可能会令代码变得难于理解和调试。因此,很多JavaScript 库实现了一个可以将函数绑定到指定环境的函数。这个函数一般都叫bind()。
一个简单的bind()函数接受一个函数和一个环境,并返回一个在给定环境中调用给定函数的函数,并且将所有参数原封不动传递过去。语法如下:
function bind(fn, context) { return function() { return fn.apply(context, arguments); }; }
这个函数似乎简单,但其功能是非常强大的。在bind()中创建了一个闭包,闭包使用apply()调用传入的函数,并给apply()传递context 对象和参数。注意这里使用的arguments 对象是内部函数的,而非bind()的。当调用返回的函数时,它会在给定环境中执行被传入的函数并给出所有参数。
bind()函数按如下方式使用:
var handler = { message: "Event handled", handleClick: function(event) { alert(this.message); } }; var btn = document.getElementById("my-btn"); EventUtil.addHandler(btn, "click", bind(handler.handleClick, handler));
在这个例子中,我们用bind()函数创建了一个保持了执行环境的函数,并将其传给EventUtil.addHandler()。event 对象也被传给了该函数,如下所示:
var handler = { message: "Event handled", handleClick: function(event) { alert(this.message + ":" + event.type); } }; var btn = document.getElementById("my-btn"); EventUtil.addHandler(btn, "click", bind(handler.handleClick, handler));
handler.handleClick()方法和平时一样获得了event 对象,因为所有的参数都通过被绑定的函数直接传给了它。
ECMAScript 5 为所有函数定义了一个原生的bind()方法,进一步简单了操作。换句话说,你不用再自己定义bind()函数了,而是可以直接在函数上调用这个方法。例如:
var handler = { message: "Event handled", handleClick: function(event) { alert(this.message + ":" + event.type); } }; var btn = document.getElementById("my-btn"); EventUtil.addHandler(btn, "click", handler.handleClick.bind(handler));
运行一下
原生的bind()方法与前面介绍的自定义bind()方法类似,都是要传入作为this 值的对象。支持原生bind()方法的浏览器有IE9+、Firefox 4+和Chrome。
只要是将某个函数指针以值的形式进行传递,同时该函数必须在特定环境中执行,被绑定函数的效用就突显出来了。它们主要用于事件处理程序以及 setTimeout() 和 setInterval()。然而,被绑定函数与普通函数相比有更多的开销,它们需要更多内存,同时也因为多重函数调用稍微慢一点,所以最好只在必要时使用。
22.1.5 函数柯里化
与函数绑定紧密相关的主题是函数柯里化(function currying),它用于创建已经设置好了一个或多个参数的函数。函数柯里化的基本方法和函数绑定是一样的:使用一个闭包返回一个函数。两者的区别在于,当函数被调用时,返回的函数还需要设置一些传入的参数。请看以下例子。
function add(num1, num2) { return num1 + num2; } function curriedAdd(num2) { return add(5, num2); } alert(add(2, 3)); //5 alert(curriedAdd(3)); //8
这段代码定义了两个函数:add()和curriedAdd()。后者本质上是在任何情况下第一个参数为5的add()版本。尽管从技术上来说curriedAdd()并非柯里化的函数,但它很好地展示了其概念。
柯里化函数通常由以下步骤动态创建:调用另一个函数并为它传入要柯里化的函数和必要参数。下面是创建柯里化函数的通用方式。
function curry(fn) { var args = Array.prototype.slice.call(arguments, 1); return function() { var innerArgs = Array.prototype.slice.call(arguments); var finalArgs = args.concat(innerArgs); return fn.apply(null, finalArgs); }; }
curry()函数的主要工作就是将被返回函数的参数进行排序。curry()的第一个参数是要进行柯里化的函数,其他参数是要传入的值。为了获取第一个参数之后的所有参数,在arguments 对象上调用了slice()方法,并传入参数1 表示被返回的数组包含从第二个参数开始的所有参数。然后args 数组包含了来自外部函数的参数。在内部函数中,创建了innerArgs 数组用来存放所有传入的参数(又一次用到了slice())。有了存放来自外部函数和内部函数的参数数组后,就可以使用concat()方法将它们组合为finalArgs,然后使用apply()将结果传递给该函数。注意这个函数并没有考虑到执行环境,所以调用apply()时第一个参数是null。curry()函数可以按以下方式应用。
function add(num1, num2) { return num1 + num2; } var curriedAdd = curry(add, 5); alert(curriedAdd(3)); //8
在这个例子中,创建了第一个参数绑定为5 的add()的柯里化版本。当调用curriedAdd()并传入3 时,3 会成为add()的第二个参数,同时第一个参数依然是5,最后结果便是和8。你也可以像下面例子这样给出所有的函数参数:
function add(num1, num2) { return num1 + num2; } var curriedAdd = curry(add, 5, 12); alert(curriedAdd()); //17
运行一下
在这里,柯里化的add()函数两个参数都提供了,所以以后就无需再传递它们了。
函数柯里化还常常作为函数绑定的一部分包含在其中,构造出更为复杂的bind()函数。例如:
function bind(fn, context) { var args = Array.prototype.slice.call(arguments, 2); return function() { var innerArgs = Array.prototype.slice.call(arguments); var finalArgs = args.concat(innerArgs); return fn.apply(context, finalArgs); }; }
对curry()函数的主要更改在于传入的参数个数,以及它如何影响代码的结果。curry()仅仅接受一个要包裹的函数作为参数,而bind()同时接受函数和一个object 对象。这表示给被绑定的函数的参数是从第三个开始而不是第二个,这就要更改slice()的第一处调用。另一处更改是在倒数第3 行将object 对象传给apply()。当使用bind()时,它会返回绑定到给定环境的函数,并且可能它其中某些函数参数已经被设好。当你想除了event 对象再额外给事件处理程序传递参数时,这非常有用,例如:
var handler = { message: "Event handled", handleClick: function(name, event) { alert(this.message + ":" + name + ":" + event.type); } }; var btn = document.getElementById("my-btn"); EventUtil.addHandler(btn, "click", bind(handler.handleClick, handler, "my-btn"));
在这个更新过的例子中,handler.handleClick()方法接受了两个参数:要处理的元素的名字和event 对象。作为第三个参数传递给bind()函数的名字,又被传递给了handler.handleClick(),而handler.handleClick()也会同时接收到event 对象。
ECMAScript 5 的bind()方法也实现函数柯里化,只要在this 的值之后再传入另一个参数即可。
var handler = { message: "Event handled", handleClick: function(name, event) { alert(this.message + ":" + name + ":" + event.type); } }; var btn = document.getElementById("my-btn"); EventUtil.addHandler(btn, "click", handler.handleClick.bind(handler, "my-btn"));
运行一下
JavaScript 中的柯里化函数和绑定函数提供了强大的动态函数创建功能。使用bind()还是curry()要根据是否需要object 对象响应来决定。它们都能用于创建复杂的算法和功能,当然两者都不应滥用,因为每个函数都会带来额外的开销。