面向对象的 JavaScript 编程及其 Scope 处理

时间:2022-06-08 20:37:06

本文首先对 JavaScript 的机制进行讲解,并结合当前流行的开源 JavaScript 框架讲解如何在 JavaScript 中实现面向对象和继承机制;之后本文将对面向对象 JavaScript 编程中容易引起误解和 Scope 的几个问题做详细阐述;最后针对面向对象的 JavaScript 编程中的 Scope 问题给出几点建议。

面向对象和模拟继承

JavaScript 是一种弱类型解释运行的脚本语言,就语言本身来讲它不是一门面向对象语言。但是我们可以利用一些语言的特性来模拟面向对象编程和继承机制,这所有的一切都需要从 JavaScript 中的 function 说起。

Function 是一个定义一次但可以调用或执行无数次的 JavaScript 代码片段。Function 可以有零个或者多个输入参数和一个返回值,它通常被用来完成一些计算或者事务处理等任务。通常我们这样定义一个 function:


清单 1. Function 定义示例

function distance(x1, y1, x2, y2) {
var dx = x2 - x1;
var dy = y2 - y1;
return Math.sqrt(dx*dx + dy*dy);
}

在 JavaScript 中,function 不仅是是一种语法结构,它还可以被作为数据。这意味着它可以被赋值给变量,在对象或数组中的元素的属性存储,或者作为函数参数传递等等。例如:


清单 2. Function 作为数据示例

var d = distance;
d(1,1,2,2);

当在一个object 中定义和调用一个 function 时,这个 function 被称作该 object 的一个方法。需要注意的是当这个 function 被调用时,这个 object 会以隐含参数的形式传入到 function 里,function 内部可以通过 this 关键字来引用这个 object 的属性。例如下面这个例子运行结果 calculator.result的值为 2。


清单 3. This 关键字示例

var calculator = {
operand1: 1,
operand2: 1,
compute: function () {
this .result = this .operand1 + this .operand2;
}
};
calculator.compute();
alert(calculator.result);

在 JavaScript 中,对象的创建通常是通过 new 运算符来完成的。new 关键字后面必须是一个可执行的 function。例如:


清单 4. New 关键字示例

var array = new Array(10);
var today = new Date( );

当上面这条创建语句被执行时,首先会创建一个空的对象赋给前面的变量,然后再调用后面紧跟的 function,并将这个空的对象作为隐含参数传入到 function 内部。这样在 function 内部就可以通过 this 关键字引用该对象,做一些对象初始化工作。这样的一个 function 通常被称为构造方法或者简单构造方法。例如:


清单 5. 构造方法示例

function Rectangle(w, h) {
this .width = w;
this .height = h;
}
var rect1 = new Rectangle(2, 4);

讲到这里我们已经有了对象、函数、作为数据的函数、对象方法和构造函数,于是我们可以使用这些 JavaScript 的特性模拟写出类似于 java 风格面向对象的代码。例如:


清单 6. 模拟面向对象示例

function Rectangle(w, h) {
this .width = w;
this .height = h;
this .area = function (){
return
this .width*this .height;
};
}
var rect1 = new Rectangle(2, 4);
rect1.area();

上面的代码似乎十分完美,但是看起来似乎还是缺少一些东西。面向对象的精髓是对事物的抽象和继承,以到达代码重用的目的。然而在 JavaScript 中如何做到这一点呢?

JavaScript 中每一个 object 都有一个 prototype 属性,这个属性引用了另一个 object 对象。当你需要读取一个 object 中的某个属性 p 时,JavaScript 引擎首先会查找这个 object 中是否存在属性 p。如果该属性存在则返回该属性值,如果该属性不存在则会在这个 object 的 prototype 对象中查找是否存在属性 p。这样做会有两点好处:第一,在每个类中可以抽象出来一些共有的属性和方法定义在 prototype 对象中。当使用构造函数创建多个类的实例时,多个实例使用同一 prototype 对象,这样大大减小了对内存的占用量。第二,新创建的对象的属性是在对象创建时,由 prototype 对象带来的,这样就可以实现属性和方法的继承了。

在前面对 function 的介绍中,我们曾提到当 new 操作符被调用时,它会创建一个空的对象赋给变量并且调用后面的构造函数。实际上在 new 操作符创建一个空对象后,它还会为这个对象设置它的 prototype属性,这个属性值等于它的构造函数的 prototype 属性值。所有的函数在其定义时就已经自动创建和初始化好了 prototype 属性。这个初始化好的 prototype 属性指向一个只包含一个 constructor属性的对象,并且这个 constructor 属性指向这个 function 自己。这就解释了为什么每一个 object 都会有一个 constructor 属性。如果你运行下面的代码,打印出一个名为 Object 的 function。


清单 7. Constructor 属性示例

var obj = new Object();
alert(obj.constructor);

讲到这里我们对如何在 JavaScript 中模拟面向对象编程有了清楚的了解。我们可以通过将一些共有的属性和方法定义在构造方法来实现属性的继承,例如,


清单 8. 模拟继承示例

function Rectangle(w, h) {
this .width = w;
this .height = h;
}
Rectangle.prototype.area = function () {
return
this .width * this .height;
};



另外如果需要继承另一个类并且重写和添加一些方法的时候,你可能就需要拷贝这个类的构造方法的 prototype 对象中的属性和方法,然后再根据需要重写和添加一些方法和属性了。

JavaScript 框架中的面向对象的实现

在前面一节中我们介绍了一些 JavaScript 基本的特性和如何利用这些特性模拟实现面向对象和继承。目前开源社区有众多流行的 JavaScript 类库,如 jQuery、Extjs、Dojo 等,他们各自也都对面向对象编程和继承等机制有着不同的支持。那么这些开源 JavaScript 是如何实现面向对象和继承机制的呢?接下来我们介绍一下这些开源框架是如何实现这些机制的。


清单 9. 在 Dojo 中声明一个类

dojo.declare('com.test.MyClass',[com.test.MySuperClassOne,com.test.MySuperClassTwo],{
myName: 'Xiong ZhengXiang',
myMethod: function(){
return "I am Xiong ZhengXiang";
}
});

通过上面的声明,可以得到一个名为“com.test.MyClass”的 Dojo 类,它继承自“com.ibm.MySuperClassOne”和“com.ibm.MySuperClassTwo”两个类。类型声明后,就可用如下代码获得一个类的实例。其与前文所述的创建对象的方式是一致的。


清单 10. 获得 Dojo 类的实例

var myObj = new com.test.myClass();

在 ExtJS 中,类型间的继承关系式通过 extend 这个函数来实现的,下面给出了 ExtJS 中声明类之间继承关系的方式。


清单 11. ExtJS 中的 extend 函数的使用

MyClass = Ext.extend(SuperClass, {/* 新加入的属性方法 */});

或者

 Ext.extend(MyClass, SuperClass, {/* 新加入的属性方法 */}); 

对于第二中继承声明方式,除了 SuperClass 事先已定义好之外,MyClass 也需要事先定义,而第一种则是由 extend 函数直接构造出类型,即是在声明类的同时建立了继承关系。由此可见,这两种 extend 的调用方式的使用场景是不同的。

那么 Dojo、ExtJS 等 JavaScript 库中的这种面向对象的继承是如何实现的呢?这就需要用到前文中提到的 JavaScript 语法中比较特殊的 prototype 的机制。由前文可知,prototye 是一个函数的内置属性,这个属性指向另一个对象的引用,在这个对象中定义的属性和方法将被这个函数经过 new 操作得到的对象共享。如果某个函数被视作 JavaScript 中的类型定义,那么这个函数的 prototype 对象的属性和方法将会被这个函数所代表的类的实例共享,也就是说 prototype 对象是这个类的的代表和载体。因此,利用 prototype 的特性,Dojo、ExtJS 等 JavaScript 类库模拟了面向对象的语言机制。而 Dojo 和 ExtJS 中的面向对象继承功能有一个很大的不同,就是 ExtJS 实现的是单继承,即一个子类只有一个父类,而 Dojo 实现了多继承,即一个子类可以有多个父类,这从上面给出的代码示例中可以看出。

在实现上,ExtJS 中每声明一个类,都会将这个类的方法、属性放到这个类的 prototype 中去,然后将这个类的 ptototype 对象的类型的 prototype 指向父类的 prototype 对象,如下图所示。


图 1. ExtJS 中类的 prototype 的结构关系
面向对象的 JavaScript 编程及其 Scope 处理 

在上图中,可以清楚地看到 MyClass 的 prototype 的对象是一个临时生成的函数(类)实例化生成的,而这个临时的类的 prototype 指向了 SuperClass 的 prototpye 对象,于是根据 JavaScript 中 prototype 的传递特性,MyClass 的实例可以调用 MyClass 的 prototype 对象的方法,进而可以调用 MyClass 的 prototype 对象的类即图中的临时类的 prototype 对象中的方法,也是 SuperClass 的 prototype 对象的方法。这样便实现了 MyClass 与 SuperClass 之间的继承关系。

上面解释了 MyClass 和 SuperClass 的继承关系的,这是单继承的实现方式,若是有多个类之间的单继承,则可以通过上面的方式串成一个继承链,如下图所示。


图 2. ExtJS 中的继承链
面向对象的 JavaScript 编程及其 Scope 处理 

ExtJS 只支持单继承,即一个子类最多有一个父类。而由于 Dojo 支持多继承,所以一个子类是允许有多个父类的。Dojo 首先会将多继承的关系通过某种算法转换为单继承链,在利用上面描述的 prototype 链的方式将单继承链实现。如下图所示。


图 3. 多继承关系转换为单继承链
面向对象的 JavaScript 编程及其 Scope 处理 

Dojo 中使用的多继承转换算法叫做 C3 Method Resolution Order 是一种比较通用的算法。

除了实现继承关系,ExtJS、Dojo 等 JavaScript 库还为面向对象实现了一些其他的功能和机制,比如构造函数、父类函数调用等等,这在后续的相关文章中将会详细论述。

面向对象 JavaScript 中 Scope 问题的处理

在面向对象的 JavaScript 编程中,我们包装继承了大量的对象,同时对象之间还有很多复杂的引用关系,这就使得很多时候我们在 function 调用时的 Scope 不是我们想要的 Scope。产生这些问题的原因最终都源于我们没有深入的了解 JavaScript 的机制。在下面的一节我们将就这些容易产生 Scope 问题的机制做一个简单的介绍,并给出一些如何避免 Scope 问题的建议。

Scope 和 Closure

很多程序员在使用 JavaScript 的时候都会对 function 的 Scope 运行范围和 Closure 闭包的使用很困惑,这都源于他们对 JavaScript 原理的不了解。

在 JavaScript 中 function 是作用于词法范围而不是动态运行范围。也就是说 function 的作用范围是它声明的范围,而不是它在执行时的范围。简单的来说,一个 function 执行时的上下文环境 Context 是在其定义的时候就固定下来了,就是它定义的时的作用范围。这点是我们需要注意的,很多时候我们动态的将某个方法注入到一个对象内部,然而在运行时总是得不到想要的上下文环境,这就是因为没有正确理解 JavaScript 的 Scope。

当一个 function 被 JavaScript 引擎调用执行时,这个 function 的 Scope 才起作用被加到 Scope 链中。然后将一个叫做 Call Object 调用对象或者运行对象加到 Scope 的最前面。这个调用对象初始化时会加入一个 arguments 属性,用来引用 function 调用时的参数。如果这个 function 显示的定义了调用参数的话,这些参数也会被加入到这个对象中。之后再这个函数运行中所有的局部变量也都将包含在这个对象中。这也就是在 function 体内部既可以通过 arguments 数组,又可以直接通过显示定义参数名来引用调用时传入参数的原因。需要注意的是,这个调用对象和通过 this 关键字引用的对象是两个概念。调用对象是 function 在运行时的 Scope,其中包含了 function 在运行时的全部参数和局部变量。通过 this 关键字引用的对象,是当 function 作为一个这个对象的方法运行时对这个对象的引用。如果这个 function 没有被定义在一个对象中时,传入给 this 的对象是全局对象,因此在这个 function 内部通过this 取到的变量就是全局定义的变量。例如下面代码运行结果应该弹出"Hello,I am Qin Jian":


清单 12. 闭包定义示例

var hello = "Hello,I am Shao Yu!";
function sayHello(){
var hello = "Hello, I am Qin Jian."
function anotherFun(){
alert(this .hello);
}
anotherFun();
}
sayHello();

在 JavaScript 中允许定义匿名 function 和在 function 中嵌套定义另一个 function,由于 Scope 链的作用,function 的内部总是可以访问到外部的变量而外部不能访问到内部的变量。另外由于 Call Object 的机制,使得我们可以使用 function 嵌套定义和调用来做很多事情。在 JavaScript 中这样的调用被称为 Closure 闭包。


清单 13. 用闭包生成唯一 ID

uniqueID = (function () {
var id = 0;
return
function () { return id++; };
})();
alert(uniqueID());// 显示结果 0
alert(uniqueID());// 显示结果 1
alert(uniqueID());// 显示结果 2

上面这段代码很清楚的说明了闭包做了件什么事情。当外层的function被执行时,它的Scope被加入到Scope chain上,然后为它创建call object 加入到 scope 中。之后又创建了局部变量 id 将它保存在该 function 的 call object 中。如果没有“return function() {return id++; };这条语句的话,外层的 function 将要运行结束退出,同时他的 call object 会被释放,scope 会从 Scope chain 上移除。由于“return function () {return id++; };这条语句创建了一个内部的 function,并将其引用返回给一个变量。这样内部 function 的 scope 会被添加到之前外部 function 的 scope 之下,使得在外部 function 运行结束后他的 scope 不能被撤销和释放。这样就是用外部 function 的 call object 保存了变量 id,并且除了内部的 function 以为没有别的程序能访问到这个变量。虽然看起来有些复杂和拗口,但是闭包确实是以项非常有用的功能,经常用来保存变量控制访问域等用途。例如下面这个例子


清单 14. 利用 call object 和闭包保存数据

function makefunc(x) {
return
function () { return x; }
}
var a = [makefunc("I am Qin Jian"), makefunc("I am shao yu"), makefunc("I am xu ming")];
alert(a[0]());// 显示结果 I am Qin Jian
alert(a[1]());// 显示结果 I am shao yu
alert(a[2]());// 显示结果 I am xu ming

Call 和 Apply 方法

在 JavaScript 中提供了两个非常有意思的方法 call 和 apply,它使得你可以将一个 function 作为另一个 object 的对象方法来调用。也就是说你可以选择 function 调用时,传入给 this 关键字的对象。这两个方法第一个参数是相同的,为你想要传入给 this 关键字的对象。不同之处是,call 方法直接将 function 参数列在后面,而 apply 方法是将所有 function 参数以一个数组的形式传入。例如下面这个例子:


清单 15. call apply 方法示例

Var fun = function (arg1,arg2){
//do anything you want here
}
fun.call(object, arg1, arg2);
fun.apply(object, [arg1, arg2]);

这两个方法在面向对象的 JavaScript 编程中非常有用,有时你希望给某个对象添加一个事件监听,然而回调方法的 context 却不一定是你需要的。这时你就需要 call 或者 apply 方法了。

Eval 方法

eval 方法用于执行某个字符串中的 JavaScript 脚本,并反回执行结果。它能够允许动态生成的变量、表达式、函数调用、语句段得到执行,使得 JavaScript 的程序的设计更为灵活,比如通过 Ajax 方式从服务器端获得代表 JavaScript 脚本的字符串,然后就可以用这发方法在浏览器端来执行这段脚本。因为传统的 Ajax 通讯设计更多的是在服务器端与浏览器端交换数据,但在这个方法的支持下就可以在两者之间交换逻辑(在 JDK1.6 支持在 JVM 中运行 JavaScript 脚本),所以这是一种很有趣很神奇的事情。

eval 用法很灵活也比较简单,调用时将要执行的 JavaScript 脚本字符串作为参数传给这个方法。比较常用的就是将服务器端发送过来的 json 字符串在浏览器端转化为 json 对象,如下面这个例子。


清单 16. eval 方法将 json 字符串转换为对象

var jsonString = "{'name':{'qinjian':'I am qinjian','shaoyu':'I am shaoyu'}}";
var jsonObject = eval("(" + jsonString + ")");
alert(jsonObject.name.qinjian)

在上面的代码中,在 json 字符串又加上了一对括号,这样做可以迫使 eval 函数在评估 JavaScript 代码时强制将原最外层括号内的内容作为表达式来执行从而得到 json 对象,而非作为语句来执行。所以只有用新的小括号将原来的 json 字符串包起来才能够转换出所需的 json 对象。

对于执行一般的包含在字符串中的 JavaScript 语句,自然就不需要像上面那样再次添加括号,如下例所示。


清单 17. eval 方法执行包含在字符串中的 json 语句

function testStatement() {
eval("var m = 1");
alert(m);
}

testStatement();

上面的函数会在弹出的提示对话框中输出变量 m 的值。eval 函数的里面的语句只是一个简单的赋值,但有一个问题是值得注意的,就是 eval 中的语句是在什么样的 scope 中执行,为了更好的说明问题,执行下例中所示的代码。


清单 18. 测试 eval 中的 JavaScript 语句执行的 scope

function testStatement() {
eval("var m = 1");
alert(m);
}

testStatement();
alert(typeof m);

可以得到,变量 m 所在的 scope 是在 testStatement 函数内,从 eval 调用的位置来看这个执行结果是合理的。接下来用 window.eval 替换清单 3 中的 eval 后,在 Firefox 上执行(注意是 Firefox),从执行后的结果中可以发现,这时的 m 的作用域变成了 window,也就是说 m 变成了一个全局变量。那么在 IE 下会如何呢,经测试发现,使用 window.eval 和使用 eval 在 IE 浏览器上是相同的结果,即 m 的作用域在 testStatement 函数中。但是 IE 浏览器提供了另外的一个方法 execScript,它会将输入的 JavaScript 脚本字符串放到全局作用域下执行。所以总结一下就是:eval 方法是将输入的代码在局部作用域下执行,若要实现 JavaScript 字符串中代码的全局作用域下执行的效果,或者把 eval 放到全作用域下调用,或者在 Firefox 下使用 window.eval,在 IE 下使用 execScript。

最后需要提醒的是,因为 eval 的执行效率较低,所以一般在程序中最好不要频繁使用。

避免 Scope 问题的几点建议

  • 巧用闭包。了解了闭包利用 Call Object 的产生原理后,我们就很容易利用闭包,如 namespace 的隔离和保存局部数据等等。同时闭包也很容易使我们的 this 实例不是我们想要的 this 示例。这时我们就可以利用内层可以访问外层变量的特点将外层 this 实例赋给一个变量,内层可以通过这个变量顺利访问外层 this 实例。例如下面代码片段:

清单 19. 处理闭包中 scope 问题示例

...
switchAds : function (index){
var _this = this ;

dojo.fadeOut({
node : _this.adBack,
duration : 500,
onEnd:function (){
dojo.fadeIn({
node : _this.adBack,
duration : 500,
onEnd:function (){
_this.currentAd = index;
}
}).play();
}
}).play();
},
...

  • 使用 Call 和 Apply 指定 function 调用时的 Scope。我们会经常会提供一种类似回调函数的机制,在设计这个接口的时候并不提供函数的实现只负责调用该接口。例如对外提供一个 onclick 事件接口。由于 JavaScript 的 Scope 机制是词法范围而不是动态运行范围,因此回调函数运行的 Scope 往往不是我们想要的。对于这种情况我们可以在注册回调函数的时候,将 Scope 一起传进来,在需要调用该回调函数时使用 call 和 apply 方法来调用该方法,同时将需要的 scope 传递给这个函数。例如 dojo 的事件机制就是这样处理这个问题的,在注册回调函数的同时你可以指定函数调用的 Scope。

清单 20.Dojo 中传递 Scope 示例

dojo.connect(node, "onclick", this , this ._collapse);

  • 慎用 Eval。Eval 可以动态的从字符串中执行代码,它使得 JavaScript 的功能更加强大。通常 Eval 有全局或者局部两种运行 Scope,之前我们说过用户可以指定是否在全局运行。当其运行在一个局部的 function 中时,需要注意的是这时 Eval 运行在这个 function 的 call object 中它可以通过 this 关键字访问到这个 function 的 scope。另外,Eval 方法运行效率非常低,且运行的脚本是未经验证的,因此在使用 Eval 方法时要十分慎重。

小结

本文首先介绍了 JavaScript 语言的一些特性并如何利用这些特性实现面向对象和继承机制;随后我们结合目前流行的开源 JavaScript 类库,讲解他们是如何实现面向对象和继承机制等特性的;之后我们对 JavaScript 面向对象编程中几个容易引起误解和 Scope 问题的要点,包括闭包、call 函数、apply 函数,eval 函数等做了详细的讲解;最后根据作者的开发经验给出几个如何处理 Scope 问题的建议供大家参考,希望对大家以后的开发工作有一定的帮助。


转载自:http://www.ibm.com/developerworks/cn/web/1103_qinjian_javascriptscope/index.html?ca=drs-