是往菜菜嘴里怼的前端迷魂汤啊 —— 面向对象:继承

“A language that doesn’t affect the way you think about programming, is not worth knowing” —— Alan J. Perlis(首届图灵奖的获得者)

从语言说起

JavaScript 最初被设计为用作脚本语言,但已被广泛用作通用编程语言(a fully featured general propose programming language)。

  1. “Fully featured”:意味着这种编程语言具有完成各种编程任务所需的所有基本特性和功能。这可能包括数据类型、控制结构(如循环和条件语句)、函数或方法定义、错误处理、输入/输出操作等。

  2. “General purpose”:意味着这种编程语言不是为特定类型的任务或领域设计的,而是可以用于各种类型的编程任务。与之相反的是领域特定语言(Domain-Specific Languages,DSLs),它们通常针对特定的问题领域或任务类型进行优化。

所以,当我们说一种编程语言是 “fully featured general purpose programming language” 时,意味着这种语言提供了完成各种类型的编程任务所需的所有工具和功能。

JavaScript 提供了一整套的功能,包括变量、操作符、数据类型、控制结构(如循环和条件判断)等等,允许开发者编写复杂的程序。另外,JavaScript 支持多种编程范式,包括命令式编程、面向对象编程和函数式编程等。

面向对象

面向对象三大特性”封装、”多态”、”继承”

继承可以把它想象成生物学里的遗传,就像一个人可能从他的父母那里或隔代继承了一些特征,比如眼睛的颜色,头发的质地等。

继承的目的是为了代码复用和组织。

在 JavaScript 中,继承主要是通过原型链实现的。

原型链(prototype chain)查找

当你试图访问一个对象的某个属性时,JavaScript 会首先检查该对象自身是否有这个属性,如果有就直接返回。

如果没有,则接下来检查该对象的原型链。

  • 也就是先去该对象的原型(即它的构造函数的 prototype 属性指向的对象)中查找,如果原型对象中有这个属性就返回。

  • 如果没有就继续沿着原型链向上查找,直到找到这个属性或者查找到原型链的末端(即 Object.prototype 的原型,这是原型链的顶端,它的值为 null)。

原型(prototype)

prototype:为其他对象提供共享属性的对象

  • 当创建一个对象时,这个对象隐式地引用(implicitly references)构造函数的 prototype 属性以解决访问属性引用的问题。1

  • 所有共享同一原型的对象,通过继承机制共享添加到原型中的属性。

  • 可以通过 constructor.prototype 这个表达式来访问构造器的原型属性

理解原型(prototype)需要它们配合

1. 对象(object)

  • 对象是 Object 类型的成员(member of the type Object)

  • 在 JavaScript 中创建对象的方式有很多种2

  • 对象是属性的集合

  • 构造函数创建的每个对象都有一个对其构造函数的 prototype 属性值的隐式引用(称为对象的原型),这个原型的值有可能是 null3

2. 构造器(constructor) & 构造函数(constructor function):创建和初始化对象的函数对象

  • JavaScript 中,构造器(constructor)和构造函数(constructor function)通常是指同一概念4

  • 构造函数是一个 Function 对象,用来创建和初始化对象

  • 在 JavaScript 中,任何一个函数都可以作为构造函数来使用。当一个函数被作为构造函数使用(即通过new操作符来调用)时,这个函数就被称为构造函数

  • 每个构造函数都有一个名为 prototype 的特殊属性,这个属性就是原型对象(prototype)

  • 每个构造函数的原型对象上,都默认有一个 constructor 属性5,它指回函数本身。

  • 对于一个被创建的对象来说,则指向了创建该对象的构造函数或者类

 function Foo() {
  //...
 }

 console.log(Foo.prototype.constructor === Foo); // true

constructor 属性指向创建该对象的构造函数有什么作用

  1. 创建相同类型的新对象:当你有一个对象,但不知道它是如何构造的(比如,你收到了一个通过网络传送的对象),constructor属性就可以帮助你创建一个新的相同类型的对象。这在动态编程中特别有用,因为你可以在运行时创建和操作新的对象。

例如:

let obj = new MyClass();
//...
let obj2 = new obj.constructor();

在上面的代码中,我们不需要知道obj的确切构造函数就可以创建一个新的对象。

  1. 确定对象的构造函数/类:在JavaScript中,我们可以使用instanceof运算符来确定一个对象是否是一个类的实例。然而,在某些情况下(例如跨窗口),instanceof可能无法正确工作。这时,我们可以使用constructor属性来判断一个对象的构造函数。

例如:

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

   let alice = new Person("Alice", 25);

   console.log(alice.constructor === Person); // true

在这个例子中,alice.constructor指向Person函数,所以alice.constructor === Person的结果为true,说明alice是由Person构造函数创建的。

JavaScript 中的继承方式有哪些

1. 原型链继承(Prototype Chain Inheritance)

function Animal() {
    this.species = '动物';
}

function Cat() {}

Cat.prototype = new Animal();

let cat = new Cat();

console.log(cat.species);  // '动物'

2. 构造函数继承(Constructor Inheritance)

在子类构造函数中通过 callapply 方法调用父类构造函数。这样,子类就会继承父类的属性。

function Animal() {
    this.species = '动物';
}

function Cat() {
    Animal.call(this);
}

let cat = new Cat();

console.log(cat.species);  // '动物'

但是,这种方法只能继承父类的实例属性和方法,不能继承原型属性和方法。

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

// 在原型上添加一个方法
Animal.prototype.speak = function() {
    console.log(this.name + ' 拆家 ');
}

function Cat(name) {
    Animal.call(this, name);
}


var cat = new Cat('Tom');

// 尝试调用继承自父类原型的方法
cat.speak(); // 抛出错误: cat.speak is not a function

尽管 speak 方法存在于Animal的原型上,但 Cat 并没有继承这个方法。

3. 组合继承(Combination Inheritance)

结合 原型链继承构造函数继承

利用原型链实现对原型属性和方法的继承,通过借用构造函数来实现对实例属性的继承。

这样,即可以实现函数复用,有可以保证每个实例都有它自己的属性。

function Animal() {
    this.species = '动物';
}

function Cat() {
	// 通过构造函数继承实例属性
    Animal.call(this);
}

// 通过原型链继承原型属性和方法
Cat.prototype = new Animal();

let cat = new Cat();

console.log(cat.species);  // '动物'
  • Animal.call(this);

    • Cat 构造函数内部调用了 Animal 构造函数,通过 call() 方法,使 Animal 内的 this 指向 Cat 的实例。这样就能在创建 Cat 实例时,将 Animal 的实例属性(这里是 species)复制到 Cat 实例上。
  • Cat.prototype = new Animal();

    • 使得 Cat 的原型指向 Animal 的一个实例,实现了 Cat 原型链对 Animal 原型链的继承。这样 Cat 的实例就可以访问 Animal 原型上的属性和方法了。

组合继承的不足是父类构造函数会被调用两次(一次是在子类构造函数内部,一次是在创建子类原型时),这可能会导致不必要的性能开销。

寄生组合式继承是对组合继承的改进,理解寄生组合式继承,需要从原型式继承开始。

4. 原型式继承(Prototypal Inheritance)

直接基于一个已存在的对象创建新对象,避免定义一个自定义的构造函数或类

这种继承的主要实现方法是 Object.create()

Object.create() 可以显式的指定原型,从而创建新对象

let animal = {
    species: '动物'
};

let cat = Object.create(animal);

console.log(cat.species);  // '动物'

与原型链继承的区别:主要是目标有所不同

  • 原型链继承主要用于实现真正的”类”继承

  • 而原型式继承主要用于实现对象的复制

原型式继承的主要缺点是对于引用类型的属性,因为所有实例共享相同的原型,所以它们也会共享相同的引用类型的属性。这就意味着如果你改变了一个实例上的某个属性,这个改变也会影响到所有其他的实例。

 let animal = {
    species: '动物',
    traits: ['吃', '拆家']
};

let cat = Object.create(animal);
let dog = Object.create(animal);

cat.traits.push('抓老鼠');
dog.traits.push('拿耗子');

console.log(cat.traits);  // ['吃', '拆家', '抓老鼠', '拿耗子']
console.log(dog.traits);  // ['吃', '拆家', '抓老鼠', '拿耗子']
cat.traits === dog.traits // true

在这个例子中,catdog 对象的 traits 属性指向的是同一块内存空间。

这意味着我们不能创建一组具有相同结构但是属性值不同的对象。如果你试图改变一个通过原型式继承创建的对象的属性,那么这个改变会影响所有的对象,这并不是我们在创建子类时所期望的行为。

所以,原型式继承更适合于不需要创建新的子类,而只是想创建一个已有对象的副本的情况。(原型式继承,其实本质是对对象进行浅拷贝)。

5. 寄生式继承(Parasitic Inheritance)

寄生式继承同样是以一个已有的对象作为新创建对象的原型。

但它会在此基础上添加或修改新对象的属性或者方法,以实现对继承来的属性和方法的重写。

对于”寄生”这个词,理解为继承在某种意义上”利用”了已有对象来提供新的对象。

// 定义一个动物对象
let animal = {
    species: '动物',
    traits: ['吃', '拆家'],
    getSpecies: function() {
        return this.species;
    }
};

// 定义一个函数,用于创建新的动物对象
function createAnimal(parent) {
    // 使用Object.create方法创建一个新的对象,这个新对象的原型是传入的parent对象
    let newAnimal = Object.create(parent);
    // 为新对象分配一个新的traits数组
    newAnimal.traits = [...parent.traits];
    // 在新对象上添加一个sayTraits方法,用于打印动物的特征
    newAnimal.sayTraits = function() {
        console.log("我可以" + this.traits.join(", "));
    };
    // 返回新创建的对象
    return newAnimal;
}

// 创建一个新的动物对象cat,它的原型是animal对象
let cat = createAnimal(animal);
// 添加新的特征'抓老鼠'
cat.traits.push('抓老鼠');
// 打印cat的特征
cat.sayTraits();  // '我可以吃, 拆家, 抓老鼠'

// 创建一个新的动物对象dog,它的原型是animal对象
let dog = createAnimal(animal);
// 添加新的特征'拿耗子'
dog.traits.push('拿耗子');
// 打印dog的特征
dog.sayTraits();  // '我可以吃, 拆家, 拿耗子'

在这个例子中,catdog 对象的 traits 属性是独立的,所以对一个对象的 traits 属性的修改不会影响其他对象的traits属性。

newAnimal.traits = [...parent.traits]展开语法(Spread syntax)

寄生式继承虽然解决了原型式继承中引用类型值共享的问题,但是它也有一些不足之处。

如在上面的例子中:

  • 每次调用 createAnimal 函数创建新的动物对象时,都会创建一个新的 sayTraits 函数。即使这个函数的功能对所有动物对象来说都是相同的,但是每个动物对象都有自己的 sayTraits 函数副本,这就造成了不必要的内存浪费。
cat.sayTraits === dog.sayTraits  // false
  • 由于每个动物对象都有自己的 sayTraits 函数副本,这就意味着这个函数无法被多个对象共享使用。如果我们可以将 sayTraits 函数定义在 animal 对象的原型上,那么所有的动物对象就都可以共享使用这个函数,而不需要为每个对象创建一个新的函数副本。

总结为以下:

  1. 效率问题:每次创建新对象时,都会为新对象创建一份方法的副本,这显然是没有必要的,因为这些方法可以共享使用。

  2. 无法复用:由于每次都需要为新对象创建方法,这导致无法实现函数复用,这不仅浪费内存,也增加了代码的复杂性。

  3. 原型链问题:寄生式继承并没有使用到原型链,因此无法利用原型链的特性,例如实例化对象无法指向原型对象,无法使用 instanceof isPrototypeOf 等方法。

因此,寄生式继承通常不会单独使用,而是和其他继承模式一起使用,例如寄生组合式继承,这种继承模式结合了寄生式继承和构造函数继承,既能有效地复用函数,又能避免引用类型值共享的问题。

6. 寄生组合式继承(Parasitic Combination Inheritance)

这是一种更有效的继承方式,通过借用构造函数来继承属性,通过将子类的原型设置为父类的一个实例来继承方法。

基本思路是:不必为了指定子类的原型而调用父类的构造函数,我们所需要的无非就是父类的一个副本而已。

本质上,寄生组合式继承就是继承父类原型(寄生式),然后再将结果指定给子类原型(组合式)。

// 定义一个动物对象
let animal = {
    species: '动物',
    traits: ['吃', '拆家'],
    getSpecies: function() {
        return this.species;
    },
    sayTraits: function() {
        console.log("我可以" + this.traits.join(", "));
    }
};

// 定义一个函数,用于创建新的动物对象
function createAnimal(parent) {
    // 使用Object.create方法创建一个新的对象,这个新对象的原型是传入的parent对象
    let newAnimal = Object.create(parent);
    // 为新对象分配一个新的traits数组
    newAnimal.traits = [...parent.traits];
    // 返回新创建的对象
    return newAnimal;
}

// 创建一个新的动物对象cat,它的原型是animal对象
let cat = createAnimal(animal);
// 添加新的特征'抓老鼠'
cat.traits.push('抓老鼠');
// 打印cat的特征
cat.sayTraits();  // '我可以吃, 拆家, 抓老鼠'

// 创建一个新的动物对象dog,它的原型是animal对象
let dog = createAnimal(animal);
// 添加新的特征'拿耗子'
dog.traits.push('拿耗子');
// 打印dog的特征
dog.sayTraits();  // '我可以吃, 拆家, 拿耗子'

在这个示例中,sayTraits 方法是定义在 animal 对象上的,因此所有通过 createAnimal 函数创建的新对象都可以共享使用这个方法。同时,每个新对象都有自己独立的 traits 属性,因此它们可以独立地添加和修改自己的特征。这就是寄生组合式继承的工作原理。

7. class关键字继承(Class Inheritance)

这是 ES6 引入的新的面向对象编程的语法糖,我们可以使用 classextends 关键字来实现继承。在子类中使用 super 关键字可以调用父类的方法和构造函数。

class 关键字内部还是使用的原型链实现的继承。class关键字只是让原型链继承更加清晰,更像是面向对象语言的继承。

class Animal {
    constructor() {
        this.species = '动物';
    }
}

class Cat extends Animal {
    constructor() {
        super();
    }
}

let cat = new Cat();
console.log(cat.species);  // '动物'

在使用 class 关键字进行继承时,有几点需要注意:

  1. 子类的构造函数中,super() 必须被调用,否则新建实例时会报错。
  2. super 关键字既可以当作函数使用,也可以当作对象使用。在子类的构造函数中,super 作为函数调用时,代表父类的构造函数。super 作为对象时,在普通方法中,指向父类的原型对象;在静态方法中,指向父类。
  3. super 并不是指向父类的实例,而是指向父类的原型。这意味着,如果你在父类的实例上添加了一些属性,然后试图通过super关键字在子类中访问这些属性,是无法访问到的,因为 super 只能访问到父类原型上的属性和方法。

使用到的API

Object.create()

具体的工作流程为:

  1. 检查参数类型,如果参数既不是 Object 也不是 Null,则那么抛出一个 TypeError 异常。
  2. 使用接收的参数作为原型,创建一个新的对象 newObj ,这个对象将继承指定原型的所有属性和方法
  3. 如果提供了第二个参数 Properties,并且它不是 undefined,那么将 Properties 的所有自身可枚举属性添加到新创建的对象 obj 上。且这些属性的属性描述符将被同样应用到新对象上。如果在添加属性的过程中出现错误(比如,试图添加一个不可配置的属性),那么会抛出一个异常,并且新对象不会被返回。
  4. 最后,返回新创建的对象 obj

关于第二个参数 Properties

Properties参数是一个可选的对象,它的自身可枚举属性将被添加到新创建的对象上。这里的”自身可枚举属性”指的是那些直接定义在Properties对象上(不是从原型链上继承来的),并且这些属性的enumerable属性描述符为true的属性。

这些属性不仅仅是值被复制过去,它们的属性描述符也会被一同复制。属性描述符是一个对象,它的键是属性名,值是另一个对象,这个对象包含了这个属性的一些特性,比如value(属性的值)、writable(属性是否可以被改写)、enumerable(属性是否可以被枚举)和configurable(属性是否可以被删除或修改)。

如果在添加属性的过程中出现错误(比如,试图添加一个不可配置的属性),那么会抛出一个异常,并且新对象不会被返回。这是因为Object.create()方法在添加属性时,会严格按照属性描述符的要求来添加,如果不能满足要求,就会抛出错误。

 var person = {
  name: 'John',
  age: 30
};

var descriptor = {
  name: {
    value: 'John',
    writable: true,
    enumerable: true,
    configurable: true
  },
  age: {
    value: 30,
    writable: true,
    enumerable: true,
    configurable: true
  }
};

var newPerson = Object.create(person, descriptor);

在这个例子中,newPerson 对象是以 person 对象为原型创建的,同时,newPerson 对象还添加了 nameage 两个属性,这两个属性的描述符分别是 descriptor 对象的 nameage 属性的值。

展开语法(Spread syntax)

扩展语法的工作原理:简单来说,它就是通过一个迭代器遍历 parent.traits 所有元素,然后将这些元素包装成一个新的数组(此处数组通过字面量的方式 [] 来创建)。

扩展语法在JavaScript引擎内部的工作原理是有些抽象和复杂的。在实际编程中,通常只需要知道扩展语法的基本用法就足够了。

区别与应用场景

组合继承与寄生组合式继承

在选择使用组合继承还是寄生组合式继承时,主要考虑的因素是对性能的需求。如果对性能要求不高,或者父类型构造函数的执行开销不大,那么可以选择使用更简单的组合继承。如果对性能有较高要求,或者父类型构造函数的执行开销较大,那么应该选择使用寄生组合式继承。

class 继承与寄生组合式继承

class继承是ES6引入的新特性,它提供了一种更接近传统面向对象语言的继承方式。class继承的语法更清晰、更简洁,更易于理解和使用。class继承在语法上更加严格,例如必须先调用super()才能使用this关键字。class继承适用于大型项目和需要大量使用继承的场景,以及对ES6语法有良好支持的环境。

大量使用继承的场景

  1. 复杂的业务逻辑:在一些复杂的业务逻辑中,可能存在多层级的对象关系。例如,一个电商网站可能有用户、商家、管理员等多种角色,这些角色都有一些共同的属性和方法(如登录、登出等),但也有各自特有的属性和方法。这时,我们可以定义一个通用的用户类,然后让商家类、管理员类等继承自用户类。

  2. UI组件库:在开发UI组件库时,通常会有一些基础组件,如按钮、输入框等。这些基础组件有一些共同的属性和方法,如显示、隐藏等。然后,我们可能会基于这些基础组件开发一些更复杂的组件,如日期选择器、富文本编辑器等。这时,我们可以定义一个基础组件类,然后让其他组件类继承自基础组件类。

  3. 游戏开发:在游戏开发中,通常会有很多种类的游戏对象,如角色、怪物、道具等。这些游戏对象有一些共同的属性和方法,如位置、移动等,但也有各自特有的属性和方法。这时,我们可以定义一个游戏对象类,然后让角色类、怪物类、道具类等继承自游戏对象类。

寄生组合式继承是一种在ES5及之前版本中实现继承的主要方式,它结合了原型链继承和构造函数继承的优点,避免了它们的缺点。寄生组合式继承的主要思想是:使用原型链实现对原型属性和方法的继承,通过借用构造函数实现对实例属性的继承,然后通过将子类的原型设置为父类的实例来实现继承。寄生组合式继承适用于ES5及之前的版本,以及需要更灵活、更细粒度控制的场景。

更灵活、更细粒度控制的场景

  1. 动态继承:在运行时决定一个对象的父对象。在JavaScript中,可以通过Object.create()方法动态地创建一个新对象,并指定其原型。这种方式在处理复杂的继承关系或者需要在运行时改变继承关系的场景中非常有用。

  2. 多重继承:一个对象需要继承多个父对象的属性和方法。虽然JavaScript不直接支持多重继承,但可以通过混入(mixin)的方式实现类似的效果。混入允许一个对象继承多个对象的行为,通过复制函数,而不是通过原型链继承。

  3. 选择性继承:一个对象只需要继承父对象的部分属性和方法。通过寄生组合式继承,可以在子类中选择性地调用父类的构造函数,从而只继承需要的属性和方法。

  4. 控制属性的可枚举性:在某些情况下,你可能希望控制某些属性是否出现在for...in循环或Object.keys()方法中。通过使用Object.defineProperty()Object.defineProperties(),你可以精确地控制属性的可枚举性、可写性和可配置性。

class 继承和寄生组合式继承各有优点,选择哪种方式主要取决于具体的项目需求和环境支持。

Footnotes

  1. 属性访问问题即原型链查找

  2. 使用字面量的方式来创建对象时( var obj = {} ),实际上并不会显式地触发或调用构造函数。这是因为对象字面量是一种直接定义对象的语法,而不是通过调用构造函数创建对象。然而,需要注意的是,虽然没有显式调用构造函数,但在底层,JavaScript引擎仍然使用了内建的 Object 构造函数来创建新的对象。然后,这个新的对象的原型会被自动设置为 Object.prototype 。这就是为什么使用对象字面量创建的对象可以访问 Object.prototype 上定义的方法(如 toStringhasOwnProperty)的原因。所以,虽然在语法层面上我们并没有显式地调用构造函数,但在底层,JavaScript引擎实际上还是使用了类似构造函数的机制来创建对象。

  3. 原型链的末端,即 Object.prototype 的原型,它的值为 null

  4. JavaScript 是一种基于原型的语言,而不是一种基于类的语言。在基于类的语言中,对象是由类实例化出来的,而类中的构造器方法负责初始化新实例。而在 JavaScript 中,并没有类的概念(在 ES6 中引入的 class 语法糖实际上是基于原型的实现),对象是通过特定的函数(也就是构造函数)与 new 关键字创建出来的。构造函数在 JavaScript 中就像是构造器的角色,它定义了对象的初始状态和行为。因此,尽管 “构造器” 这个词在 JavaScript 的语境中并不常见,但在 JavaScript中说 “构造器”,通常就是指代构造函数。

  5. constructor 属性是定义在原型对象(prototype)上的,而不是实例对象上。大多数通过构造函数或对象字面量创建的对象默认都会继承 constructor 属性。这是因为大多数对象都继承自 Object.prototype,而 Object.prototype 有一个名为 constructor 的属性,该属性指向了 Object 构造函数。但如果你创建的对象是通过特殊方式创建的,如 Object.create(null) 创建的对象,或者你手动修改了对象的原型链,那么这个对象可能就没有 constructor 属性,那么这时你需要显式地给新的 prototype 对象添加constructor属性。

原文链接:https://juejin.cn/post/7235915906886451257 作者:是一只小木头啊

(0)
上一篇 2023年5月23日 上午10:10
下一篇 2023年5月24日 上午10:00

相关推荐

发表评论

登录后才能评论