欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

JavaScript原型的工作原理(以及如何利用它来实现类的继承)

程序员文章站 2022-10-31 11:57:59
原文:How JavaScript Prototype Really Works 本文是JavaScript技术系列的第二篇,这次我打算深入讨论下最令人困惑的JavaScrip...

原文:How JavaScript Prototype Really Works


本文是JavaScript技术系列的第二篇,这次我打算深入讨论下最令人困惑的JavaScript的原型对象,以及如何用它来实现继承。


在上一篇文章里,我们以及详细分析过构造函数,还有怎样将其变成面向对象语言里的类。但是JavaScript不是基于类的编程语言,它是基于原型的编程语言。这到底是什么意思呢?简单来说,JavaScript不是通过类来创建对象,而是通过对象来创建对象。




JavaScript:一种基于原型的语言(prototype-based language)


在传统的基于类实现面向对象的语言里(比如Java或者C++),在创建一个对象之前,你要先创建一个类来定义这个对象的属性和方法。于是这个类就成为用来创建对象的蓝图,就好像在现实中依照真的蓝图来盖房子一样。


你定义好了类之后,便马上可以用它来创建对象了。我们通过new关键字来根据类创建一个相应的对象,对于这一过程,我们有个绝妙的称呼:类的实例化。新创建的对象将会具有类定义的所有的属性和方法。在传统的基于类的语言里,我们可以这样做:

public class Shape {
	private int height;
	private int width;
	public Shape(int h, int w) {
		height = h;
		width = w;
	}
	public int area() {
		return height * width;
	}
}
Shape shape = new Shape(10, 2);
System.out.println(shape.area());  // 20


JavaScript没有类,有的只是对象和基本数据类型。为简化问题起见,我们姑且说JavaScript里的所有东西都是对象,从实数到字符串,甚至是函数本身。前一篇文章已经讲到过,有两种创建对象的方法:一种是从字面上直接创建(有些地方称为通过JSON符号创建),另一种方法是通过构造函数。由于本文的主旨,我们以后将只考虑后者。


在能够创建对象之前,你需要先创建一个函数(也就是说构造函数)。接下来在用new关键字创建对象的时候,这个函数会充当蓝图使用。新创建的对象上面会拷贝所有在函数体内定义的属性和方法。这便是原型语言如何用对象来创建对象的(译者注:函数就是个function类型的对象嘛):

function Shape() {
	this.height = 10;
	this.width = 10;
	this.area = function() {
		return this.height * this.width;
	};
};
var shape = new Shape();
console.log(shape.area());  // 100


但是,也有很多属性和方法并没有在相应的构造函数中定义,可是用它们所创建的对象却仍然可以访问到。比如说toString()方法。任何对象都可以用这个方法输出一个关于自身的字符串。Shape()函数体内并没定义这个方法,但是shape对象却仍然可以使用它。
console.log(shape.toString());  // "[object Object]";


那么,对象是从哪里得到这些方法的呢?答案是从JavaScript的原型那里。




JavaScript的原型



JavaScript的每一个对象都有一个通常是被隐藏了的属性,被称作[[Prototype]]。[[Prototype]]属性的值实际上是一个指针,它指向一个对象,当JavaScript在当前对象上面找不到一个属性的时候,便会委托(delegation)给这个对象(译者注:就是[[Prototype]]指向的东东)。委托(delegation)的意思就是当你做不了一件事情的时候,你叫别人代替你去做。


当你要在一个对象*问一个属性时,JavaScript首先去看看这个对象本身上面是否有这个属性。如果没有,JavaScript就会去查找它的[[Prototype]]值,去看看这个值所指向的对象上面是否有被请求的这个属性。这个操作会一直持续下去,直到找到这个属性或者最终[[Prototype]]返回null值为止。这就是所谓的原型链了,将对象都链接起来,每一个对象都是这个链条中的一个节点。在大多数浏览器里,一个对象的[[Prototype]]属性可以通过一个非标准方法来访问,那就是一个叫__proto__的属性,不过除此以外,另外还有一个办法一般都可行,就是用Object.isPrototypeOf()方法。


现在回到前面说的shape.toString()的例子。当我们要在shape对象上面呼叫toString()方法时,JavaScript首先在shape对象上面寻找这个方法,可是却发现它并没有。于是JavaScript开始查找shape的[[Prototype]]值指向的另一个对象。而在这个对象上也没有toString()方法。于是继续查找这个对象的[[Prototype]]值所指向的又一个新对象。最后,JavaScript终于在这上面找到了toString()方法,于是JavaScript使用这个方法,并终止了原型链查找过程。


JavaScript原型的工作原理(以及如何利用它来实现类的继承)


现在我们用hasOwnProperty()方法来验证一下上述推论,每个对象都拥有这个方法。如果一个对象拥有某个属性,那么hasOwnProperty()将会返回true,否则返回false。
vcD4KPHByZSBjbGFzcz0="brush:java;">console.log(shape.hasOwnProperty('toString')); // false console.log(shape.__proto__.hasOwnProperty('toString')); // false console.log(shape.__proto__.__proto__.hasOwnProperty('toString')); // true


那么,既然我们什么都没有做,这个原型链又是如何被建立的呢?shape对象又是如何被加入到其中的呢?看起来JavaScript会自动将所有被创建的对象加入到一个原型链中。可是它究竟是怎样做的呢?实际上是通过Shape函数上面的function.prototype属性。




函数的function.prototype属性


每个函数都有一个公共属性,叫function.prototype。它与每个对象都具有的[[Prototype]]属性并不是一回事,不过两者也不是完全无关。function.prototype实际上是一个指针,它指向一个对象,当你通过new关键字来使用一个函数创建实例(其实实例就是个对象而已,可是这篇文章中使用了太多“对象”这个术语,为了在语言上尽量避免误解,这里我称一个被构造函数创建的东东为“实例”。)的时候,JavaScript会把函数的function.prototype赋值给这个被新创建的实例的[[Prototype]]属性,这样一来,实例的[[Prototype]]属性和函数的function.prototype属性就都指向同一个对象了。通过function.prototype,JavaScript能够知道如何把新创建的对象加入到原型链中。函数的function.prototype属性可以通过
“prototype”关键字直接访问。


那么,一个函数的function.prototype属性又是如何被设定的呢?我们并不需要自己来实现这个步骤,JavaScript会替我们作。当你创建一个函数的时候,JavaScript会为它创建一个新的对象,来做它的function.prototype属性。这个新对象自身没有什么属性或者方法,当然,除了[[Prototype]]属性,它的用途实际上仅仅是用来作为原型链上的一个环节,把不同的东西链接在一起而已。由于缺乏一个合适的术语,我姑且称这个对象为“函数的空对象”。在“空对象”被创建之后,JavaScript会将函数的function.prototype属性指向它,于是,也就准备好了以后建立原型链时所需要的信息。


再回到前面的Shape例子,当创建Shape函数的时候,JavaScript顺便创建一个“空对象”给它,然后再把Shape函数的function.prototype属性指向这个“空对象”。当你通过new关键字来使用Shape函数创建新实例的时候,JavaScript会寻找Shape函数的function.prototype属性(它指向着“空对象”),然后将新实例的[[Prototype]]属性也指向这个“空对象”。


JavaScript原型的工作原理(以及如何利用它来实现类的继承)


我们可以通过比较操作符来验证一下,看看shape实例的[[Prototype]]属性和Shape函数的function.prototype属性到底是不是指向着同一个东西。我们可以用严格比较操作符,只有两个对象相同时,它才返回true:

function Shape() {
	this.height = 10;
	this.width = 10;
	this.area = function() {
		return this.height * this.width;
	};
};
console.log(Shape.prototype);  // Shape {}

var shape = new Shape(); 
console.log(shape.__proto__); // Shape {} 
console.log(shape.__proto__ === Shape.prototype); // true 


你完全可以手动改变函数的function.prototype属性,让它按照你的意思指向别的对象,这样你就可以控制如何建立原型链。这也就是在JavaScript里实现类的继承的原理。




JavaScript的原型继承


JavaScript的原型链是继承的关键。JavaScript的继承并不像基于类的语言那样。在一个基于类的语言中,根据一个子类创建的对象上面会同时保存有子类和父类所声明的所有属性和方法。


在一个原型语言中,没有子类对象或者父类对象。实际上,一个对象只会保存用来创建它的函数中所声明的属性和方法,至于父类的属性和方法,它会通过原型链来动态获得。这就是所谓的原型继承。


为了实现继承,我们需要先创建两个函数,一个用来继承另一个:

function Shape() {
	this.height = 10;
	this.width = 10;
	this.area = function() {
		return this.height * this.width;
	};
};

// The Triangle function will inherit from the Shape function function 
Triangle() { 
	this.area = function() { 
		return this.height * this.width / 2; 
	}
} 


然后我们把一个函数的function.prototype属性指向另一个函数所创建的实例。这样一来,所有通过“子函数”创建的对象都能够通过原型链获得了所有“父函数”所定义的属性。


在这个例子里,triangle对象并没有height和width属性,所以JavaScript会通过原型链最终在shape对象上找到它们。当我们呼叫triangle.area()时,实际上被执行的是triangle对象自己的area()方法,而不是shape对象的,因为既然triangle对象自身已经有这个属性,那么便不需要去原型链查找了。

JavaScript原型的工作原理(以及如何利用它来实现类的继承)




增加新的属性


原型继承的一大优点就是,你可以很容易地通过给一个对象添加新属性,进而使得所有沿着原型链继承这个对象的其他对象都具有这个属性。比如说,我们现在想给Shape增加一个新方法来计算周长。我们可能会想到把这个方法增加给Shape函数,可是就像前一篇文章讲过的,这样做行不通:

function Shape() {
	this.height = 10;
	this.width = 10;
	this.area = function() {
		return this.height * this.width;
	};
};
var shape = new Shape();
console.log(shape.perimeter());  // TypeError: Object # has no method 'perimeter'
Shape.perimeter = function() {
	return this.height * 2 + this.width * 2;
};

var shape1 = new Shape(); 
console.log(shape1.perimeter()); // TypeError: Object # has no method 'perimeter' 


其实我们应该直接给function.prototype添加这个方法,这样一来,它就被添加进了原型链里,那么所有用Shape()函数创建的实例便都有这个方法了。

Shape.prototype.perimeter = function() {
   return this.height * 2 + this.width * 2;
}

console.log(shape.perimeter()); // 40 

JavaScript原型的工作原理(以及如何利用它来实现类的继承)


那么,为什么把这个新方法放在创建对象的构造函数上反而不行呢?在一个函数被创建以后,在它上面添加的任何属性都会变成静态属性,这也就意味着你只能通过函数自身来访问这些属性,即Shape.perimeter()。而且,静态属性不会被添加到用这个函数所创建的实例上面。所以,如果你要添加新属性,只能通过function.prototype(译者注:JavaScript里,所有东西都是对象,记得吧?所以函数本身也是个对象,像示例代码中的Shape和Triangle这样的构造函数也是对象,那么像上面那样直接给Shape添加一个方法,实际上结果是给这个函数对象上面添加了一个方法,所以它当然不在原型链里面,所以这样做根本影响不到用这些构造函数创建出来的实例)。




结论


对于那些熟悉基于类的语言,并刚刚开始JavaScript的人来说,它的原型机制确实很令人迷惑。不过既然JavaScript是作为一种原型语言,那么要想彻底理解它是怎样运作的,就必须先理解它的原型机制。由于把不同的对象用原型链链接到了一起,于是,当在一个对象上找不到它应该有的属性时,便可以顺藤摸瓜去寻找这个属性。


在下一篇文章里我将讨论下JavaScript的一些常见的误区和陷阱,还有如何更为简洁有效地创建原型链。