JavaScript 原型体系的变迁,以及 ES6 class

概述

JavaScript 的原型体系是最初就有的言语设想。但随着 ES 范例的进化和新特征的增加。它也一直在不断进化。这篇文章的目标就是梳理一下初期到 ES5 和如今 ES6,新特征的到场对原型体系的影响。

假如你对原型的明白还停留在 function + new 这个层面而不知道更深切的操纵原型链的技能,或许你想相识 ES6 class 的学问,置信本文会有所协助。

这篇文章是我进修 You Don’t Know JS 的副产品,引荐任何想体系性地进修 JavaScript 的人去浏览此书。

JavaScript 原型简述

许多人应当都对原型(prototype)不生疏。简朴地说,JavaScript 是基于原型的言语。当我们挪用一个对象的属性时,假如对象没有该属性,JavaScript 诠释器就会从对象的原型对象上去找该属性,假如原型上也没有该属性,那就去找原型的原型。这类属性查找的体式格局被称为原型链(prototype chain)。

对象的原型是没有公然的属性名去接见的(下文再谈 __proto__ 属性)。以下为了轻易称谓,我把一个对象内部对原型的援用称为 [[Prototype]]。

JavaScript 没有类的观点,原型链的设定就是少数能够让多个对象同享属性和要领,以至模仿继续的体式格局。在 ES5 之前,假如我们想设置对象的 [[Prototype]],只能经由历程 new 症结字,比方:

function User() {
  this._name = 'David'
}

User.prototype.getName = function() {
  return this._name
}

var user = new User()
user.getName()                  // "David"
user.hasOwnProperty('getName')  // false

User 函数被 new 症结字挪用时,它就相似于一个构造函数,其天生的对象的 [[Prototype]] 会援用 User.prototype 。由于 User.prototype 也是一个对象,它的 [[Prototype]] 是 Object.prototype

平常我们对这类构造函数定名都邑采纳 CamelCase ,并把它称谓为“类”,这不仅是为了跟 OOP 的理念保持一致,也是由于 JavaScript 的内建“类”也是这类定名。

SomeClass 天生的对象,其 [[Prototype]] 是 SomeClass.prototype。除了稍显烦琐,这套逻辑是能够自作掩饰的,比方:

  1. 我们用 {..} 竖立的对象的 [[Prototype]] 都是 Object.prototype,也是原型链的极点。

  2. 数组的 [[Prototype]] 是 Array.prototype

  3. 字符串的 [[Prototype]] 是 String.prototype

  4. Array.prototypeString.prototype 的 [[Prototype]] 是 Object.prototype

模仿继续

模仿继续是自定义原型链的典范运用场景。但假如用 new 的体式格局则比较贫苦。一种罕见的解法是:子类的 prototype 即是父类的实例。这就触及到定义子类的时刻挪用父类的构造函数。为了防止父类的构造函数在类定义历程当中的潜伏影响,我们平常会制作一个暂时类去做代替父类 new 的历程。

function Parent() {}
function Child() {}

function createSubProto(proto) {
  // fn 在这里就是暂时类
  var fn = function() {}
  fn.prototype = proto
  return new fn()
}

Child.prototype = createSubProto(Parent.prototype)
Child.prototype.constructor = Child

var child = new Child()
child instanceof Child   // true
child instanceof Parent  // true

ES5: 自在地操控原型链

既然原型链实质上只是竖立对象之间的关联,那我们可不能够直接操纵对象的 [[Prototype]] 呢?

在 ES5(准确的说是 5.1)之前,我们没有办法直接猎取对象的原型,只能经由历程 [[Prototype]] 的 constructor

var user = new User()
user.constructor.prototype          // User
user.hasOwnProperty('constructor')  // false

类能够经由历程 prototype 属性猎取天生的对象的 [[Prototype]]。[[Prototype]] 里的 constructor 属性又会反过来援用函数自身。由于 user 的原型是 User.prototype ,它天然也能够经由历程 constructor 猎取到 User 函数,进而猎取到本身的 [[Prototype]]。比较绕是吧?

ES5.1 以后加了几个新的 API 协助我们操纵对象的 [[Prototype]],自此以后 JavaScript 才真的有自在操控原型的才。它们是:

  • Object.prototype.isPrototypeOf

  • Object.create

  • Object.getPrototypeOf

  • Object.setPrototypeOf

注:以上要领并不完全是 ES5.1 的,isPrototypeOf 是 ES3 就有的,setPrototypeOf 是 ES6 才有的。但它们的范例都在 ES6 中修正了一部分。

下面的例子里,Object.create 竖立 child 对象,并把 [[Prototype]] 设置为 parent 对象。Object.getPrototypeOf 能够直接猎取对象的 [[Prototype]]。isPrototypeOf 能够推断一个对象是不是在另一个对象的原型链上。

var parent = {
  _name: 'David',
  getName: function() { return this._name },
}

var child = Object.create(parent)

Object.getPrototypeOf(child)           // parent
parent.isPrototypeOf(child)            // true
Object.prototype.isPrototypeOf(child)  // true
child instanceof Object                // true

既然有 Object.getPrototypeOf,天然也有 Object.setPrototypeOf 。这个函数能够修正任何对象的 [[Prototype]] ,包含内建范例。

var anotherParent = {
  name: 'Alex'
}

Object.setPrototypeOf(child, anotherParent)
Object.getPrototypeOf(child)  // anotherParent

// 修正数组的 [[Prototype]]
var a = []
Object.setPrototypeOf(a, anotherParent)
a instanceof Array        // false
Object.getPrototypeOf(a)  // anotherParent

天真运用以上的几个要领,我们能够异常轻松地竖立原型链,或许在已知原型链中插进去自定义的对象,弄法只取决于想象力。我们以此修正一下上面的模仿继续的例子:

function Parent() {}
function Child() {}

Child.prototype = Object.create(Parent.prototype)
Child.prototype.constructor = Child

由于 Object.create(..) 传入的参数会作为 [[Prototype]] ,所以这里有一个有意义的小技能。我们能够用 Object.create(null) 竖立一个没有任何属性的对象。这个技能适合做 proxy 对象,有点相似 Ruby 中的 BasicObject

为难的私生子 __proto__

说到操纵 [[Prototype]] 就不得不提 __proto__ 。这个属性是一个 getter/setter ,能够用来猎取和设置恣意对象的 [[Prototype]] 。

child.__proto__           // equal to Object.getPrototypeOf(child)
child.__proto__ = parent  // equal to Object.setPrototypeOf(child, parent)

它原本不是 ES 的范例,没法浩瀚浏览器早早地都完成了这个属性,而且应用得还挺普遍的。到了 ES6 为了向下兼容性只好回收它成为范例的一部分。这是典范的实际倒逼范例的例子。

看看 MDN 的形貌都充满了怨念。

The use of proto is controversial, and has been discouraged. It was never originally included in the EcmaScript language spec, but modern browsers decided to implement it anyway. Only recently, the proto property has been standardized in the ECMAScript 6 language specification for web browsers to ensure compatibility, so will be supported into the future. It is deprecated in favor of Object.getPrototypeOf/Reflect.getPrototypeOf and Object.setPrototypeOf/Reflect.setPrototypeOf (though still, setting the [[Prototype]] of an object is a slow operation that should be avoided if performance is a concern).

__proto__ 是不被引荐的用法。大部分状况下我们依然应当用 Object.getPrototypeOfObject.setPrototypeOf 。什么是少数状况,待会再讲。

ES6: class 语法糖

不得不说开发者天下受 OO 的影响异常之深,虽然 ES5 给了我们充足天真的 API ,然则:

  • 许多人照样倾向于用 class 来构造代码。

  • 许多类库、框架制造了本身的 API 来完成 class 的功用。

发生这一征象的缘由有许多,但事实如此。而且假如用他人的轮子,有些事是我们没法挑选的。也许是看到了这一征象,ES6 时期终究有了 class 语法,有望一致各个类库和框架不一致的类完成体式格局。来看一个例子:

class User {
  constructor(firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName
  }

  fullName() {
    return `${this.firstName} ${this.lastName}`
  }
}

let user = new User('David', 'Chen')
user.fullName()  // David Chen

以上的类定义语法异常直观,它跟以下的 ES5 语法是一个意义:

function User(firstName, lastName) {
  this.firstName = firstName
  this.lastName = lastName
}

User.prototype.fullName = function() {
  return '' + this.firstName + this.lastName
}

ES6 并没有转变 JavaScript 基于原型的实质,只是在此之上供应了一些语法糖。class 就是其中之一。其他的另有 extendssuperstatic 。它们大多数都能够转换成等价的 ES5 语法。

我们来看看另一个继续的例子:

class Child extends Parent {
  constructor(firstName, lastName, age) {
    super(firstName, lastName)
    this.age = age
  }
}

其基础等价于:

function Child(firstName, lastName, age) {
  Parent.call(this, firstName, lastName)
  this.age = age
}

Child.prototype = Object.create(Parent.prototype)
Child.constructor = Child

无疑上面的例子越发直观,代码构造越发清楚。这也是到场新语法的目标。不过虽然新语法的实质照样基于原型的,但新到场的观点或多或少会引起一些连带的影响。

extends 继续内建类的才

由于言语内部设想缘由,我们没有办法自定义一个类来继续 JavaScript 的内建类的。继续类每每会有种种题目。ES6 的 extends 的最大的卖点,就是不仅能够继续自定义类,还能够继续 JavaScript 的内建类,比方如许:

class MyArray extends Array {
}

这类体式格局能够让开发者继续内建类的功用制造出相符本身想要的类。一切 Array 已有的属性和要领都邑对继续类见效。这确实是个不错的引诱,也是继续最大的吸引力。

但实际老是悲催的。extends 内建类会激发一些新鲜的题目,许多属性和要领没办法在继续类中一般事情。举个例子:

var a = new Array(1, 2, 3)
a.length  // 3

var b = new MyArray(1, 2, 3)
b.length  // 0

假如说语法糖能够用 Babel.js 这类 transpiler 去编译成 ES5 处理 ,扩大的 API 能够用 polyfill 处理,然则这类内建类的继续机制显然是须要浏览器支撑的。而现在唯一支撑这个特征的浏览器是………… Microsoft Edge 。

好在这并不是什么致命的题目。大多数此类需求都能够用封装类去处理,无非是多写一点 wrapper API 罢了。而且个人认为封装和组合反而是比继续更天真的处理方案。

super 带来的新观点(坑?)

super 在 constructor 和一般要领里的差别

在 constructor 内里,super 的用法是 super(..)。它相当于一个函数,挪用它即是挪用父类的 constructor 。但在一般要领内里,super 的用法是 super.prop 或许 super.method()。它相当于一个指向对象的 [[Prototype]] 的属性。这是 ES6 范例的划定。

class Parent {
  constructor(firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName
  }

  fullName() {
    return `${this.firstName} ${this.lastName}`
  }
}

class Child extends Parent {
  constructor(firstName, lastName, age) {
    super(firstName, lastName)
    this.age = age
  }

  fullName() {
    return `${super.fullName()} (${this.age})`
  }
}

注重:Babel.js 对要领里挪用 super(..) 也能编译出准确的效果,但这应当是 Babel.js 的 bug ,我们不应以此得出 super(..) 也能够在非 constructor 里用的结论。

super 在子类的 constructor 里必需先于 this 挪用

假如写子类的 constructor 须要操纵 this ,那末 super 必需先挪用!这是 ES6 的划定规矩。所以写子类的 constructor 时只管把 super 写在第一行。

class Child extends Parent {
  constructor() {
    this.xxx()  // invalid
    super()
  }
}

super 是编译时肯定,不是运行时肯定

什么意义呢?先看代码:

class Child extends Parent {
  fullName() {
    super.fullName()
  }
}

以上代码中 fullName 要领的 ES5 等价代码是:

fullName() {
  Parent.prototype.fullName.call(this)
}

而不是

fullName() {
  Object.getPrototypeOf(this).fullName.call(this)
}

这就是 super 编译时肯定的特征。不过为何要如许设想?个人明白是,函数的 this 只要在运行时才肯定。因此在运行时依据 this 的原型链去取得上层要领并不太相符 class 的通例头脑,在某些状况下更轻易发生毛病。比方 child.fullName.call(anotherObj)

super 对 static 的影响,和类的原型链

static 相当于类要领。由于编译时肯定的特征,以下代码中:

class Child extends Parent {
  static findAll() {
    return super.findAll()
  }
}

findAll 的 ES5 等价代码是:

findAll() {
  return Parent.findAll()
}

static 貌似和原型链没紧要,但这不阻碍我们议论一个题目:类的原型链是如何的?我没查到相干的材料,不过我们能够测试一下:

Object.getPrototypeOf(Child) === Parent             // true
Object.getPrototypeOf(Parent) === Object            // false
Object.getPrototypeOf(Parent) === Object.prototype  // false

proto = Object.getPrototypeOf(Parent)
typeof proto                             // function
proto.toString()                         // function () {}
proto === Object.getPrototypeOf(Object)  // true
proto === Object.getPrototypeOf(String)  // true

new proto()  //TypeError: function () {} is not a constructor

可见自定义类的话,子类的 [[Prototype]] 是父类,而一切顶层类的 [[Prototype]] 都是同一个函数对象,不管是内建类如 Object 照样自定义类如 Parent 。但这个函数是不能用 new 症结字初始化的。虽然这类设想没有 Ruby 的对象模子那末奇妙,不过也是能够自作掩饰的。

直接定义 object 并设定 [[Prototype]]

除了经由历程 classextends 的语法设定 [[Prototype]] 以外,如今定义对象也能够直接设定 [[Prototype]] 了。这就要用到 __proto__ 属性了。“定义对象并设置 [[Prototype]]” 是唯一发起用 __proto__ 的处所。别的,别的注重 super 只要在 method() {} 这类语法下才用。

let parent = {
  method1() { .. },
  method2() { .. },
}

let child = {
  __proto__: parent,

  // valid
  method1() {
    return super.method1()
  },

  // invalid
  method2: function() {
    return super.method2()
  },
}

总结

JavaScript 的原型是很有意义的设想,从某种程度上说它是越发地道的面向对象设想(而不是面向类的设想)。ES5 和 ES6 到场的 API 能更有效地操控原型链。言语层面支撑的 class 也能让忠于类设想的开发者用越发一致的体式格局去设想类。虽然现在 class 仅仅供应了一些基础功用。但随着范例的提高,置信它还会扩大出更多的功用。

本文的主题是原型体系的变迁,所以并没有触及 getter/setter 和 defineProperty 对原型链的影响。想体系地进修原型,你能够去看 You Don’t Know JS: this & Object Prototypes

参考材料

You Don’t Know JS: this & Object Prototypes
You Don’t Know JS: ES6 & Beyond
Classes in ECMAScript 6 (final semantics)
MDN: Object.prototype.__proto__

    原文作者:darkbaby123
    原文地址: https://segmentfault.com/a/1190000003798438
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞