深入理解 JavaScript 中的原型

这篇Understanding “Prototypes” in JavaScript,是超级偶像Yehuda Katz的大作,推荐了许多次,最后决定来做翻译。自己对JavaScript了解不算太深,加上英文水平有限,不足之处还望见谅。

以下为译文:

在这篇文章中,我会来讲解下在ECMAScript 5.1中 JavaScript 对象的定义。这种基本的语义早在第3版中就存在,但是没有被很好的表现出来。

####一个全新的对象

在JavaScript中,对象是成对的键值对(Ruby语言中称这种结构为Hash;Python则叫做dictionary)。举例来说,如果我想要描述我的名字,我可以使用一个对象含有两个键:”firstName”赋值为”Yehuda”,”lastName”赋值为”Katz”。在JavaScript对象中键是个字符串格式。

在JavaScript中,你可以使用 Object.create 语法来创建这个简单的对象:

1
var person = Object.create(null); // this creates an empty objects

为什么不直接使用 var person = {}; ? 别急!在对象中用键名来查找对应值,使用方括号的语法。如果对象中不存在该键值,则JavaScript会返回’undefined’。

1
person['name'] // undefined

如果字符串是一个有效标识符[1],你也可以用点来调用:

1
person.name // undefined

[1] 通长来说,一个标识符会以字符、$、_、开头,接着是以任何数量的字符或数字。一个有效标识符必须不是一个保留关键字。也有其它字符被允许使用,像unicode combining marks, unicode connecting punctuation 和 unicode escape sequences。进入上面的链接查看更多详细信息。

####为对象添值

现在你已经有一个空的对象。没什么用,嗯?我们可以添加一些属性,不过我们需要先理解在JavaScript中属性(规范中称作“命名的数据属性”)是什么东西。

很明显的,属性拥有名字和值。然后,属性能够被枚举可配置并且可写。如果一个值中可枚举的,在对对象使用for进行循环时将被显示出来。如果属性是可写的,你就可以对其值进行替换。如果属性是可配置的,就可以删除或改变它的其它特性。

通常来说,当我们创建一个新的属性时,我们希望设置其为可枚举,可配置,可写。但事实上,ECMAScript 5中,仅有一种属性可被直接创建。

我们能使用 Object.defineProperty 方法向对象添加属性。让我们尝试在刚创建的新对象上添加姓氏和名字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var person = Object.create(null);
Object.defineProperty(person, 'firstName', {
value: "Yehuda",
writable: true,
enumerable: true,
configurable: true
});
Object.defineProperty(person, 'lastName', {
value: "Katz",
writable: true,
enumerable: true,
configurable: true
});

显然,这是最繁琐的步骤了。我们能通过消除公共项的方式让过程简单一点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var config = {
writable: true,
enumerable: true,
configurable: true
};
var defineProperty = function(obj, name, value) {
config.value = value;
Object.defineProperty(obj, name, config);
}
var person = Object.create(null);
defineProperty(person, 'firstName', "Yehuda");
defineProperty(person, 'lastName' "Katz");

显然,这种创建属性列表的方式很丑陋。在我们找到更好的解决方案前,我们先为我们的JavaScript对象工厂添加另一个武器。

####原型

目前为止,我们已经讨论了简单键值对的对象。事实上,在JavaScript对象中还存在一个附加的特性:指向另一个对象的指针。我们称这个指称为对象的原型。如果你试着在对象中找一个键而该键并不存在当前对象时,JavaScript就会去它的原型里去找。它会遵循原型链的方式向上直到找到一个为null的值。在这个例子中,它返回undefined。

还记得我们通过调用 Object.create(null) 来创建了一个新的对象。这个参数告诉JavaScript将什么设为对象类的原型。你可以通过 Object.getPrototypeOf 方式来查看一个对象的原型:

1
2
3
4
5
6
7
8
9
10
11
12
var man = Object.create(null);
defineProperty(man, 'sex', "male");
var yehuda = Object.create(man);
defineProperty(yehuda, 'firstName', "Yehuda");
defineProperty(yehuda, 'lastName', "Katz");
yehuda.sex // "male"
yehuda.firstName // "Yehuda"
yehuda.lastName // "Katz"
Object.getPrototypeOf(yehuda) // returns the man object

我们也可以使用这种方式来在多个对象间添加 function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var person = Object.create(null);
defineProperty(person, 'fullName', function() {
return this.firstName + ' ' + this.lastName;
});
// this time, let's make man's prototype person, so all
// men share the fullName function
var man = Object.create(person);
defineProperty(man, 'sex', "male");
var yehuda = Object.create(man);
defineProperty(yehuda, 'firstName', "Yehuda");
defineProperty(yehuda, 'lastName', "Katz");
yehuda.sex // "male"
yehuda.fullName() // "Yehuda Katz"

####属性设置

通常来说创建一个可写、可配置、可枚举的属性是一件再普通不过的事情了,JavaScript也提供了一种赋值语法来简化这一过程。我们一起用新的赋值来代替defineProperty重写刚才的那个实例吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var person = Object.create(null);
// instead of using defineProperty and specifying writable,
// configurable, and enumerable, we can just assign the
// value directly and JavaScript will take care of the rest
person['fullName'] = function() {
return this.firstName + ' ' + this.lastName;
});
// this time, let's make man's prototype person, so all
// men share the fullName function
var man = Object.create(person);
man['sex'] = "male";
var yehuda = Object.create(man);
yehuda['firstName'] = "Yehuda";
yehuda['lastName'] = "Katz";
yehuda.sex // "male"
yehuda.fullName() // "Yehuda Katz"

就像查找属性时,如果定义属性时你使用的是一个标识符,就可以使用“.”来代替方括号”[]“。在上例中,可以使用man.sex = “male”。

####对象字面值

我们仍需每次都设置一堆的属性,让人感觉不爽。不过JavaScript也提供了语法来一次性创建对象和属性赋值。

1
var person = { firstName: "Paul", lastName: "Irish" }

这种语法实际等同于:

1
2
3
var person = Object.create(Object.prototype);
person.firstName = "Paul";
person.lastName = "Irish";

最重要的就是新的对象的原型总是指向到 Object.prototype 的。从内部来看下:

默认的 Object.prototype dictionary中包含的一系列的方法会被新的对象所继承,通过这个神奇的原型链,所有的新对象在被创建时都包含了这些属性。当然,对象可以很方便的自定义这些属性来实现重写。通常,新类中开发人员会重写 toString 方法。

1
2
3
4
5
6
7
8
9
10
11
var alex = { firstName: "Alex", lastName: "Russell" };
person.toString() // "[object Object]"
var brendan = {
firstName: "Brendan",
lastName: "Eich",
toString: function() { return "Brendan Eich"; }
};
brendan.toString() // "Brendan Eich"

因为该类中会强制使用原型链上继承来的toString方法,所以按自己需求重写格外有用。

不幸的是,这种语法只能在使用 Ojbect.prototype 创建新的对象原型时工作。这就不能很方便的像刚才所见的那样使用prototype来共享属性。很多情况下,这种简单的对象语法在便利性上的所得大于所失,你仍需要一种简单的方式去创建新的含有全部prototype的对象。在下面我来解释它的正确做法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
var fromPrototype = function(prototype, object) {
var newObject = Object.create(prototype);
for (var prop in object) {
if (object.hasOwnProperty(prop)) {
newObject[prop] = object[prop];
}
}
};
var person = {
toString: function() {
return this.firstName + ' ' + this.lastName;
}
};
var man = fromPrototype(person, {
sex: "male"
});
var jeremy = fromPrototype(man, {
firstName: "Jeremy",
lastName: "Ashkenas"
});
jeremy.sex // "male"
jeremy.toString() // "Jeremy Ashkenas"

我们细看下 fromPrototype 方法。这个方法的目的就是用一系列属性来创建一个对象,但是是和一个特有的原型。首先,我们会使用 Object.creqate() 来创建一个新的对象,按我们需要来创建属性。接下来,我们枚举该对象里的所有属性,并把它们复制到新的对象中。

记得每当你创建一个新的对象标识符,就像刚使用的 fromPrototype ,它总是使用 Object.prototype 作为它的原型。JavaScript总是会默认的包含 不可枚举的 Object.prototype ,所以我们不用担心在循环中的valueOf。无论如何,Objet.prototype 和其它的任何对象没有太大差异,谁都可以用它来创建新的对象并标记为可枚举。

结果,当我们遍历我们传入对象的所有属性时,我们总是希望知道哪些是我们在当前对象内自身定义的,而不是继承于原型。JavaScript 的 Object.prototype 中提供了一个方法叫 hasOwnProperty 去判断一个属性是否为该对象自身所定义。 所有的对象都会使用 Object.prototype 作为其原型,在使用中可以利用这一点。

这个对象创建的过程看起来是这样的:

####自然面向对象

从这点来看,prototype在功能上能被用作继承,就像传统的面向对象语言。为了便于使用这种方式,JavaScript提供了 new 操作符。

为了方便面向对象编程,JavaScript充许你用一个Function 对象作为原型的组合去创建新对象并作为构造函数调用。

1
2
3
4
5
6
7
8
var Person = function(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
Person.prototype = {
toString: function() { return this.firstName + ' ' + this.lastName; }
}

这里,我们拥有一个单一个 Function object,即作为构造方法,同时也是新建对象的原型。我们来实现一个方法从这个Person对象中来创建新的实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function newObject(func) {
// get an Array of all the arguments except the first one
var args = Array.prototype.slice.call(arguments, 1);
// create a new object with its prototype assigned to func.prototype
var object = Object.create(func.prototype);
// invoke the constructor, passing the new object as 'this'
// and the rest of the arguments as the arguments
func.apply(object, args);
// return the new object
return object;
}
var brendan = newObject(Person, "Brendan", "Eich");
brendan.toString() // "Brendan Eich"

JavaScript中new操作符本质上做这样的工作,提供一个让传统面向对象语言开发人员熟悉的语法:

1
2
var mark = new Person("Mark", "Miller");
mark.toString() // "Mark Miller"

本质上来说,JavaScript中的”class”就是一个功能对象负责构造和绑定原型对象。我注意到在早期的JavaScript版本中并不具备Object.create,直到大多数人开始使用new 操作符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var createObject = function (o) {
// we only want the prototype part of the `new`
// behavior, so make an empty constructor
function F() {}
// set the function's `prototype` property to the
// object that we want the new object's prototype
// to be.
F.prototype = o;
// use the `new` operator. We will get a new
// object whose prototype is o, and we will
// invoke the empty function, which does nothing.
return new F();
};

我超级喜欢ECMAScript 5,这一新版本开始指出了实现细节的方方面面,就像充许你直接定义非可枚举属性或直接使用 prototype chain 来定义对象。