likes
comments
collection
share

从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

作者站长头像
站长
· 阅读数 22

往期回顾

前言

在本篇文章中,我们会继续探讨 JavaScript 中实现继承的方式。并聊一聊ES5、ES6所带来的新规范对于实现继承的影响。

阅读完上、下两篇文章后,我想你应该再也不会担心面试官让你现场手写一个 JavaScript 的继承了。如果可以的话,你还能当场给他画一个原型、构造函数、实例的关系图。

闲话就说到这,让我们开始今天的学习~

Object.create

在上一篇文章的末尾,聊【原型式】继承的时候,我们探讨了是否存在一种更便捷的写法,能够不需要写那么多行代码:

var TempCtor = function () {}; 
TempCtor.prototype = Car.prototype; 
SportsUtilityVehicle.prototype = new TempCtor();

便捷的写法是存在的,那就是利用ES5推出的 Object.create()

Object.create()  静态方法以一个现有对象作为原型,创建一个新对象。

换句话说,ES5 通过新增 Object.create()将【原型式】继承的概念规范化了。

入参

使用

接下来,我们就利用 Object.create() 来修改一下之前我们写的代码:

SportsUtilityVehicle.prototype = Object.create(Car.prototype);

然后我们看一下运行后的结果:

从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

可以看到,没有任何问题,并且代码变得简洁了许多。

注意事项

在【入参】这一小节,我们介绍了 Object.create 所接收的2个参数。当我们在给第1个参数,也就是 proto 赋值时,要注意传值的类型,只能是 null 或者 对象类型,否则会抛出异常。

从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

延伸面试题

如何创建一个真正空空对象

如果我们使用let a = {} 这样创建一个空对象,那么实际上它并不是真正的空,因为变量a存在[[Prototype]]/__proto__,指向它的原型。

从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

此时,我们可以使用Object.create(null)去创建一个真正空的空对象。

从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

源码对比

在【注意事项】章节里,我特意提了一嘴关于第1个入参的使用限制。还没有聊第2个入参的使用建议。

这是因为我想把它留到这个章节里来说,因为 inherits-browser 的源码里也使用到了Object.create() 去实现继承,并且它还传入了一个对象作为第2个入参。

// implementation from standard node.js 'util' module
module.exports = function inherits(ctor, superCtor) {
  if (superCtor) {
    ctor.super_ = superCtor
    ctor.prototype = Object.create(superCtor.prototype, {
      constructor: {
        value: ctor,
        enumerable: false,
        writable: true,
        configurable: true
      }
    })
  }
};

它的写法是不是很妙,或者说很优雅?

既然我们都使用了 Object.create,为啥我们还要手动去多写一行:

SportsUtilityVehicle.prototype.constructor = SportsUtilityVehicle;

来解决默认 constructor 丢失的问题呢?

如果我们在面试的时候,面试官要求我们手写继承,我们还是用这种老套的写法,他肯定会追问我们 Object.create的第二个参数有什么作用,为什么我们不用。

如果我们回答不出来,是不是就会被他怀疑我们是默写的继承代码,而没有去真的了解过代码的写法。

这显然不是我们所期望的,我们期望自己能够尽量做到无懈可击,不露出弱点。

所以一起来仔细地学一学 Object.create 的第2个入参吧~

我们回顾一下第2个参数的定义:

定义很长,我们提取一些关键词:

  1. 可枚举的自有属性
  2. 将为新创建的对象添加
  3. Object.defineProperties() 

不知道这些关键词大家熟悉多少?别担心,我们会一一介绍。

属性的类型

在聊什么是【可枚举的自有属性】之前,我们先来看看什么是属性的类型。

ECMA-262 使用一些内部特性来描述属性的特征。这些特性是由为 JavaScript 实现引擎的规范定义 的。因此,开发者不能在 JavaScript 中直接访问这些特性。为了将某个特性标识为内部特性,规范会用 两个中括号把特性的名称括起来,比如 [[Enumerable]]

属性分两种:数据属性访问器属性

——《JavaScript高级程序设计》

【数据属性】顾名思义,就是保存数据值的属性。

具体来说,数据属性包含一个保存数据值的位置,这个位置既可以用于值的读取,也可用于值的写入

目前存在4个特性,用于描述数据属性的行为:

  • [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。

    它的取值是 true 或 false。

    • true:可以通过 delete 删除并重新定义;可以修改它的特性;可以把它改为访问器属性。
    • false不可以通过 delete 删除并重新定义;不可以修改它的特性;不可以把它改为访问器属性。

    默认情况下,所有直接定义在对象上的属性的这个特性都是true,我们可以通过代码来看下实际情况:

    从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

    由上图可以看到,直接定义在a上的name属性可以被修改,被删除。

  • [[Enumerable]]:表示属性是否可以通过for-in循环返回。

    它的取值是 true 或 false。

    • true:可以通过for-in循环返回。
    • false不可以通过for-in循环返回。

    默认情况下,所有直接定义在对象上的属性的这个特性都是true,我们也可以通过代码来看下实际情况:

    从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

    由上图可以看到,直接定义在a上的nameagegender 属性均可以通过for-in循环返回。

  • [[Writable]]:表示属性的值是否可以被修改。

    它的取值是 true 或 false。

    • true:可以被修改。
    • false不可以被修改。

    默认情况下,所有直接定义在对象上的属性的这个特性都是true,我们当然可以通过代码来看下实际情况:

    从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

    由上图可以看到,直接定义在a上的age属性可以被修改。

  • [[Value]]:包含属性实际的值。这就是我们先前聊到的那个读取和写入属性值的位置

    这个特性的默认值为 undefined

终于,我们结合实际的代码例子以及特性的定义语言,看完了这四个特性,小憩一下,整理一下目前的思绪,我们再继续后续的扩展。

从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

OK,休息结束,咱们继续聊。

在聊这4个特性的时候,我们一直在说默认情况是true、默认情况是true,那么有没有什么方式,能够让我们手动去修改这些特性的值呢?比如手动将 [[Writable]] 改为 false

有的,那就是 Object.defineProperties() 。

我们可以使用Object.defineProperties()来修改属性的默认特性。

这个方法接收3个参数: 要给其添加属性的对象、属性的名称和一个描述符对象

最后一个参数,即描述符对象上的属性可以包含:configurable、enumerable、writable 和 value,跟相关特性的名称一一对应。

根据要修改的特性,可以设置其中一个或多个值。

我们通过一个代码例子直观地看下它的使用:

let a = {};
a.name = "海石";
Object.defineProperty(a, "age", {
  value: 18,
  writable: false,
  configurable: false,
  enumerable: false,
});

我们创建了一个对象 a ,并通过2种不同的方式给它设置了2个属性

  • 默认方式设置 name
  • Object.defineProperty 设置 age

👇然后我们来看看面对同样的操作行为,这2个属性的表现吧👇:

从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

由上图可以看到,当我们手动将对象 aage 属性的4个特性全部改为 false 后,我们对 age 属性做的删除、修改、遍历操作全部都失败了。

在了解完毕数据属性及其描述特性之后,我们再回过头来看看之前的源码:

ctor.prototype = Object.create(superCtor.prototype, {
  constructor: {
    value: ctor,
    enumerable: false,
    writable: true,
    configurable: true,
  },
});

现在我们是不是特别好理解 Object.create 函数所接收的第2个参数的作用了?

这个参数的作用就是给 Object.create 即将返回的新对象增加一个属性,并且能够显示地设置这个属性的4个特性

通过SportsUtilityVehicle.prototype.constructor = SportsUtilityVehicle;这样的方式设置的 constructor 是默认可枚举的。

而实际上的constructor不可枚举的属性。

(我们可以通过 Object.prototype.propertyIsEnumerable() 来判断一个属性是否可枚举)

请出我们的老朋友 Car,来给我们做个演示。

从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

因此我们也要保持constructor不可枚举的这一特点,所以我们应该再次修改一下之前写的【组合继承】的代码:

function Car(brand, engine, wheels) {
  this.brand = brand;
  this.engine = engine;
  this.wheels = wheels;
}

Car.prototype.getBriefIntro = function () {
  return "This is a " + this.brand + " car with " + this.engine + " engine.";
};

function SportsUtilityVehicle(brand, engine, wheels, type, size) {
  Car.call(this, brand, engine, wheels);
  this.type = type;
  this.size = size;
}

SportsUtilityVehicle.prototype = Object.create(Car.prototype, {
  constructor: {
    value: SportsUtilityVehicle,
    enumerable: false,
    writable: true,
    configurable: true,
  },
});
var suv = new SportsUtilityVehicle("Benz", "V8", 4, "SUV", 5);

从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

继承之【寄生】

还记得基于【原型链继承】实现的【组合继承】嘛?

其实在本文中,我们一直在说的【组合继承】,包括刚刚我们改版后的代码,实际上都是基于【原型式继承】实现的。

我知道,讲到这里大家可能会有点混淆了,其实我也觉得没必要这样死抠细节,只要给出最佳实践就可以了。

但是为了说明和解释清楚,所以在这里再唠叨一句:

原型链继承】和【原型式继承】的区别就在于重写原型的方式。

  • 原型链继承:
SportsUtilityVehicle.prototype = new Car();
  • 原型式继承:
var TempCtor = function () {};
TempCtor.prototype = Car.prototype;
SportsUtilityVehicle.prototype = new TempCtor();

而如果我们使用基于【原型链继承】实现的【组合继承】,这会存在一个问题,那就是当我们创建一个子类的实例时,我们会调用两遍父类的构造函数。

从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

因此当我们不期望调用多次父类的构造函数,并且还期望对继承后的对象做一些强化工作(比如创建一些子类原型上独有的函数方法),我们就可以使用【寄生继承】。

与原型式继承比较接近的一种继承方式是寄生式继承(parasitic inheritance),也是 Crockford首倡的 一种模式。寄生式继承背后的思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种 方式增强对象,然后返回这个对象。

——《JavaScript高级程序设计》

它的写法和原型式继承非常的相似:

function createSUVProtptype() {
  var TempCtor = function () {};
  TempCtor.prototype = Car.prototype;
  let clone = new TempCtor();
  clone.getType = function () {
    console.log("Hello, This is SUV.");
  };

  return clone;
}

从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

我们可以看到,【寄生继承】本身实际上并不关注构造函数。

我们通过 createSUVProtptype() 方法返回一个被加强后的对象(加强是指这里给它新增了一个函数方法)作为 SUV 的原型。

上述演示的例子实际上并不纯粹是【寄生继承】,而是结合了【盗用构造函数】的【寄生组合继承】。

从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

继承之【寄生组合】

不关注构造函数、类似工厂模式的【寄生继承】也缺失了类型标识(上一篇有提到有关类型标识的问题,可以回顾一下),这显然并不是一个完善的实现继承的方法。

于是就和【组合继承】一样,我们要使用【寄生组合继承】。

当我们阅读完这么多章节的内容后,不知道大家产生了多少想法?

一千位读者有一千位哈姆雷特。

就我个人而言,我觉得【寄生组合继承】就是 plus 版本的基于【原型式继承】实现的【组合继承】。

不知道各位又是什么见解呢?方便的话,欢迎在评论区交流~

因此如果面试官让我们写一个完善的【寄生组合继承】,我们用下方的最终版代码应该就可以了:

function Car(brand, engine, wheels) {
  this.brand = brand;
  this.engine = engine;
  this.wheels = wheels;
}

Car.prototype.getBriefIntro = function () {
  return "This is a " + this.brand + " car with " + this.engine + " engine.";
};

function SportsUtilityVehicle(brand, engine, wheels, type, size) {
  Car.call(this, brand, engine, wheels);
  this.type = type;
  this.size = size;
}

SportsUtilityVehicle.prototype = Object.create(Car.prototype, {
  constructor: {
    value: SportsUtilityVehicle,
    enumerable: false,
    writable: true,
    configurable: true,
  },
});

var suv = new SportsUtilityVehicle("Benz", "V8", 4, "SUV", 5);

ES6之后的继承

经过上面内容的阅读,我们不难感受到,使用原型和原型链的方式去实现继承,哪怕是使用【寄生组合继承】,都要写非常长的一段代码,真的太累人了,🤦‍。

此时此刻,不禁在内心中呼唤,英雄在哪里,帮助我们脱离代码苦海的英雄何时现身?

回应我们的是ES6,它带着 class 关键字来拯救我们了!

如何用 class 关键字实现继承?

Talk is cheap, show me the code.

class Car {
  constructor(brand, engine, wheels) {
    this.brand = brand;
    this.engine = engine;
    this.wheels = wheels;
  }

  getBriefIntro() {
    return `This is a ${this.brand} car with${this.engine} engine.`;
  }
}

class SportsUtilityVehicle extends Car {
  constructor(brand, engine, wheels, type, size) {
    super(brand, engine, wheels);
    this.type = type;
    this.size = size;
  }
}

const suv = new SportsUtilityVehicle("Benz", "V8", 4, "SUV", 5);

告诉我看完上面代码的第一感受是什么?

少,没错,就是少!

使用 class 关键字来实现继承,字符数出现了显著的下降:

641 => 469

整整少了 27% 的代码量,太爽了~

当然, class 关键字也并不是随心所欲地使用的,要通过它来实现继承,需要遵循以下使用规范。

使用规范

使用 extends 关键字实现继承

如果我们期望子类继承父类,则我们需要使用extends 关键字。

class SportsUtilityVehicle extends Car {}

在子类的构造函数中使用 super 关键字

使用extends 关键字只是第一步,我们还必须注意,要在子类的构造函数中使用 super 关键字。

class SportsUtilityVehicle extends Car {
  constructor(brand, engine, wheels, type, size) {
    super(brand, engine, wheels);
    this.type = type;
    this.size = size;
  }
}

使用 super 时,还有一些注意事项:

  • super 只能在派生类构造函数和静态方法中使用。

  • 不能单独引用 super 关键字,要么用它调用构造函数,要么用它引用静态方法。

  • 调用 super() 会调用父类构造函数,并将返回的实例赋值给 this

    • 因此我们可以看到,当我们在SportsUtilityVehicle的构造函数内部调用 super()时,我们需要传递参数:brand, engine, wheels,因为父类 Car 的构造函数中需要这些参数。
  • 如果没有定义类构造函数,在实例化派生类时会调用 super(),而且会传入所有传给派生类的 参数

    假如我们没有在SportsUtilityVehicle的构造函数内部调用 super(),则会默认在实例化的时候调用 super(),此时通过实例化时传入的参数,会被传给 super(),这一点我们可以通过代码例子来看下:

    class Car {
      constructor(brand, engine, wheels) {
        this.brand = brand;
        this.engine = engine;
        this.wheels = wheels;
      }
    
      getBriefIntro() {
        return `This is a ${this.brand} car with${this.engine} engine.`;
      }
    }
    
    class SportsUtilityVehicle extends Car {
      constructor(brand, engine, wheels, type, size) {
        this.type = type;
        this.size = size;
      }
    }
    
    const suv = new SportsUtilityVehicle("Benz", "V8", 4, "SUV", 5);
    

    上面这样的写法会报错,因为这和下一条规则有关呢

    从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

  • 在类构造函数中,不能在调用 super() 之前引用 this

    为了遵循这条规则,我们再修改一下代码:

    class Car {
      constructor(brand, engine, wheels) {
        this.brand = brand;
        this.engine = engine;
        this.wheels = wheels;
      }
    
      getBriefIntro() {
        return `This is a ${this.brand} car with${this.engine} engine.`;
      }
    }
    
    class SportsUtilityVehicle extends Car {
      constructor(brand, engine, wheels, type, size) {}
    }
    
    const suv = new SportsUtilityVehicle("Benz", "V8", 4, "SUV", 5);
    

    这次我们删干净了SportsUtilityVehicle的构造函数内部中的 this,再试一次

    从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

    还是报错,为啥?唉,其实这又和下一条规则有关

  • 如果在派生类中显式定义了构造函数,则要么必须在其中调用 super(),要么必须在其中返回 一个对象。

    得得,这次我们再在SportsUtilityVehicle的构造函数内部返回一个空对象。

    class Car {
      constructor(brand, engine, wheels) {
        this.brand = brand;
        this.engine = engine;
        this.wheels = wheels;
      }
    
      getBriefIntro() {
        return `This is a ${this.brand} car with${this.engine} engine.`;
      }
    }
    
    class SportsUtilityVehicle extends Car {
      constructor(brand, engine, wheels, type, size) {
        return {};
      }
    }
    
    const suv = new SportsUtilityVehicle("Benz", "V8", 4, "SUV", 5);
    

    然后我们运行代码看看:

    从开源项目聊JavaScript中的继承(下篇)很碰巧的是,JavaScript的原型和原型链、继承等都是面试中比较热门

    报错是没有了,但是代码也没用了.....

    因此我们还是要记住,一定要在子类的构造函数中调用 super() 哦。

结语

经过上、下两篇文章的阅读,我想我们对于 JavaScript 中的继承应该具备了充足的理论基础。但是这就和我之前写变量提升相关的博客一样:

对于继承,我们也需要整一篇【面试强化版的文章】,通过具体问题的磨练,来加强我们对于理论知识的掌握和理解。

当我们能够消化问题背后的原理时,我们回答问题时也就能淡定自若,口若悬河。

讲故事背故事,在境界上可谓是天差地别。

期待与你在之后的【面试强化版文章】相遇~

转载自:https://juejin.cn/post/7417290543557263394
评论
请登录