[TOC]

对象原型继承

面向对象编程(Object Oriented Programming,OOP)是目前主流编程范式。

它将真实世界各种复杂的关系抽象为一个个对象,然后由对象之间分工与合作,完成对真实世界的模拟。

面向对象编程具有灵活、代码可复用、高度模块化等特点,容易维护和开发,比起由一系列函数或指令组成的传统的过程式编程(procedural programming),更适合多人合作的大型软件项目。

  • 对象是单个实物的抽象
  • 对象是一个容器,封装了属性(property)和方法(method)

典型的面向对象语言(C++、Java)都有类(class)的概念:类就是对象的模板,对象是类的实例。

而JavaScript语言的对象体系是基于构造函数(constructor)和原型链(prototype).

构造函数相当于其他语言的类

原型链类相当其他语言的继承

1. 构造函数:JS的类

JavaScript语言中构造函数(constructor):

  • 是对象的模板,描述实例对象的基本结构。实例对象的属性和方法,可以定义在构造函数内部
  • 是用来生成实例对象的函数
  • 是一个普通函数,但有自己的特征和用法
  • 为了与普通函数区别,构造函数名字第一个字母通常大写

构造函数的特点:

  • 函数体内部使用了this关键字,代表了所要生成的对象实例。
  • 生成对象的时候必须使用new命令。
  • 如果不用new命令就执行则会成为普通函数,此时会给this指向的对象创建变量,可使用严格模式防止忘记new命令,或在内部判断是否使用了new命令
function Foo(foo, bar){
  // 'use strict';	// 在严格模式下,如果不指定this,则this是undefined
  // 或者使用判断是否使用了 new命令
  // if (!(this instanceof Foo)) {return new Foo(foo) }
  this.tt = 200;
}
// 使用上面注释部分代码可解决不使用new问题
Foo()			// 这里报错 TypeError: Cannot set property 'tt' of undefined
new Foo()).tt	// 正常访问

1.1 使用new命令生成一个对象

对构造函数使用new命令后:

  1. 创建一个空对象(obj),作为将要返回的对象的实例
  2. 将obj的原型指向构造函数的prototype属性
  3. 将obj赋值给函数内部的this关键字
  4. 开始执行构造函数的内部代码

也就是说构造函数就是操作一个空对象将其“构造”为需要的样子。

使用new命令注意:

  • new命令总是返回一个对象要么是实例对象,要么是return语句指定的对象,如果return的不是对象,则忽略return
  • 如果普通函数(内部没有this),new命令则会返回空对象
  • new命令本身就可以执行构造函数,所以构造函数可以带括号,也可以不带。
function A() { this.one = 200; return 30;}
console.log((new A()) === 1000)		// false
function B() { this.one = 200; return {tt: 10};}
console.log((new B()).tt)			// 10	构造函数使用括号
console.log((new B.tt)				// 10	构造函数没有使用括号
function hello(){return 'hello';}
console.log((new hello()))

1.1.1 new.target

函数内部有一个属性:new.target,如果使用new命令调用函数则new.target指向当前函数,否则为undefined。

可使用该属性来判断调用函数的手是否使用了了new命令

function f() { console.log(new.target === f); }
f() 	// false
new f() // true

1.2 Object.create() 创建实例对象

Object.create:是ES5方法,以现有的对象为模板生成新的实例对象。

创建一个对象:如果没有构造函数,只有对象,这个时候 Object.create就用到了,或者自己写一个克隆函数的方法。

var person1 = { name: 'jack', };
var person2 = Object.create(person1);
console.log(person2.name)

2. 对象原型的概述

2.1 构造函数的缺点

JavaScript 通过构造函数生成新对象,因此构造函数可以视为对象的模板。实例对象的属性和方法,可以定义在构造函数内部。

通过构造函数为实例对象定义属性,虽然很方便,但是有一个缺点。同一个构造函数的多个实例之间,无法共享属性,从而造成对系统资源的浪费。

function Cat(name, color) {
  this.name = name;
  this.color = color;
  this.meow = function () { console.log('喵喵'); };
}
var cat1 = new Cat('大毛', '白色');
var cat2 = new Cat('二毛', '黑色');
cat1.meow === cat2.meow // false

cat1和cat2是同一个构造函数的两个实例,由于meow方法是生成在每个实例对象上面,所以两个实例就生成了两次。也就是说,每新建一个实例,就会新建一个meow方法。这既没有必要,又浪费系统资源,因为所有meow方法都是同样的行为,完全应该共享。

这个问题的解决方法,就是JS的原型对象(prototype)

2.2 函数的prototype属性的作用

JS的继承机制:原型对象的所有属性和方法,都能被实例对象共享。也就是说,如果属性和方法定义在原型上,那么所有实例对象就能共享,不仅节省了内存,还体现了实例对象之间的联系。

JavaScript 规定,每个函数都有一个prototype属性,指向一个对象。对于普通函数来说,该属性基本无用。但是,对于构造函数来说,生成实例的时候,该属性会自动成为实例对象的原型。

function Animal(name) {
  this.name = name;
}
Animal.prototype.color = 'white';
var cat1 = new Animal('大毛');
var cat2 = new Animal('二毛');
cat1.color // 'white'
cat2.color // 'white'
Animal.prototype.color = 'yellow';
cat1.color // "yellow"
cat2.color // "yellow"

上面代码中,构造函数Animalprototype属性,就是实例对象cat1cat2的原型对象。原型对象上添加一个color属性,结果,实例对象都共享了该属性。

原型对象的属性不是实例对象自身的属性。只要修改原型对象,变动就立刻会体现在所有实例对象上。

当实例对象本身没有某个属性或方法的时候,它会到原型对象去寻找该属性或方法。这就是原型对象的特殊之处。

如果实例对象自身就有某个属性或方法,它就不会再去原型对象寻找这个属性或方法。

cat1.color = 'black';

cat1.color // 'black'
cat2.color // 'yellow'
Animal.prototype.color // 'yellow';

上面代码中,实例对象cat1color属性改为black,就使得它不再去原型对象读取color属性,后者的值依然为yellow

总结一下,原型对象的作用,就是定义所有实例对象共享的属性和方法。这也是它被称为原型对象的原因,而实例对象可以视作从原型对象衍生出来的子对象。

2.3 原型链

JavaScript 规定,所有对象都有自己的原型对象(prototype),任何一个对象,都可以充当其他对象的原型。

由于原型对象也是对象,所以它也有自己的原型。因此,就会形成一个“原型链”(prototype chain):对象到原型,再到原型的原型……

实例对象的原型一层层上溯,最终都可以上溯到Object.prototype,即Object构造函数的prototype属性。这就是所有对象都象都有valueOftoString方法的原因。

那么,Object.prototype对象有没有它的原型呢?回答是Object.prototype的原型是nullnull没有任何属性和方法,也没有自己的原型。因此,原型链的尽头是null

Object.getPrototypeOf(Object.prototype)
// null

读取对象的某个属性时,JavaScript 引擎先寻找对象本身的属性,如果找不到,就到它的原型去找,如果还是找不到,就到原型的原型去找。如果直到最顶层的Object.prototype还是找不到,则返回undefined。如果对象自身和它的原型,都定义了一个同名属性,那么优先读取对象自身的属性,这叫做“覆盖”(overriding)。

注意,一级级向上,在整个原型链上寻找某个属性,对性能是有影响的。所寻找的属性在越上层的原型对象,对性能的影响越大。如果寻找某个不存在的属性,将会遍历整个原型链。

2.4 constructor属性

prototype对象上有一个constructor属性,默认指向prototype对象所在的构造函数。可以被所有实例对象继承。

function P() {}
var p = new P();

P.prototype.constructor === P // true
// 对象p自身没有constructor属性,该属性其实是读取原型链上面的P.prototype.constructor属性
p.constructor === P // true
p.hasOwnProperty('constructor') // false

constructor属性作用:

可以知道实例对象是哪一个构造函数创建的。

可以通过实例对象的constructor属性新建另一个实例对象。

// 判断对象的构造函数
function F() {};
var f = new F();
f.constructor === F // true
f.constructor === RegExp // false

// 从一个实例对象新建另一个实例
function Constr() {}
var x = new Constr();
var y = new x.constructor();
y instanceof Constr // true

constructor表达了原型对象和构造函数的关联关系,如果修改了原型对象,要同时修改constructor属性,防止引用时出错。

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

Person.prototype.constructor === Person // true
Person.prototype = { method: function () {} };
Person.prototype.constructor === Person // false
Person.prototype.constructor === Object // true

上面代码中,构造函数Person的原型对象改掉了,但是没有修改constructor属性,导致这个属性不再指向Person。由于Person的新原型是一个普通对象,而普通对象的constructor属性指向Object构造函数,导致Person.prototype.constructor变成了Object

// 坏的写法
C.prototype = {
  method1: function () { ... },
  // ...
};
// 好的写法
C.prototype = {
  constructor: C,
  method1: function () { ... },
  // ...
};
// 更好的写法
C.prototype.method1 = function () { ... };

如果不能确定constructor属性是什么函数,还有一个办法:通过name属性,从实例得到构造函数的名称。

function Foo() {}
var f = new Foo();
f.constructor.name // "Foo"

3. JS实现继承

3.1 原型链继承

简单来说就是将父类的实例作为子类的原型,父类新增属性和方法,子类都能访问到

function A() {
  this.num = 0
  this.arr = [1,2,3,4]
}
function B() {
}
B.prototype = new A()
var b1 = new B()
var b2 = new B()
b1.num = 1
b1.arr.push(5)
console.log(b1.num)//1
console.log(b2.num)//0
console.log(b1.arr)//[1,2,3,4,5]
console.log(b2.arr)//[1,2,3,4,5]

上述代码以A的实例作为B的原型,B的实例b1和b2均继承了A的num属性和arr属性,达到了继承的目的。但是当我们修改b1的num值时,b2的值不受影响,这确实符合我们的预期,但是当我们修改b1的arr时,b2的arr也改变了,这显然不是我们想看到的,这就是原型继承的缺陷

综合评价:可以继承原型属性,实现简单,但是创建子类实例时无法向父类构造函数传参,并且由于原型对象是共享的,所以如果某个子类实例改变了原型属性,那么所有实例的该属性都会改变,注意是对象属性。

3.2 构造函数继承

第一步、是在子类的构造函数中,调用父类的构造函数。目的是让子类实例继承父类实例的属性和方法。

第二步、是让子类的原型指向父类的原型,目的是让子类继承父类原型上的属性和方法。

注意:不能让子类的原型直接等于父类原型,如果这样做那么子类原型和父类原型是同一个对象,那么子类在原型上添加方法,父类原型也修改了,导致父类对象也能获取到这个方法。

function Super() { this.myName = 'super' }
// 第一步:在实例上调用父类的构造函数Super,让子类实例继承父类实例的属性和方法。。
function Sub(value) {
  Super.call(this);
  this.prop = value;
}
// 第二步:设置子类的原型
Sub.prototype = Object.create(Super.prototype);
Sub.prototype.constructor = Sub;
Sub.prototype.method = '...';

var sub = new Sub();
sub instanceof Super // true
sub instanceof Sub // true

另外一种实现,子类构造函数的prototype等于父类的实例:

// 这种写法也有继承的效果,但是子类会具有父类实例的方法。有时,这可能不是我们需要的,所以不推荐使用这种写法。
Sub.prototype = new Super();

上面代码中,子类是整体继承父类。有时只需要单个方法的继承,这时可以采用下面的写法。

ClassB.prototype.print = function() {
  ClassA.prototype.print.call(this);
}

4. 多重继承

JavaScript 不提供多重继承功能,即不允许一个对象同时继承多个对象。但是,可以通过变通方法,实现这个功能。

function M1() { this.hello = 'hello'; }
function M2() { this.world = 'world'; }
function S() {
  M1.call(this);
  M2.call(this);
}
// 继承 M1
S.prototype = Object.create(M1.prototype);
// 继承链上加入 M2
Object.assign(S.prototype, M2.prototype);
// 指定构造函数
S.prototype.constructor = S;
var s = new S();
s.hello // 'hello'
s.world // 'world'

上面代码中,子类S同时继承了父类M1M2。这种模式又称为 Mixin(混入)。

5. instanceof运算符:判断对象的类型

instanceof运算符返回一个布尔值,表示对象是否为某个构造函数的实例。

instanceof运算符的左边是实例对象,右边是构造函数,它会检查构造函数的原型对象是否在对象的原型链上,因此同一个实例对象,可能会对多个构造函数都返回true

有一种特殊情况,就是左边对象的原型链上,只有null对象。这时,instanceof判断会失真。

但是,只要一个对象的原型不是nullinstanceof运算符的判断就不会失真。

注:

instanceof只能用于对象,不适用原始类型的值,原始值返回false。

对于undefinednullinstanceOf运算符总是返回false

利用instanceof运算符解决调用构造函数时,忘记加new命令的问题。

var d = new Date();
d instanceof Date // true
d instanceof Object // true
Object.create(null) instanceof Object // false

'hello' instanceof String	// false
undefined instanceof Object // false
function F (foo) {
    if (!(this instanceof F) { } // 没有使用new操作符
}

参考资料

对象的继承 阮一峰

深入理解 JavaScript 原型

Last Updated: 6/10/2024, 11:27:37 AM