类从ES5到ES6发生了什么

1,294 阅读6分钟

背景

我们都知道,在 es6 以前,我们定义类是依靠一种约定来进行——将函数名的首字母大写;而在 es6 中,则采用了与其他静态语言类似的关键字 class 来定义一个类,那么这个转变的背后隐藏着什么呢?extends 又是如何取代 es6 以前的继承手段呢?无论是以前的约定还是目前的 class 都绕不开 prototype,因此让我们围绕着这个概念,从历史开始这段旅程吧。

原型

function Shape(x, y) {
  this.x = x || 0;
  this.y = y || 0;
}

在 JavaScript 中,我们或许都听说过万物皆对象,虽然有点夸张的成分,比如说 undefined 就不是,但依旧表明了大部分的数据都是对象,我们上面定义的函数也不例外,在各种教程中都会提到,我们一旦定义了函数,该函数会有一个 prototype 属性指向该函数的原型对象,同时该原型对象具有一个只读的 constructor 属性指向原函数。

我们每次创建新的 Shape 类型的对象,只需要调用 new Shape(),这个对象具有一个隐式的 [[proto]] 属性,指向函数的原型(各个浏览器统一将其定义为 __proto__

在控制台打印的结果可以验证上述的结论

这个时候我们发现事实并非如此的简单,红色部分正如我们预期的那样不仅具有 x、y 属性,还拥有了指向 Shape.prototype 的标识 [[Prototype]](这个名称是 chrome 自定义的,不同的浏览器可能展示的不同),但是也有黄色部分是超出我们预期而存在的,为什么会有这部分呢?正如我们上面提到过的,new Shape()是一个由 Shape 实例化出来的对象,因此具有指向 Shape 原型的 __proto__,那么 Shape 的原型也是一个对象,势必也会具有一个 __proto__ 来指向实例化该对象的某个函数的原型。那么这个函数是什么呢?没错,就是大名鼎鼎的 Object。

在 JavaScript 中规定,Object 原型的 __proto__ 属性指向 null,即Object.prototype.__proto__ === null,到这里并没有结束,因为JavaScript 万物皆对象嘛,那么必然 Shape函数,Object 函数本身也具有 __proto__ 属性,而这些属性均指向 Function 函数的原型

那么 Function 原型以及 Function 函数本身的 __proto__ 呢?下面的图就展示了 JavaScript 最具争议性,也最具迷惑性的部分

我们发现,最长的那根蓝色线条 Function.prototype.__proto__ === Object.prototype,这代表了 Function 的原型是由 Object 函数实例化而来的,而结合之前的介绍 Object.__proto__ === Function.prototype 意味着 Object 函数是由 Function 函数实例化而来的。两者之间出现了套娃式的依赖,也就是具有迷惑性的鸡生蛋还是蛋生鸡的问题。

原型链

从上面最后一幅图我们可以得出来以下几点结论

const shape = new Shape();
shape.__proto__ === Shape.prototype;
shape.__proto__.__proto__ === Object.prototype;
shape.__proto__.__proto__.__proto__ === null;

我们通过 __proto__ 构建起了一条访问数据的路径,只要在该路径上的数据都可以被直接访问到,比如说我们可以直接调用 shape.toString(),而该函数是来自于 Object.prototype。直接访问的背后所对应的这种链式查找过程就可以称之为原型链。在 JavaScript 中提供了配套的 API 对原型进行操作,instanceOf、isPrototypeOf、setPrototypeOf、getPrototypeOf。

instanceOf、isPrototypeOf 用于判断一个函数的原型是否在另一个对象的原型链上

shape instanceof Shape;
shape instanceof Object;

Shape.prototype.isPrototypeOf(shape);
Object.prototype.isPrototypeOf(shape);

上面返回的结果均为 true,Shape 函数的原型、Object 函数的原型均可以通过 shape 的 __proto__ 进行链式访问。

getPrototypeOf 用于获取用于实例化某个对象的函数的原型

Object.getPrototypeOf(shape) === Shape.prototype
Object.getPrototypeOf(shape) === Object.prototype

上面第一行代码返回 true,第二行返回 false,原因是直接示例化 shape 的函数是 Shape 函数,而不是 Object 函数

setPrototypeOf 用于将某个对象的 __proto__ 属性设置为另一个对象

Object.setPrototypeOf(shape, Object.prototype);

为了方便演示,如无必要,之后的绘图将不再画出 Function、Object 的示意图

通过 setPrototypeOf,shape 对象将 __proto__ 的指向由 Shape 的原型换成了 Object 的原型,这样的改变也会导致一系列的变化,比如说Object.getPrototypeOf(shape) 将指向 Object 的原型,不再是 Shape 的原型,也就意味着,shape 是由 Object 实例化而来而不是 Shape 函数。

继承

无论是 es5 还是 es6,继承的背后都使用到了原型链的原理,那么让我们从 es5 最常用的继承方式开始看起

function Shape(x, y) {
  this.x = x || 0;
  this.y = y || 0;
}

Shape 的代码保持不变,现在着手为其增加一个方法和定义一个子类

Shape.prototype.getArea = function() {
  return this.x * this.y;
}
function Square(x) {
  Shape.call(this, x, x);
}

Square.prototype = Object.create(Shape.prototype);

const square = new Square();

此时我们创建新的 Square 对象所具备的原型链就如上图所示,在这里,我们 Square 函数和 Shape 函数均在 Square 对象的原型链上,即

square instanceof Shape; // true
square instanceof Square; // true

但同时也出现一个新的问题,那就是 square 的 constructor 是 Shape 而不是 Square,因此一般在完成 prototype 修改之后,都需要修正 constructor 的指向

function Square(x) {
  Shape.call(this, x, x);
}

Square.prototype = Object.create(Shape.prototype);
// 修正 constructor 的指向
Square.prototype.constructor = Square;

const square = new Square();

实战

有了上面的介绍,相信大家很快可以判断出下面代码的表达,如果觉得不太确定,可以画个图结合控制台进行验证

function Shape() {
	this.x = 0;
  this.y = 0;
}
Shape.prototype.move = function(x, y) {
  this.x += x;
  this.y += y;
  console.info("Shape moved.");
};

function Rectangle() {
  Shape.call(this);
}

Rectangle.prototype = Object.create(Shape.prototype);
var rect = new Rectangle();
console.log(rect.__proto__);
console.log(rect instanceof Rectangle);
console.log(rect instanceof Shape);
console.log(rect.constructor === Rectangle);
console.log(rect.constructor === Shape);

console.log("===========================");
console.log(Rectangle.prototype);  // 此处竟然没有constructor是由于重写原型
console.log(Rectangle.prototype.constructor);
console.log(Rectangle.prototype.__proto__);
console.log(Rectangle.prototype.isPrototypeOf(rect));

ES6

时间来到当下,我们书写的 class、extends 背后的实现机制到底是什么样子的呢?先从一个实例开始

class Shape {
  constructor(x = 0, y = 0) {
    this.x = x;
    this.y = y;
  }
  getArea() {
    return this.x * this.y;
  }
}

class Squre extends Shape {
  constructor(x = 0) {
    super(x, x);
  }
  
  getPerimeter() {
    return this.x * 4;
  }
}

通过 babel 在线链接进行编译,去除非核心逻辑后,我们得到如下的代码

var Shape = function () {
  function Shape() {
    var x = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;
    var y = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;
    this.x = x;
    this.y = y;
  }

  _createClass(Shape, [{
    key: "getArea",
    value: function getArea() {
      return this.x * this.y;
    }
  }]);

  return Shape;
}();

var Squre = function (_Shape) {
  _inherits(Squre, _Shape);
  var _super = _createSuper(Squre);

  function Squre() {
    var x = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;
    return _super.call(this, x, x);
  }

  return Squre;
}(Shape);

直接来看涉及到继承的第 20 行 _inherits 函数,精简后,我们得到

function _setPrototypeOf(o, p) {
  return Object.setPrototypeOf(o, p);
}

function _inherits(subClass, superClass) {
  subClass.prototype = Object.create(superClass && superClass.prototype, {
    constructor: {
      value: subClass,
      writable: true,
      configurable: true
    }
  });
  if (superClass) {
    _setPrototypeOf(subClass, superClass);
  }
}

我们发现这个继承的套路就是我们上面提到的,唯一多出来的点,就是通过第 2 行 Object.setPrototypeOf(o, p),将 Square 函数的 proto 由 Function 的原型修改为 Shape 的原型,这样一来,可以保证直接在 Shape 设置的属性也可以被继承