JavaScript 是一种极具弹性的语言,可用于多种样式。一般来说,编写 JavaScript 要么使用过程方式,要么使用面向对象方式。然而,由于它天生的动态属性,这种语音还能使用更为复杂和有趣的模式。这些技巧要利用 ECMAScript 的语言特点、BOM 扩展和 DOM 功能来获得强大的效果。
18.1 高级函数
函数是 JavaScript 中最有趣的部分之一。它们本质上是十分简单和程序化的,但也可以是非常复杂和动态的。一些额外的功能可以通过使用闭包来实现。此外,由于所有函数都是对象,所以使用函数指针非常简单。这些令 JavaScript 函数不仅有趣而且强大。以下几节描绘了几种在 JavaScript 中使用函数的高级方法。
18.1.1 作用域安全的构造函数
第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 属性是用于识别链接目标和框架的,所以这里对该属性的偶然覆盖可能会导致页面上出现其他错误。这个问题的解决方法就是创建一个作用域安全的构造函数。
作用域安全的构造函数在进行任何更改前,首先确认 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 代码的环境中,作用域安全构造函数就很有用了。届时,对全局对象意外的更改可能会导致一些常常难以追踪的错误。除非你单纯基于构造函数窃取来实现继承,推荐作用域安全的构造函数作为最佳实践。
18.1.2 惰性载入函数
因为浏览器之间行为的差异,多数 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"];
for (var i=0, len=version.length; i<len; i++){
try {
new ActiveXObject(versions[i]);
arguments.callee.activeXString = version[i];
break;
} catch(ex){
// 跳过
}
}
}
return new ActiveXObject(arguments.callee.activeXString);
} else {
throw new Error("No XHR object available.");
}
}
惰性载入表示函数执行的分支仅会发生 1 次:即函数第一次调用的时候。在第一次调用的过程中,该函数会被覆盖为另外一个按合适方式执行的函数,这样任何对原函数的调用都不用再经过执行的分支了。例如,可以用下面的方式使用惰性载入重写 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"];
for(var i=0, len=version.length; i<len; i++){
try{
var xhr = new ActiveXObject(versions[i]);
arguments.callee.activeXString = versions[i];
return xhr;
} catch(ex){
// skip
}
}
}
return new ActiveXObject(arguments.callee.activeXString);
};
} else {
createXHR = function(){
throw new Error("No XHR object avilable.");
};
}
return createXHR();
}
在这个惰性载入的 createXHR()中,if语句的每一个分支都会为 createXHR 变量赋值,有效覆盖了原有的函数。最后一步便是调用新赋的函数。下一次调用 createXHR()的时候,就会直接调用被分配的函数,这样就不用再次执行 if 语句了。
惰性载入函数有两个主要优点。第一,要执行的适当代码只有当实际调用函数时才进行。某些 JavaScript 库一开始就根据浏览器功能或者怪癖执行很多代码分支,将所有东西事先设置好。惰性载入则将这些计算尽可能推迟,保证了恰当的功能而又不影响初始脚本的执行时间。第二,尽管第一次调用该函数会因为额外的第二个函数调用而稍微慢一点,但是所有后续的调用都会更快,因为后面避免了多重 if 条件。
18.1.3 函数绑定
另一个日益流行的高级技巧叫做函数绑定。函数绑定要创建一个函数,可以在特定环境中以指定参数调用另一个函数。该技巧常常和回调函数与事件处理程序一起使用,以便在将函数作为变量传递的同时保留代码执行环境。请看以下例子:
var EventUtil = {
addHandler: function(element, type, handler){
if(element.addEventListener){
element.addEventListener(type, handler, false);
} else if(element.attachEvent){
element.attachEvent("on" + type, handler);
} else {
element["on" + type] = handler;
}
}
};
var handler = {
message: "Event handled",
handleClick: function(event){
alert(this.message);
}
};
var btn = document.getElementById("my-btn");
EventUtil.addHandler(btn, "click", handler.handleClick);
/* 相当于
EventUtil.addHandler(btn, "click", function(event){alert(this.message);});
*/
在上面这个例子中,创建了一个叫做 handler 的对象。handler.handleClick() 方法被分配为一个 DOM 按钮的事件处理程序。当按下该按钮时,就调用该函数,显示一个警告框。虽然貌似警告框应该显示 Event handled,然而实际上显示的是 undefined。这个问题在于没有保存 handler.handleClick() 的环境,所以 this 对象最后是指向了 DOM 按钮而非 handler。可以如下面例子所示,使用一个闭包来修正这个问题:
var EventUtil = {
addHandler: function(element, type, handler){
if(element.addEventListener){
element.addEventListener(type, handler, false);
} else if(element.attachEvent){
element.attachEvent("on" + type, handler);
} else {
element["on" + type] = handler;
}
}
};
var handler = {
message: "Event handled",
handleClick: function(event){
alert(this.message);
}
};
var btn = document.getElementById("my-btn");
//EventUtil.addHandler(btn, "click", handler.handleClick);
EventUtil.addHandler(btn, "click", function(event){
handler.handleClick(event);
});
/* 相当于
EventUtil.addHandler(btn, "click", function(event){
(function(event){
alert(handler.message);
})(event);
});
*/
一个简单的 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 EventUtil = {
addHandler: function(element, type, handler){
if(element.addEventListener){
element.addEventListener(type, handler, false);
} else if(element.attachEvent){
element.attachEvent("on" + type, handler);
} else {
element["on" + type] = handler;
}
}
};
function bind(fn, context){
return function(){
return fn.apply(context, arguments);
};
}
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));
一旦要将某个函数指针以值的形式进行传递,同时该函数必须在特定环境中执行,被绑定函数的效用就突显出来了。它们主要用于事件处理程序以及 setTimeout() 和 setInterval() 。然而,被绑定函数与普通函数相比有更多的开销 -- 它们需要更多内存,同时也因为多重函数调用稍微慢一点 -- 所以最好只在必要时使用。
18.1.4 函数柯里化
与函数绑定紧密相关的主题是函数柯里化 (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 对象再额外给事件处理程序传递参数时,这非常有用,例如:
<script>
var EventUtil = {
addHandler: function(element, type, handler){
if(element.addEventListener){
element.addEventListener(type, handler, false);
} else if(element.attachEvent){
element.attachEvent("on" + type, handler);
} else {
element["on" + type] = handler;
}
}
};
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);
};
}
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"));
</script>
JavaScript 中的柯里化函数和绑定函数提供了强大的动态函数创建功能。使用 bind() 还是 curry() 要根据是否需要 object 对象响应来决定。它们都能用于创建复杂的算法和功能,当然两者都不应滥用,因为每个函数都会带来额外的开销。