基础篇 | 原型链”继承” 和 class 的前世今生

305 阅读13分钟

上一篇文章中,简单介绍了 __proto__ 的基本内容。其大致的原理,是从对象的一个内部链接到另外的一个对象上。 这篇文章将要来继续介绍 JavaScript 中的原型链继承机制以及 ES6 提出的 class 关键字的原理。

TL;DR

  • __proto__ 就是存在于一个对象内部的引用其他对象的链接。
  • 沿着原型链来查找某个方法或者属性,更加精确的描述应该是委托访问。
  • JavaScript 没有类,只有对象。
  • JavaScript 没有传统意义上的“继承”,只有关联引用,或委托访问。
  • class 只是披着 prototype 的语法糖,本质还是基于原型链的委托访问。

前情提要

简单回顾一下前面提及的内容:

  1. 每个构造函数都有自己的原型对象 prototype
  2. 原型对象都有一个指向构造函数的指针 constructor
  3. 实例对象都包含着一个指向原型对象的内部指针 __proto___ or [[prototype]]
  4. __proto__ 的机制就是指对象中一个内部链接到另外一个对象。

有些内容之前文章中已经提过,这里可能篇幅原因就不再赘述了。建议没有看过的朋友可以先看看上一篇文章~ 传送门: 基础篇 | 再谈 prototype

说说 __proto__

我们知道对象内部的 __proto__ 指向了另外一个对象,这个对象里面的 __proto__ 又联结着另一个对象。这样一个一个串起来,就变成了我们大名鼎鼎的原型链。听起来还是挺绕的,怎么就串起来了?__proto__ 指向的对象里面又有啥?所以开篇,为了更加方便我们的理解,我们简单再用代码语言翻译一下 __proto__

Object.defineProperty(Object.prototype, "__proto__", {
  get: function () {
    return Object.getPrototypeOf(this)
  },
  set: function (obj) {
    // ES6 标准语法,设置对象原型链
    Object.setPrototypeOf(this, obj)
    return obj
  }
})

我们先简单记住,这个 __proto__ 其实可以理解为 getter/setter

  • 当我们访问某个对象的 __proto__ 时,它返回的是当前对象的原型对象;
  • 通常而言,我们并不需要修改已有对象的 __proto__

这里仅是展示原理所用,关于里面提到的语法后面会继续展开解释。

__proto__ 就是存在于一个对象内部的引用其他对象的链接。通常而言,这个关联的作用,可以在对象上查不到相关的属性或者方法的时候,使得查找的过程可以沿着 __proto__ 关联的对象逐层查找。直至到达了 root 对象依旧查无此属性或者方法的时候,才会抛出 undefined 。这种关联关系,实际上定义了一条“原型链”,在查找属性时,会对其进行遍历。

到这里,我们只需要记住 __proto__ 翻译成代码语言就是存在于一个对象内部的引用其他对象的链接即可。那它们又是怎么关联起来的呢?为了更好理解所谓的原型链继承,我们需要破除两个在 JavaScript 的误解最深的两个东西——类和继承。如果还被这两个 JavaScript 怪异的理解缠绕,我们就没法体会到 JavaScript 的 “继承” 到底是什么。

迷思·“类”

JavaScript 只有对象,没有类。在面向类的语言中,类是可以被实例化,又或者说是复制。每次实例化一个类,即代表我们把类的行为复制到另外一个物理对象中。而 JavaScript 则不然。

JavaScript 没有类的概念。所谓“类”的概念都是 JavaScript 利用了函数的特殊特性—— prototype 模仿出来的。JavaScript 中并没有类似于面向类语言中的复制机制。你无法创建类的多个实例,只能创建多个对象。它们的 __proto__ 指向了同一个 prototype 对象,因此,它们依旧是相互关联的。

迷思·继承

在 JavaScript 的语境下来谈继承,这可能是 JavaScript 中最令人误解的部分。 因为继承,意味着复制,但是 JavaScript 默认是不会进行复制的,只会在两个对象之间创建联系。因为,JavaScript 中只有对象。各种各样模仿类的行为,其实都是利用了 JavaScript 中函数的特殊特性:所有函数都拥有一个名为 prototype 的不可枚举的公有属性,而它,指向了另一个对象。这个对象通常被我们称之为原型对象,我们可以通过 func.prototype 来访问这个原型对象。

从上一篇文章中,我们也可以看到:let person = new Parent() ,并不意味着 person 是从 Parent 处复制而来的一个实例;相反,它们之间甚至没有直接关联。 person 是通过 person.__proto__ = Parent.prototype,是和其构造函数的原型对象创建了关联,间接和构造函数产生关联。

一个比继承更加精确描述 JavaScript 中的对象关联机制的名词为委托。JavaScript 在两个对象之间创建链接,使得一个对象可以通过委托访问的方式去访问另一个对象的属性和函数。正如我们在文章开头提到的沿着原型链来查找某个方法或者属性,更加精确的描述应该是委托访问。

如果还是有点迷糊,那我们重新回顾一下 new 操作符的内部实现原理:

function myNew(func, ...args) {

    // 1. 在内存中创建一个新对象
    const obj = Object.create()
		
    // 2. 在这个新对象内部的 [[ prototype ]] 特性被赋值为构造函数的 prototype 属性
    obj.__proto__ = func.prototype
		
    // 3. 构造函数内部的 this 被赋值为这个新对象
    // 执行构造函数内部代码
    let result = func.apply(obj, args)
    
    // 4. 如果构造函数返回一个非空对象,则返回该对象,都则则返回刚刚创建的新对象
    return result instanceof Object ? result : obj
}

当我们使用 new 操作符,从类实例化一个实例对象,创建的每个对象都会被 __proto__ 链接到对应的 prototype 对象上,使得我们获得一个关联到其他对象的新对象。本质上还是一种对象的关联关系

而我们常常在 JavaScript 中使用这类语言作为描述,其实是因为它们长得像,却不是真的是。只是为了方便我们日常沟通,但却让很多人深陷迷思。讲了这么多,终于到了本文标题的核心了——原型链继承。

原型链“继承”

前面我们已经将 JavaScript 中令人迷思的继承和类讲了一遍,请再次记住:

  • JavaScript 没有类,只有对象。
  • JavaScript 没有传统意义上的“继承”,只有关联引用,或委托访问。

到了这里,我们已经知道,JavaScript 中的类,其实是抱着一层皮的 __proto__ 对象关联。在 ES6 中提出的 类(Class) 可以理解为也是 prototype 的语法糖。但在 JavaScript 中,使用“类”、“构造函数”、“实例” 这些面向类语言的术语,实则会影响大家对 JavaScript 真实机制原理的理解。我们更加精准的描述应该是 “委托访问”,或者“关联”

下图为 YDKJS 里面的一张图,展示 Foo.prototypeBar.prototype 之间的委托关系,以及对象实例 a1Foo.prototype 之间的委托关系。我们可以看到前者看起来和类继承非常相似,区别在于它们之间是 Bar.prototype 指向了 Foo.prototype,这说明了他们之间是委托(关联)的关系,而非是复制关系。

image.png

function Foo(name) {
  this.name = name
}

Foo.prototype.myName = function () {
  return this.name
}

function Bar(name, age) {
  Foo.call(this, name)
  this.age = age
}

// 将 Bar.prototype 重写,并关联到 Foo.prototype 真正使二者产生关联
Bar.prototype = Object.create(Foo.prototype)

Bar.prototype.myAge = function () {
  return this.age
}

var bar = new Bar('kk', 21)
console.log(bar.myAge())
console.log(bar.myName())

那如何可以实现上述关联呢? 有两个大胆的推测。

  • Bar.prototype = Foo.prototype

    这和我们想要达到的委托关联的机制完全不同,这相当于直接重写了 Bar.prototype ,将其修改为 Foo.prototype 的引用。这样子,当我们为 Bar.prototype 新增方法时,也会同步影响到 Foo.prototype 。而这和我们想要达成的目标不一致。

  • Bar.prototype = new Foo() ?

    可以基本满足我们想要的关联,但是可能带来意外的副作用。当我们调用 Bar.prototype = new Foo() 时,确实内部会创建一个关联到 Foo.prototype 的新对象。但是,在 new 操作中,还有一个语句是 Foo.apply(obj, args) 。万一 Foo 方法上存在一些给 this 添加属性,或者其他一些可能会带有副作用的方法时,就会影响到所有关联到 Bar 的对象。而对于 Bar 的“后代”而言,他需要了解到 Foo 的相关实现,否则就是不可预期的副作用了。

我们既想创建 Bar.prototypeFoo.prototype 之间的关联,却又不想 Foo 函数去影响 Bar 的实现,有没有什么方式只关联而不做其他的内容呢?

这就不得不提到 Object.create() 方法。调用 Object.create() 会创建一个新的对象,并把新对象里面的 __proto__ 指向对应关联的对象。 在下面的代码中,使 Bar.prototype 指向了 Foo.prototype 最核心的代码就是 Bar.prototype = Object.create(Foo.prototype) 。这样做的唯一缺点就是 Object.create() 会创建一个新的对象,去重写 Bar.prototype 。原有的 Bar.prototype 则会因为被重写而丢弃,被 GC。那进一步想想,能不能不要创建新的,直接修改原来的呢

在 ES6 中,添加了一个新的辅助方法 Object.setPrototypeOf(...) ,这是一个标准且可靠地修改对象中的 __proto__ 关联方法。现在我们就可以通过这种标准语法来进行修改:

// ES6 之前
Bar.prototype = Object.create(Foo.prototype)

// ES6 之后
Object.setPrototypeOf(Bar.prototype, Foo.prototype)

虽然 ES6 提供了一个标准的辅助函数,但是我们都应该了解到现代 JavaScript 引擎对于属性访问查找的优化,通过 Object.setPrototypeOf 修改对象内的 __proto__ 都会带来严重的性能问题。因为这是 JavaScript 原型特性的问题,这不仅仅会影响到所有涉及访问任何 __proto__ 的代码,有兴趣可以参阅这篇文章进一步了解一下: JavaScript engine fundamentals: optimizing prototypes

总而言之,在我们 JavaScript 引擎开发人员优化这一语言特性带来的性能之前,最好的方式还是使用 Object.create() 来创建包含我们想要的 __proto__ 关联的 prototype 对象。

至此,我们已经回答了本文开头的问题,__proto__ 之间是怎么串起来的。简而言之,__proto__ 的机制就是一个对象内部链接的引用另外一个对象。JavaScript 机制的本质就是对象之间的实时委托关系

ES6 Class 语法糖

从我们之前的内容,我们应该可以看出来,JavaScript 中的 “类” 实则是一种怪异的模仿行为,通过 prototype 来模拟类行为。JavaScript 中没有传统面向类语言中的类概念,在传统面向类语言中,父类、子类、实例之间是一个复制关系,而 prototype 只是关联引用的关系。说了这么多,可能大家对这个语法糖还是停留在“它是 prototype 的语法糖”,这种数学公式式的了解。 ES6 的 class 又是什么呢?它解决了什么问题呢?

Class 的前世今生

要回答这几个问题,我们先看看下面这几段代码,可能会有一个更加直观的感受。如题,我们的目标是创建两个动物,cckk,那我们又可以通过什么方式来完成创建这两个动物的工作的呢?

  • 对象风格,最最最基础的方式

    let animal = {}
    animal.name = 'kk'
    
    animal.eat = function (amount) {
      console.log(`${this.name} is eating.`)
    }
    
    let animal2 = {}
    animal2.name = 'cc'
    
    animal2.eat = function (amount) {
      console.log(`${this.name} is eating.`)
    }
    

    从上面的代码我们看到,我们创建了 cckk 两个动物对象。但是我们可以看到两个对象的创建方式几乎一模一样,但是因为他们是分别的两个对象。所以每次创建的时候,我们都要重新写一遍。既然他们的创建过程一模一样,那么我们就可以通过逻辑的抽取,将重复的部分封装成一个函数

  • 函数风格

    function Animal(name) {
      let animal = {}
      animal.name = name
      animal.eat = function () {
         console.log(`${this.name} is eating.`)
       }
      return animal
    }
    
    let kk = Animal('kk')
    let cc = Animal('cc')
    
    kk.eat()
    

    我们通过将重复的代码逻辑进行提取,封装到一个函数里面。通过这种方式,我们创建两个 animal 对象只需要分别调用函数即可,不同的部分进需要通过参数的方式传入,就可以获得两个名字不同的 Animal 对象。

    但是我们也可以看到,其实 animal.eat 的部分,每次 eat 方法都要重新声明、创建。那既然这个方法内部的逻辑是一样,所以我们可以进一步将其抽出,这样就不用在内存中反复创建相同函数方法。

  • 基于函数风格改造(原型风格的初步原理)

    function Animal(name) {
      // 关联
      let animal = Object.create(AnimalMethos)
      animal.name = name
    
      return animal
    }
    
    const AnimalMethos = {
      eat() {
        console.log(`${this.name} is eating.`)
      }
    }
    
    let kk = Animal('kk')
    let cc = Animal('cc')
    
    kk.eat()
    cc.eat()
    

    这里我们可以看到,我们将 eat 方法,单独放到另外一个对象中,作为 AnimalMethods 中的一个方法。我们在创建 Animal 的时候,进需要将新创建的 Animal 对象和 AnimalMethods 进行关联。这样我们就可以只需要创建一次方法,不用在内存中反复创建相同代码。

    同时,对象在执行方法的时候,当当前对象查不到这个名字的方法时,就会沿着 __proto__ 寻找。在这个 case 中,它会找到 AnimalMethods 中的 eat 方法。同时,由于 eat 中的调用通过 this 根据咱们之前提到的 this 的绑定四个原则。所以,能够获得和咱们之前预期的方法的效果。

    这种方法看起来是不是已经非常接近在前文提到的原型链“继承”的方式——对象关联。其实这也是原型写法的一个基本原理。只是把 AnimalMethods 换成了 prototype

  • 原型风格

    function Animal(name) {
      let animal = Object.create(Animal.prototype)
      animal.name = name
    
      return animal
    }
    
    Animal.prototype.eat = function () {
      console.log(`${this.name} is eating.`)
    }
    
    const kk = Animal('kk')
    const cc = Animal('cc')
    
    kk.eat()
    cc.eat()
    

    我们将上一个例子中的 AnimalMethods 换成了函数特有的属性 prototype 之后,是不是看起来就是我们熟悉的原型继承的写法。还是熟悉的对象关联,还是熟悉的 prototype

    插播一下,我们回想一下反复提到多次的 new 操作符构建基本步骤:

    1. 创建对象;
    2. 通过 Object.create 将对象的 __proto__ 和函数的原型关联起来;
    3. 构造对象里的方法;
    4. 返回对象。

    我们和上文的代码一一对应一下,这不就是咱们的 new 操作符的基本动作么?ok 咱们继续优化一下。

  • 继续优化原型风格

    function Animal(name) {
      this.name = name 
    }
    
    Animal.prototype.eat = function () {
      console.log(`${this.name} is eating.`)
    }
    
    const kk = new Animal('kk')
    const cc = new Animal('cc')
    
    kk.eat()
    cc.eat()
    

    new 操作符会自动执行创建-关联-返回的动作,所以我们只需要将原来函数里面新生成的对象改为 this 即可。改完之后,看看这段代码。

    使用 prototype 使得我们可以在所有实例间去共享这些方法,而无需共享的部分,我们放在了构建函数之中,这样每次构建出来的对象都是独立的,而需要共享使用的方法则通过 prototype 实现了实例之间的共享。

    在 ES6 之后,出现了 class 的语法糖,我们可以通过 class 来重构我们上述的代码。

  • 类风格的代码

    class Animal {
      constructor(name) {
        this.name = name
      }
      eat(amount) {
        console.log(`${this.name} is eating.`)
      }
    }
    
    const kk = new Animal('kk')
    const cc = new Animal('cc')
    
    kk.eat()
    cc.eat()
    

    上述代码中, constructor 的作用,我们基本上可以等同于创建了一个对象,这个部分可以近似等同于上一个例子中的 function Animal() 的作用。定义在 Animal Class 上的方法,可以理解为是绑定在 Animal.prototype 上面的方法。

    简而言之, class 这个关键字,可以让我们通过 new 进行构造调用的函数,同时在函数的原型上增加一些供“实例”共享的方法。

至此,我们应该对“ class 只是 prototype 的语法糖”这句话有了更加直观的了解。

ES6 class 还带来了什么?

看到这段 class 重构后的代码,对于一些熟悉面向类语言的朋友,应该觉得非常熟悉和舒适,这是我们熟悉的编写方式。借助 ES6 提出的其他一些语法糖,我们还可以在 class 的基础上:

  • 通过 extends 去声明”父子类”之间的“继承”关系,无需再去显式写 Object.create() 替换原本的 prototype 对象,或者显式设置 Object.setPrototypeOf()
  • 通过 super 显式调用”父类”的方法,实现相对多态。这个是原本 prototype 无法完成的内容,因为一旦原型链上出现同名方法,原型链上的方法将会被遮蔽。而 super 却可以实现调用上层的代码。
  • 通过 extends 自然拓展对象,或者子类的对象。在没有 class 之前,我们很难去做到这一点,现在我们都可以做到了。
  • 同时,由于 class 的出现,我们也几乎不用再去写 XX.prototype.methods = … 这种令人头疼的语法了。

Class 陷阱

坦白讲,class 的出现,使得我们的代码变得简洁。但是我们也要留意到其中有一些隐藏的风险。class 无法定义类成员属性,只能定义方法。如果需要共享实例之间的状态,你只能继续使用 prototype 的方式,而这又会导致我们又回到了 prototype 的世界,这和 class 的初衷相悖。

class Animal {
  constructor(name) {
    this.name = name
    // 确保需要修改共享状态,而非在实例上面创建遮蔽属性
    Animal.prototype.count++
  }
  showTotal() {
    console.log(`${Animal.prototype.count} is eating.`)
  }
}

Animal.prototype.count = 0
let kk = new Animal('kk')
kk.showTotal()
let cc = new Animal('cc')
cc.showTotal()
kk.showTotal()

class 的出现可能加深了我们对 JavaScript 中的 “类” 的误解,屏蔽了一些原本 JavaScript 更加本质的语言特性。所以,在使用 class 的时候,我们都必须深刻了解到,这个 class 仅仅只是 prototype 方法的一种语法糖。

总结

本文又继续延展 prototype 的部分,把 JavaScript 令人误解的 class 和继承的机制简单梳理了一遍。此外又通过 class 的前世今生,将 class 的基本原理,和为什么将其称之为 prototype 的语法糖的原因也讲清楚了。通过这两篇文章,应该可以解释清楚上一篇文章中开头的那个图片里面弯弯绕绕的引用链了,希望对你有帮助。

因为是顺着上一篇文章继续,如果部分名词仍然有点迷糊,可以先看看 基础篇 | 再谈 prototype ,欢迎留言讨论。

Reference

  1. YDKJS chap4-6 + 附录A
  2. Class 的前世今生 这个油管博主讲得不错。
  3. JavaScript常用八种继承方案 ,作为参考阅读,本文无提及的部分