学习JavaScript"继承"

280 阅读7分钟

“类”

JavaScript本没有类的概念,但是为了模仿其他面向对象语言,于是人为的创造了JavaScript中的“类”。其实都是利用了函数的一种特殊特性:所有的函数默认都会拥有一个名为prototype的公有并且不可枚举的属性,它会指向另一个对象。

function Foo() { // ...
}
Foo.prototype; // { }

这个对象通常被称为Foo的原型。这个对象是在调用new Foo()(学习this关键字(二)说明了new的过程)时创建的。最后被关联到Foo.prototype对象上。

我们来测试一下:

function Foo() { // ...
}
var bar = new Foo();

Object.getPrototypeOf(bar) === Foo.prototype;  //true

现在我们总结一下:

  • 在面向类的语言中,类可以被复制(或者说实例化)多次,就像用模具制作东西一样。之所以会这样是因为实例化(或者继承)一个类就意味着“把类的行为复制到物理对象中”,对于每一个新实例来说都会重复这个过程。
  • 但是在JavaScript中,并没有类似的复制机制。你不能创建一个类的多个实例,只能创建多个对象,它们[[Prototype]]关联的是同一个对象。但是在默认情况下并不会进行复制, 因此这些对象之间并不会完全失去联系,它们是互相关联的。new Foo()会生成一个新对象(我们称之为bar),这个新对象的内部链接[[Prototype]]关联的是Foo.prototype对象。
  • 最后我们得到了两个对象,它们之间互相关联,就是这样。我们并没有初始化一个类,实际上我们并没有从“类”中复制任何行为到一个对象中,只是让两个对象互相关联。
  • new Foo()这个函数调用实际上并没有直接创建关联,这个关联只是一个意外的副作用。new Foo()只是间接完成了我们的目标:一个关联到其他对象的新对象。

关于继承

继承意味着复制操作,JavaScript默认并不会复制对象属性。

JavaScript中,我们并不会将一个对象(“类”)复制到另一个对象(“实例”),JavaScript会在两个对象之间创建一个关联,这样一个对象就可以通过委托访问另一个对象的属性和函数。这个机制通常被称为“原型继承”。[[Prototype]]机制如下图所示(图0):

图0

“构造函数”

思考示例代码:

function Foo() { // ...
}

Foo.prototype.constructor === Foo; // true

var bar = new Foo();
bar.constructor === Foo; // true

Foo.prototype默认有一个公有并且不可枚举的属性constructor(图一),这个属性引用的是对象关联的函数(本例中是Foo)。此外,我们可以看到通过“构造函数”调用new Foo()创建的对象也有一个constructor 属性(图二),指向 “创建这个对象的函数”(本例中是Foo)。

图一

图二

我们很容易认为Foo是一个构造函数,因为我们使用new操作“构造”了一个对象。实际上Foo和我们写的其他函数没有任何区别。区别在于我们在调用函数前使用了new操作符。就会把它变成一个“构造函数”调用。

new会劫持函数并用构造对象的形式来调用它

例如下面的代码:

function normal(){
    console.log("normal func");
}

var foo = new normal(); // "normal func"

console.log(foo); //{}

normal只是一个普通函数,但是它使用了new操作符,它就会构造一个对象并赋值给foo。这个调用是一个构造函数调用,但是normal本身并不是一个构造函数。

JavaScript中对于“构造函数”最准确的解释是,所有带new的函数调用。

函数不是构造函数,但是当且仅当使用new时,函数调用会变成“构造函数调用”。

模仿“类”

不过我们还是会经常模仿“类”的行为:

function Foo(name, age) {
    this.name = name;
    this.age = age;
}

Foo.prototype.getName = function() {
    return this.name;
}

Foo.prototype.getAge = function() {
    return this.age;
}

var o1 = new Foo("tom",24);
var o2 = new Foo("cat",22);

o1.getName();  // "tom"
o2.getAge();  // 22     

这段代码看起来似乎创建o1o2时会把Foo.prototype对象复制到这两个对象中,然而事实并不是这样。因为通过之前的学习我们发现,在创建的过程中,o1o2的内部[[Prototype]]都会关联到 Foo.prototype上。当o1o2中无法找到getName时,它会在Foo.prototype上找到。

在“构造函数”我们讲过,看起来bar.constructor === Foo;意味着bar确实有一个指向Fooconstructor属性。实际上,constructor属性同样被委托给了Foo.prototype,而Foo.prototype.constructor默认指向Foo

根据以上分析,我们很容易就把这种模仿“类”的行为理解错误产生误导。

举例来说:

function Foo() { /* .. */ }

Foo.prototype = { /* .. */ }; // 创建一个新原型对象

var bar = new Foo();

bar.constructor === Foo; // false 
bar.constructor === Object; // true

图三

Object(..)并没有“构造”bar,看起来应该是Foo()“构造”了它。大部分情况下我们都认为是Foo() 执行了构造工作,bar.constructor应该是Foo,但是它并不是Foo!

bar并没有constructor属性,所以它会委托[[Prototype]]链上的Foo.prototype。但是Foo.prototype = { /* .. */ }对象也没有constructor属性(不过默认的Foo.prototype对象有这个属性),所以它会继续委托,这次会委托给委托链顶端的Object.prototype。这个对象有constructor属性,指向内置的Object(..)函数(图三)。

在看一段代码:

function Foo() { /* .. */ }

Foo.prototype = { /* .. */ }; // 创建一个新原型对象

Object.defineProperty( Foo.prototype, "constructor" , {
    enumerable: false,
    writable: true,
    configurable: true,
    value: Foo // 让 .constructor 指向 Foo
});

这段代码向我们展示了,我们可以在Foo.prototype上手动添加一个constructor属性,constructor并不是一个不可变属性,它只是不可枚举,但是它的值是可以写的。

结论:bar.constructor是一个非常不可靠并且不安全的引用。

原型“继承”

现在我们已经了解了JavaScript中的“类”行为和原型继承的机制。还记得这张图吗,那么我们现在来用JavaScript实现一下。

这段代码就是典型的原型“继承”:

function Foo(name){
    this.name = name;
}

Foo.prototype.getName = function (){
    return this.name;
}

function Bar(name, title){
    Foo.call(this,name);
    this.title = title;
}

// 我们创建了一个新的 Bar.prototype 对象并关联到 Foo.prototype
// 注意!现在没有 Bar.prototype.constructor 了
// 如果你需要这个属性的话可能需要手动修复一下它
Bar.prototype = Object.create(Foo.prototype);

Bar.prototype.getTitle = function() { 
    return this.title;
}

var obj = new Bar( "tom", "cat" );

obj.getName();  // "tom"
obj.getTitle(); //"cat"

这里核心的语句就是Bar.prototype = Object.create(Foo.prototype),调用Object.create(..)会创建一个新对象并且把新对象的[[Prototype]]关联到指定的对象(示例中的Foo.prototype)。

还有其他的方法也能实现例如:

Bar.prototype = Foo.prototype;

Bar.prototype = Foo.prototype并不会创建一个关联到Bar.prototype的新对象,它只是让Bar.prototype直接引用Foo.prototype对象。因此当你执行类似Bar.prototype.age = ...的赋值语句时会直接修改Foo.prototype对象本身。显然这不是我们想要的结果,否则你根本不需要Bar对象,直接使用Foo就可以了,这样代码也会更简单一些。

Bar.prototype = new Foo();
Bar.prototype.constructor = Bar; 

Bar.prototype = new Foo()的确会创建一个关联到Bar.prototype的新对象。但是它使用了 Foo(..)的“构造函数调用”,如果函数Foo有一些副作用的话,就会影响到Bar()的“后代”,所以来还指定Bar.prototype.constructor = Bar这样的代码,过于复杂不利于理解。

因此比较好的方法是使用Object.create(..)而不是使用具有副作用的Foo(..)。这样做唯一的缺点就是需要创建一个新对象然后把旧对象抛弃掉,不能直接修改已有的默认对象。

检查对象和“类”的关系

我们在开发中经常会想这个对象在那一个函数(“类”)中。这时我们需要一个能帮助我们检查的方案。

  1. instanceof
function Foo() {
    //...
}

var bar = new Foo();

bar instanceof Foo;  //true

instanceof用于检测在bar[[Prototype]]链中是否有指向Foo.prototype的对象。但是这个方法只能处理对象和函数之间的关系。如果我想判断两个对象之间的关系只用instanceof无法实现。(instanceof官网文档)

  1. isPrototypeOf
function Foo() {
    //...
}

var bar = new Foo();

Foo.prototype.isPrototypeOf(bar);

isPrototypeOf用于检测对象Foo.prototype是否出现在对象bar[[Prototype]]链中。可以用于两个对象的关系检查。(isPrototypeOf官网文档)

我们也可以直接获得一个对象的[[Prototype]]链。

Object.getPrototypeOf(bar);

Object.getPrototypeOf(bar) === Foo.prototype; //true

getPrototypeOf用于获取对象的[[Prototype]]。(getPrototypeOf官网文档)

小结

这一小节,完善了对原型,“构造函数”和“继承”。下一节将介绍具体的实践。

参考