2

本文原文来源:《Object-Oriented JavaScript》By Stoyan Stefanov
本文翻译来源:赤石俊哉 原创翻译
版权申明: 如果您是原文的原作者并且不希望此文被公开,可以联系作者删除。本文翻译由 赤石俊哉 翻译整理,您可以用于学习交流的目的,但是禁止用于其他用途,因私自滥用引发的版权纠纷本人概不负责。

原型模式(Prototype)

在这一章节中你将会学习使用“函数(function)”对象中的prototype属性。在JavaScript的学习过程中,理解prototype的工作原理是很重要的一个部分。毕竟,JavaScript被分类为是一个基于原型模式对象模型的语言。其实原型模式并不难,但是它是一种新的观念而且往往需要花些时间去理解。它是JavaScript中的一部分(闭包是另一部分),一旦你“get“了他们,他们就会变得很容易理解也是很有意义的。在本书的剩余部分中,强烈建议多打多试这些示例。那样会更加容易地学习和记住这些观念。
本章中将会讨论以下话题:

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

  • 向prototype中添加属性。

  • 使用向prototype中添加的属性。

  • 函数自身属性以及原型属性的区别。

  • 每一个对象保存在prototype中的私密链接——__proto__

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

  • 如何加强内建对象,比如数组(array)和字符串(string)。

原型属性

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()函数本身产生任何影响。他们只会在当你使用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;
};

你也可以通过另一种方式达到同样的目的,就是完全覆盖掉原型属性,将它换成你选择的对象:

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];
};

尽管我们在定义get()方法之前已经生成了newtoy对象,然而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()方法,而且它的原型属性对象里也没有,他就会一直往上找,最后会调用Object对象的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循环。在第二章节中,学习了如何遍历一个数组里面的所有元素:

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];
}

这个结果也包含对象的方法(那是因为方法是正好类型是函数的属性):

name = webcam
color = black
someMethod = function(){ return 1;}
price = 100
rating = 3

如果你想区分对象自身属性和原型属性的属性,使用hasOwnProperty(),试试这个:

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

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

让我们再来循环一次,但是这次只显示自身的属性:

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

结果:

name=webcam
color=black
someMethod=function(){return 1;}

接下来让我们试试propertyIsEnumerable()。如果自身属性不是内置的属性,这个函数就会返回true:

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

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

任何从原型链上来的属性都是不可枚举的:

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

注意,虽然如果你获取了包含在原型属性中对象,并且调用了它的propertyIsEnumerable(),这个属性是可以枚举的。

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

isPrototypeOf()

每一个对象都有isPrototypeOf()方法。这个方法会告诉你指定的对象是谁的原型属性。
我们先写一个简单的对象monkey

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

接下来,让我们建立一个Human()构造函数,然后设定它的prototype属性指向monkey

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

如果你创建一个叫作georgeHuman对象,然后问它:“monkeygeorge的原型属性吗?”,你就会得到true

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

秘密的__proto__链接

如你所知的,当你尝试访问一个不存在与当前对象的属性时,它会查询原型属性的属性。
让我们继续使用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"

可以从developer对象中得到原型属性对象呢?当然,可以啦。使用constructor作为中间对象,就像developer.constructor.prototype指向monkey一样。但是这并不是十分可靠的。因为constructor大多时候用于提供信息的用途,而且是可以随时被覆盖修改的。你甚至可以用一个不是对象的东西覆盖掉它。这样做丝毫不会影响到原型链的功能。

让我们看一些字符串的构造属性:

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

看上去,prototype已经乱成一团了:

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

但是事实却并非如此,因为开发者仍然呼吸着“空气”(developerbreathes属性仍然是air):

>>> 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"

再次强调,你可以在Debug或者是学习的时候使用__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()方法给String的原型属性。浏览Array.prototype.reverse()(这和第四章末尾的练习相似)。

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

这个代码使用split()使用字符串生成了一个数组,然后调用了这个数组上的reverse()方法,生成了一个反转的数组。然后再使用join()将反转的数组变回了字符串。让我们试试新的方法:

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

扩充内建对象——讨论

通过原型属性来扩充内建对象是一项强力的技术,而且你可以用它来将JavaScript塑造成你想要的样子。你在使用这种强有力的方法之前都要彻底地思考清楚你的想法。
看看一个叫做Prototype的JavaScript库,它的作者太爱这个方法了,以至于连库的名字都叫这个了。使用这个库,你可以使用一些JavaScript方法,让使用JavaScript如Ruby语言一样灵活。
YUI(雅虎用户界面)库是另一个比较流行的JavaScript库。它的作者则是明确地反对这个领域。他们不会以任何方式更改内建对象。不管你用的是什么库,修改核心对象都只会迷惑库的使用者,而且造成意料之外的错误。
事实是,JavaScript发生了变化,浏览器也带来了支持更多功能的新版本。现在你认为需要扩充到原型属性的缺失的功能,也许在明天就变成了内建的方法。因此你的方法可能就不被需要了。但是如果你使用这种方法已经写了很多代码而且你的方法又有些不同于内建的新内建实现呢?
最起码来说你能做的是,在实现一个方法之前先去检查一下它是否存在。我们的上一个例子就应该像这样:

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

一些原型属性的陷阱

在处理原型属性的时候,这两个现象是需要考虑在内的:

  • <!--The prototype chain is live with the exception of when you completely replace the prototype object.-->

  • 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()。在扩充方法和属性之前,检查是否存在,为你的代码添加未来的保证。


赤石俊哉
127 声望227 粉丝