Explore the creation and inheritance process of "class" in JS

This article is participating in the "Golden Stone Project. Divide 60,000 cash prize". In view of the fact that junior and intermediate front-end programmers rarely come into contact with the concept of "class" in normal business development, but in fact, they often examine related concepts during interviews. Some excellent codes Classes are also frequently used in the warehouse. When it comes to the concepts of constructor and super, we often feel timid.

In our usual development, we often encounter various scenes of dealing with objects. If we have a deeper understanding of objects and classes, we may write more exciting code. Since there is no concept of "class" in JS, it is actually a class implemented through prototype chains and constructors. Before contacting class, we might as well use this article to understand the process of "class" creation and inheritance.

What are objects and classes

Before figuring out what a class is, we first need to know what an object is. An object is an abstraction of objective things . Simply put, an object is a code description of objective things.

An object is an unordered collection of properties. Strictly speaking, an object is a set of values ​​in no particular order. Each property or method of an object is identified by a name, which maps to another value. You can think of an object as a hash table, where the content is a set of name/value pairs , and the value can be data or a function . -- "JavaScript Advanced Programming (4th Edition)"

For example, humans, animals, fruits, etc., these objective things will have types. In the code world, we call the abstraction of such objects with the same characteristics and behaviors "classes".

In general, a class is an abstraction of an object, and an object is the embodiment of a class. In other words, a class is a template for an object, and an object is an instance of a class.

Since the "class" is not supported in the JS language , we can only explore and achieve similar effects through the existing JS language. Although ES6 already has the class keyword to define a "class" , the implementation behind it still relies on the constructor and the prototype chain. In the process of exploring and implementing "classes", we have experienced many attempts. The following figure can give you an overview of the more classic implementation methods.

Create a "class"

In the actual development process, we may encounter creating objects with the same characteristics and behaviors. Manually setting these objects will inevitably generate a lot of repetitive code. For example, we need to collect information about the owner of a company, including name, age, gender, job, etc. We may write the following code.

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

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

...
复制代码

Once the number of people increases, it will be more difficult for us to collect. The first solution we can think of is one of the classic design patterns - the factory pattern.

factory pattern

The factory pattern is a simple function that creates an object, adds properties and methods to it, and returns the object.

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')

...
复制代码

It can be seen that the factory mode can easily solve the problem of creating similar objects, and only need to pass parameters without thinking to create similar objects one by one.

shortcoming

The disadvantage of the factory pattern is also obvious: it does not solve the problem of object identification (that is, what type of newly created object is) . In layman's terms, the created object does not have a binding relationship with the function that created it. When a problem occurs, there is no way to trace the source.

Constructor

In order to solve the problem that the factory pattern does not solve the object identification, we need to use a constructor, which is similar to a normal function, the only difference is that the new operator needs to be used when calling.

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");
复制代码

Difference between constructor and factory pattern

  • Objects are not explicitly created.
  • Properties and methods are directly assigned to this.
  • There is no return.
  • Fixed object identification issues

Fixed an issue with object identification

The constructor attribute can be used to point to the constructor and can be used to identify the object type.

The constructor attribute appears for the first time in this article, but we need to focus on this attribute, because it is reflected in the later prototype mode and the class declared by the class keyword, which can be understood as the identifier of the constructor.

person1.constructor == Person // true
person1 instanceof Person // true
复制代码

new operator

构造函数名称的首字母都是要大写的,以此区分构造函数与普通函数要创建 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 的属性,指回与之关联的构造函数。

function Person() {}
console.log(Person.prototype); // {}
console.log(Person.prototype.constructor); // Person {}
复制代码

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

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

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

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

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 的主要继承方式。其基本思想就是通过原型继承多个引用类型的属性和方法

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

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

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
复制代码

总结

image.png

后记

This article deeply refers to the content of Chapter 8 Objects, Classes and Object-Oriented in "JS Advanced Programming (Fourth Edition)", simplifies the content, and summarizes it. It is recommended that after reading this article, you can read Ruan Yifeng's introduction to class in ES6 Interpretation , I believe it will benefit you a lot.

Guess you like

Origin juejin.im/post/7166949915640233992