likes
comments
collection

探究JS中“类”的创建与继承过程

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

本文正在参加「金石计划 . 瓜分6万现金大奖」 鉴于初中级前端程序员在平时业务开发中比较少去接触“类”这个概念,但在面试中其实经常会考察到相关的概念,一些优秀代码仓库中对类的使用也比较频繁。当涉及到constructor、super这些概念的时候,我们经常会望而生怯。

我们平时的开发中也经常会遇到各种处理对象的场景,我们如果对对象与类的了解更为深刻的话,说不定会写出更精彩的代码。由于JS中本没有“类”的概念,实际上是通过原型链以及构造函数等来实现的class,在接触class之前,我们不妨先借以本文来了解“类”的创建与继承的过程。

什么是对象和类

在弄清什么是类之前,我们首先要知道什么是对象,对象是对客观事物的抽象,简单来说,对象就是对客观事物的代码描述。

对象是一组属性的无序集合,严格来说,对象就是一组没有特定顺序的值。对象的每个属性或方法都由一个名称标识,此名称映射到另一个值。可以把对象想象成一个散列表,其中的内容就是一组名/值对值可以是数据或者函数。 --《JavaScript高级程序设计(第4版)》

比如人类、动物、水果等等,这些客观事物都会有类型的区分,在代码世界中,我们对这类具有相同特性和行为的对象的抽象称之为“类”。

总的来说,类是对象的抽象,对象就是类的具象化。换句话说,类就是对象的模板,而对象就是类的实例。

由于在JS语言中,不支持“类” ,我们只能通过现有的JS语言去探索实现类似的效果。虽然ES6中已经有了 class关键字来定义“类”,但其背后的实现仍然是依靠构造函数以及原型链。在探索实现“类”的过程中,我们经历了很多尝试,下图可以一览比较经典的实现方法。

探究JS中“类”的创建与继承过程

创建“类”

在实际开发过程中,我们可能会遇到创建具有相同特性和行为的对象,手动去设置这些对象难免会产生很多重复代码。比如我们需要收集一个公司所有人的信息,信息包括姓名、年龄、性别、工作等等,我们可能会写下如下代码。

const person1 = {
  name: 'little red',
  age: 23,
  sex: 'female',
}

const person2 = {
  name: 'little blue',
  age: 26,
  sex: 'male'
}

...

人数一旦多起来,我们收集就比较困难了,首先我们可以想到的解决方法是经典设计模式之一--工厂模式。

工厂模式

工厂模式就是一个简单的函数,这个函数可以创建对象,为它添加属性和方法,然后返回这个对象。

function createPerson(name,age,sex){
  let o = new Object()
  o.name = name 
  o.age = age
  o.sex = sex
  return o
}
const person1 = createPerson('little red',23,'female')
const person2 = createPerson('little blue',26,'male')

...

由此可见,工厂模式是可以很方便的解决创建类似对象的问题,只需要无脑传参就可以创建一个个类似的对象。

缺点

工厂模式的缺点也很明显:没有解决对象标识问题(即新创建的对象是什么类型) 。通俗来说,就是创建出来的对象没有与创建它的函数有一个绑定关系,出现问题时,没办法追根溯源。

构造函数

为了解决工厂模式没有解决对象标识的问题,我们需要使用构造函数,构造函数和普通函数类似,唯一的不同就是调用时需要使用new操作符。

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

const person1 = new Person("little red", 25, "doctor");
const person2 = new Person("little blue", 24, "teacher");

构造函数与工厂模式的区别

  • 没有显式地创建对象。
  • 属性和方法直接赋值给了 this。
  • 没有 return。
  • 解决了对象标识问题

解决了对象标识的问题

constructor属性可以用来指向构造函数,可以用于标识对象类型的。

constructor这个属性在本文中第一次出现,但我们需要重点关注这个属性,因为后面的原型模式以及class关键字声明的类中都有体现,都可以理解为构造函数的标识。

person1.constructor == Person // true
person1 instanceof Person // true

new操作符

构造函数名称的首字母都是要大写的,以此区分构造函数与普通函数要创建 Person 的实例,应使用 new 操作符。 以这种方式调用构造函数会执行如下操作。

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

缺点

构造函数定义的方法会在每个实例上都创建一遍。

function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
  this.sayName = ()=>{
    console.log(this.name)
  }
}

const person1 = new Person("little red", 25, "doctor");
const person2 = new Person("little blue", 24, "teacher");

对前面的例子而言,person1 和 person2 都有名为 sayName()的方法,但这两个方法不是同一个 Function 实例,造成了不必要的内存增加。

临时解决缺点的方法

共享全局作用域上的方法

const sayName = (name)=>{
  console.log(name)
}

function Person (name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
  this.sayName = sayName
}

但这样也带来了一些问题:

  • 全局作用域因此被搞乱了,因为那个函数实际上只能在一个对象上调用。
  • 如果这个对象需要多个方法,那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好地聚集一起。

原型模式

为了解决构造函数定义的方法会在每个实例上都创建一遍的问题,我们需要尝试原型模式。

每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。实际上,这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享

所以原来在构造函数中直接赋给对象实例的值,可以直接赋值给它们的原型。

理解原型

无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象)。默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数。

探究JS中“类”的创建与继承过程

function Person() {}
console.log(Person.prototype); // {}
console.log(Person.prototype.constructor); // Person {}

对前面的例子而言,Person.prototype.constructor 指向 Person。然后,因构造函数而异,可能会给原型对象添加其他属性和方法。

在自定义构造函数时,原型对象默认只会获得 constructor 属性,其他的所有方法都继承自 Object。

每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象。

脚本中没有访问这个[[Prototype]]特性的标准方式,但 Firefox、Safari 和 Chrome会在每个对象上暴露__proto__属性,通过这个属性可以访问对象的原型。在其他实现中,这个特性完全被隐藏了。关键在于理解这一点:实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。 探究JS中“类”的创建与继承过程 代码释义

function Person() {}
const person1 = new Person();
console.log(person1.__proto__); // {}
console.log(person1.__proto__ === Person.prototype); // true
console.log(Person.prototype.constructor === Person); // true
console.log(person1.__proto__.constructor === Person); // true
console.log(Person.prototype.__proto__ === Object.prototype); // true

这里需要牢记的一点是,原型对象直接存在于构造函数上,通过prototype属性来访问,而实例对象中需要依靠__proto__属性来访问原型对象。实际访问原型对象的属性时,可以省略中间的原型属性,因为依靠原型链会一层一层往上寻找,直到找到Object上面。

缺点

  • 它弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。
  • 原型的最主要问题源自它的共享特性。原型上的所有属性是在实例间共享的,这对函数来说比较合适。另外包含原始值的属性也还好,可以通过在实例上添加同名属性来简单地遮蔽原型上的属性。真正的问题来自包含引用值的属性
function Person() {}
Person.prototype.friends = ["little red", "little blue"];

const person1 = new Person();
person1.friends.push("little wang");

const person2 = new Person();
console.log(person1.friends); // [ 'little red', 'little blue', 'little wang' ]
console.log(person2.friends); // [ 'little red', 'little blue', 'little wang' ]

总结

类的创建方式主要过程优点缺点
工厂模式用一个工厂函数批量创建对象,用参数区分对象简单方便没有解决对象标识问题
构造函数与工厂函数类似,没有显示创建对象,属性和方法直接赋值给了this,没有return。需要使用new操作符实例化。解决了工厂模式没有对象标识的问题实例化不同的对象时,方法会重复创建,造成不必要的内存增加。
原型模式将公共方法和属性挂载在原型对象上面,让所有的实例都可以访问到。解决构造函数定义的方法会在每个实例上都创建一遍的问题挂载在原型对象上面的引用类型在一个实例上被修改时,会污染原型对象上的值。

“类”的继承

我们知道,类是对象的抽象,比如人类,也可以按不同的标准分为很多子类,比如按性别分,可以分为男性与女性。在这里人类就可以称为父类,男性女性就可以称为派生类。由于这种情况比较普遍,我们就有了“类”继承的概念。

在JS中,我们主要有六种方式去实现继承,主要是原型链、盗用构造函数、组合继承、原型式继承、寄生式继承、寄生式组合继承。其中使用最多的是组合继承,最优的是寄生式组合继承,主要都是依靠原型链来实现的。

原型链

原型链为 ECMAScript 的主要继承方式。其基本思想就是通过原型继承多个引用类型的属性和方法

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

探究JS中“类”的创建与继承过程

我们可以通过代码来推理下:

function A() {}
function B() {}
const b = new B();
A.prototype = b;
console.log(A.prototype === b); // true
console.log(B.prototype === b.__proto__); // true
console.log(B.prototype === A.prototype.__proto__); // true

将A的原型设置为B的实例,那A的原型以及A的实例就可以访问到B的原型对象,将公共方法及属性进行传承,实现继承。

通过这个方法 ,我们可以创建一条原型链。在读取实例上的属性时,首先会在实例上搜索这个属性。如果没找到,则会继承搜索实例的原型。在通过原型链实现继承之后,搜索就可以继承向上,搜索原型的原型。对属性和方法的搜索会一直持续到原型链的末端。

默认情况下,所有引用类型都继承自 Object,这也是通过原型链实现的。任何函数的默认原型都是一个 Object 的实例,这意味着这个实例有一个内部指针指向Object.prototype。

优点

公共方法和属性可以简单的实现共享。

缺点

和上面原型模式的缺点一样,如果原型上的属性涉及到引用类型,不同的实例之间可能会相互污染。

盗用构造函数

也称“对象伪装”或“经典继承”。在子类构造函数中调用父类构造函数。因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用apply()和 call()方法以新创建的对象为上下文执行构造函数。

这里浅谈一下我对盗用的理解: 通过apply/call/bind这些方法巧妙的将this指向父类,这样我们就可以将父类的方法和属性拿过来用了,确实有偷盗的嫌疑。

function SuperType(name) {
  this.name = name
  this.colors = ["red", "blue"];
}

function SubType(name) {
  // 继承SuperType
  // SuperType.apply(this);
  SuperType.call(this,name);
}

const a = new SubType('a');
const b = new SubType('b');
a.colors.push("purple");

console.log(a, b);
// a ["red", "blue","purple"]
// b ["red", "blue"]

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

优点

可以在子类构造函数中向父类构造函数传参。

缺点

  1. 主要缺点:必须在构造函数中定义方法,因此函数不能重用。
  2. 子类不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。

组合继承(使用最多)

也叫“伪经典继承”,组合了原型链以及盗用构造函数,将两者的优点结合起来。

基本思路:

  1. 使用原型链继承原型上的属性和方法
  2. 通过盗用函数继承实例属性

这样既可以把方法定义在原型上实现重用,又可以让每个实例都有自己的属性。

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

function SubType(name, age) {
  SuperType.call(this, name);// 第二次调用 SuperType()
  this.age = age;
}
SubType.prototype = new SuperType();// 第一次调用 SuperType()
SubType.prototype.sayAge = function () {
  console.log(this.age);
};
const a = new SubType("little red", 25);
const b = new SubType("little blue", 24);
a.colors.push("purple");
console.log(a, b);
a.sayName();
b.sayName();
a.sayAge();
b.sayAge();

优点

由于综合了盗用构造函数以及原型链的优点,所以这里的优点就是这两者的优点,并解决了两者会带来的问题。

缺点

父类会被调用两次,会有一定的效率问题。

原型式继承

Crockford介绍了一种不涉及严格意义上构造函数的继承方法,出发点是即使不自定义类型也可以通过原型实现对象之间的信息共享。

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

let person = {
  name: "lee",
  friends: ["zhao", "qian", "sun"],
};

let person1 = object(person);
person1.name = "wang";
person1.friends.push("wu");

let person2 = object(person);
person2.name = "zhou";
person2.friends.push("zheng");
console.log(person); 
// { name: 'lee', friends: [ 'zhao', 'qian', 'sun', 'wu', 'zheng' ] }

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

// Object.create()代替object()
let person = {
  name: "lee",
  friends: ["zhao", "qian", "sun"],
};

let person1 = Object.create(person);
person1.name = "wang";
person1.friends.push("wu");

let person2 = Object.create(person);
person2.name = "zhou";
person2.friends.push("zheng");
console.log(person); 
// { name: 'lee', friends: [ 'zhao', 'qian', 'sun', 'wu', 'zheng' ] }

优缺点

引用属性可以共享的特点既带来了优点又带来了缺点。

优点:

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

缺点:

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

寄生式继承

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

function createAnother(origin) {
  let clone = Object(origin);
  clone.sayHi = () => {
    console.log("hi");
  };
  return clone;
}
let person = {
  name: "lee",
  friends: ["zhao", "qian", "sun"],
};
let person1 = createAnother(person);
person1.sayHi()

缺点

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

寄生式组合继承

组合继承其实也存在效率问题。最主要的效率问题就是父类构造函数始终会被调用两次:一次在是创建子类原型时调用,另一次是在子类构造函数中调用。 本质上,子类原型最终是要包含超类对象的所有实例属性,子类构造函数只要在执行时重写自己的原型就行了。

寄生式组合继承实际上就是寄生式继承+组合式继承。汲取了寄生式继承创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象的这个特点与组合式继承的特点。可以解决组合继承的父类被调用两次的效率问题。

寄生式组合继承代码示例:

// 这一步参考了寄生式继承:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象
function inheritPrototype(subType, superType) {
  let prototype = Object(superType.prototype); // 创建对象
  prototype.constructor = subType; // 增强对象
  subType.prototype = prototype; // 赋值对象
}

function SuperType(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function () {
  console.log(this.name);
};
function SubType(name, age) {
  SuperType.call(this, name);
  this.age = age;
}
inheritPrototype(SubType, SuperType);
SubType.prototype.sayAge = function () {
  console.log(this.age);
};

这里只调用了一次 SuperType 构造函数,避免了 SubType.prototype 上不必要也用不到的属性,因此可以说这个例子的效率更高。而且,原型链仍然保持不变,因此 instanceof 操作符和isPrototypeOf()方法正常有效。寄生式组合继承可以算是引用类型继承的最佳模式。

but,笔者经实践发现,由于prototype是引用类型,inheritPrototype这一步实际也将父类的构造函数指向了子类。这个会不会造成父类在实例化时实例对象的标识问题呢,有待考证。

console.log(SubType.prototype.constructor); // SubType
console.log(SuperType.prototype.constructor); // SubType

总结

探究JS中“类”的创建与继承过程

后记

本文深度参考《JS高级程序设计(第四版)》第八章对象、类与面向对象的内容,精简了内容,并加以总结,建议看完本文后可以阅读下阮一峰大佬ES6对class的解读,相信一定会让你受益匪浅。