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

438 阅读29分钟

“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:为其他对象提供共享属性的对象

  • 只有 Function 对象(Function Object)才有 prototype 属性1

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

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

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

  function MyConstructor() {}

  let myObject = new MyConstructor();

  console.log(myObject.constructor.prototype); // 输出:MyConstructor {}

隐式原型([[Prototype]],__proto__)

[[Prototype]]

所有普通对象3都有一个名为 [[Prototype]] 的属性,它指向该对象的原型。

  • [[Prototype]] 的值要么是 null 要么是一个对象

  • [[Prototype]] 的默认值,是创建这个对象的构造函数的 prototype 属性

  • [[Prototype]] 是一个访问器属性(accessor property)

__proto__

__proto__ 属性最早是由 JavaScript 的创造者 Brendan Eich 在 JavaScript 的早期版本中引入的,用于访问对象的内部 [[Prototype]] 属性。后来,这个属性被 Mozilla 的 SpiderMonkey JavaScript 引擎实现,并且在 Firefox 浏览器中得到了广泛的使用。

尽管 __proto__ 属性在 ECMAScript 2015(也称为 ES6)中被正式纳入了标准,作为浏览器必须支持的一部分。但是由于它的性能问题和可能导致的安全问题,更推荐使用 Object.getPrototypeOf()Object.setPrototypeOf() 方法来获取和设置对象的原型

"隐式引用"(Implicitly References)与 "显式引用"(Explicitly References)

在编程中,"隐式引用"(Implicitly References)和"显式引用"(Explicitly References)都是常见的方式

  • "隐式引用"是指在代码中不直接指明引用的对象,而是通过上下文或者某种规则来确定引用的对象。例如,在JavaScript中,当你在一个对象的方法中使用this关键字时,this就是对当前对象的隐式引用。

  • "显式引用"则是在代码中明确指出引用的对象。例如,如果你有一个变量myObject,并且你在代码中写myObject.someMethod(),那么myObject就是对对象的显式引用。

隐式引用的优点是可以使代码更简洁,更易读。在某些情况下,隐式引用也可以帮助我们避免命名冲突。然而,隐式引用的缺点是可能会导致代码的可读性和可维护性降低,因为读者需要理解更多的上下文信息才能明白隐式引用的真正含义。

显式引用的优点是清晰明了,易于理解,不需要理解额外的上下文信息。然而,显式引用可能会使代码变得更冗长,特别是在需要频繁引用同一个对象的情况下。

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

1. 对象(object)

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

  • 对象是属性的集合

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

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

2. Object 与 Function Objects(函数对象)

对象在 JavaScript 中具有多种含义

在 JavaScript 中,数据类型分为两种:基本数据类型(Primitive types)和对象类型(Object type)

  • 基本数据类型包括:Undefined、Null、Boolean、Number、String、Symbol(ES6 新增)和 BigInt(ES10 新增)。这些类型的特点是它们的值不可变。
  • 对象类型则包括:Object、Array、Function、Date、RegExp、Error 等。对象类型的特点是它们的值是可变的,并且它们都有自己的属性和方法,可以进行操作。

所以当我们说 JavaScript 中万物皆对象时,是指几乎所有的东西(除了基本数据类型的值)都可以被视为对象。

理解对象类型中 Object 与 Funtion 的关系对于理解原型链是至关重要的。

Object 与 Function 都是函数对象(Function Object)

 typeof Object // function
 typeof Function // function

并且

 Object instanceof Function // true

为什么?

jsobj_full.jpg

由这张图的起点(Object.prototype)我们可以知道

  1. Object.prototype 首先在 JavaScript 引擎内部被创建
  2. 基于 Object.prototype 创建了 Function.prototype
  3. 通过 Function.prototype 这个原型对象创建了 Function
  4. 最后又通过 Function 创建了 Object

1. 为什么要这么创建?

Object.prototype 为所有的对象和函数提供了共享的属性和方法。这种机制可以节省内存,因为不需要在每个对象或函数上都复制一遍这些属性和方法,只需要在原型上定义一次,然后所有的对象和函数都可以通过原型链访问到它们。

这就是继承目的的体现,为了代码复用和组织

2. 为什么用函数对象(Function Object)来定义 Object 与 Function

在JavaScript中,函数是一等公民(或一等对象)。

例如 Function,它既是一个函数(可以被调用),又是一个对象(可以有自己的属性和方法)。这就是 JavaScript 中函数是一等公民的体现,函数既可以像其他对象一样被操作,又可以被执行。

Function 作为一个函数,它有自己的 prototype 属性,这个属性指向了所有由 Function 构造函数创建的函数对象的原型。这就是为什么我们可以在所有的 JavaScript 函数上调用 Function.prototype 上定义的方法(如 callapply),因为这些方法都被继承了下来。

Function.prototype 的原型又指向 Object.prototype。这就形成了一个原型链,使得 Function 对象可以访问 Object.prototype 上定义的方法(如 toStringhasOwnProperty)。

一切还是为了继承。

3. 构造器(constructor) & 构造函数(constructor function)

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

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

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

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

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

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

  // 构造函数的原型上的 constructor,指回函数本身
  function Foo() {
   //...
 }

  console.log(Foo.prototype.constructor === Foo); // true
  // 被创建的对象上的 constructor,指向了创建该对象的构造函数或者类
  function Foo() {}

  let myObject = new Foo();

  console.log(myObject.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属性。

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

如在上面的例子中:

  • 虽然使用了 Object.create(parent) 来创建新对象,这个新对象的原型是 parent 对象,这看起来像是使用了原型链机制,但是这个新对象并没有利用到原型链上的任何属性或方法。新对象的所有属性和方法都是在 createAnimal 函数中直接添加的,没有通过原型链来继承。
cat.sayTraits === dog.sayTraits  // false
  • 由于每个动物对象都有自己的 sayTraits 函数副本,这就意味着这个函数无法被多个对象共享使用。

总结为以下:

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

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

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

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

但寄生式继承也有其独特的应用场景:在只有一个已有对象的场景中,无法获取或者更改这个对象的构造方法的时候,寄生式继承就变成了不二之选。因为这时要使用寄生组合式继承是非常复杂的,也可能会引入一些不必要的问题。

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

寄生组合式继承是 JavaScript 中最推荐的继承模式。

寄生组合式继承的第一个重要思想:保持原型链的完整

  • 所有的子对象都会共享父对象原型上的方法,不需要进行复制

寄生组合式继承的第二个重要思想:保持正确的构造函数

  • 子类型的实例的 constructor 属性正确地指向子类型的构造函数
// 定义父类构造函数
function Animal(species) {
    this.species = species;
}

// 在父类原型上添加方法
Animal.prototype.getSpecies = function() {
    return this.species;
};

// 定义子类构造函数
function Cat(name) {
    // 调用父类构造函数,实现属性继承
    Animal.call(this, '猫');
    // 子类特有的属性
    this.name = name;
}

// 创建父类原型的副本作为子类原型,实现方法继承
Cat.prototype = Object.create(Animal.prototype);

// 修复constructor属性,因为上一步的操作会将其指向Animal
Cat.prototype.constructor = Cat;

// 在子类原型上添加子类特有的方法
Cat.prototype.sayName = function() {
    console.log(this.name);
};

// 创建子类实例
let cat = new Cat('Tom');

// 测试
console.log(cat.getSpecies());  // '猫'
console.log(cat.sayName());  // 'Tom'

这段代码展示了寄生组合式继承的主要思想:通过构造函数继承属性,通过原型链继承方法。

  • 继承父类原型上的方法,而不需要创建父类的实例

  • 调用父类的构造函数,继承父类的属性

  1. 寄生:在寄生组合式继承中,"寄生"指的是创建一个父类原型的副本,并将其作为子类的原型。这是通过 Object.create(Animal.prototype) 实现的。这个步骤是寄生的,因为我们创建了一个新的对象(子类的原型),这个对象的原型是父类的原型,这就像一个寄生生物依赖于宿主生物一样,新的对象依赖于父类的原型。
// 创建父类原型的副本作为子类原型,实现方法继承
// 这是寄生组合式继承的关键步骤,也是“寄生”的部分
Cat.prototype = Object.create(Animal.prototype);
  • 这种方式避免了直接将 Cat.prototype 设置为 Animal 的实例,从而避免了调用 Animal 构造函数和创建不必要的 Animal 实例属性。
  1. 组合:在寄生组合式继承中,"组合"指的是将父类的实例属性和原型属性同时继承给子类。这是通过在子类构造函数中调用父类构造函数(实现实例属性继承),以及将子类的原型设置为父类原型的副本(实现原型属性继承)来实现的。
// 调用父类构造函数,实现属性继承
Animal.call(this, '猫', traits);

// 创建父类原型的副本作为子类原型,实现方法继承
Cat.prototype = Object.create(Animal.prototype);
  • 在子类构造函数中,我们通过使用 call 方法调用父类构造函数,将父类的属性复制到子类实例中。这样,每个子类实例都会有一份父类属性的副本,可以独立修改,不会影响其他实例或父类原型。这是“组合式”的一部分。
  • 我们通过 Object.create 方法创建了一个新的对象,这个对象的原型是父类的原型,然后将这个新对象赋值给子类的原型。这样,子类就可以继承父类原型上的方法。这是 “寄生式” 的一部分,也是 “组合式” 的一部分。

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)

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

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

call()

简单来说,call 方法就是用来改变函数的 this 指向,并立即执行这个函数的。

这个方法的参数是 thisArg...argsthisArg 是你希望函数在运行时将 this 指向的对象,...args 是你希望传递给函数的参数列表。

call 方法被调用时,它会执行以下步骤:

  1. func 成为 this 值。这里的 this 是调用 call 的函数。
  2. 如果 func 不是一个可调用的函数,那么就抛出一个 TypeError 异常。
  3. 返回 Call(func, thisArg, args) 的结果。Call 是一个内部操作,它会调用 func,并将 thisArgargs 作为参数传递给 func

在 JavaScript 的面向对象编程中,call 方法常常被用于实现继承。例如,子类构造函数可以通过 call 方法调用父类构造函数,从而继承父类的属性。

内部操作:在 ECMAScript(JavaScript)规范中,"内部操作"(internal operation)是一种抽象的概念,用于描述 JavaScript 引擎内部的行为和操作。这些操作并不直接暴露给 JavaScript 代码,而是由 JavaScript 引擎在执行代码时自动进行的。它们帮助我们理解 JavaScript 的工作原理,但我们无法直接在 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 继承和寄生组合式继承各有优点,选择哪种方式主要取决于具体的项目需求和环境支持。

参考文献:

ECMAScript 2015 Language Specification – ECMA-262 6th Edition

ECMAScript® 2024 Language Specification

Javascript Object Hierarchy

Footnotes

  1. 这是因为只有函数才有可能被用作构造函数来创建新的对象(成为构造函数的前提是使用 new 关键字来创建一个新的对象),而 prototype 属性中保存着所有对象实例都应该共享的属性和方法,当我们使用 new 关键字和一个函数(构造函数)来创建一个新的对象时,这个新对象的内部属性 [[Prototype]] 会被链接到构造函数的 prototype 对象。这就是 JavaScript 实现继承和属性共享的方式。如果一个对象不能作为构造函数来使用(即不能用来创建新的对象),那么给它一个 prototype 属性就没有意义,因为没有其他对象会把它的 [[Prototype]] 链接到这个对象的 prototype 对象。从设计思想的角度来说,prototype 属性是为了支持 JavaScript 的原型继承和构造函数模式而存在的,所以只在有继承需求的场景中才有意义。

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

  3. 非普通对象:null

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

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

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

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