通过原型这种机制,JavaScript 中的对象从其他对象继承功能特性;这种继承机制与经典的面向对象编程语言的继承机制不同。本文将探讨这些差别,解释原型链如何工作,并了解如何通过 prototype
属性向已有的构造器添加方法
预备知识: | 基本的计算机素养,对 HTML 和 CSS 有基本的理解,熟悉 JavaScript 基础(参见 First steps 和 Building blocks)以及面向对象的JavaScript (OOJS) 基础(参见 Introduction to objects)。 |
---|---|
目标: | 理解 JavaScript 对象原型、原型链如何工作、如何向 prototype 属性添加新的方法。 |
JavaScript 常被描述为一种基于原型的语言 (prototype-based language)——每个对象拥有一个原型对象,对象以其原型为模板、从原型继承方法和属性。原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推。这种关系常被称为原型链 (prototype chain),它解释了为何一个对象会拥有定义在其他对象中的属性和方法。
准确地说,这些属性和方法定义在 Object 的构造器函数之上,而非对象实例本身。
在传统的 OOP 中,首先定义“类”,此后创建对象实例时,类中定义的所有属性和方法都被复制到实例中。在 JavaScript 中并不如此复制——而是在对象实例和它的构造器之间建立一个连接(作为原型链中的一节),以后通过上溯原型链,在构造器中找到这些属性和方法。
以上描述很抽象;我们先看一个例子。
让我们回到 Person()
构造器的例子。请把这个例子载入浏览器。如果你还没有看完上一篇文章并写好这个例子,也可以使用 oojs-class-further-exercises.html 中的例子(亦可参考源代码)。
本例中我们将定义一个构造器函数:
function Person(first, last, age, gender, interests) { // 属性与方法定义 };
然后创建一个对象实例:
var person1 = new Person('Bob', 'Smith', 32, 'male', ['music', 'skiing']);
在 JavaScript 控制台输入 'person1.
',你会看到,浏览器将根据这个对象的可用的成员名称进行自动补全:
在这个列表中,你可以看到定义在 person1
的原型对象、即 Person()
构造器中的成员—— name
、age
、gender
、interests
、bio
、greeting
。同时也有一些其他成员—— watch
、valueOf
等等——这些成员定义在 Person()
构造器的原型对象、即 Object
之上。下图展示了原型链的运作机制。
那么,调用 person1
的“实际定义在 Object
上”的方法时,会发生什么?比如:
person1.valueOf()
这个方法仅仅返回了被调用对象的值。在这个例子中发生了如下过程:
person1
对象是否具有可用的 valueOf()
方法。person1
对象的原型对象(即 Person
)是否具有可用的 valueof()
方法。Person()
构造器的原型对象(即 Object
)是否具有可用的 valueOf()
方法。Object
具有这个方法,于是该方法被调用,注意:必须重申,原型链中的方法和属性没有被复制到其他对象——它们被访问需要通过前面所说的“原型链”的方式。
注意:没有官方的方法用于直接访问一个对象的原型对象——原型链中的“连接”被定义在一个内部属性中,在 JavaScript 语言标准中用 [[prototype]]
表示(参见 ECMAScript)。然而,大多数现代浏览器还是提供了一个名为 __proto__
(前后各有2个下划线)的属性,其包含了对象的原型。你可以尝试输入 person1.__proto__
和 person1.__proto__.__proto__
,看看代码中的原型链是什么样的!
那么,那些继承的属性和方法在哪儿定义呢?如果你查看 Object
参考页,会发现左侧列出许多属性和方法——大大超过我们在 person1
对象中看到的继承成员的数量。某些属性或方法被继承了,而另一些没有——为什么呢?
原因在于,继承的属性和方法是定义在 prototype
属性之上的(你可以称之为子命名空间 (sub namespace) )——那些以 Object.prototype.
开头的属性,而非仅仅以 Object.
开头的属性。prototype
属性的值是一个对象,我们希望被原型链下游的对象继承的属性和方法,都被储存在其中。
于是 Object.prototype.watch()、
Object.prototype.valueOf()
等等成员,适用于任何继承自 Object()
的对象类型,包括使用构造器创建的新的对象实例。
Object.is()
、Object.keys()
,以及其他不在 prototype
对象内的成员,不会被“对象实例”或“继承自 Object()
的对象类型”所继承。这些方法/属性仅能被 Object()
构造器自身使用。
注意:这看起来很奇怪——构造器本身就是函数,你怎么可能在构造器这个函数中定义一个方法呢?其实函数也是一个对象类型,你可以查阅 Function()
构造器的参考文档以确认这一点。
prototype
属性。回到先前的例子,在 JavaScript 控制台输入: Person.prototype
prototype
属性初始为空白。现在尝试: Object.prototype
你会看到 Object
的 prototype
属性上定义了大量的方法;如前所示,继承自 Object
的对象都可以使用这些方法。
JavaScript 中到处都是通过原型链继承的例子。比如,你可以尝试从 String
、Date
、Number
和 Array
全局对象的原型中寻找方法和属性。它们都在原型上定义了一些方法,因此当你创建一个字符串时:
var myString = 'This is my string.';
myString
立即具有了一些有用的方法,如 split()
、indexOf()
、replace()
等。
重要:prototype
属性大概是 JavaScript 中最容易混淆的名称之一。你可能会认为,这个属性指向当前对象的原型对象,其实不是(还记得么?原型对象是一个内部对象,应当使用 __proto__
访问)。prototype
属性包含(指向)一个对象,你在这个对象中定义需要被继承的成员。
create()
我们曾经讲过如何用 Object.create()
方法创建新的对象实例。
var person2 = Object.create(person1);
create()
实际做的是从指定原型对象创建一个新的对象。这里以 person1
为原型对象创建了 person2
对象。在控制台输入: person2.__proto__
结果返回 person1
对象。
每个对象实例都具有 constructor
属性,它指向创建该实例的构造器函数。
person1.constructorperson2.constructor
都将返回 Person()
构造器,因为该构造器包含这些实例的原始定义。
一个小技巧是,你可以在 constructor
属性的末尾添加一对圆括号(括号中包含所需的参数),从而用这个构造器创建另一个对象实例。毕竟构造器是一个函数,故可以通过圆括号调用;只需在前面添加 new
关键字,便能将此函数作为构造器使用。
var person3 = new person1.constructor('Karen', 'Stephenson', 26, 'female', ['playing drums', 'mountain climbing']);
person3.name.firstperson3.ageperson3.bio()
正常工作。通常你不会去用这种方法创建新的实例;但如果你刚好因为某些原因没有原始构造器的引用,那么这种方法就很有用了。
此外,constructor
属性还有其他用途。比如,想要获得某个对象实例的构造器的名字,可以这么用:
instanceName.constructor.name
具体地,像这样:
person1.constructor.name
从我们从下面这个例子来看一下如何修改构造器的 prototype
属性。
prototype
属性添加一个新的方法: Person.prototype.farewell = function() { alert(this.name.first + ' has left the building. Bye for now!');}
person1.farewell();
你会看到一条警告信息,其中还显示了构造器中定义的人名;这很有用。但更关键的是,整条继承链动态地更新了,任何由此构造器创建的对象实例都自动获得了这个方法。
再想一想这个过程。我们的代码中定义了构造器,然后用这个构造器创建了一个对象实例,此后向构造器的 prototype
添加了一个新的方法:
function Person(first, last, age, gender, interests) { // 属性与方法定义};var person1 = new Person('Tammi', 'Smith', 32, 'neutral', ['music', 'skiing', 'kickboxing']);Person.prototype.farewell = function() { alert(this.name.first + ' has left the building. Bye for now!');}
但是 farewell()
方法仍然可用于 person1
对象实例——旧有对象实例的可用功能被自动更新了。这证明了先前描述的原型链模型。这种继承模型下,上游对象的方法不会复制到下游的对象实例中;下游对象本身虽然没有定义这些方法,但浏览器会通过上溯原型链、从上游对象中找到它们。这种继承模型提供了一个强大而可扩展的功能系统。
注意:如果运行样例时遇到问题,请参阅 oojs-class-prototype.html 样例(也可查看即时运行)。
你很少看到属性定义在 prototype 属性中,因为如此定义不够灵活。比如,你可以添加一个属性:
Person.prototype.fullName = 'Bob Smith';
但这不够灵活,因为人们可能不叫这个名字。用 name.first
和 name.last
组成 fullName
会好很多:
Person.prototype.fullName = this.name.first + ' ' + this.name.last;
然而,这么做是无效的,因为本例中 this
引用全局范围,而非函数范围。访问这个属性只会得到 undefined undefined
。但这个语句若放在先前定义的 prototype
的方法中则有效,因为此时语句位于函数范围内,从而能够成功地转换为对象实例范围。你可能会在 prototype
上定义常属性 (constant property) (指那些你永远无需改变的属性),但一般来说,在构造器内定义属性更好。
译者注:关于 this
关键字指代(引用)什么范围/哪个对象,这个问题超出了本文讨论范围。事实上,这个问题有点复杂,如果现在你没能理解,也不用担心。
事实上,一种极其常见的对象定义模式是,在构造器(函数体)中定义属性、在 prototype
属性上定义方法。如此,构造器只包含属性定义,而方法则分装在不同的代码块,代码更具可读性。例如:
// 构造器及其属性定义function Test(a,b,c,d) { // 属性定义};// 定义第一个方法Test.prototype.x = function () { ... }// 定义第二个方法Test.prototype.y = function () { ... }// 等等……
在 Piotr Zalewa 的 school plan app 样例中可以看到这种模式。
本文介绍了 JavaScript 对象原型,包括原型链如何允许对象之间继承特性、prototype
属性、如何通过它来向构造器添加方法,以及其他有关主题。
下一篇文章中,我们将了解如何在两个自定义的对象间实现功能的继承。
联系客服