JS语言高级 - OOP

125 阅读29分钟

在编程开发中,存在两种主流的编程思想或者说编程范式:

  • 面向对象 Object-Oriented Programming简称OOP
  • 面向过程 Procedural-Oriented Programming简称POP
5.1. 面向对象概述

面向对象的思想是将现实世界的事物抽象成对象,在真是编程场景中,通过对象来组织和管理程序的代码和数据,每个对象都有自己的数据(属性)和行为(方法)。通过对象和对象之间的交互来完成相关任务。

其核心概念如下:

核心概念组成描述
类:class创建对象的模板,定义对象的属性和方法
对象:object类的实例,通过类的模板创建具体的对象实体
封装:Encapsulation将数据和操作数据的方法捆绑在一起,隐藏对象的内部实现
继承:Inheritance子类可以继承父类的属性和方法,实现代码复用
多态:Polymorphism同一操作对不同对象可以有不同的表现形式

面向对象的思想具有以下优点:

  • 代码重用性高
  • 模块化
  • 灵活性
5.2. 面向过程概述

面向过程则强调通过执行顺序来解决问题,一般是通过一系列函数组成程序,这些函数按照既定顺序执行对数据进行处理,有点像指令式的编程。

其核心概念如下:

核心概念组成描述
过程:Function面向过程的程序由多个函数组成,函数是对数据进行操作的工具。
数据:Data数据通常是全局的,函数操作这些数据来完成任务。
顺序执行程序按照一定的步骤和顺序执行。

面向过程的优点:

  • 简洁明了:程序结构简单、容易理解,适用与小型项目
  • 数据和行为分离明确

所有主流的编程语言都支持这两种编程范式,实际开发中,我们往往会混合使用的编程范式。例如,我们通过对象描述一个人时,实际上就是面向对象,我们通过函数执行一些请求任务时这就是面向过程。重在理解

5.3. JS-OOP

所有主流的编程语言都可以良好的支持OOPPOP编程范式,由于OOP的更具难度和高抽象、以及广泛使用,因此深入理解是相对困难的。在JS中没有类class的概念,因此JSOOP和其他如Java一类的语言的OOP实现是不一样的。JS通过原型机制来实现OOP(就是函数进阶中提到的prototype)。下面我们将深入JSOOP


在数据类型浅谈对象以及对象进阶中,我们知道在JS中,创建对象的常见方式方式有两种:Object构造函数、字面量的形式。但是这些方式存在着很多不足,最显而易见的就是创建多个具有相同属性或方法的对象会很繁琐。

5.3.1. 工厂模式

工厂模式是一种设计模式,所谓设计模式就是套路,针对不场景或业务需求的解决套路。各行各业都有特定的模式。在编程开发中,工厂模式是被广泛使用的。

下面,我们通过工厂模式来创建特定对象:

function createPerson () {
  let obj = new Object();
  obj.name = name;
  obj.age = age;
  obj.sayName = function () {
    console.log(this.name)
  }
  return obj;
}

let person1 = createPerson('小明',13)
let person2 = createPerson('小刚',15)
person1.sayName() // '小明'
person2.sayName() // '小刚'

通过工厂模式可以简化创建对象的繁琐,其本质是利用函数复用代码的特性减少重复代码,但没有解决对象标识的问题。

所谓对象标识就是指创建的对象是什么类型,为了解决这个问题就需要通过构造函数。

5.3.2. 构造函数模式

构造函数在对象浅谈一章中已经提到过,现在我们可以深入了。所谓对象类型就是字面意思,JS的标准库中有很多对象,例如日期Date、正则Reg、数组Array。这些对象在广义上都是对象,但是由于所承担实现的功能的不同,因此它们内部的数据和行为也是不同的,所以被详细分了类型。注意,这里的类型不是数据类型。


构造函数本质就是普通函数,其函数名首字母大写是一种俗称约定的做法。其核心是通过new关键字进行调用,内部通过则this关键字指向实例。

function CreatePerson (name,age) {
  this.name = name
  this.age = age
  this.sayName = function () {
    console.log(this.name)
  }
}
let person1 = new CreatePerson('小明',13)
let person2 = new CreatePerson('小刚',15)
person1.sayName() // '小明'
person2.sayName() // '小刚'

可以看到,我们在构造函数内部并没有像工厂模式那样显示通过Object或者字面量的形式创建对象实例,也没有显示的返回对象实例、直接把方法和属性赋给了this。这是因为new关键字在底层隐式的做了很多操作。

  • 在内存中创建了一个新对象
  • 将新对象内部的[[prototype]]特性被赋值为了构造函数的prototype属性
  • 将构造函数内部的this被赋值为这个对象,即this指向新对象
  • 执行构造函数体内的代码,给对象添加属性和方法
  • 最终将对象进行返回:如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
// 假设我们调用的构造函数是 CreatePerson
new CreatePerson('小明', 13)

// 内部过程:
1. 创建一个空的对象 `obj`。
   obj = {}

2. 设置该对象的原型指向构造函数的 prototype 对象。
   obj.__proto__ = CreatePerson.prototype;

3. 调用构造函数,并将 `this` 绑定到新创建的对象 `obj`// 这里会执行 CreatePerson 的代码,并将 `this` 指向 `obj`
   CreatePerson.call(obj, '小明', 13);

4. 如果构造函数没有显式返回对象,`new` 表达式会默认返回该新创建的对象 `obj`return obj;  // 默认返回新对象

5. 返回的新对象 `obj` 就是通过 `new CreatePerson('小明', 13)` 创建的实例。

5.3.3. 对象标识

回到构造函数示例的代码,接着说我们的对象标识:constructor是所有对象都有的属性,指向构造这个对象的构造函数。通过访问对象的constructor属性即可获取到。

let person1 = new CreatePerson('小明',13)
let person2 = new CreatePerson('小刚',15)
console.log(person1.constructor) // 输出函数 CreatePerson
console.log(person2.constructor) // 输出函数 CreatePerson

还有一种更加推荐的做法则是使用instanceof操作符来识别对象的标识。

let person1 = new CreatePerson('小明',13)
let person2 = new CreatePerson('小刚',15)
console.log(person1 instanceof Object) // true
console.log(person2 instanceof CreatePerson) // true

可以看到,无论是instanceof Object还是instanceof CreatePerson都得到true,这是因为在原型和原型链的机制下,JS中的对象都间接或直接与Object进行连接,稍后我们会着重说明。


对于构造函数,如果我们不传参的情况下,在new调用时,可以不加括号,这里仅做补充,不做推荐。

function Person() { 
   this.name = "Jake"; 
   this.sayName = function() { 
     console.log(this.name); 
   }
} 
let person1 = new Person(); 
let person2 = new Person;
5.3.4. 构造函数的问题

构造函数有一个比较大的问题是,在函数内定义的方法,会在每个实例上都创建一遍。因此,对于上面的person1person2对象而言,都有一个名为sayName的方法。但是这两个方法不是同一个Function实例。

console.log(person1.sayName === person2.sayName) // false

什么是Function实例?

我们不止一次提到过,在JS中,函数也是对象。既然是对象,那么定义函数时,肯定会有一个初始化对象的过程。

在函数的name属性讲解时,提到了Function内置对象

this.sayName = function() { 
     console.log(this.name); 
}
/*上面这段代码,在逻辑上和下面这段代码是等价,只是为了体现两个对象的方法不是同一个实例
* 并不是说person1和person2的sayName是通过new Function实现的
*/
this.sayName = new Function("console.log(this.name)"); 

为什么要着重说这个问题呢?不就是每个对象拥有自己的方法吗,又不是不能正常使用。这是因为,每个对象的方法都是独立的函数对象的话,那么每个函数对象都要占用内存空间,当对象变多时,实际上会导致内存浪费,因为每个对象的方法功能都是一样的,做成公用的话有利于节约内存。同时也更加贴合OOP的思想。下面我们来改造一下:


将公用的sayName方法移到构造函数外部:

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

let person1 = new Person("小明", 29, "工程师"); 
let person2 = new Person("小红", 27, "医生");
person1.sayName() // '小明'
person2.sayName() // '小红'

在上面的改造中,看似很好的解决了问题。实际上并没有,因为sayName被定义在了全局,这样会污染全局作用域,如果Person类的对象有很多公用方法,只有全部定义在全局。如果还有其他对象,那这个代码就很混乱了。原型模式就能解决这个问题。

5.3.5. 原型 prototype

在函数的属性和方法中,我们提到的prototype原型,就是JSOOP的核心。

原型prototype是函数独有的属性。每个函数都有自己的prototype。它是一个对象,因此也叫原型对象。其作用就是存储特定引用类型实例的公有方法和属性。

回到这个CreatePerson案例:

function CreatePerson(name, age) {
      this.name = name
      this.age = age
      this.sayName = function() {
          console.log(this.name)
      }
}
let person1 = new CreatePerson('小明', 13)
let person2 = new CreatePerson('小刚', 15)
console.log(CreatePerson.prototype) // { } 

访问原型就通过函数名.prototype,对于person1person2来说,CreatePerson函数prototype就是他们的原型。在原型上定义的方法和属性可以被其实例共享,如下示例。

CreatePerson.prototype.sayAge = function () {
  console.log(this.age)
}
CreatePerson.prototype.type = '学生'
person1.sayAge() // 13
person2.sayAge() // 15
console.log(person1.type) // '学生'
console.log(person2.type) // '学生'
console.log(person1.type === person2.type) // true
console.log(person1.sayAge === person2.sayAge) // true

5.3.6. 原型链 [[prototype]]

再来看一个现象,上面person1person2无论是访问type属性还是sayAge方法都正常输出了,虽然我们知道他们访问的是其构造函数原型上的属性和方法,但是他们是通过什么样的方式访问到的呢? 答案就是 [[prototype]]

💁 重点

JS中,所有对象都有一个特殊的属性 [[prototype]] 当对象被创建时,这个内部属性会被指向其构造函数的原型prototype 这也是person1person2能访问到在CreatePerson原型上定义的属性和方法的原因。

当我们试图访问一个对象的属性时,如果在对象自身没有找到该属性,那么就会通过其 [[prototype]] 往对应的原型上进行查找,如果仍然找不到,就往原型的原型上进行查找,这种链式的查找方式称为原型链, 类似作用域链。在查找的过程中,找到属性则返回,找不到则继续查找,直到原型链的顶端,找不到则返回undefined

最上层的原型是Object.prototype,它的 [[prototype]] null。因此,null是原型链的顶端。

说回 [[prototype]] ,在浏览器控制台,我们打印任何对象,展开其结构时都能看到有一个 [[prototype]] 属性。两个方括号一般表示这是一个内部隐藏属性,就像我们前面提到的对象属性描述符一样,内部特性是不对开发者开放的。

由于JS是基于原型的语言,开发者可能需要查看原型或者修改原型链。而ECMA官方没有定义相关访问标准,因此各个浏览器在早期建立了一个非官方标准的属性 __proto__ 用于开发者查阅对象的原型或进行原型的修改,便于开发者理解JS原型和原型链的机制。因此 __proto__ 是等价于 [[prototype]] 的,两者是一个意思。

ES6中,官方为了标准化,推出了访问和设置原型的API,例如Object.getPrototypeOf()。以此替代非标准的做法。目前来说__proto__已经弃用,官方不推荐这种做法,不过我们下面的演示依旧基于这个做法。

5.3.7. constructor

无论何时,只要创建一个函数,那么引擎就会按照既定规则为这个函数创建一个prototype属性,值为对象。默认情况下,原型对象会自动获得一个名为constructor的属性,这个属性指向和原型关联的构造函数。我们自定义构造函数时,原型对象均只有一个属性,就是constructor,其余属性或方法都继承自Object。通过控制台我们可以打印函数的原型对象,清楚的看到内部结构。

💁 重点

constructor是每个对象都有的属性,但是它是存在于原型对象上的,是通过原型链继承而来,非对象自身所有

console.log(CreatePerson.prototype.constructor === CreatePerson) // true

函数的__proto__

JS中,函数作为特殊对象,他也有[[prototype]]属性,函数的__proto__指向的是内置函数的Fucntion的原型,前面提到的函数的call、apply方法就是在这个原型上定义的,而Function的原型的原型也指向顶层的Object.prototype

function Test () {}
console.log(Test.__proto__ === Function.prototype)
console.log(Function.prototype.__proto__ === Object.prototype)

需要注意的是,出于函数的特殊性,在控制台输出Function.prototype得到一个函数。它是函数类型的一种特殊对象。另外就是,实际开发中我们不会通过new Function()创建函数对象,一般都是使用函数声明或表达式,引擎在底层解析时,会自动关联到Function.prototype,从而让我们使用函数对象可共享的方法如call、apply

5.3.8. 原型、原型链示例
// 定义构造函数
function CreatePerson (name,age) {
  this.name = name
  this.age = age
}

// 通过原型定义公有属性
CreatePerson.prototype.type = '学生'
// 通过原型定义公有方法
CreatePerson.prototype.study = function () {
  console.log(this.name + '正在学习')
}

// 创建CreatePerson的实例对象
let p1 = new CreatePerson('小红',20)
let p2 = new CreatePerson('小明',30)

// 演示原型、原型链关系
console.log(CreatePerson.prototype.constructor === CreatePerson) // true

console.log(CreatePerson.prototype.__proto__ === Object.prototype) // true

console.log(CreatePerson.prototype.__proto__.constructor === Object) // true

console.log(CreatePerson.prototype.__proto__.__proto__ === null) // true

console.log(p1.__proto__ === CreatePerson.prototype) // true

console.log(p1.__proto__ === p2.__proto__) // true

console.log(p1.__proto__.constructor === CreatePerson) // true

// 通过instanceof操作符判断对象标识
console.log(p1 instanceof CreatePerson) // true
console.log(p1 instanceof Object) // true
console.log(CreatePerson.prototype instanceof Object) // true

// 标准API 访问原型
console.log(Object.getPrototypeOf(p1)) // { type:'xx',study:f() }
console.log(Object.getPrototypeOf(p1) == p1.__proto__) // true
console.log(Object.getPrototypeOf(p1) == CreatePerson.prototype) // true

对于设置原型Object.setPrototypeOf(),此处就不演示了。虽然是标准API,但是官方并不推荐使用。由于现代 JavaScript 引擎优化属性访问所带来的特性的关系,通过这种方法更改原型,会影响现有对象的原型链,并且可能导致引擎优化失效,会严重影响性能。此外,这种操作更改对象的原型在各个浏览器和引擎上都是一个很慢的操做。如果必须修改,建议使用Object.create()方法,这种方式基于一个新对象去更新原型,性能很高,后续会介绍。

5.3.9. 原型遮蔽

遮蔽其实就是一个属性覆盖,基于原型链的查找规则,对象查找某个属性时优先查找自身,然后进入原型链进行查找。

function Test () {}
Test.prototype.name = '姓名'

let p1 = new Test()
let p2 = new Test()
p1.name = '小明'
console.log(p1.name) // '小明'
console.log(p2.name) // '姓名'

上面这段代码:

  • 基于Test构造函数创建了p1p2对象。
  • Test的原型上定义了一个name属性,供实例公用。
  • 访问p1p2name,前者输出了自身定义的name,后者因为自身没有name,输出了原型上的name

对象自身存在和原型上同名的属性时,使用自身的属性,不会进入原型查找,就相当于遮蔽了原型上的同名属性。遮蔽不会修改,相当于PS图层,最顶层的图层盖住了下面的图层。有两个注意点:

  1. 当我们在实例上把这个同名属性置为null时,也不会恢复其和原型的关系。
  2. 但是如果使用delete操作符删除这个属性的话则会恢复其原型的关系。
p1.name = '小明'
p1.name = null
console.log(p1.name) // null
p1.name = '小明'
delete p1.name
console.log(p1.name) // '姓名'
5.3.10. 属性查找

对象进阶-引言中,我们提到,所有的对象本质上来讲都是基于Object创建的。因此对象实例的原型链上层都存在着Object.prototype对象。这也是为什么我们实际开发中,明明对象上没有的方法,我们却能正常使用,因为这些方法都来自于Object原型。例如toString()valueOf()。这里要说明一个hasOwnProperty()方法。

hasOwnProperty()用于确认某个属性是属于对象自身,还是属于其原型上。属于自身则返回true,否则返回false

function Test () {
  this.sayName = function () {
    console.log(this.name)
  }
}
Test.prototype.name = '姓名'
let p1 = new Test()
let p2 = new Test()
p1.name = '小明'

console.log(p1.hasOwnProperty('name')) // true
console.log(p2.hasOwnProperty('name')) // false

除了hasOwnProperty()in操作符可以判断对象是都存在否个属性,in操作可以单独使用也可以和for in 循环使用,无论是对象自身还是原型上的属性,只要属性存在,那么in操作符都返回true,否则返回false

console.log('name' in p1) // true
console.log('name' in p2) // true

💁 温馨提示

  • 对象可访问那么,属性正确的情况下,in均返回true
  • 操作符 in 返回truehasOwnProperty返回false时则说明属性存在,且是原型属性

5.3.11. for in 查找

for in 循环会返回可枚举的属性,无论是对象自身还是的通过原型继承的,均会返回,如下示例。

const obj = { name: 'Alice' };

Object.prototype.age = 30;  // 给 Object.prototype 添加一个属性

for (let key in obj) {
  console.log(key);  // 输出 'name' 和 'age'
}

属性描述符中我们讲到了可枚举 enumerable。如果对象有属性遮蔽了原型上不可枚举的属性,也会在循环中返回。因为开发者定义的属性默认都是可枚举的,请看如下示例。

// 创建一个原型中有不可枚举属性的对象
Object.defineProperty(Object.prototype, 'city', {
  value: 'New York',
  enumerable: false  // 不可枚举
});

const obj = { city: 'Los Angeles' };

for (let key in obj) {
  console.log(key);  // 输出 'city'
}
5.3.12. Object.keys()

Object.keys()会返回对象自身可枚举的属性,不包含原型上的属性。

const obj = {
  name: 'Alice',
  age: 30
};

console.log(Object.keys(obj));  // 输出: ["name", "age"]
const obj = {};

Object.defineProperty(obj, 'name', {
  value: 'Alice',
  enumerable: true  // 可枚举
});

Object.defineProperty(obj, 'age', {
  value: 30,
  enumerable: false  // 不可枚举
});

console.log(Object.keys(obj));  // 输出: ["name"]
5.3.13. 对象迭代

一直以来,JS的对象迭代都是一个难题。在ES2017- ES8中,新增了两个方法,用于返回对象的内容相关的数组,这两个方法分别是Object.values()Object.entries

  • Object.values()返回对象的值的数组
  • Object.entries返回键值对的数组
const obj = {
  name: 'Alice',
  age: 30
};

console.log(Object.values(obj));  // 输出: ["Alice", 30]
console.log(Object.entries(obj));  // 输出: [["name", "Alice"], ["age", 30]]

这两个方法在处理对象内容返回时,执行的是浅复制操作,如果有嵌套对象,那么修改时会影响源数据。

const obj = {
  name: 'Alice',
  details: {
    age: 30,
    city: 'New York'
  }
};

const values = Object.values(obj);
console.log(values); // 输出: ['Alice', { age: 30, city: 'New York' }]
values[1].age = 40;
console.log(obj.details.age) // 40
5.3.14. 直接定义原型注意点

在上面的案例中,我们为了讲解原型。一再给原型对象添加内容,例如:

Test.prototype.type = '学生'

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

这样看起来,会有点冗余,一旦属性或方法添加过多时,我们会抒写很多次的prototype,为了减少冗余,我们可以通过封装的方式直接定义原型,减少冗余代码如下:

function Test (name,age) {
  this.name = name
  this.age = age
}
Test.prototype = {
  type:'学生',
  sayName:function () {
    console.log(this.name)
  }
}

let p1 = new Test('小明',20)
let p2 = new Test('小红',30)
console.log(p1.type) // '学生'
console.log(p2.type) // '学生'
p1.sayName() // '小明'

但是直接定义原型,会导致constructor丢失。在前面,我们提到过,constructor属性是引擎构造原型对象时自动和构造函数关联的,然后对象实例会通过原型继承它。但是在上面这个案例中,我们通过字面量直接定义了原型对象。导致我们重写了原型对象,进而导致constructor丢失。原本Test.prototype.constructor -> Test,字面量定义原型后 -Test.prototype.constructor -> Object

console.log(Test.prototype.constructor) // fn Object
console.log(p1.constructor) // fn Object

但是instanceof操作符还是会正常返回值。

console.log(p1 instanceof Test) // true
console.log(p1 instanceof Object) // true

如果constructor的值非常重要的话,可以在字面量定义原型对象时,手动将正确的值赋回去,如下示例。

Test.prototype = {
  constructor:Test,
  type:'学生',
  sayName:function () {
    console.log(this.name)
  }
}

但是这种手动赋值的方式仍然有弊端,因为引擎默认构造的constructor是不可枚举的,而开发者添加的属性,正常情况下是可枚举的。此外,还有一个弊端就是,直接修改原型对象会导致对象实例和之前的原型切断联系,导致访问出错,请看下面两段代码

function Person() {
  
} 
let friend = new Person(); 

Person.prototype = { 
 constructor: Person, 
 name: "Nicholas", 
 age: 29, 
 job: "Software Engineer", 
 sayName() { 
 console.log(this.name); 
 } 
}; 

friend.sayName(); // 错误

上面调用错误,是因为friend创建时,原型对象就已经存在了,只不过后面通过字面量的形式修改了。但friend的原型依旧指向构造函数创建之初的原型,所以导致出错。

let friend = new Person(); 

Person.prototype.sayHi = function() { 
 console.log("hi"); 
}; 
friend.sayHi(); // "hi",没问题!

上面这段代码正常调用,因为没有重新定义原型,原型在创建对象实例之前就被构建好了,因此没有错误。

💁 温馨提示

建议先定义好原型上所需要的方法和属性,再进行实例的创建,确保所有实例都正常继承原型上的方法和属性

5.3.15. 原生的原型

原生的原型一般是指内置的不同的引用类型的构造函数的原型,常见的就是ObjectStringArray等。这些内置的构造函数的原型上定义了非常多的方法,供和各种类型的实例使用。其中Obejct.prototype是属于老大哥。数组、字符串之类的内置类的原型的原型几乎都继承自Object.prototype,这也是JS万物皆对象的由来,其核心就是通过原型和原型链机制。

💁 温馨提示

尽量避免修改原生原型上的方法,避免造成潜在问题,如下示例:

Object.prototype.toString = function () {
    return 1
}
function Test () {
}
let p1 = new Test()
console.log(p1.toString()) // 1
5.3.16. 原型的问题

原型最明显的问题就是弱化了向构造函数传递初始化参数的能力,因为原型导致所有实例默认取得相同的属性值。虽然原型的共享特性正是要让实例共享方法和属性,但是不同的实例,应该有着独立的属性副本。请看示例:

function Test () {
  
}
Test.prototype = {
  constructor:Test,
  name:'实例',
  info:{
    city:'成都'
  }
}

let p1 = new Test()
let p2 = new Test();
p1.info.city = '上海'
console.log(p2.info.city) // 上海 

可以看到,通过p1修改了原型上的info上的city,导致p2info也发生了改变。如果这是刻意修改的就还好,如果不是这将映射到其他其他实例。因此实际开发中,一般不单独使用原型模式。

其实通过原型的学习,我们已经慢慢熟悉了OOP中的对象的概念了(类的实例,通过类的模板创建具体的对象实体),上面的p1、p2就相当于类Test的实例。构造函数可以看成是类,JS语言没有类的概念。

5.4. 继承

继承是OOP中最关键也是最核心的概念了。很多语言支持两种继承模式:接口继承、实现继承。在函数重载一章中,我们知道ES没有通过参数类型、参数数量去判断实现函数重载,因为JS语言的数据类型是动态不可控的。因此ES中只支持一种继承就是实现继承,在JS中就是大名鼎鼎的原型、原型链的方式。

[[prototype]]我们讲解了原型链,只不过我们是基于已有的链条,现在我们自己来建立一下继承关系:

function Parent () {
  this.parentProperty = true
}
Parent.prototype.getPropertyValue = function () {
  return this.parentProperty
}

function Child () {
  this.childProperty = false
}

Child.prototype = new Parent() // 关键点
Child.prototype.getSubValue = function () {
  return this.childProperty
}

let instance = new Child()
console.log(instance.getPropertyValue()) // true
console.log(instance.getSubValue()) // false

上面这段代码关键点就在于,我们将Child的原型赋值为了Parent的实例,从此Child实例就可以访问到Parent原型上的方法和属性。这种关系就是继承,Child构造函数可以理解为子类,Parent就是父类。

除了instanceof操作符外,还可以使用原型上的方法isPrototypeOf

console.log(Object.prototype.isPrototypeOf(instance)); // true 
console.log(Parent.prototype.isPrototypeOf(instance)); // true 
console.log(Child.prototype.isPrototypeOf(instance)); // true

💁 温馨提示

JS原型机制中,我们需要明白的一点是,所以的引用类型都继承自Object,任何函数的默认原型都是一个Object实例,这个实例内部的[[prototype]]指向的都是Object.prototype。这也是为什么所有实例都能访问使用toString()valueOf()的原因。

了解了继承的核心是基于原型和原型链以后,我们来看看JS中有哪些更好的实现继承的方式,这些方式都是社区开发者基于原型和原型链在实际开发中面摸索总结出来的,其目的都是为了不断优化,解决一些原生存在的问题,找到合理的方式。

5.4.1. 借用构造函数的继承

这种方式主要是解决原型中包含引用值的的问题,也称盗用构造函数继承,基本思路很简单就是在子类构造函数调用父类构造函数的代码。

// 父类 构造函数
function Parent () {
    this.colors = ['red','blue','green']
}

// 子类 构造函数
function Child () {
    Parent.call(this)
}

// 创建子类实例
let child1 = new Child()
console.log(child1.colors) // ['red','blue','green']
child1.colors.push('black')
console.log(child1.colors) // ['red','blue','green','black'] 

let child2 = new Child() 
console.log(child2.colors); // [ 'red','blue','green']

上面的代码中,通过call()在子类函数中调用了构造函数的代码。相当于把colors做为了每个实例独自的好属性,所以child1在向colors``push以后,不影响child2colors

// 父类 构造函数
function Parent (name) {
    this.name = name
}
Parent.prototype.sayName = function () {
  console.log(this.name)
}
// 子类 构造函数
function Child () {
  Parent.call(this,'小红')
  this.type = '孩子'
}

// 创建子类实例
let child1 = new Child()
console.log(child1.name) // '小红'
console.log(child1.type) // '孩子'
console.log(child1.sayName) // undefined

所谓盗用构造函数,其实就是通过call或者apply等方法,改变this指向,在子类函数中复用/借用构造函数中的代码。其问题在于,父类和子类的原型没有关联性,因此子类不能使用父类原型上的数据,因此也算不上继承。顶多只能算复用代码。

5.4.2. 组合式继承

组合式继承也叫伪经典继承,它将原型链和借用构造函数的优点结合了起来,基本思路是使用原型链继承原型上的属性和方法,通过借用构造函数继承实例属性,这样既可以将方法定义在原型上复用,也可以让每个实例都有自己的属性。

// 父类 构造函数
function Parent (name) {
    this.name = name
    this.colors = ['red','green','black']
}
Parent.prototype.sayName = function () {
  console.log(this.name)
}
// 子类 构造函数
function Child (name,age) {
  Parent.call(this,name)
  this.age = age
}
// 子类的原型修改为父类的实例对象
Child.prototype = new Parent();
Child.prototype.constructor = Child
// 在子类原型上添加方法
Child.prototype.sayAge = function () {
  console.log(this.age)
}
// 创建子类实例
let child1 = new Child('小红',20)
child1.colors.push('pink')
child1.sayName() // '小红'
child1.sayAge() // 20

let child2 = new Child('小明',19)
child2.sayName() // '小明'
child2.sayAge() // 19
console.log(child2.colors) // ['red','green','black']

上面这个案例,就是组合式继承,其关键点就在于将子类的原型修改为父类的实例,从而建立继承关系,然后结合借助构造函数的方式实现的OOP继承的一种方式,是JS中使用的最多的继承方式。但是存在一个缺点就是,创建子类时父类的构造函数被调用了一遍,设置子类原型也被调用了一遍。

5.4.3. 原型式继承

在早期,Douglas CrockfordJSON格式的主要推动者、JSConf会议的创始人),写了一篇文章,叫JS的原型式继承。通过不自定义类型的方式实现JS的原型继承,这不不赘述,感兴趣的开发者可以网上查询。

为了标准化原型式继承的概念,ES5引入了Object.create()方法。

Object.create()方法用于创建一个新的对象,并允许我们指定一个对象用于新对象的原型。

语法如下:

  • Object.create(protoObj) protoObj只能是null或者Object,否则引发错误
  • Object.create(protoObj,propertiesObject) propertiesObject是可选的和Object.defineProperties第二个参数一样,定义配置属性描述符。
// 创建对象 定义原型
let protoObj = {
  type:'学生'
}
let obj = Object.create(protoObj)
console.log(obj) // {}
console.log(obj.__proto__) // protoObj



// 可选参数示例
let person = { 
 name: "Nicholas", 
 friends: ["Shelby", "Court", "Van"] 
}; 
let anotherPerson = Object.create(person, { 
 name: { 
 value: "Greg" 
 } 
}); 
console.log(anotherPerson.name); // "Greg

// 传入 null 创建一个没有任何原型的空对象
let obj1 = Object.create(null)
console.log(obj1) // {}
console.log(obj.__proto__) // undefined

上面就是Object.create()的用法,它允许我们在创建对象的过程中进行更精细化的控制,在底层来说,字面量创建对象的形式就是Object.create()的一种语法糖:

两者等价
let obj = {}
let obj = Object.create(Object.prototype)

两者等价
let obj = Object.create(null);
let obj = { __proto__: null }
5.4.4. 寄生组合式继承

下面我们通过Object.creat()改造一下上面的代码,形成寄生组合式继承。思路其实很简单,依旧借用父类构造函数,只不过子类的原型不再设置为创建父类实例,而是通过Object.create()创建,避免了父类函数的二次调用。

// 父类 构造函数
function Parent (name) {
    this.name = name
    this.colors = ['red','green','black']
}
Parent.prototype.sayName = function () {
  console.log(this.name)
}
// 子类 构造函数
function Child (name,age) {
  Parent.call(this,name)
  this.age = age
}
// 子类的原型修改为父类的实例对象
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child
// 在子类原型上添加方法
Child.prototype.sayAge = function () {
  console.log(this.age)
}
// 创建子类实例
let child1 = new Child('小红',20)
child1.colors.push('pink')
child1.sayName() // '小红'
child1.sayAge() // 20

let child2 = new Child('小明',19)
child2.sayName() // '小明'
child2.sayAge() // 19
console.log(child2.colors) // ['red','green','black']
5.4.5. 静态方法和原型方法说明

实际开发中,我们总是在使用各种各样的方法,总是在讲各种继承。Object.keysObject.prototype.toString(),像这种直接通过内函对象调用的方法,称为静态方法,这是直接定义在对象上的,而原型方法是定义在类的原型prototype上的。这一点可以参考MDN标准库。原型方法一般都是继承共享,静态方法一般是基类调用,传入具体的实例对象,得到某种数据。

function BankAccount(initialBalance) {
  // 私有字段
  let balance = initialBalance;

  // 存储余额方法
  this.deposit = function(amount) {
    if (amount > 0) {
      balance += amount;
    }
  };
  // 减少余额
  this.withdraw = function(amount) {
    if (amount > 0 && amount <= balance) {
      balance -= amount;
    }
  };

  // 获取余额
  this.getBalance = function() {
    return balance;
  };
}

const account = new BankAccount(1000);
account.deposit(500);
account.withdraw(200);
console.log(account.getBalance());  // 输出: 1300

上面案例中,在构造函数BankAccount中定义了变量balance来模拟私有字段。而depositwithdrawgetBalance引用了BankAccount内的变量,从而形成闭包。其核心点就在于,闭包函数能通过作用域链的机制访问上层函数的上下文,因此balance会被记录保存。

💁 温馨提示

ES6之前通过上面闭包的方式仅仅只是模拟实现私有字段,字段并不属于对象本身。而class中有一个新增特性就是允许定义私有字段,用#前缀修饰,该私有字段是切实属于对象自身的,并且严格限制了访问权限。


类中的私有字段,必须在类体内声明,且只能预先声明,不能动态创建、其读写操作也只能在类的内部进行,否则将引发错误,这里有一个注意点就是在浏览器控制台通过实例对象.私有属性时能输出数据,不会报错,这是因为浏览器控制台是一个交互式的环境,并不是标准的JS代码行为规范。除此外,和普通属性没有区别。下面来看示例:

class BankAccount {
  #balance = 0; // 定义私有字段 每个BankAccount实例将会拥有一个私有字段
}
let p1 = new BankAccount()
console.log(p1.#balance) // 报错 因为不能在类外部访问

💁 温馨提示

私有属性在类体中直接声明,可在类体任意地方使用,例如:构造函数、实例方法、以及getter/setter

class BankAccount {
  // 定义私有字段 每个BankAccount实例将会拥有一个私有字段
  #balance = 0; 
  // 构造函数
  constructor(name,age,initbalance){
    this.name = name
    this.age = age
    this.#balance = initbalance
  }
  // 设置余额 - 私有字段
  setBalance(value){
    this.#balance = value
  }
  //获取余额 - 私有字段
  getBalance(){
    console.log(`${this.name}的余额${this.#balance}`) 
  }
}
let p1 = new BankAccount('老王',40,10000)
let p2 = new BankAccount('老张',50,5000)
p1.getBalance() // 老王的余额10000
p2.getBalance() // 老张的余额5000
p1.setBalance(400000)
p1.setBalance(10000000)
p1.getBalance() // 老王的余额400000
p2.getBalance() // 老张的余额10000000
5.5.6. 类的构成getter/setter

在对象的访问器属性中详细讲了gettersetter,在类中也可以使用该语法。

class Person {
  constructor(name,age){
    this.name = name
    this.age = age
  }
  get maxAge () {
    return this.age + 5
  }
}
let p1 = new Person('小红',20)
console.log(p1.maxAge) // 25

这种语法,看起来像是在访问在某个属性,实际上实例上并不存在这个属性,仅仅是内部的一个方法调用,。只有get没有set的情况下,它将是只读的。如果强行设置,在非严格模式下引擎将自动忽略,严格模式下则引发错误。

5.5.7. 类的构成 - 公共字段

上面讲了私有字段,对应的还有公共字段。公有字段直接定义在类体中。所有的实例都将获得这个公有字段。

class Person {
  // 定义公有字段
  luckyNumber = Math.random()
  constructor(name,age){
    this.name = name
    this.age = age
  }
}
let p1 = new Person('小刚',20)
let p2 = new Person('小明',40)
console.log(p1) // { luckyNumber:xx, name:'小刚',age:20 }
console.log(p2) // { luckyNumber:xx, name:'小明',age:40 }

💁 温馨提示

公有字段和直接在构造函数中定义属性几乎是等价的,没有什么区别。构造函数中初始化对象属性已经能满足大部分需求。该设计,主要是官方为了提供一种更加规范的方式来实现OOP,在复杂的继承逻辑下使得字段设计更清晰。

class User {
  name;
  email;
  address;

  constructor(name, email, address) {
    this.name = name;
    this.email = email;
    this.address = address;
  }

  getUserInfo() {
    return `Name: ${this.name}, Email: ${this.email}, Address: ${this.address}`;
  }
}

const user2 = new User("王五", "wangwu@example.com", "北京市");
console.log(user2.getUserInfo()); // Name: 王五, Email: wangwu@example.com, Address: 北京市
class VIPUser extends User {
  vipLevel; // VIP 用户等级

  constructor(name, email, vipLevel) {
    super(name, email);
    this.vipLevel = vipLevel;
  }

  getVipInfo() {
    return `${this.getUserInfo()}, VIP Level: ${this.vipLevel}`;
  }
}

const vip1 = new VIPUser("赵六", "zhaoliu@example.com", 1);
console.log(vip1.getVipInfo()); // Name: 赵六, Email: zhaoliu@example.com, VIP Level: 1
5.5.8. 类的构成 - 静态属性

静态属性是直接定义在类上的特性,如同Object.keys等方法,是直接定义在基类上的,而非实例。类中的静态属性主要包括:静态方法静态字段静态getter/setter

💁 温馨提示

静态属性和实例属性的区别主要是,静态属性需要static前缀,静态属性不能从实例中访问。其作用就是用于存储类本身相关的共享数据或者常量、以及一些工具方法。只能通过类名访问。

class Person {
  static species = 'Homo sapiens'; // 静态属性
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
}

// 访问静态属性
console.log(Person.species); // Homo sapiens 只能通过类名访问
class Person {
  static greet() {
    console.log('Hello!');
  }
}

Person.greet(); // Hello!

场景一⏩:如果我们有一个表示动物的类,可能会有一个静态属性表示所有动物的分类。

class Animal {
  static kingdom = 'Animalia';
  static getKingdom() {
    return Animal.kingdom;
  }
}

console.log(Animal.kingdom);  // Animalia
console.log(Animal.getKingdom()); // Animalia

场景二⏩: 用于记录类的实例数量或其他统计数据。这对于跟踪实例的创建次数或者共享某些信息很有用。

class Person {
  static count = 0; // 记录实例数量

  constructor(name) {
    this.name = name;
    Person.count++;  // 每次实例化时增加计数
  }
}

const p1 = new Person('Alice');
const p2 = new Person('Bob');
console.log(Person.count); // 2

场景三⏩: 静态方法也可以用于提供和类相关的工具方法,这些方法通常不需要访问实例的具体数据。

class MathUtils {
  static square(number) {
    return number * number;
  }

  static cube(number) {
    return number * number * number;
  }
}

console.log(MathUtils.square(4)); // 16
console.log(MathUtils.cube(3)); // 27

场景四⏩: 单例模式 保证类只有一个实例。通过静态属性存储实例,可以确保只有一个实例被创建

class Singleton {
  static instance;

  constructor() {
    if (Singleton.instance) {
      return Singleton.instance; // 如果实例已存在,则直接返回已创建的实例
    }
    Singleton.instance = this; // 创建实例
  }
}

const instance1 = new Singleton();
const instance2 = new Singleton();
console.log(instance1 === instance2); // true
5.5.9. 静态初始化块

静态初始块是一个特殊的语句块,它是在类第一次加载时运行的代码块,是自动执行的,几乎等价于在类声明之后立即执行一些代码。唯一的区别是静态块中可以访问静态私有属性。其作用就是在类声明以后,帮助我们自动执行一些初始化的操作,避免手动处理。

class ClassWithSIB {
  static {
    // …
  }
}

没有静态初始化块的做法,需要手动初始化一下,示例如下:

class MyClass {
  static someStaticField;

  // 传统的静态初始化方法
  static initialize() {
    MyClass.someStaticField = 42;
  }
}

MyClass.initialize();
console.log(MyClass.someStaticField);  // 输出 42

有了静态初始化块的做法,自动执行,示例如下:

class MyClass {
  static someStaticField;

  static {
    // 静态初始化块
    console.log('静态初始化块执行');
    MyClass.someStaticField = 42; // 初始化静态字段
  }
}

console.log(MyClass.someStaticField);  // 输出 42

执行更为复杂的初始化逻辑,示例如下:

class Config {
  static apiUrl;
  static timeout;

  static {
    console.log('静态初始化块开始执行');
    // 假设我们根据某些条件设置静态字段的值
    if (process.env.NODE_ENV === 'production') {
      Config.apiUrl = 'https://api.prod.com';
      Config.timeout = 5000;
    } else {
      Config.apiUrl = 'https://api.dev.com';
      Config.timeout = 10000;
    }
  }
}

console.log(Config.apiUrl);  // 根据环境变量,输出不同的 URL
console.log(Config.timeout);  // 输出不同的超时时间

常情况下,普通的static已经满足绝大部分需求

5.5.10. 类的继承

类还有一个强大的特性就是继承,继承在前面已经讲过,就是一个对象可以借用另一个对象的的大部分行为和数据,用于覆盖或者增强自身某部分逻辑。在类中,继承需要使用extends

⏩ 基础继承示例:

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(`${this.name} 大声尖叫`);
  }
}

class Dog extends Animal {
  constructor(name) {
    super(name); // 调用父类的构造函数
  }

  speak() {
    console.log(`${this.name} 狗叫`);
  }
}

const dog = new Dog('阿黄');
dog.speak(); // 输出 "阿黄 狗叫"

在子类中使用extends关键字来继承父类,子类将自动获得父类的属性和方法。子类还可以重写(覆盖)父类的方法或添加自己的方法。

spuer关键字

在子类构造函数中:

  • 必须使用 super() 调用父类的构造函数
  • super 还可以用来调用父类的方法
  • super()必须是子类构造函数第一条语句,在super()前访问this会引发错误
class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(`${this.name} makes a noise.`);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);  // 调用父类的构造函数
    this.breed = breed;
  }

  speak() {
    super.speak();  // 调用父类的 speak 方法
    console.log(`${this.name} barks.`);
  }
}

const dog = new Dog('Buddy', 'Golden Retriever');
dog.speak(); 
// 输出:
// Buddy makes a noise.
// Buddy barks.

⏩ 继承静态方法:静态方法属于类本身,而不是类的实例。子类也可以继承父类的静态方法,并且可以调用或覆盖这些静态方法。

class Animal {
  static greet() {
    console.log('Hello from Animal');
  }
}

class Dog extends Animal {
  static greet() {
    super.greet();  // 调用父类的静态方法
    console.log('Hello from Dog');
  }
}

Dog.greet();
// 输出:
// Hello from Animal
// Hello from Dog

⏩ 多级继承示例如下

// 定义动物类
class Animal {
  speak() {
    console.log('Animal makes a noise');
  }
}

// 定制哺乳动物类
class Mammal extends Animal {
  walk() {
    console.log('Mammal walks');
  }
}
// 定义狗类
class Dog extends Mammal {
  bark() {
    console.log('Dog barks');
  }
}
// 创建 Dog实例
const dog = new Dog();
dog.speak();  // 输出 "Animal makes a noise"
dog.walk();   // 输出 "Mammal walks"
dog.bark();   // 输出 "Dog barks"