2020年3月

从对象介绍到继承的几种实现,现在才要真正的看看Babel是如何处理class和extends。

Babel如何翻译class

假设我们有如下ES6代码:

// demo.js
class SuperType {
  constructor(name) {
    this.superName = name;
    this.superFriends = ['Json'];
  }

  sayHi() {
    console.log("Super HI!");
  }

  static getVersion() {
    return "12.2";
  }
}

包含了基本类型值的属性,引用类型值的属性,实例方法和一个静态方法,来看一下Babel翻译后的代码,看看如何实现class这个语法糖。!注意!静态属性这里不讨论,虽然行为上不特殊,但是需要额外的支持,先关注核心的内容。

// demo_babel.js
"use strict";

function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError("Cannot call a class as a function");
  }
}

function _defineProperties(target, props) {
  for (var i = 0; i < props.length; i++) {
    var descriptor = props[i];
    descriptor.enumerable = descriptor.enumerable || false;
    descriptor.configurable = true;
    if ("value" in descriptor) descriptor.writable = true;
    Object.defineProperty(target, descriptor.key, descriptor);
  }
}

function _createClass(Constructor, protoProps, staticProps) {
  if (protoProps) _defineProperties(Constructor.prototype, protoProps);
  if (staticProps) _defineProperties(Constructor, staticProps);
  return Constructor;
}

var SuperType =
  /*#__PURE__*/
  function () {
    function SuperType(name) {
      _classCallCheck(this, SuperType);

      this.superName = name;
      this.superFriends = ['Json'];
    }

    _createClass(SuperType, [{
      key: "sayHi",
      value: function sayHi() {
        console.log("Super HI!");
      }
    }], [{
      key: "getVersion",
      value: function getVersion() {
        return "12.2";
      }
    }]);

    return SuperType;
  }();

从上到下依次介绍:

_classCallCheck:这是一个常用的,判断是普通调用还是new调用的方式。根据new操作符的执行原理,this将是一个内在的已有原型指向的对象,构造法对其进行构造,从而形成对应类型的实例。而普通调用,在没有改变执行上下文的情况下,this都是顶层对象,所以通过instanceof来判断。

_defineProperties:是_createClass依赖的工具函数,从名称和签名上可以揣测一下,应该是和Object.defineProperty类似的功能。简单看一下逻辑,其实就是可以批量的defineProperty。

_createClass:创建class的核心方法,包含向原型添加属性,和向构造函数本身添加属性,前者是处理实例定义,后者是处理静态定义。

通过一个立即执行函数创建一个大的闭包,将整个过程隔离起来。假设我们用普通的写法自己实现class,大致会像这样:

function SuperType(name) {
    // 大部分时候都会忽略new调用和普通调用要区分开
  this.name = name;
  this.superFriends = ['Json'];
}

SuperType.getVersion = function () {
  return "12.1"
};

SuperType.prototype.sayHi = function() {
  console.log("Super HI!", this.name);
};

Babel实现的class一方面增加了调用检查,另一方面,在定义多个实例方法的使用,可以通过_defineProperties来批量实现。所以看上去其实区别并不很大。

Babel如何翻译class+extends

似乎终于来到了正题,为了减少贴太多代码,下面仅包含增量代码:

// demo.js
class SubType extends SuperType {
  constructor(name, age) {
    super(name);
    this.age = age;
  }

  sayHi() {
    console.log("Sub HI!", this.name, this.age);
  }
}

翻译后的代码,第一部分,先介绍工具函数:

// demo_babel.js
function _typeof(obj) {
  if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") {
    _typeof = function _typeof(obj) {
      return typeof obj;
    };
  } else {
    _typeof = function _typeof(obj) {
      return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj;
    };
  }
  return _typeof(obj);
}

function _possibleConstructorReturn(self, call) {
  if (call && (_typeof(call) === "object" || typeof call === "function")) {
    return call;
  }
  return _assertThisInitialized(self);
}

function _assertThisInitialized(self) {
  if (self === void 0) {
    throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
  }
  return self;
}

function _getPrototypeOf(o) {
  _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) {
    return o.__proto__ || Object.getPrototypeOf(o);
  };
  return _getPrototypeOf(o);
}

function _inherits(subClass, superClass) {
  if (typeof superClass !== "function" && superClass !== null) {
    throw new TypeError("Super expression must either be null or a function");
  }
  subClass.prototype = Object.create(superClass && superClass.prototype, {
    constructor: {
      value: subClass,
      writable: true,
      configurable: true
    }
  });
  if (superClass) _setPrototypeOf(subClass, superClass);
}

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

// 上文介绍过的三个工具函数

// 上文列出过的SuperType的声明

_typeof:是对原生typeof的一个扩展,在那些没有Symbol特性的环境下,如果通过特殊手段实现了Symbol,该函数依然能够判断扩展的类型。注意else分支中的三元表达式。

_possibleConstructorReturn和_assertThisInitialized:这两个要一起看。在调用处会具体介绍。

*_getPrototypeOf和_setPrototypeOf*:在MDN文档中你会看到一些浏览器支持__proto__ 但不支持Object.getPrototypeOf,所以这两个方法用于兼容。

_inherits

你看到这个方法和别的工具方法不同,被H2了!单独把这个函数拎出来:

function _inherits(subClass, superClass) {
  if (typeof superClass !== "function" && superClass !== null) {
    throw new TypeError("Super expression must either be null or a function");
  }
  subClass.prototype = Object.create(superClass && superClass.prototype, {
    constructor: {
      value: subClass,
      writable: true,
      configurable: true
    }
  });
  if (superClass) _setPrototypeOf(subClass, superClass);
}

如果superClass,也就是父类这个参数,并没有传构造法,会抛出一个错误,合情合理。

接下来的代码你可能会有一些熟悉,上一篇文章介绍继承的集中方式式,最后一种“寄生组合”:

// 本函数只是在原理上介绍寄生组合,并没有考虑constructor这个属性的本身细节
function inheritPrototype(subType, superType) {
    let prototype = Object.create(superType.prototype);
    prototype.constructor = subType;
    subType.prototype = prototype;
}

注意到在恢复constructor的步骤上,Babel更加严谨的使用Object.create第二个参数,没有配置enumerable特性,所以默认是false的,这个属性并不会被迭代。具体可以执行一下Object.getOwnPropertyDescriptor(Object.prototype, "constructor") 。这个时候子类的原型,是一个以父类原型为原型的对象,但是后来执行了一个过程“subClass.__proto__=superClass”,直观上看是为了继承父类的静态方法,具体作用只能先这么理解了。

等具体看到后面的代码,你会发现Babel确实是寄生组合实现的继承。

具体继承

_inherits函数基本上指明了组合寄生的道路,那么具体的实现看看还有哪些细节:

// demo_babel.js
var SubType =
  /*#__PURE__*/
  function (_SuperType) {
    _inherits(SubType, _SuperType);

    function SubType(name, age) {
      var _this;

      _classCallCheck(this, SubType);

      _this = _possibleConstructorReturn(this, _getPrototypeOf(SubType).call(this, name));
      _this.age = age;
      return _this;
    }

    _createClass(SubType, [{
      key: "sayHi",
      value: function sayHi() {
        console.log("Sub HI!", this.name, this.age);
      }
    }]);

    return SubType;
  }(SuperType);

寄生组合的思想主要是两部分:1.借用父类构造函数构造自身;2.正确处理原型关系。_inherits函数处理了2。上一篇文章的例子,通过SuperClass.call的方式,将构造过程转移到子类实例上,而Babel的做法更为精妙,仔细看:

_this = _possibleConstructorReturn(this, _getPrototypeOf(SubType).call(this, name));

先看第二个参数_getPrototypeOf(SubType).call(this, name) 会不会好奇SubType的原型为什么能用call,回想一下_inherits函数的最后一步,SubType的原型就是SuperType。这一步就可以认为是SuperType.call的过程,而_possibleConstructorReturn 函数检查了第二个参数,如果第二个参数是个对象或者函数,那么说明父类构造法有覆盖实例的情况;然后会通过_assertThisInitialized 函数来校验this 究竟有没有被父类实例化过。按照这样的逻辑,_getPrototypeOf(SubType).call(this, name)这一步理论会对this正确的父类实例化,但是这种校验不是没有道理的。

假设我们将super(name) 注释掉,也许你的编辑器会给你报错,但是这个代码是可以正确翻译的,会变成这样:

var SubType =
/*#__PURE__*/
function (_SuperType) {
  _inherits(SubType, _SuperType);

  function SubType(name, age) {
    var _this;

    _classCallCheck(this, SubType);

    // super(name);
    _this.age = age;
    return _possibleConstructorReturn(_this);
  }

  _createClass(SubType, [{
    key: "sayHi",
    value: function sayHi() {
      console.log("Sub HI!", this.name, this.age);
    }
  }]);

  return SubType;
}(SuperType);

这也解释了为什么会有一个_this出现,就是用来标记this是被父类初始化过的。可以简单的看成_this才是子类实例的一个等待状态,而this 必须要经过父类实例过程,才能使_this真正生效。

总结

至此Babel翻译class+extends基本就介绍完了,上一篇文章基本上是对《高程》第六章的复习,从理解对象,到构造对象,重点把握工厂模式,构造函数模式(new关键字的作用过程),原型以及原型对象(__proto__和prototype)。

JavaScript在ES6中增加了class和extends关键字,作为类型的语法糖。Babel的实现可能更巧妙的处理实例方法。到了继承,先从逻辑上梳理各种继承的模式,核心还是原型链的问题。通过父类实例建立原型关系,通过Object.create建立原型关系等等。Babel翻译的继承采用寄生组合,寄生表现在SuperClass.call的实现方式,Babel还增加了必要的检查,也就是super()是否调用;组合体现在原型链的构建上,Object.create直接建立关系,同时照顾constructor的特性。额外的还建立两个构造方法的原型关系,后面借助这个关系隐式的调用父类构造法,实际操作上还能直接继承父类的静态方法。

继承是面向对象编程不可避免的话题,尝试把继承看做是代码共享的一种好的方式,共享的部分约束细节从而具有类型划分的功能。一般来说继承关系是固定的,从复用代码的角度说,父类的代码被子类复用,这是一个确定的事情,总不能说我用着用着突然不想用了,想复用别的父类的代码,听上去就不靠谱,可能会引起很多问题。但是JavaScript貌似就是这样的奇葩。

虽然本文的标题是“跟着Babel学JS的继承”,但是首先要较为全面的了解JS中的继承,再来看Babel是如何处理class&extends语法糖。要想搞清楚继承,那还得从对象和面向对象说起,稍微介绍一些基础内容,再具体看看都有哪些继承的方式,最后看看Babel是如何实现更为全面的继承。

本文大部分内容都从《高程》中总结,辅助以MDN文档。

理解对象

ECMA-262将对象定义为:无序的属性集合,属性可以包含基本只,对象或者函数。高程中给了我们一个非常好的描述,用“散列表”来想象对象。对象是属性的集合,那属性的特征用什么描述,实际上在对象内部有专门用于描述属性的“特性—attribute”,而特性对于程序员来说基本可以认为是不可见的,这些特性通常用[[]] 来表述。特性用于描述属性的各项特征,所以属性就被粗略的分成两种—数据属性&访问器属性。

数据属性

根据高程的描述,数据属性包含一个数据值的位置,可读可写,有四个特性:

  • [[Configurable]] 用于描述是否可delete,是否可改为访问器属性,默认true
  • [[Enumerable]] 用于描述是否可迭代,即for-in循环中是否可枚举,默认true
  • [[Writable]] 用于描述是否可修改属性值,默认true
  • [[Value]] 用于描述属性值,读取属性实际从这个位置读取,默认undefined

通过字面量创建对象不能直接定义属性的特性,Object.defineProperty用于创建自定义特性的属性值。特定的特性可能会导致不一样的效果,比如[[Writable]] 为false时,表示属性值不能被修改,严格模式下,尝试赋值将导致报错,非严格模式则静默失败,保持原有的值。另外[[Configurable]] 一旦被设定了就无法修改,毕竟修改特性也被认为是一种配置。如果设置了false,delete操作会在非严格模式下静默失败,严格模式下报错。而且在使用Object.defineProperty时,不指明各种特性的值,将保持false。(上面说的默认true是在使用字面量设置属性时)

访问器属性

经典的来了,访问器或者叫访问器属性,是正经的属性,并不是附加在普通属性上的什么东西。访问器属性并不包含数据值,也就是没有[[Value]] ,而是通过[[Get]][[Set]] 两个特性来实现访问和修改:

  • [[Configurable]][[Enumerable]] 与数据属性的相似,这里不多赘述
  • [[Get]] 读取使用的函数,注意!该特性应当对应一个函数,默认是undefined
  • [[Set]] 修改使用的函数,默认是undefined

这些特性同样通过Object.defineProperty进行设置,严格模式下,尝试对“残缺”的属性读取或写入都将报错。并且不能同时设置[[Value]] 和getter&setter,这是Object.defineProperty不允许的,会直接报错。

使用Object.defineProperty设置特性,使用Object.getOwnPropertyDescriptor读取特性。

创建对象

理论上创建对象应当是非常复杂的一章,这里简要介绍工厂模式和构造函数,至于原型对象将在下一章节介绍。对象字面量和Object构造函数都可以创建一个朴素的对象,都是最直接的属性集合。然而面向对象的要求远远不止于此,类型要求创建对象时拥有共同细节,所以如何更好的创建对象也需要继续思考。

工厂模式

工厂模式是设计模式的一种,基于某种抽象来创建具体对象的思路。函数包含一个具体的操作流程,而这个流程就像是工厂组装的过程,只要使用工厂方法,就会用一个朴素对象组装出一个符合预期的对象。

function createPerson(name, age, job) {
  varo = newObject();
  o.name = name;
  o.age = age;
  o.job = job;
  o.sayName = function () {
    alert(this.name);
  };
  return o;
}

缺点显而易见,工厂生产出的对象虽然拥有相同的细节(都有特定的字段和方法/行为),但是除此以外并没有办法进行分类,从代码层面识别对象的分类是困难的,如果有多个字段,挨个检查非常的不合理。

构造函数模式

构造函数长得很像工厂函数,用new操作符来隐去创建对象和返回对象的过程,并内在的进行一些可以用来划分类型的操作,具体如何划分的可以关注原型对象的介绍。

function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
  this.sayName = function () {
    alert(this.name);
  };
}

构造函数用一般函数的形式声明,只在new的时候有特殊的行为,为了区分构造函数和普通函数,通常用首字母大写的大驼峰命名,和其他语言声明类时相似。

简单来说new 操作有以下四个步骤:

  • 创建一个新的对象,这个过程是隐式
  • 将构造函数的上下文绑定到这个对象,也就是说构造函数中的this指向这个新的对象
  • 执行构造函数,也就是对这个新的对象进行构造
  • 返回这个新对象,作为new操作符对应的语句的返回值

构造函数的缺陷这里也简单说一下,上面的例子中,sayName是一个实例方法,这个属性值在每次初始化的时候都要声明一个匿名函数,每一个实例的sayName属性是严格不相等的,对于其他属性来说严格不相等是很重要的,但是对于方法来说,内部执行环境随着调用者变化,就可以产生各自的结果,并不需要每个实例都拥有自己的方法。如果把匿名函数拿到更外层的环境,虽然实例不再拥有独立的方法,但是在编写过程中,大权限的函数会变多,且如果没有很好地封装特性(被提取到外面的方法往往拥有更大的访问权限)。

至于构造函数是如何解决类型区分的问题,是通过实例上可以访问的constructor属性实现的,这个属性是在new的过程中挂上去的,具体可以继续了解原型对象。

这里要额外说一下,构造函数也是函数,也可以普通调用,并且有以下这样的迷惑行为:

let o = new Object();

Person.call(o);

console.log(o.name);
console.log(o.constructor)

看上去像不像工厂,你品,你细品。

原型模式

虽然原型模式应该被当做创建对象的一种模式,但是由于其实在是太重要了,就单独详细的介绍,和后面的继承也有密切的关系。

[[Prototype]]

这是JS世界中,每个对象都有的一个内置指针,理论上它只能讨论却对程序员来说”不可用“,有些环境会让它以__proto__ 的形式“可见”。

每个实例对象(object)都有一个私有属性__proto__ 用于指向这个对象的构造函数的原型对象(prototype)。

下面在介绍这个内置指针相关的关系时,为了好表达,都用__proto__

虽然这个内置指针对程序员来说”不可用“,不能直接读取和赋值,但是可以通过Object.getPrototypeOf来获得某个对象的原型对象,也就是__proto__ 所指向的实际对象。虽然有Object.setPrototypeOf可以修改某个对象的原型对象,但是在文档中会看到红色的提示,主要是提示性能问题,和不同环境实现可能引起问题,推荐使用Object.create在创建时直接变更原型对象。

理解原型对象

不管是__proto__ 还是[[Prototype]] ,都指向传说中的原型对象,原型模式为了解决类型与实例之间的共享问题,而原型对象最直观的作用就是共享空间。

之前也了解了原型对象是挂在函数的prototype属性上的,这是创建函数时根据特定的规则,由语言引擎自动添加的。这个原型对象自动获取一个constructor属性,指向这个函数本身,也就是Person.prototype.constructor===Person。而实例本身将拥有一个特殊的指针,这个指针理论上并没有名字,但是为了具体化,浏览器会通过可见但不可用的__proto__属性来表达,而在语言规范中,用[[Prototype]]来说明有这么一个特殊的指针。这个指针就是指向构造函数的原型对象,也就是person1.__proto__ === Person.prototype。

回到之前的工厂方法与构造方法的对比,new操作符会内在的创建一个对象,并且这个对象会包含一个constructor属性指向构造函数,依次来作为类型划分的标志,但是实际上并不是直接这么做的。为什么这么说呢,是因为如果实例上直接挂了一个属性,值是一个指向构造函数的指针,还是没有彻底解决封装的问题,你会发现和原型模式要解决的问题类似。之所以能得到person1.constructor === Person这个结论,那就还要在了解对象属性的搜索,这对理解原型以及原型链非常有帮助。

对象属性搜索

读取对象的属性,实际上是一个搜索的过程,在对象自身搜索,或者在原型对象上搜索。这个搜索在原型对象上实际是一个递归过程。对象本身可以认为是一个属性集合,所以访问属性会先从这个集合中进行搜索,有就返回,没有就通过内置的指针找对原型,递归这个搜索过程。

假如有以下代码环境:

function Person() {
    
}

function OldPerson() {
    this.name = "hahahah";
    this.sayHi = function() {
        console.log(this.name);
    }
}

Person.prototype.name = "hahaha";

Person.prototype.sayHi = function() {
    console.log("name:", this.name);
}

let person1 = new Person();
let person2 = new OldPerson();

console.log(Object.getOwnPropertyNames(person1));
console.log(Object.getOwnPropertyNames(person2));

不难发现person1本身就像是一个空的属性集合,for-in递归也会发现没有属性;而person2是包含了具体属性的。因此person2.sayHi实际上就是在其本身的属性集合中,找到sayHi这个属性并调用;而person1.sayHi就略有不同,其本身并不包含属性,因此要通过其内部的特殊指针__proto__或者说[[Prototype]]得到Person.prototype,对其进行搜索。

这里要注意的是Person.prototype本身是一个对象,虽然没有看到他的全貌,但是他也有自己的原型对象,因此如果我们检索的属性不在Person.prototype上,那检索过程会继续向上。

不难发现,原型另外一个好处就是可以在实例自身层次上屏蔽原型链上的共享属性。因为实例本身作为对象是一个属性集合,访问某个属性没有结果时,会在原型对象上递归搜索。那么如果在对象本身增加了一个同名的属性,那么搜索过程就会提前停止,且不会影响共享的原型对象,其他实例仍然能够共享到原型链上的值。这一点也被用来在原型链继承时“重写”父类的值,通过搜索屏蔽,还不是直接复写。

由Object继承下来的hasOwnProperty方法,可以用来判断某个属性是否在实例自身的属性集合中。

同时注意in操作符,判断是否包含某属性,是一个完整的访问过程,所以在for-in循环的时候要注意这一点。Object.keys这个新的API,可以返回对象直接包含的属性键。Object.getOwnPropertyNames也可以返回,但是会额外返回不可枚举的constructor属性,而Object.keys比较守规矩,只有那些可枚举的属性。

原型字面量的坑

扩展原型对象都是规规矩矩的一个字段一个字段添加,看上去有些傻,就有了之前的想法,对Date.prototype直接挂一个X对象的操作。在了解了原型对象和属性搜索的工作方式后,其实这样的操作也是可以的,只是要额外的进行一个“恢复”的操作。

funciton Person() {}

Person.prototype = {
    name: "hahaha",
    sayHi() {
        console.log(this.name);
    }
} // 这个对象字面量且叫做X吧

let person1 = new Person();

这个时候重新梳理一下:在person1上搜索name属性,能够正确找到X对象中的name;instanceof也能正常识别。但是到底是哪里少了?原型对象通过constructor属性,让所有实例共享这个“类型标记”,虽然instanceof能够正常工作,但是person1.constructor===Person已经不能正常工作。

如果改成这样呢:

Person.prototype = {
  constructor: Person,
    name: "hahaha",
    sayHi() {
        console.log(this.name);
    }
}

看起来很OK,但是constructor这个属性的特性是不可枚举,因此还是要恢复它的特性。

这种情况还算比较好处理的,但是还要考虑一个问题, 就是JavaScript语言的动态性,导致原型在程序中是可以随时修改的。实例包含一个内在的指针指向原型,如果中途突然把构造函数的原型对象直接替换掉,将会产生不符合预期的错误。也就是说,我们要在实例化对象之前,就定义好原型,且中途最好不要在改变。这里要提醒一下,之前我们在描述指针,构造函数和原型之间的关系,person.__proto__ === Person.prototype,这是一个直接指向关系,再由Person.prototype.contructor===Person指向构造函数,因此实例中的内置指针只与原型相关,完全不会和构造函数直接挂钩。

另外!对于原生对象的原型,不推荐扩展,主要是为了防止命名冲突,考虑到不同的环境具体的实现可能不同,最好还是不要随便扩展。

原型模式的缺点

构造函数的原型对象,作为实例的共享空间,尽可能的为了复用代码考虑,但是复用基本值可以,复用引用类型的值就很尴尬了:

function Person(){}
Person.prototype.things = [];

let person1 = new Person();
let person2 = new Person();

person1.things.push("banana");

console.log(person2.things);

person1.things访问拿到原型上的数组引用,操作这个引用显然没啥问题,虽然它在原型对象中,于是乎其他的实例再去访问这个属性,就拿到了别人操作后的数组,且person1.things===person2.things,这难免会引起疑惑和错误。因此,一般来说引用类型的值,会在构造函数中为实例初始化,毕竟这种值通常是实例严格相关,而不是类型严格相关。

还有其他的解决方案,比如动态原型,既然原型可以动态指定,那么就干脆在构造函数中通过逻辑来控制原型的扩展与否。

构造函数与原型模式的结合

这部分本应该属于构造对象章节,但是依赖原型对象和原型模式,所以延迟到现在再介绍。

回到构造函数模式,遗留了一个问题,就是方法共享的问题。有了原型对象,就可以将要共享的方法封在原型对象中:

function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
  this.friends = ["Shelby", "Court"];
}

Person.prototype = {
  constructor: Person, // 细考虑的话用defineProperty修正这个属性的特性
  sayName: function () {
    alert(this.name);
  }
}

动态原型模式这里就简单提一下:

function Person(name, age, job) {
  //属性
  this.name = name;
  this.age = age;
  this.job = job;
  // 方法
  if (typeof this.sayName != "function") {
    Person.prototype.sayName = function () {
      alert(this.name);
    };
  }
}

仅在第一次用Person构造对象时扩展原型对象。

寄生构造函数模式也简单说一下,和工厂模式很相似,只是仍然用new来创建对象,利用的是构造方法return会覆盖new创建的对象并返回,读到这你可能已经发现问题了,new的时候创建的对象会挂constructor来区分类型,那被return覆盖了就没有区分类型的属性了。这种模式主要用在扩展,或者说共享内置对象时使用:

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

还有一个稳妥构造模式,更像工厂了,核心是构造时使用的参数不会直接体现在”实例“上,而且通过闭包暴露访问权限。具体可以参见《高程》的6.2.7。

继承

原型模式和构造函数提供了非常好的共享代码的体系,继承从面向对象的角度来说,是子类继承父类的“描述细节”,从而有父子继承的关系,从代码层面其实就是为了复用部分代码,在复用中求同存异。所以继承通常更多的作为类型区分考虑,在设计层面具有很强的类型一致性,而行为和细节,不推荐通过继承进行传递,因为继承也具有一定的局限性,不利于维护等等,要注意“多组合,少继承”的原则。

对象的__proto__指向其原型对象,原型对象也是普通对象,也有__proto__指向原型对象的原型对象,以此形成原型链。利用这个特性,可以通过原型来向下共享属性和方法。

person的原型对象是Person.prototype,如果Person.prototype是human,human的原型对象是Human.prototype,在person上完全可以访问到human的属性和方法,可以认为Person继承了Human。

原型链与继承

function SuperType() {
  this.property = true;
}

SuperType.prototype.getSuperValue = function () {
  return this.property;
};

function SubType() {
  this.subproperty = false;
}

//继承了SuperType
SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function () {
  return this.subproperty;
};

var instance = new SubType();
alert(instance.getSuperValue());//true

instance如何访问到getSuperValue属性的,试着根据对象查找属性的流程自己分析一下。这种模式就是常见的”子类的原型对象是父类的实例“,在原型链上构建上下层关系完成继承。但是你也要发现,instance.constructor不再是SubType而是SuperType。

插播

这里还是要具体说一下instanceof:

instanceof 运算符用来检测 constructor.prototype 是否存在于参数 object 的原型链上。

结合上面的例子,instance instanceof SubType和instance instanceof SuperType表达式值都是true,我们来看一下过程:

instance.__proto__ === SubType.prototype,所以instance instanceof SubType为true

SubType.prototype = new SuperType()

SubType.prototype.__proto__ === SuperType.prototype,所以instance instanceof SuperType为true

起码要知道instanceof是怎么检测的,否则有时候虽然丢了constructor属性,但是仍能找对类型。

插播结束

传统原型链的基本模式,要注意继承覆盖的问题,因为这种模式核心是SubType.prototype = new SuperType(),所以要注意在建立继承关系后,再扩展子类的原型对象。

还有一个巨大的问题就是父类构造方法中,如果声明了一个值为应用类型的属性,会产生共享引用的问题:

function SuperType() {
  this.colors = ["red", "blue", "green"];
}

function SubType() {
}

//继承了SuperType
SubType.prototype = new SuperType();
var instance1 = new SubType();
instance1.colors.push("black");
alert(instance1.colors);//"red,blue,green,black"
var instance2 = new SubType();
alert(instance2.colors);//"red,blue,green,black"

对比一下其他语言的继承,你会发现这种模式的继承,子类的构造完全不影响父类的细节,也就是说父类是固定的,而继承往往需要更具体的构造父类实例的细节,所以这种传统模式更适用于父类相对静止,不受子类构造影响的继承关系。

借用构造函数

为了解决父类实例中包含引用类型值,被子类实例共享而被错误修改,要让父类构造的引用类型属性下放到子类构造过程中,来保证这样的属性不被共享;同时还要解决不能向父类构造方法传递参数的问题,基本上就得让父类构造过程在子类构造过程中显示的进行,根据这样的思路,有这样的继承方式:

function SuperType() {
  this.colors = ["red", "blue", "green"];
}

function SubType() {
  //继承了SuperType
  SuperType.call(this);
}

var instance1 = new SubType();
instance1.colors.push("black");
alert(instance1.colors);//"red,blue,green,black"
var instance2 = new SubType();
alert(instance2.colors);//"red,blue,green"

子类的构造方法,借用父类构造方法来构造自己(this),这样colors属性就被下放到子类的实例上,不存在错误的共享使用,同时call的时候也能传递参数,根据不同的情况也可以使用apply。但是,这种模式有个明显的问题,一眼就可以看出来,父类型构造法的调用是通过call,也就是普通调用修改上下文的方式,并没有用new,所以子类型返回的实例并不包含父类型构造应有的类型信息。另外,如果父类的方法都定义在父类构造函数中,那么又回到构造函数的缺陷问题,如果父类在prototype上扩展方法和属性,子类构造法通过call的方式也无法继承下来。

组合继承(相对重点)

借用父类构造函数构造自己,同时用父类实例修正自己的原型对象,也就是传统经典继承+借用构造函数:

function SuperType(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function () {
  alert(this.name);
};

function SubType(name, age) {
  //继承属性
  SuperType.call(this, name);
  this.age = age;
}

//继承方法
SubType.prototype = new SuperType();
SubType.prototype.sayAge = function () {
  alert(this.age);
};

缺点也是显而易见的,在子类构造方法中call一次,设置子类prototype又要实例化,也就是说父类的构造方法要执行两次。真正new子类时,子类构造法call了父类构造法来装饰自身,同时覆盖掉原型上共享下来的同名属性。

原型式继承(Object.create)

利用原型链,创建继承关系,子类实际隐藏起来,内置的完成原型链的变更:

function object(o) {
    function F(){}
    F.prototype = o;
    return new F();
}

在ECMAScript 5中标准化为Object.create,通过直接指明想要的原型对象,即可创建有明确原型指向关系的继承。这种方式受语言版本压制,且可能存在性能问题。注意Object.create支持第二个参数,提供一个描述符,处理描述符是消耗性能的。关于Object.create的使用方法,可以参见MDN标准文档

总的来说这种模式你会发现没有看到子类的构造函数,而且在object方法中,传入的参数o想被当做原型,可惜只是一个浅复制,如果o上仍存在引用类型值,还是会存在误操作的问题。

寄生式继承

原型式继承用一个临时的构造函数,改造它的原型对象,从而创建一个有继承关系的对象,这样做由于无法直接定义子类的细节,使得构造出来的对象只具有父类的细节。如果想要丰富子类的细节,就在原型式继承的基础上,在封装一层用于扩展子类的细节:

function createAnother(o) {
  // 原文这里本来是用上面的object,用Object.create好理解一些
    let clone = Object.create(o);
    clone.sayHi = function() {
        console.log("hi");
    }
    return clone;
}

这种方式如果将引用类型值挂在clone上,创建实例将能够在一定程度上避免被滥用的问题,但是原型对象o的问题依然没有解决。

可以试想一下缺陷,在向clone添加sayHi的时候,是不是像最开始介绍构造函数的例子,所以也会存在函数复用不理想的问题。

寄生组合式继承

组合寄生的问题也说到过,父类构造函数,在子类构造函数中call一次,同时在定义子类原型对象时创建父类实例又调用一次。试想:定义子类原型对象时,构造一个父类实例,子类原型拥有了父类实例的属性,从而使得子类实例能够通过对象类型搜索访问到这些属性。然而当真正构造子类时,由于”寄生“,用父类的构造过程构造自身,使得子类实例上直接得到父类实例属性,从而屏蔽了上一步子类原型留下来的属性,简单来说就是一条链上总是有重复的属性。

寄生组合式继承,保留子类构造函数中用父类构造自身的过程,从而保证父类实例属性能够留下来,同时改善子类原型对象的关系,不需要在子类的原型上保留父类实例属性,只保留应该保留的原型链关系,即子类的原型对象,是一个以父类原型对象为原型的对象。关键代码如下:

function inheritPrototype(subType, superType) {
    let prototype = Object.create(superType.prototype);
    prototype.constructor = subType;
    subType.prototype = prototype;
}

假设subType是子类SubType的实例,superType是父类SuperType的实例,本来subType.__proto__指向SubType.prototype,而SubType.prototype是superType,会通过superType.__proto__再指向SuperType.prototype。加粗的过程是需要构造父类的。那么寄生组合的作用,不再需要SubType.prototype=new SuperType(),只是额外注意恢复constructor。

function SuperType(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function () {
  alert(this.name);
};

function SubType(name, age) {
  SuperType.call(this, name);
  this.age = age;
}

inheritPrototype(SubType, SuperType);
SubType.prototype.sayAge = function () {
  alert(this.age);
};

核心就是简化子类原型对象与父类原型对象之间的联系,通过Object.create或者说原型式继承直接建立和父类原型对象的关系,减少不必要的实例构建。