深入对象系列(三)——类

166 阅读10分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第13天,点击查看活动详情

前言

这是深入对象系列的第三篇,第一篇深入对象系列(一)——对象的属性和特性中提到 JavaScript 中对象的特性有三点,分别是对象的原型(prototype)、对象的类(class)、对象的扩展标记(extensible flag),并且详细讲解了其扩展性,第二篇深入对象系列(二)——原型与原型链讲解了原型,这一篇我们来聊聊类。

其实很多人会有疑惑,为什么类(class)会作为 JavaScript 中对象的特点之一,它不是关键字吗?事实上在早期 JavaScript 中类(class)的定义是一个私有属性 [[class]],语言标准为内置类型诸如 Number、String、Date 等指定了 [[class]] 属性,以表示它们的类。语言使用者唯一可以访问 [[class]] 属性的方式是 Object.prototype.toString:

var o = new Object;
var n = new Number;
var s = new String;
var b = new Boolean;
var d = new Date;
var arg = function(){ return arguments }();
var r = new RegExp;
var f = new Function;
var arr = new Array;
var e = new Error;
console.log([o, n, s, b, d, arg, r, f, arr, e].map(v => Object.prototype.toString.call(v))); 
// (10) ['[object Object]', '[object Number]', '[object String]', '[object Boolean]', '[object Date]', '[object Arguments]', '[object RegExp]', '[object Function]', '[object Array]', '[object Error]']

因此在 JavaScript 早期版本我们可以通过 Object.prototype.toString 方法来区分变量类型。

同时我们也可以感受到,在 ES3 和之前的版本,JavaScript 中类的概念是相当弱的,它仅仅是运行时的一个字符串属性。在 ES5 开始,[[class]] 私有属性被 Symbol.toStringTag 代替,Object.prototype.toString 的意义从命名上不再跟 class 相关。我们甚至可以自定义 Object.prototype.toString 的行为,以下代码展示了使用 Symbol.toStringTag 来自定义 Object.prototype.toString 的行为:

var o = { [Symbol.toStringTag]: "MyObject" }
console.log(o + ""); 
// [object MyObject]

这里创建了一个新对象,并且给它唯一的一个属性 Symbol.toStringTag,我们用字符串加法触发了 Object.prototype.toString 的调用,发现这个属性最终对 Object.prototype.toString 的结果产生了影响。

这也是类(class)被称为 JavaScript 对象三大特性之一的原因,JavaScript 在设计之初的选择就是一门基于原型的弱类型的语言,虽然因为一些公司的政治原因一直在模仿 Java 基于类面向对象的实现,比如 new、构造函数、this 等等,但是 JavaScript 这样的半吊子模拟,缺少了继承等关键特性,导致大家试图对它进行修补,进而产生了种种互不相容的解决方案。

首先我们介绍一下 JavaScript 早期基于原型实现的"继承"。

基于原型的继承

一、原型链继承

核心:父类型的实例作为子类型的原型。 示例:

function SupType() {
    this.property = true;
}
SupType.prototype.getSuperValue = function() {
    return this.property;
}
function SubType() {
    this.subproperty = false;
}
// 继承了Suptotype
SubType.prototype = new Suptotype;
// 添加新方法
SubType.prototype.getSubValue = function() {
    return this.subproperty;
}
var instance = new subType();
console.log(instance.getSuperValue()); // false

原型继承的实现本质是重写原型对象,代之以一个新类型的实例,在上面示例中原来存在于 Suptype 的所有实例中的属性和方法,现在也存在于 SubType.prototype 中了。在确定了继承关系后我们给 Subtype.prototype 添加了一个方法,这样就在继承了 SuperType 属性的方法和方法的基础上又添加了一个新方法,示例中的继承关系如下图所示:

image.png

优点:

  • 非常纯粹的继承关系,实例是子类型的实例,也是父类型的实例
  • 父类型新增原型方法或原型属性,子类型都能访问到
  • 简单,易于实现

缺点:

  • 可以在 SubType 构造函数中,为 SubType 实例增加实例属性。如果要新增原型属性和方法,则必须放在 new SupType() 这样的语句之后执行
  • 无法实现多继承
  • 来自原型对象的所有属性被所有实例共享
  • 创建子类型实例时,无法向父类型构造函数传参
  • 不能使用对象字面量创建原型方法,会重写原型链

二、借用构造函数继承

核心:子类型构造函数的内部调用父类型构造函数,等于是复制父类的实例属性给子类(JavaScript 中的函数本质上是在特定环境中执行代码的对象,因此也可以通过使用 apply()call() 方法也可以在(将来)新创建的对象上执行构造函数)

示例:

function SuperType() {
  this.colors = ["red", "blue", "green"];
}
function SubType() {
  // 继承了SupType
  SupType.call(this)
}
var instance1 = new SubType();
instance1.colors.push("black");
console.log(instance1.colors); // "red, blue, green, black"

var instance2 = new SubType()
console.log(instance2.colors); // "red, blue, greeen" 

优点:

  • 解决了原型继承中,子类型实例共享父类型引用属性的问题
  • 创建子类型实例时,可以向父类型传递参数
  • 可以实现多继承(call 多个父类对象)

缺点:

  • 实例并不是父类型的实例,只是子类型的实例
  • 只能继承父类型的实例属性和方法,不能继承原型属性/方法
  • 无法实现函数复用,每个子类型都有父类型实例函数的副本,影响性能

三、组合继承

核心:使用原型链实现原型属性和方法的继承,通过借用构造函数实现对实例属性的继承。 示例:

function SuperType(name) {
    this.name = name;
    this.colors = ["red", "blue", "green"];
}
SupType.prototype.sayName = function() {
    console.log(this.name)
}
function SubType(name, age) {
    // 继承实例属性
    SupType.call(this, name);
    this.age = age;
}
// 继承原型方法 
SubType.prototype = new SuperType();
SubType.prototype.sayAge = function () {
    console.log(this.age)
}
var instance1 = new SubType('xiaozhang', 23);
instance1.colors.push("black");
console.log(instance1.colors) // "red, blue, green, black"
instance1.sayName() // "xiaozhang"
instance1.sayAge() // 23 
var instance2 = new SubType('xiaoli', 22);
console.log(instance2.colors); // "red, blue, green, black"
instance2.sayName() // "xiaoli"
instance2.sayAge() // 22

优点:

  • 弥补了借用构造函数继承的缺陷,可以继承实例属性/方法,也可以继承原型属性/方法
  • 既是子类型的实例,也是父类型的实例,可以使用 instanceof 和 isPrototypeOf() 来识别组合继承的对象
  • 不存在引用属性共享问题
  • 可传参
  • 函数可复用 缺点:
  • 调用了两次父类构造函数,生成了两份实例(后文会进行详细解释)

四、原型式继承

核心:对传入对象进行浅复制,作为原型对象 示例:

function object(o) {
    // 创建临时性构造函数
    function F(){}
    F.prototype = o;
    // 返回临时类型的新实例
    return new F();
}
var Person = {
    name: 'xiaozhang',
    colors: ["red", "blue", "green"]
};
var anotherPerson = object(Person);
anotherPerson.name = 'xiaoli';
anotherPerson.colors.push('black');
var yetAnotherPerson = object(person);
yetAnotherPerson.colors.push('yellow');
console.log(person.colors) // "red, blue, green, black, yellow"

ES5 中新增了 Object.create() 方法规范了原型式继承,这个方法接受两个参数:一个用作新对象原型的对象和(可选的)一个为新对象定义额外属性的对象,第二个参数与 Object.defineProperties() 方法的第二个参数格式相同:每个属性都是通过自己的描述符定义的。

因此上面的示例可重写为:

var Person = {
    name: 'xiaozhang',
    colors: ["red", "blue", "green"]
};
var anotherPerson = object.create(Person);
anotherPerson.name = 'xiaoli';
anotherPerson.colors.push('black');
var yetAnotherPerson = object.create(person);
yetAnotherPerson.colors.push('yellow');
console.log(person.colors) // "red, blue, green, black, yellow"

优点:

  • 支持多继承
  • 简单方便

缺点:

  • 包含引用类型的属性始终都会共享相应的值

五、寄生式继承

核心:创建一个仅用于封装继承过程的函数。 示例:

function createAnother(original) {
    // 通过调用Object.create()创建一个新对象
    var clone = Object.create(original);
    // 以某种方式增强这个对象
    clone.sayHi = function() {
            alert("hi");
    }
    // 返回这个对象
    return clone;
}
var person = {
    name: "xiaozhang",
    age: 23
}
var anotherPerson = createAnother(person);
another.sayHi(); // "hi"

优点:

  • 示例中使用的 Object.create() 方法并不是必须的,任何能够返回新对象的函数都适用于此模式。
  • 在主要考虑对象而不是自定义对象或者构造函数的情况下,寄生式继承显得更为灵活与方便。

缺点:

  • 使用寄生式继承来为对象添加函数时会由于不能做到函数复用而降低效率,这一点与构造函数模式类似。

六、寄生组合式继承

核心:通过寄生方式,砍掉父类型的实例属性,这样,在调用两次父类型的构造函数的时候,就不会初始化两次实例方法/属性,避免的组合继承的缺点。

组合继承是 JavaScript 最常用的继承模式,但是它最大的不足就是无论在什么情况下都会调用两次父类型构造函数,一次是在创建子类型原型的时候,另一次是在子类型构造函数内部,因此这导致子类型的原型上创建了不必要的实例属性,只不过当我们在子类型的实例中进行访问时被子类型构造函数中的实例属性所覆盖。以我们之前组合继承模式的示例为例:

function SuperType(name) {
    this.name = name;
    this.colors = ["red", "blue", "green"];
}
SupType.prototype.sayName = function() {
    console.log(this.name)
}
function SubType(name, age) {
    // 第二次调用父类型方法
    SupType.call(this, name);
    this.age = age;
}
// 第一次调用父类型方法
SubType.prototype = new SuperType();
SubType.prototype.sayAge = function () {
    console.log(this.age)
}

组合式继承的主要思路是通过借用构造函数来继承属性,通过原型链的混成形式来继承方法,而寄生式组合继承的优化主要是不必为了制定子类型的原型而调用父类型的构造函数,我们所需的无非就是父类型原型的副本而已,因此我们可以通过寄生式继承来继承父类型原型,然后再将结果制定给子类型的原型。

示例:

function SupType(name) {
    this.name = name,
    this.colors = ["red", "blue", "green"]
}
function SubType(name, age) {
    SupType.call(this, name);
    this.age = age;
}
// 实现寄生式继承的另一种思路
(function() {
    // 创建一个实例方法的类
    var clone = function(){};
    clone.prototype = SupType.prototype;
    // 将实例作为子类型的原型
    SubType.prototype = new SupType;
})();
// 向子类型的原型添加专属方法
SubType.prototype.sayAge = function() {
    alert(this.age)
}
var instance = new SubType("xiaozhang", 23)
console.log(instance.age) // 23
console.log(instance.name) // "xiaozhang"
instance.getAge() // 23

优点:

  • 只调用了一次父类型构造函数,因此避免了在子类型的原型上创建不必要的、多余的属性。
  • 原型链保持不变,能够正常使用 instanceofisPrototypeOf()

ES6 中的类

ES6 中引入了 class 关键字,并且在标准中删除了所有 [[class]] 相关的私有属性描述,类的概念正式从属性升级成语言的基础设施,从此,基于类的编程方式成为了 JavaScript 的官方编程范式。

但是 JavaScript 其基于原型的本质一直没有改变,ES6 中 class 关键字其本质上仍然是基于原型运行时系统的模拟,逻辑上 JavaScript 认为每个类是有共同原型的一组对象,类中定义的方法和属性则会被写在原型对象之上。我们以下面代码为例:

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  toString() {
    return '(' + this.x + ', ' + this.y + ')';
  }
}

其对应到 ES5 构造函数的写法为:

function Point(x, y) {
  this.x = x;
  this.y = y;
}

Point.prototype.toString = function () {
  return '(' + this.x + ', ' + this.y + ')';
};

var p = new Point(1, 2);

但是相比于 new 和 function 的这种怪异组合 class 无疑是个巨大的进步,而且箭头函数的提出也无疑表明了 JavaScript 想让函数回归其本质的想法,所以推荐大家还是尽可能使用 class 定义类并通过它实现对象的继承。

ES6 中的 class 主要通过 extends 实现继承,举个🌰:

class Foo {
  constructor() {
    this.name = 'foo'
  }
  
  toString() {
    return this.name
  }
}

class Bar extends Foo {
  constructor() {
    super();
    this.age = 16
  }
  
  toString() {
    return 'parent is ' + super.toString()
  }
}

const bar = new Bar();
bar.

子类 Bar 通过 extends 继承了父类 Foo,需要注意 constructor() 方法和 toString() 方法内部,都出现了 super 关键字。super 在这里表示父类的构造函数,用来新建一个父类的实例对象。

ES6 规定,子类必须在 constructor() 方法中调用 super(),否则就会报错。这是因为子类自己的 this 对象,必须先通过父类的构造函数完成塑造,得到与父类同样的实例属性和方法,然后再对其进行加工,添加子类自己的实例属性和方法。如果不调用 super() 方法,子类就得不到自己的 this 对象。

当我们使用类的思想来设计代码时,应该尽量使用 class 来声明类,而不是用旧语法,拿函数来模拟对象。

参考资料

《重学前端》专栏

《JavaScript 高级程序设计第三版》

ECMAScript 6 入门