五、原型

在本章中,您将了解函数对象的 prototype属性。理解原型如何工作是学习 JavaScript 语言的一个重要部分。毕竟,JavaScript 被归类为具有基于原型的对象模型。原型没有什么特别难的,但它是一个新概念,因此有时可能需要一些时间来理解。这是 JavaScript 中的其中一件事(闭包是另一件事),一旦你“得到”它们,它们就变得如此明显,而且非常有意义。就像这本书的其余部分一样,我们强烈鼓励你输入和玩例子;这使得学习和记忆概念变得容易得多。

本章将讨论以下主题:

  • 每个函数都有一个 prototype属性,它包含一个对象

  • 向原型对象添加属性

  • 使用添加到原型中的属性

  • 自身属性和原型属性的区别

  • __proto__,每个对象与其原型保持的秘密联系

  • 方法如 isPrototypeOf(), hasOwnProperty()propertyIsEnumerable()

  • 如何增强内置对象,如数组或字符串

原型属性

JavaScript 中的函数是对象,它们包含方法和属性。你已经熟悉的一些方法是 apply()call(),一些属性是 lengthconstructor。功能对象的另一个属性是 prototype

如果您定义了一个简单的函数 foo(),您可以像访问任何其他对象一样访问它的属性:

>>> function foo(a, b){return a * b;}
>>> foo.length
2
>>> foo.constructor
Function()

prototype是一个一旦定义了函数就被创建的属性。它的初始值是一个空对象。

>>> typeof foo.prototype
"object"

就好像你自己这样添加了这个属性:

>>> foo.prototype = {}

您可以用属性和方法来扩充这个空对象。它们不会对 foo()功能本身产生任何影响;它们只会在你使用 foo()作为构造函数时使用。

使用原型添加方法和属性

在前一章中,您学习了如何定义构造函数,这些函数可以用来创建(构造)新的对象。主要思想是在用 new调用的函数中,您可以访问值 this,它包含构造函数要返回的对象。增强(向 this对象添加方法和属性)是向正在创建的对象添加功能的方法。

让我们来看看构造函数 Gadget(),它使用 this向它创建的对象添加两个属性和一个方法。

function Gadget(name, color) {
this.name = name;
this.color = color;
this.whatAreYou = function(){
return 'I am a ' + this.color + ' ' + this.name;
}
}

向构造函数的 prototype属性添加方法和属性是向该构造函数生成的对象添加功能的另一种方式。让我们再添加两个属性, pricerating,以及一个 getInfo()方法。由于 prototype包含一个对象,您可以像这样继续添加:

Gadget.prototype.price = 100;
Gadget.prototype.rating = 3;
Gadget.prototype.getInfo = function() {
return 'Rating: ' + this.rating + ', price: ' + this.price;
};

而不是添加到 prototype对象,另一种实现上述结果的方法是完全覆盖原型,用您选择的对象替换它:

Gadget.prototype = {
price: 100,
rating: 3,
getInfo: function() {
return 'Rating: ' + this.rating + ', price: ' + this.price;
}
};

使用原型的方法和属性

一旦使用构造函数创建了一个新对象,您添加到原型中的所有方法和属性都可以直接使用。如果使用 Gadget()构造函数创建 newtoy对象,则可以访问已经定义的所有方法和属性。

>>> var newtoy = new Gadget('webcam', 'black');
>>> newtoy.name;
"webcam"
>>> newtoy.color;
"black"
>>> newtoy.whatAreYou();
"I am a black webcam"
>>> newtoy.price;
100
>>> newtoy.rating;
3
>>> newtoy.getInfo();
"Rating: 3, price: 100"

需要注意的是,原型是“活的”。对象在 JavaScript 中通过引用传递,因此原型不会与每个新的对象实例一起复制。这在实践中意味着什么?这意味着您可以随时修改原型,并且所有对象(甚至是那些在修改之前创建的对象)都将继承这些更改。

让我们继续这个例子,向原型添加一个新方法:

Gadget.prototype.get = function(what) {
return this[what];
};

即使 newtoy是在定义 get()方法之前创建的, newtoy仍然可以使用新方法:

>>> newtoy.get('price');
100
>>> newtoy.get('color');
"black"

自有属性与原型属性

在上例中 getInfo()在内部使用 this来寻址对象。它也可以用 Gadget.prototype达到同样的结果:

Gadget.prototype.getInfo = function() {
return 'Rating: ' + Gadget.prototype.rating + ', price: ' + Gadget.prototype.price;
};

有什么区别?为了回答这个问题,让我们更详细地研究原型是如何工作的。

我们再来看看我们的 newtoy对象:

>>> var newtoy = new Gadget('webcam', 'black');

当你试图访问一个属性 newtoy,比方说 newtoy.name时,JavaScript 引擎会在对象的所有属性中寻找一个名为 name的属性,如果找到它,就会返回它的值。

>>> newtoy.name
"webcam"

如果你试图访问 rating属性呢?JavaScript 引擎将检查 newtoy的所有属性,并且不会找到名为 rating的属性。然后脚本引擎将识别用于创建该对象的构造函数的原型(就像你做 newtoy.constructor.prototype一样)。如果在原型中找到该属性,则使用该属性。

>>> newtoy.rating
3

这与直接访问原型是一样的。每个对象都有一个构造函数属性,它是对创建该对象的函数的引用,因此在我们的例子中:

>>> newtoy.constructor
Gadget(name, color)
>>> newtoy.constructor.prototype.rating
3

现在让我们把这个查找再向前推进一步。每个对象都有一个构造函数。原型是一个对象,所以它也必须有一个构造函数。它又有一个原型。换句话说,你可以做到:

>>> newtoy.constructor.prototype.constructor
Gadget(name, color)
>>> newtoy.constructor.prototype.constructor.prototype
Object price=100 rating=3

这可能会持续一段时间,这取决于原型链有多长,但您最终会得到内置的 Object()对象,这是最高级别的父对象。实际上,这意味着如果你尝试 newtoy.toString() newtoy没有自己的 toString()方法,它的原型也没有,最终你会得到对象的 toString()

>>> newtoy.toString()
"[object Object]"

用自己的属性覆盖原型的属性

正如上面的讨论所展示的,如果你的一个对象没有自己的特定属性,它可以在原型链的某个地方使用一个(如果存在的话)。如果对象确实有自己的属性,原型也有一个同名的属性呢?自己的属性优先于原型的属性。

让我们来看一个场景,其中属性名既作为自己的属性存在,也作为原型对象的属性存在:

function Gadget(name) {
this.name = name;
}
Gadget.prototype.name = 'foo';

“foo”

创建一个新的对象并访问其 name属性可以获得该对象自己的 name属性。

>>> var toy = new Gadget('camera');
>>> toy.name;
"camera"

如果删除此属性,原型的同名属性将“穿透”:

>>> delete toy.name;
true
>>> toy.name;
"foo"

当然,您总是可以重新创建对象自己的属性:

>>> toy.name = 'camera';
>>> toy.name;
"camera"

枚举属性

如果要列出一个对象的所有属性,可以使用 for-in循环。在第 2 章中,您看到了如何循环遍历数组的所有元素:

var a = [1, 2, 3];
for (var i in a) {
console.log(a[i]);
}

数组是对象,因此您可以预期 for-in循环也适用于对象:

var o = {p1: 1, p2: 2};
for (var i in o) {
console.log(i + '=' + o[i]);
}

这会产生:

p1=1

p2=2

有一些细节需要注意:

  • 并非所有属性都出现在 for-in循环中。例如, length(用于数组)和 constructor属性将不会显示。出现的属性称为可枚举。您可以借助每个对象提供的 propertyIsEnumerable()方法来检查哪些是可枚举的。

  • 原型链中的原型也会出现,前提是它们是可枚举的。您可以使用 hasOwnProperty()方法检查某个属性是自有属性还是原型属性。

  • propertyIsEnumerable()将返回所有原型属性的 false,甚至是那些可枚举的并且将出现在 for-in循环中的属性。

让我们看看这些方法的实际应用。就拿这个简化版 Gadget():来说吧

function Gadget(name, color) {
this.name = name;
this.color = color;
this.someMethod = function(){return 1;}
}
Gadget.prototype.price = 100;
Gadget.prototype.rating = 3;

创建新对象:

var newtoy = new Gadget('webcam', 'black');

现在,如果使用 for-in循环,您会看到对象的所有属性,包括来自原型的属性:

for (var prop in newtoy) {
console.log(prop + ' = ' + newtoy[prop]);
}

结果还包含对象的方法(因为方法只是碰巧是函数的属性):

名称=网络摄像头

颜色=黑色

some method = function(){ return 1;}

价格= 100

额定值= 3

如果要区分对象自身属性和原型属性,请使用 hasOwnProperty()。先试试:

>>> newtoy.hasOwnProperty('name')
true
>>> newtoy.hasOwnProperty('price')
false

让我们再次循环,但只显示自己的属性:

for (var prop in newtoy) {
if (newtoy.hasOwnProperty(prop)) {
console.log(prop + '=' + newtoy[prop]);
}
}

结果是:

名称=网络摄像头

颜色=黑色

some method = function(){ return 1;}

现在让我们试试 propertyIsEnumerable()。对于非内置属性,该方法返回 true:

>>> newtoy.propertyIsEnumerable('name')
true

大多数内置属性和方法不可枚举:

>>> newtoy.propertyIsEnumerable('constructor')
false

原型链中的任何属性都是不可枚举的:

>>> newtoy.propertyIsEnumerable('price')
false

但是,请注意,如果您到达原型中包含的对象并调用其 propertyIsEnumerable(),则这些属性是可枚举的。

>>> newtoy.constructor.prototype.propertyIsEnumerable('price')
true

ispro type of()

每个对象也得到 isPrototypeOf()法。此方法告诉您该特定对象是否用作另一个对象的原型。

我们拿一个简单的对象 monkey来说。

var monkey = {
hair: true,
feeds: 'bananas',
breathes: 'air'
};

现在让我们创建一个 Human()构造函数,并将它的 prototype属性设置为指向 monkey

function Human(name) {
this.name = name;
}
Human.prototype = monkey;

现在如果你创建一个名为 george的新 Human对象,然后问:“是 monkey george's原型吗?”,你会得到 true

>>> var george = new Human('George');
>>> monkey.isPrototypeOf(george)
true

秘密 __ 原型 _ _ 链接

正如您已经知道的,当您试图访问当前对象中不存在的属性时,将会参考原型属性。

让我们再次拥有一个名为 monkey的对象,并在使用 Human()构造函数创建对象时将其用作原型。

var monkey = {
feeds: 'bananas',
breathes: 'air'
};
function Human() {}
Human.prototype = monkey;

现在让我们创建一个 developer对象,并赋予它一些属性:

var developer = new Human();
developer.feeds = 'pizza';
developer.hacks = 'JavaScript';

现在让我们参考一些属性。 hacksdeveloper对象的属性。

>>> developer.hacks
"JavaScript"

feeds也可以在对象中找到。

>>> developer.feeds
"pizza"

breathes并不作为 developer对象的属性存在,所以原型是向上看的,好像有一个秘密链接指向原型对象。

>>> developer.breathes
"air"

你能从开发者对象到原型对象吗?嗯,你可以,利用 constructor作为中间人,所以拥有类似 developer.constructor.prototype的东西应该指向 monkey。问题是这个不太可靠,因为 constructor更多的是为了信息的目的,可以随时轻松覆盖。您可以用甚至不是对象的东西覆盖它,这不会影响原型链的正常功能。

让我们将构造函数属性设置为某个字符串:

>>> developer.constructor = 'junk'
"junk"

似乎 prototype现在全乱了:

>>> typeof developer.constructor.prototype
"undefined"

...但事实并非如此,因为 developer还是 breathesT2【空气】:

>>> developer.breathes
"air"

这表明原型的秘密链接仍然存在。这个秘密链接在火狐中被公开为 __proto__属性(单词“proto”前面有两个下划线,后面有两个下划线)。

>>> developer.__proto__
Object feeds=bananas breathes=air

您可以将此秘密属性用于学习目的,但在您的真实脚本中使用它并不是一个好主意,因为它不存在于 Internet Explorer 中,所以您的脚本不会是可移植的。例如,假设您已经创建了多个以 monkey为原型的对象,现在您想要更改所有对象中的某些内容。您可以更改 monkey,所有实例都将继承该更改:

>>> monkey.test = 1
1
>>> developer.test
1

__proto__不同于 prototype. __proto__是实例的属性,而 prototype是构造函数的属性。

>>> typeof developer.__proto__
"object"
>>> typeof developer.prototype
"undefined"

同样,您应该仅将 __proto__用于学习或调试目的。

扩充内置对象

内置的对象,比如构造函数 Array, String,甚至 ObjectFunction都可以通过它们的原型进行扩充,这意味着你可以,比如说,给 Array原型添加新的方法,这样就可以让所有数组都可以使用它们。我们开始吧。

在 PHP 中有一个名为 in_array()的函数,它告诉你数组中是否存在一个值。在 JavaScript 中没有 inArray()方法,所以让我们实现它并将其添加到 Array.prototype中。

Array.prototype.inArray = function(needle) {
for (var i = 0, len = this.length; i < len; i++) {
if (this[i] === needle) {
return true;
}
}
return false;
}

现在所有的数组都会有新的方法。让我们测试一下:

>>> var a = ['red', 'green', 'blue'];
>>> a.inArray('red');
true
>>> a.inArray('yellow');
false

那很简单!我们再来一次。想象一下,你的应用经常需要反转字符串,你觉得应该有一个内置的 reverse()方法来处理字符串对象。毕竟阵列有 reverse()。你可以很容易地将这个 reverse()方法添加到字符串原型中,借用 Array.prototype.reverse()(在第 4 章的末尾有一个类似的练习)。

String.prototype.reverse = function() {
return Array.prototype.reverse.apply(this.split('')).join('');
}

这段代码使用 split()从一个字符串创建一个数组,然后在这个数组上调用 reverse()方法,产生一个反向数组。使用 join()将结果数组变回字符串。让我们测试一下新方法:

>>> "Stoyan".reverse();
"nayotS"

扩充内置对象—讨论

通过原型扩充内置对象是一种非常强大的技术,您可以使用它以任何您喜欢的方式来塑造 JavaScript。由于它的力量,在使用这种方法之前,您应该始终彻底考虑您的选择。

以流行的叫做 Prototype 的 JavaScript 库为例。它的创造者非常喜欢这种方法,甚至以它的名字给图书馆命名。使用这个库,您可以使用与 Ruby 语言非常相似的方法来使用 JavaScript。

YUI(雅虎!用户界面)库是另一个流行的 JavaScript 库。它的创建者恰恰相反:他们不会以任何方式修改内置对象。原因是,一旦你知道了 JavaScript,你就会期望它以同样的方式工作,不管你使用的是哪个库。修改核心对象只会混淆库的用户,并产生意外的错误。

事实是,JavaScript 改变了,浏览器出现了支持更多功能的新版本。今天您认为缺少的特性,并决定扩充原型,明天可能是一个内置的方法。在这种情况下,不再需要您的方法。但是,如果您已经编写了大量使用方法的代码,并且您的方法与新的内置实现略有不同,该怎么办?

您至少可以在实现该方法之前检查它是否存在。我们的最后一个例子应该是这样的:

if (!String.prototype.reverse) {
String.prototype.reverse = function() {
return Array.prototype.reverse.apply(this.split('')).join('');
}
}

最佳实践

如果您决定用一个新属性来扩充内置对象的原型,请首先检查新属性是否存在。

一些原型陷阱

以下是处理原型时需要考虑的两个有趣的行为:

  • 原型链是活动的,除非您完全替换了原型对象

  • prototype.constructor不可靠

创建简单的构造函数和两个对象:

>>> function Dog(){this.tail = true;}
>>> var benji = new Dog();
>>> var rusty = new Dog();

即使在创建对象之后,您仍然可以向原型添加属性,并且对象可以访问新属性。让我们抛出方法 say():

>>> Dog.prototype.say = function(){return 'Woof!';}

这两个对象都可以访问新方法:

>>> benji.say();
"Woof!"
>>> rusty.say();
"Woof!"

到目前为止,如果您咨询您的对象,询问哪个构造函数被用来创建它们,它们会正确地报告它。

>>> benji.constructor;
Dog()
>>> rusty.constructor;
Dog()

有趣的是,如果问原型对象的构造函数是什么,也会得到 Dog(),不太正确。原型只是一个用 Object()创建的普通对象。它不具有用 Dog()构建的对象的任何属性。

>>> benji.constructor.prototype.constructor
Dog()
>>> typeof benji.constructor.prototype.tail
"undefined"

现在让我们用一个全新的对象完全覆盖原型对象:

>>> Dog.prototype = {paws: 4, hair: true};

原来,我们的旧对象无法访问新原型的属性;他们仍然保留着指向旧原型对象的秘密链接:

>>> typeof benji.paws
"undefined"
>>> benji.say()
"Woof!"
>>> typeof benji.__proto__.say
"function"
>>> typeof benji.__proto__.paws
"undefined"

从现在开始,您创建的任何新对象都将使用更新的原型:

>>> var lucy = new Dog();
>>> lucy.say()
TypeError: lucy.say is not a function
>>> lucy.paws
4

秘密 __proto__链接指向新的原型对象:

>>> typeof lucy.__proto__.say
"undefined"
>>> typeof lucy.__proto__.paws
"number"

现在,新对象的构造函数属性不再正确报告。它应该指向 Dog(),而不是指向 Object()

>>> lucy.constructor
Object()
>>> benji.constructor
Dog()

最令人困惑的部分是当您查找构造函数的原型时:

>>> typeof lucy.constructor.prototype.paws
"undefined"
>>> typeof benji.constructor.prototype.paws
"number"

下面将修复上述所有意外行为:

>>> Dog.prototype = {paws: 4, hair: true};
>>> Dog.prototype.constructor = Dog;

最佳实践

覆盖原型时,最好重置 constructor属性。

总结

让我们总结一下你在本章中学到的最重要的话题。

  • 所有函数都有一个名为 prototype的属性。最初它包含一个空对象。

  • 您可以向原型对象添加属性和方法。你甚至可以用一个你选择的对象完全代替它。

  • 当您使用函数作为构造函数(使用 new)创建对象时,对象将有一个指向其原型的秘密链接,并且可以访问原型的属性作为自己的属性。

  • 自己的属性优先于同名原型的属性。

  • 使用 hasOwnProperty()方法区分自身属性和原型属性。

  • 有一个原型链:如果你的对象 foo没有属性 bar,当你做 foo.bar的时候,JavaScript 会寻找原型的 bar属性。如果没有找到,它将继续在原型的原型中搜索,然后原型的原型的原型,并一直向上到最高级别的父级 Object

  • 您可以扩充内置的构造函数,所有对象都会看到您的添加。给 Array.prototype.flip分配一个函数,所有数组会立刻得到一个 flip()方法, [1,2,3].flip()。一定要检查您想要添加的方法/属性是否已经存在,这样您就可以对您的脚本进行未来验证。

练习

  1. 1.创建一个名为 shape的对象,该对象具有 type属性和 getType()方法。

  2. 2.定义一个原型为 shapeTriangle()构造函数。使用 Triangle()创建的对象应该有三个自己的属性— a, b, c代表三角形的边。

  3. 3.给原型增加一个新的方法叫做 getPerimeter()

  4. 4.使用以下代码测试您的实现:

>>> var t = new Triangle(1, 2, 3);
>>> t.constructor
Triangle(a, b, c)
>>> shape.isPrototypeOf(t)
true
>>> t.getPerimeter()
6
>>> t.getType()
"triangle"
  1. 5.在 t上循环,只显示自己的属性和方法(没有原型)。

  2. 6.让这段代码发挥作用:

>>> [1,2,3,4,5,6,7,8,9].shuffle()
[2, 4, 1, 8, 9, 6, 5, 3, 7]