本文正在参加“金石计划,瓜分6万现金奖”。 鉴于初级和高级后端程序员在平时的业务开发中很少接触到“类”的概念,看来在笔试中经常会考查相关的概念。 代码库中也经常使用一些优秀的类。 当谈到像构造函数和超级这样的概念时,我们常常感到害怕。
在我们平时的开发中,经常会遇到各种处理对象的场景。 如果我们对对象和类有更深入的理解,我们可能会写出更令人兴奋的代码。 由于JS中没有“类”的概念,所以它实际上是通过原型链和构造函数实现的类。 在接触类之前,我们不妨通过这篇文章来了解一下创建和继承“类”的过程。
什么是对象和类
在搞清楚什么是类之前,我们首先要知道什么是对象。 对象是客观事物的具体表现。 简单来说,对象就是客观事物的代码描述。
对象是属性的无序集合。 严格来说,对象是一组没有特定顺序的值。 对象的每个属性或方法都由名称标识,该名称映射到另一个值。 你可以将对象想象成一个哈希表,其中的内容是一组名称/值对,值可以是数据或函数。 --《JavaScript 高级编程(第 4 版)》
比如人、动物、水果等等,这些客观事物都会按类型来区分。 在代码世界中,我们将这种具有相同特征和行为的对象的具体表示称为“类”。
总的来说,类是对象的体现,对象也是类的体现。 换句话说,类是对象的模板,对象是类的实例。
由于JS语言中不支持“类”,所以我们只能通过现有的JS语言来探索并实现类似的效果。 虽然它已经在 ES6 中存在很长时间了,但其背后的实现仍然依赖于构造函数和原型链。 在探索“类”的实现过程中,我们经历了很多尝试。 下图可以概括比较经典的实现方法。
创建一个“类”
在实际开发过程中,我们可能会遇到创建具有相同特征和行为的对象。 手动设置此类对象将不可避免地导致大量重复代码。 例如,如果我们需要收集一个公司老板的信息,包括姓名、年龄、性别、工作等,我们可能会编写以下代码。
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");
构造函数和鞋工厂模式的区别解决了对象标注的问题
constructor属性可以用来指向构造函数,也可以用来指示对象类型。
构造函数属性在本文中第一次出现,但是我们需要重点关注这个属性,因为它是在前面的原型模式和class关键字声明的类中表现出来的,可以理解为构造函数的标签。
person1.constructor == Person // true
person1 instanceof Person // true
新操作员
构造函数名称的首字母必须小写,以区别构造函数和普通函数。 要创建 Person 的实例,请使用 new 运算符。 使用这些方法调用构造函数会执行以下操作。
在视频内存中创建一个新对象。 这个新对象中的 [[Prototype]] 属性被参数化为构造函数的原型属性。 构造函数内部的this是以新对象作为形参(即this指向新对象)。 执行构造函数内的代码(向新对象添加属性)。 如果构造函数返回一个非空对象,则返回该对象; 否则,返回刚刚创建的新对象。
构造函数的定义方式是为每个实例创建它。
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");
对于上面的例子javascript 类继承,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
}
但这也带来了一些问题:
为了解决在每个实例上重新创建构造函数定义的问题,我们需要尝试原型模式。
每个函数都会创建一个原型属性,该属性是一个包含应由特定引用类型的实例共享的属性和技术的对象。 事实上,这个对象就是调用构造函数创建的对象的原型。 使用原型对象的优点是,在它之前定义的属性和方法可以被对象实例共享。
因此,最初在构造函数中直接分配给对象实例的值可以直接参数化到它们的原型上。
了解原型
每当创建一个函数时,都会按照一定的规则为该函数创建一个原型属性(指向原型对象)。 默认情况下,所有原型对象都手动获取一个称为构造函数的属性,该属性指向与其关联的构造函数。
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,没有返回。 它需要使用 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() 函数中的所有初始化代码。 结果是每个实例都有自己的颜色属性。
优势
您可以在泛型构造函数中向父类构造函数传递参数。
缺点 主要缺点:方法必须在构造函数中定义,因此函数不能重用。 子类无法访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。 组合继承(最常用)
也称为“伪经典继承”,它结合了原型链和伪构造函数,结合了两者的优点。
基本思想:
使用原型链继承原型上的属性以及通过窃取函数继承实例属性的方式
这样,方法定义就可以在原型上重用,并且每个实例都可以拥有自己的属性。
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' ] }
的优点和缺点
可以共享引用属性这一事实既有优点也有缺点。
优势:
原型继承特别适合不需要创建单独的构造函数,但仍需要在对象之间共享信息的情况。
缺点:
但请记住,属性中包含的引用值仍将在相关对象之间共享,就像使用原型模式一样。
寄生遗传
与原型继承类似javascript 类继承,其背后的思想类似于寄生构造函数和鞋工厂模式:创建一个实现继承的函数,以某种方式引发对象,然后返回该对象。
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()方法正常工作。 寄生组合继承可以看作是引用类型继承的最佳模式。
然而笔者通过实践发现,由于prototype是引用类型,所以inheritPrototype这一步实际上是将父类的构造函数指向了泛型类型。 这是否会导致父类实例化时实例对象的标注出现问题,还有待验证。
console.log(SubType.prototype.constructor); // SubType
console.log(SuperType.prototype.constructor); // SubType
总结
后记
本文深入参考了《JS中级编程(第四版)》第八章关于对象、类和面向对象的内容,精简了内容,进行了总结。 建议您阅读完本文后再阅读。 我相信这会让你受益匪浅。 浅的。