概述

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__

你可能感兴趣的文章

11 条评论
n͛i͛g͛h͛t͛i͛r͛e͛ · 2015年09月27日

多谢谢,等着看

回复

Cam · 2015年10月05日

写的很详细,用 ES6 的 class 时能少踩很多坑,谢谢

回复

chbinghu · 2015年10月10日

1 class Child extends Parent时,Child的[[Prototype]]是Parent,Child.prototype的[[Prototype]]是Parent.prototype。也就是构造函数之间也是一种"继承"关系。
2 static关键字定义的属性是构造函数的属性,也可以在子类中访问。
3 个人对于super关键字的推测,Child的constructor 中使用super(),相当于执行Parent(),由于constructor之间有"constructor chain";普通方法里使用super相当于使用Parent.prototype,因为i有Prototype Chain.

回复

darkbaby123 作者 · 2015年10月10日

@chbinghu 你说的都对。谢谢提醒!关于第三点是可以这样理解。不过补充一点, super 只是一个 keyword 而不是具体的 function 或者 prototype ,所以我文中都只用了 “相当于” 而不是 “是”,基于同样的原因,我们也没法用 super.call 或者 super.bind 之类对 function/property 有效的方式去操作。

回复

KUMO贝尔 · 2016年04月28日

文中提到:
user.constructor.prototype // User

user.constructor 指向的是User构造函数
user.constructor.prototype 指向是 Object{},是User构造函数的prototype值

回复

KUMO贝尔 · 2016年04月28日

关于extends以前继承内建类

function SpecialArray() {
  // 创建数组
  var values = new Array();
  
  // 添加值
  values.push.apply(values, arguments);
  
  // 添加方法
  values.toPipedString = function () {
    return this.join('|');
  };
  
  // 返回数组
  return values;
}

var colors = new SpecialArray('red', 'blue', 'green');
colors.toPipedString(); // "red|blue|green"

回复

darkbaby123 作者 · 2016年04月29日

@KUMO贝尔 嗯,这个思路不错!

回复

KUMO贝尔 · 2016年04月30日

colors.__proto__.__proto__ === Array.prototype // false
colors.__proto__ === Array.prototype // true
其实这里并没有继承,是我理解错误。

回复

darkbaby123 作者 · 2016年05月01日

@KUMO贝尔 这个做法没有利用原型链,只是用 JS 的动态特性往 array object 上直接加方法。我能想到的缺点就是做不到 OO 那样的方法重载,比如为子类写一个特殊的 push 方法(比如去重)。不过它能满足大部分的 ”继承内建类“ 的要求,也是个好方案。

回复

aplayer · 6月29日

请问为什么 class 里面的定义的 method 就没有 prototype 呢?一般 function 都带 prototype。两者有什么区别呢?

class A { y (){} }
A.prototype.y // function y(){}
A.prototype.y.prototype // undefined

回复

0

这点也许跟 ES6 class 的语法有关。我也没太深入研究这一点

darkbaby123 作者 · 7月3日
载入中...
darkbaby123 darkbaby123

969 声望

发布于专栏

David Chen 的编程大杂烩

前端,后端,编程技巧,各种被坑经验,无所不包(其实就是想什么放什么)

22 人关注