likes
comments
collection
share

原生JavaScript 如何实现继承?什么是构造函数?

作者站长头像
站长
· 阅读数 32

JavaScript 中的继承

最近在读 《JavaScript 高级程序设计》,看到了继承这里,学到了很多,根据书中的内容和自己学到的内容进行总结输出了这样的一篇文章

构造函数

提问:如何在 JavaScript 中创建一个对象呢?

答:

const obj1 = {};
const obj2 = Object.create();

提问:如何在 JavaScript 创建对象的时候,共用一些属性呢?

答:用工厂模式,如下,调用 createPerson 函数生成的对象都具有 name 属性

function createPerson(name) {
  const o = Object.create();
  o.name = name;
  return o;
}

const person1 = createPerson('小明');
person1; // {name:"小明"}
const person2 = createPerson('小红');
person2; // {name:"小红"}

提问:还有没有什么方式能在创建对象的时候共用属性吗?

答:构造函数。

什么是构造函数

构造函数其实就是一个函数,只不过在使用构造函数的时候需要使用 new 关键字。

或者说,使用 new 关键字调用的函数就是构造函数。

上面工厂函数可以写成下面这样的构造函数。

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

const person1 = new Person('小明');
person1; // {name:"小明"}
const person2 = new Person('小红');
person2; // {name:"小红"}

按照惯例,构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头。通常我们用一个函数首字符是否大写来判断这个函数是构造函数还是普通函数。

构造函数的作用

ECMAScript 的构造函数就是能创建对象的函数。

要创建构造函数的实例,应使用 new 操作符。使用 new 关键字调用构造函数会执行如下操作。

  1. 在内存中创建一个新对象。
  2. 这个新对象的原型([[Prototype]]特性)赋值为构造函数的 prototype 属性。
  3. 构造函数内部的 this 被赋值为这个新对象 (即 this 指向新对象)。
  4. 执行构造函数内部的代码 (给新对象添加属性)。
  5. 如果构造函数返回非空对象,则返回该对象; 否则,返回刚创建的新对象。

判断构造函数实例

用下面两种方法可以判断一个实例是不是由构造函数创建的。

// 方法1
console.log(person1.constructor == Person); // true
console.log(person2.constructor == Person); // true

// 方法2
console.log(person1 instanceof Object); // true
console.log(person1 instanceof Person); // true
console.log(person2 instanceof Object); // true
console.log(person2 instanceof Person); // true

构造函数也是函数

不使用 new 关键字调用的时候,构造函数中的 this就会一层一层向上找,最终找到window结束。

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

const person1 = new Person('小明');
person1; // {name:"小明"}

const person2 = Person('小红'); // 没有使用 new 函数内部的this就会一层一层向上找
window.name; // 小红

构造函数的问题

  • 构造函数中的共用,并不是完美的。

如下例子

function Person(name) {
  this.name = name;
  this.sayName = function () {
    console.log(this.name);
  };
}

我们新加了一个 sayName 方法,用来打印 name 属性,这个是一个很通用的方法,但是每一次在创建实例的时候都会重新创建一次 sayName 方法。

function Person(name) {
  this.name = name;
  this.sayName = new Function('console.log(this.name)'); // 逻辑等价
}

const person1 = new Person('小明');
const person2 = Person('小红');

person1.sayName === person2.sayName; // false

sayName 方法是一个很通用的方法,每创建一个实例就要创建一次方法,这就体现了不完美的共用了。

这样也有解决方法。

  • 解决方法 1,可以把函数定义转移到构造函数外部
function sayName() {
  console.log(this.name);
}
function Person(name) {
  this.name = name;
  this.sayName = sayName;
}

缺点:sayName定义到了构造函数外,污染了全局作用域。

  • 解决方法 2,使用原型模式

原型模式

每个函数都会有一个 prototype 属性,它是一个对象类型,这个对象就叫原型。在通过new 调用构造函数创建内部对象的时候,创建的这个内部对象是从原型拷贝来的。

也就是说,原型上有的属性,构造函数创建的实例也就会拥有

function Person(name) {
  this.name = name;
}
Person.prototype.sayName = function () {
  console.log(this.name);
};
const person1 = new Person('小明');
person1.sayName(); // 小明
const person2 = new Person('小红');
person2.sayName(); // 小红

person1.sayName === person2.sayName; // true

理解原型

上面我们说原型是一个对象类型。

首先他继承自 Object,也就是说Object有的属性和方法,原型都有,这很好理解。

其次,原型有一个自己特殊的属性 constructor,这个属性的值是构造函数本身。

Person.prototype.constructor === Person; // true

使用构造函数创建实例的时候,实例的[[Prototype]] 特性就是构造函数的原型,不过我们不能直接访问这个特性,所以在不同的浏览器上,浏览器给实例上暴露了__proto__属性用来访问构造函数的原型。

function Person(name) {}
const person1 = new Person();

person1.__proto__ === Person.prototype; // true

小总结一下

  • 构造函数都有一个 prototype属性,是一个对象类型,我们叫它原型对象。
  • 用构造函数创建实例的时候,创建的实例是基于 原型对象来的。
  • 原型对象有一个 constructor 属性指向构造函数,原型对象上的其他属性继承于Object对象。
  • 实例对象上有一个 __proto__属性,指向构造函数的原型。

小扩展,正常的原型链都会终止于 Object 的原型对象。

于是下面的判断就成立了。

console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Person.prototype.__proto__.constructor === Object); // true
console.log(Person.prototype.__proto__.__proto__ === null); // true

原型层级、搜索机制

通过对象访问属性的时候,会先在实例上找,找到了值就返回,没找到会去找原型对象。 原型对象也是一个对象,在原型对象上没有找到,就会去找原型对象的原型对象,直到找到原型对象的终点,null

原型的问题

动态性

因为从原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对象所做的修改也会在实例上反映出来。

function Person() {}

// 先创建实例
const person1 = new Person();

// 在创建原型后 给原型定义方法
Person.prototype.sayHi = function () {
  console.log('hi');
};

// 实例拥有 创建实例后 修改原型的方法
person1.sayHi(); // "hi"

共享特性

原型上的所有属性是在实例间共享的,这对函数来说比较合适。

另外包含原始值的属性也还好,可以通过在实例上添加同名属性来简单地遮蔽原型上的属性。

真正的问题来自包含引用值的属性。

举个 例子:

function Person() {}

Person.prototype.friends = [];

let person1 = new Person();
let person2 = new Person();

person1.friends.push('小明');
console.log(person1.friends); // ['小明']
console.log(person2.friends); // ['小明']
console.log(person1.friends === person2.friends); // true

由于这个 friends 属性是一个引用类型,存在于 Person.prototype 而非 person1 上。

person1.friends.push('小明');新加的这个字符串也会在(指向同一个数组的)person2.friends 上反映出来。如果这是有意在多个实例间共享数组,那没什么问题。但一般来说,不同的实例应该有属于自己的属性副本。

继承

继承能够让代码复用变得更加容易,提高程序的可维护性,提高开发效率和开发质量。

1. 原型链继承

重温一下构造函数、原型和实例的关系: 每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。

如果原型是另外一个类型的实例,那么这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链。

原型链代码示例:

function Dog() {
  this.name = '狗';
}
Dog.prototype.run = function () {
  console.log('我能跑');
};

function SmallDog() {
  this.name = '小狗';
}

SmallDog.prototype = new Dog(); // 继承Dog
let smallDog = new SmallDog();
console.log(smallDog.name); // 小狗
console.log(smallDog.run()); // 我能跑

SmallDog 通过创建 Dog 实例并赋值给自己的原型实现了对 Dog 的继承。

这样一来,SmallDog 的实例不仅能从 Dog 的实例中继承属性和方法,而且还与 Dog 的原型挂上了钩。

原型链扩展了前面描述的原型搜索机制。

默认原型

默认情况下,所有引用类型都继承自 Object,这也是通过原型链实现的。

也是为什么自定义类型能够继承包括 toString()valueOf()在内的所有默认方法的原因。

原型与继承关系

第一种方式是使用 instanceof 操作符,如果一个实例的原型链中出现过相应的构造函数,则 instanceof 返回 true

console.log(smallDog instanceof Object); // true
console.log(smallDog instanceof Dog); // true
console.log(smallDog instanceof SmallDog); // true

确定这种关系的第二种方式是使用 isPrototypeOf()方法。原型链中的每个原型都可以调用这个 方法,如下例所示,只要原型链中包含这个原型,这个方法就返回 true

console.log(Object.prototype.isPrototypeOf(smallDog)); // true
console.log(Dog.prototype.isPrototypeOf(smallDog)); // true
console.log(SmallDog.prototype.isPrototypeOf(smallDog)); // true

原型链的问题

  • 问题一:主要问题出现在原型中包含引用值的时候。

原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。

在使用原型链实现继承时,原型实际上变成了另一个类型的实例。这意味着原先的实例属性摇身一变成为了原型属性。

function Dog() {
  this.colors = ['red', 'blue', 'green'];
}
function SmallDog() {}
SmallDog.prototype = new Dog(); // 继承Dog

let smallDog1 = new SmallDog();
smallDog1.colors.push('black');
console.log(smallDog1.colors); // "red,blue,green,black"

let smallDog2 = new SmallDog();
console.log(smallDog2.colors); // "red,blue,green,black"

SmallDog.prototype 变成了 Dog 的一个实例,因而也获得了自己的 colors 属性。这类似于创建了 SmallDog.prototype.colors 属性。

最终结果是,SmallDog 的所有实例都会 共享这个 colors 属性。这一点通过 smallDog1.colors 上的修改也能反映到 smallDog1.colors 上就可以看出来。

  • 问题二:子类型在实例化时不能给父类型的构造函数传参。

事实上,我们无法在不影响所有对象实例的情况下把参数传进父类的构造函数。再加上之前提到的原型中包含引用值的问题, 就导致原型链基本不会被单独使用。

2. 盗用构造函数继承

为了解决原型包含引用值导致的继承问题,一种叫作“盗用构造函数”(constructor stealing)的技 术在开发社区流行起来(这种技术有时也称作“对象伪装”或“经典继承”)。

基本思路很简单:在子类构造函数中调用父类构造函数。

因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用 apply()call()方法以新创建的对象为上下文执行构造函数。

引用值问题解决

function Dog() {
  this.colors = ['red', 'blue', 'green'];
}

function SmallDog() {
  // 继承 Dog
  Dog.call(this);
}

let smallDog1 = new SmallDog();
smallDog1.colors.push('black');
console.log(smallDog1.colors); // "red,blue,green,black"

let smallDog2 = new SmallDog();
console.log(smallDog2.colors); // "red,blue,green"

Dog 构造函数在为 SmallDog 的实例创建的新对象的上下文中执行了。这相当于新的 SmallDog 对象上运行了 Dog()函数中的所有初始化代码。结果就是每个实例都会有自己的 colors 属性。

子类向父类传参问题解决

相比于使用原型链,盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参。

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

function SmallDog() {
  // 继承 Dog 并传参
  Dog.call(this, '狗');

  // 实例属性
  rhis.age = 10;
}
let smallDog1 = new SmallDog();
smallDog1.name; // "狗";
smallDog1.age; // 10

SmallDog 构造函数中调用 Dog 构造函数时传入这个参数,实际上会在 SmallDog 的实例上定义 name 属性。

为确保 Dog 构造函数不会覆盖 SmallDog 定义的属性,可以在调用父类构造函数之后再给子类实例添加额外的属性。

盗用构造函数的问题

盗用构造函数实际上是把父类的构造函数内容在子类中执行了,没有使用到原型链相关的内容。

那么构造函数有的问题,盗用构造函数继承的方式也会拥有。

比如:

  • 构造函数中定义的函数不是一个完美的基础。每个子类都会创建一遍这个函数。
  • 子类只能获得父类构造函数

3. 组合继承

组合继承(有时候也叫伪经典继承)综合了原型链和盗用构造函数,将两者的优点集中了起来。

基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。

function Dog(name) {
  this.name = name;
  this.colors = ['red', 'blue', 'green'];
}
Dog.prototype.sayName = function () {
  console.log(this.name);
};

function SmallDog(name, age) {
  Dog.call(this, name); // 通过盗用构造函数的方式 继承属性

  this.age = age;
}
SmallDog.prototype = new Dog(); // 通过原型链方式 继承方法
SmallDog.prototype.sayAge = function () {
  console.log(this.age);
};

let instance1 = new SmallDog('Nicholas', 29);
instance1.colors.push('black');
console.log(instance1.colors); // "red,blue,green,black"
instance1.sayName(); // "Nicholas";
instance1.sayAge(); // 29

let instance2 = new SmallDog('Greg', 27);
console.log(instance2.colors); // "red,blue,green"
instance2.sayName(); // "Greg";
instance2.sayAge(); // 27

组合继承弥补了原型链和盗用构造函数的不足,是 JavaScript 中使用最多的继承模式。而且组合继 承也保留了 instanceof 操作符和 isPrototypeOf()方法识别合成对象的能力。

4. 原型式继承

2006 年,Douglas Crockford 写了一篇文章:《JavaScript 中的原型式继承》(“Prototypal Inheritance in JavaScript”)

这篇文章介绍了一种,不涉及严格意义上构造函数的继承方法。

他的出发点是即使不自定义类型也可以通过原型实现对象之间的信息共享。

文章最终给出了一个函数:

function object(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

这个 object()函数会创建一个临时构造函数,将传入的对象赋值给这个构造函数的原型,然后返回这个临时类型的一个实例。

本质上,object()是对传入的对象执行了一次浅复制

let person = {
  name: 'Nicholas',
  friends: ['Shelby', 'Court', 'Van'],
};

let anotherPerson = object(person);
anotherPerson.name = 'Greg';
anotherPerson.friends.push('Rob');

let yetAnotherPerson = object(person);
yetAnotherPerson.name = 'Linda';
yetAnotherPerson.friends.push('Barbie');
console.log(person.friends); // "Shelby,Court,Van,Rob,Barbie"

Crockford 推荐的原型式继承适用于这种情况: 你有一个对象,想在它的基础上再创建一个新对象。 你需要把这个对象先传给 object(),然后再对返回的对象进行适当修改。

person 对象定义了另一个对象也应该共享的信息,把它传给 object()之后会返回一个新对象。这个新对象的原型 是 person,意味着它的原型上既有原始值属性又有引用值属性。这也意味着 person.friends 不仅是 person 的属性,也会跟 anotherPersonyetAnotherPerson 共享。

ECMAScript 5 通过增加 Object.create()方法将原型式继承的概念规范化了。

这个方法接收两个参数: 作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。在只有一个参数时, Object.create()与这里的 object()方法效果相同:

let person = {
  name: 'Nicholas',
  friends: ['Shelby', 'Court', 'Van'],
};

let anotherPerson = Object.create(person);
anotherPerson.name = 'Greg';
anotherPerson.friends.push('Rob');

let yetAnotherPerson = Object.create(person);
yetAnotherPerson.name = 'Linda';
yetAnotherPerson.friends.push('Barbie');
console.log(person.friends); // "Shelby,Court,Van,Rob,Barbie"

使用场景

原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。

但要记住,属性中包含的引用值始终在相关对象间共享,跟使用原型模式是一样的。

5. 寄生式继承

与原型式继承比较接近的一种继承方式是寄生式继承(parasitic inheritance),也是 Crockford 首倡的一种模式。

寄生式继承背后的思路类似于寄生构造函数和工厂模式: 创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象。

function createAnother(original) {
  let clone = object(original); // 通过调用函数创建一个新对象, 这里的 object 就是原型式继承中的 object 方法

  // 以某种方式增强这个对象
  clone.sayHi = function () {
    console.log('hi');
  };

  return clone; // 返回这个对象
}

createAnother方法 能够基于 original对象返回一个新的对象 ,并且在这个新的对象上添加 了 sayHi 方法。

寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。

通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类似。

6. 寄生式组合继承

组合继承其实也存在效率问题。最主要的效率问题就是父类构造函数始终会被调用两次:一次在是创建子类原型时调用,另一次是在子类构造函数中调用。

再来看一看之前组合继承的例子:

function Dog(name) {
  this.name = name;
  this.colors = ['red', 'blue', 'green'];
}
Dog.prototype.sayName = function () {
  console.log(this.name);
};

function SmallDog(name, age) {
  Dog.call(this, name); // 第二次调用 Dog 的构造函数

  this.age = age;
}
SmallDog.prototype = new Dog(); // 第一次调用 Dog 的构造函数,创建子类的实例对象赋值给子类原型
SmallDog.prototype.sayAge = function () {
  console.log(this.age);
};

本质上,子类原型最终是要包含超类对象的所有实例属性,子类构造函数只要在执行时重写自己的原型就行了。

寄生式组合继承通过盗用构造函数继承属性,但使用原型式继承方法混合原型链。

基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。

说到底就是使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型。

寄生式组合继承的基本模式如下所示:

function inheritPrototype(subType, superType) {
  let prototype = object(superType.prototype); // 创建对象
  prototype.constructor = subType; // 增强对象
  subType.prototype = prototype; // 赋值对象
}

这个 inheritPrototype()函数实现了寄生式组合继承的核心逻辑。 这个函数接收两个参数: 子类构造函数和父类构造函数。 在这个函数内部,第一步是创建父类原型的一个副本。然后,给返回的 prototype 对象设置 constructor 属性,解决由于重写原型导致默认 constructor 丢失的问题。最后将新创建的对象赋值给子类型的原型。

这样可能不够直观了解,请看下面这个例子:

function Dog(name) {
  this.name = name;
  this.colors = ['red', 'blue', 'green'];
}
Dog.prototype.sayName = function () {
  console.log(this.name);
};

function SmallDog(name, age) {
  Dog.call(this, name); // 这里调用 Dog 的构造函数 是不可少的,因为只有 盗用构造函数的 方式能给父类的构造函数传参数

  this.age = age;
}

//  这里替代了  SmallDog.prototype = new Dog();  能够减少一次 父类构造函数的调用,本质是 复制父类的原型 在赋值给子类原型
inheritPrototype(SubType, SuperType); 

SmallDog.prototype.sayAge = function () {
  console.log(this.age);
};

这里只调用了一次 Dog 构造函数,避免了 SmallDog.prototype 上不必要也用不到的属性, 因此可以说这个例子的效率更高。

而且,原型链仍然保持不变,因此 instanceof 操作符和 isPrototypeOf()方法正常有效。

寄生式组合继承可以算是引用类型继承的最佳模式。

总结

  1. 原型链继承是使用 创建父类实例给子类原型实现的继承。但是有 引用值的问题和不能给父类构造函数传参的问题。
  2. 盗用构造函数是 在子类构造函数中调用父类的构造函数实现的继承。解决了上面的两个问题,但是有构造函数的常见问题,子类构造函数中的方法没有完美的继承。
  3. 组合继承是结合 盗用构造函数继承和原型链继承,使用盗用构造函数继承方式继承属性和给父类传参,使用原型链继承的方式继承方法。
  4. 原型式继承是方便我们不定义一个新的类型(不创建构造函数),来实现在对象之间共享一些属性的方式。从此还诞生了 Object.create()方法。
  5. 寄生式继承是在 原型式继承的基础上,使用工厂模式对 要继承的对象额外做一些扩展。
  6. 寄生式组合继承是解决了组合继承调用两次父类构造函数的效率问题。核心思想是给子类原型赋值父类原型的clone对象,而不是创建父类的实例。