「备战金三银四」你搞懂JS原型与继承了吗?(下)

237 阅读17分钟

「这是我参与2022首次更文挑战的第3天,活动详情查看:2022首次更文挑战

上一篇中,我们介绍了一些创建对象的方式,并引申出构造函数与原型的概念,那么原型与继承究竟有何种关系呢?如何利用原型实现继承呢?

另外,相信不少程序员,都看过“我要 new 出一个对象“这种段子,那么在 JavaScriptnew 运算符到底干了什么,它为什么可以创建出一个对象呢?

在本篇文章中,笔者将会对以上问题进行一一解答,并且会尝试手写实现相关继承方法与面试常考的 new 运算符与 Object.create() 方法,干货满满,一起来学习吧!😊

继承及其实现🖇

很多面向对象语言都支持两种继承:接口继承和实现继承。而在 JavaScript 中唯一支持的继承方式就是实现继承,这主要是倚靠原型链实现的。

原型链🧬

原型链与前述提到的原型模式有一定程度的融合,它是实现继承的必要基础,鉴于网上有不少介绍原型链的优秀文章,故在此不多赘述,打算通过一张图片,带大家理顺原型链的相关概念🚗。

经典原型链案例图

image-20211122112337725

本图的主要功能是:

  • 通过自定义的构造函数 Foo() 创建了两个实例 f1f2
  • 通过内置的构造函数 Object() 创建了两个实例 o1o2

接下来对该图的原型链进行解读:

① f1 与 f2 实例涉及的原型链

图中的 Foo() 是一个构造函数,它创建了两个实例对象 f1f2,按照前文所述,对象都有一个 __proto__ 属性,指向其构造函数的原型对象 prototype 。从图中可见,两个实例对象 f1f2 共享该区域,而在该原型对象中,又有一个 constructor 属性指回这个构造函数 Foo(),两者循环引用。

JavaScript 中,函数本身也可以算是一个对象,所以构造函数 Foo() 也存在着一个 __proto__ 属性,那它应该指向的是负责构造 Foo() 函数的内置构造函数 Function() 的原型对象 Function.prototype。同样地该原型对象也存在一个 constructor 属性指回这个内置构造函数 Function()

📌在此注意一下Function.prototype 实际上是一个函数,啊什么?不是说 prototype 是原型对象吗,为什么又是个函数了?😄如果你看到这的话,恭喜你又发现了一个 JavaScript历史遗留问题,详见:为什么 Function.prototype 可以直接执行?

对此,你可以大胆地记住一个结论:除了 MathJSON 是以对象形式存在的,它们的 __proto__Object.prototype 之外,其余所有构造器(包括自定义的)/函数的 __proto__ 都指向Function.prototype

typeof Function.prototype                // 'function'

String.__proto__ == Function.prototype   // true
Array.__proto__ == Function.prototype    // true
Math.__proto__ == Function.prototype     // false

对于 Function() 这个内置的构造函数来说,又存在着 __proto__ 属性。根据其定义,我们应该找到构造出 Function() 的构造函数,欸嘿!在图中找不着了。

那么究竟是谁创建了 Function() ?根据上述结论所言,所有构造器/函数的 __proto__ 指向了 Function.prototype。并且有如下代码辅佐验证:

Function.__proto__ == Function.prototype // true  (好奇怪!再看一遍)

可见,Function() 函数的 __proto__ 就指向自身的 prototype 属性。这么一看, Function() 确实有点厉害,有点创世神的意味嗷。

不过调侃归调侃,实际上这可能是设计该门语言之初的一个约定,我们无需纠结先有鸡还是先有蛋的问题,因为设计者已经把鸡和蛋都提供给开发者了。

🧐当然,如果你有更好的理解,欢迎你在评论区分享你的想法。

另外,这个原型“对象” Function.prototype 的特殊之处除了体现在它是一个函数,还体现在它没有一个函数该有prototype 属性,所以在这一方面上,它表现得还是和普通的原型对象类似(即只有 __proto__ 属性)。

因此,Function.prototype__proto__ 属性的仍和其它原型对象一样指向了 Object.prototype 万物之源——原型链的顶点。

而对于万物之源,它本质上也是一个对象,也拥有 __proto__ 属性,约定为 null

宇宙大爆炸前会是什么样子?

大概一片虚无。

② o1 与 o2 实例涉及的原型链

实例 o1o2 由内置构造函数 Object() 创建而成,故其 __proto__ 属性指向 Object.prototype ,与实例 f1f2 的分析过程类似。

而对于 Object() 这个内置构造函数来说,其 __proto__ 属性指向了 Function.prototype,也就是前文提到的创世神结论。

其余过程与前例类似,故不再赘述。

原型链操作的注意事项

📌需要注意的是:

  1. 不要随意地在原生对象(比如 ArrayString )的 prototype 上添加方法,有可能会造成同名覆写的问题。推荐的做法是创建一个自定义的类,继承原生类型。

  2. 如无必要,不要随便改写一个对象的原型:

    由于现代 JavaScript 引擎优化属性访问所带来的特性的关系,更改对象的 [[Prototype]](即修改继承关系)在各个浏览器和 JavaScript 引擎上都是一个很慢的操作,因为这个过程会涉及所有访问了那些修改过 [[Prototype]] 的对象的代码。

  3. __proto__ 是一个属性访问器,并非普通属性。若改写原型属性为非对象,改写设置将默认无效,这正是 setter 中做了操作行为的拦截判断。

实现继承✍

从上述的原型链例子来看,我们可以感受到,实现继承的方式大概就是将原型对象一个接一个地串起来,形成一种链条。但这样就足够了吗?🧐

这样将会导致一个直观的表现,比如:定义了很多个构造函数,都继承自一个父级。若在这些构造方法的原型对象上进行方法的定义,可能会造成彼此之间的重名覆写的问题。即👇:

function SuperType() {} // 父
function SonType() {}   // 子
function DauType() {}   // 女

// 继承 SuperType 
SonType.prototype = new SuperType();

// 继承 SuperType 
DauType.prototype = new SuperType();

// 子女平级、有可能造成同名覆写现象
SonType.prototype.getValue = ...
DauType.prototype.getValue = ... 

细心的你也许还发现了一些问题:这种模式在子类进行实例化时,无法向父类的构造函数传参进行定制。 并且仍未解决前述提到的共享区域内引用数据类型的问题。

所以实现继承并不是简单的将构造函数的原型重写为到“父辈” 实例。

对于前述提到的问题,曾有许多种继承方式进行解决,接下来笔者将进行一一介绍。

盗用构造函数继承

这种技术有时也称作“对象伪装”或“经典继承”,其核心思想在于:在子类的构造函数中通过 call() 调用父类的构造函数。

function father (name) {
  this.name = name
  this.a = [1,2,3]
}

function child () {
  // 继承父类属性,可传入参数
  father.call(this,'Tom')
  // 将会生成如下属性:
  // name:'tom'
  // a:[1,2,3]
}

采用该种方式可以区分子类实例的 this,即创造了属性副本。

简单来说,就是子类实例化调用子类构造函数时,会同时利用 call() 调用父类构造函数生成新的属性,既继承了父类的属性与方法,也绑定了 this 的指向,使得每个实例的数据隔离,解决了引用共享的问题。在这里需要注意的是,父类的属性或方法可能覆盖原有子类的属性或方法,故调用父类构造方法最好放在子类构造方法的开头。

此种方法优点在于简单直接,可以向父类构造函数传递参数,但缺点也很明显:

  1. 实例继承的方法被多次创建,不能重用。(🧐前文提到的构造函数模式的弊端再一次出现)。
  2. 子类不能访问父类上定义的方法,故算不上真正意义的继承。

基于此问题,引申出下一种继承方式。

组合继承

该方式也叫做伪经典继承。其核心思路是:重写子类的原型对象为父类实例,并通过盗用构造函数继承父类实例的属性。

function father (name) {
  this.name = name
  this.a = [1,2,3]
}

father.prototype.getName = function(){}  // 方法定义在父类原型上(公共区域)

function child () {
  // 继承父类属性,可传入参数
  father.call(this,'Tom')
  // 将会生成如下属性:
  // name:'tom'
  // a:[1,2,3]
}
child.prototype = new father() // 重写原型对象

这个方法在第一种经典继承方法的基础之上,重写了子类的原型为父类实例,使得子类可以与父类的原型挂钩(子类实例的 __proto__ 属性指向了父类的原型对象)

故这种方法是 JavaScript使用得最多的继承方式。

缺点在于:效率不高,实际上调用了两次父类构造函数,并且上述重写原型对象的语句都会造成 constructor 的缺失,填补该属性的周到写法如下:

// 对象工厂,负责重写原型链
function extend(child, father) {
  // 重写了子类的原型对象,指向了父类实例
  child.prototype = Object.create(father.prototype);
  // 补回重写后的原型对象上缺失的 constructor 属性,指向子类,并不允许遍历
  Object.defineProperty(child.prototype, "constructor", {
    value: child,
    enumerable: false,
  });
}

function Person(x,y,z){
	...
}
  
function Man(...args){
  // 盗用构造函数继承实例属性
  Person.apply(this, args)
}
  
extend(Man,Person); // 重写原型链,并补上构造器属性

原型式继承

在介绍后续继承方法之前,得先介绍这样一个怪怪的继承方法,它并不属于严格意义上的继承,该方法的出发点是即使不自定义类型也可以通过原型实现对象之间的信息共享。

注意共享二字,故前述的共享引用数据问题在此不再算是一个缺点。其实现代码为:

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

let father = {}

let son = object(father)
let daughter = object(father)

基本思路是,对传入的对象做了一次浅复制,并赋值给一个空函数 F (临时类型)的原型对象,并返回一个通过 F 生成的实例。这个实例的 __proto__ 自然而然地指向了传入的对象,可以理解为一个挂钩🧷的过程。

ECMAScript 5 中,通过增加 Object.create() 方法将原型式继承的概念规范化,即替代了上述自定义的 object() 函数。所以对于 Object.create() 的手写实现,核心思路与上述的自定义函数类似,只是添加了部分参数校验的环节。具体实现请看文末。

let son = Object.create(father)  // 等同于上述代码

原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。但要记住,属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。

寄生式组合继承

前述的组合继承效率不高的问题,主要体现在父类构造函数将会被调用两次:一次在是重写子类原型时调用,另一次是在子类构造函数中调用。即:

function child () {
  father.call(this)  // 通过 call 调用父级构造函数,创建一个属性副本区域
}
child.prototype = new father() // 重写原型对象为父类实例,调用父级构造函数

实际上,我们重写原型对象的时候,目的是将子类与公共区域联系起来,便于后续实例取得可重用的方法。但 child.prototype = new father() 这一语句,还会将父类构造函数定义的属性“打包”进子类的原型对象内

而在第二次调用生成实例的时候,实例也会拥有这些值,并且是根据传入的参数“定制”的,故这个子类原型对象上的属性是多余的

这并不会导致代码出错,所以才称为效率问题,好在这个问题可以通过寄生式组合继承方法改进

其核心思路是:对于属性继承,仍是通过盗用构造函数方式,但在继承方法的时候,则使用混合式原型链继承方法。

混合式原型链继承方法,就是不通过调用父类构造函数给子类原型赋值,而是通过一个空函数(即忽略掉原构造函数的初始化代码),取得父类原型对象的一个副本,并且通过这个新的空函数生成一个干干净净的实例,且其 __proto__ 属性指向父类构造函数的原型对象,这个便是前述原型式继承的思想。然后再对该实例对象进行 constructor 属性的复原。

简单来说,就是创建一个新实例将其 __proto__ 属性指向父类的原型对象 superType.prototype实际上这个新实例并没有直接调用父类构造函数,所以解决了前述的效率问题, 随后补充该实例的属性,就可以生成一个干干净净的实例,包括 __proto__constructor 属性。

更直白地说,相当于通过 new 运算符调用了构造函数,但却没有执行方法体内的初始化代码。

寄生式组合继承的基本模式如下所示:

function inheritPrototype(subType, superType) { 
  let prototype = Object.create(superType.prototype); // 创建父类原型对象副本
  prototype.constructor = subType; // 补充属性(全面的写法请参考上述组合继承的工厂函数) 
  subType.prototype = prototype;   // 重写子类原型对象
}

function child () {
  father.call(this)  // 通过 call 调用父级构造函数,创建一个属性副本区域
}

// child.prototype = new father()  重写原型对象,调用父级构造函数

inheritPrototype(child, father);

Class 继承

前文深入讲解了如何只使用 ES5 的特性来模拟类似于类(class-like)的行为。不难看出,各种策略都有自己的问题,也有相应的妥协。正因为如此,实现继承的代码也显得非常冗长和混乱。

对此,在 ES6 中提出了一种基础性语法糖结构——类(class)。

为什么叫做语法糖? 因为类表面上看起来可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念。

它实现继承的方式简洁而优雅,使用一个关键字 extends 以及 super() 即可,extends 的操作就相当于寄生式组合继承方法中的重写原型,而 super() 就是子类构造函数调用父类构造函数的过程。

class Vehicle { 
 constructor() { 
 this.hasEngine = true; 
 } 
} 
class Bus extends Vehicle { 
 constructor() { 
 // 不要在调用 super()之前引用 this,否则会抛出 ReferenceError 
 super(); // 相当于 super.constructor() 
 console.log(this instanceof Vehicle); // true 
 console.log(this); // Bus { hasEngine: true } 
 } 
} 
new Bus();

综上可见,语言特性的发展也是渐进的、继承的。新特性往往都是由旧特性继承而来,并加以改造,以此提高开发者的效率。

实现 new 运算符✍

前文花费大量篇幅讲解了原型与继承的概念,但常常伴随出现的 new 运算符又是什么?实现 new 运算符作为一道面试常考的手写题,笔者认为有必要在此作进一步解读。

当我们使用 new 运算符调用构造函数创建对象时,这个步骤会执行如下操作:

(1) 在内存中创建一个新的空对象。

(2) 这个新对象内部的 [[Prototype]] 属性(即 __proto__ )被赋值为构造函数的 prototype

(3) 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。

(4) 执行构造函数内部的代码(给新对象添加属性)。

(5) 如果该函数没有返回对象,则返回 this,即这个创建好的实例。

基于此思路,我们可以得出如下实现(注释版):

function objectFactory() {
    // 取出第一个参数,在此默认传入的是一个构造函数
    // 可见,严谨些还需对参数进行校验,在此只是简单实现
    // arguments 是一个类数组,所以调用 shift() 需要配合 call 使用
    var Constructor = [].shift.call(arguments);  
    
    // var obj = new Object(); // 1. 创建空对象
    // obj.__proto__ = Constructor.prototype; // 2.将这个新对象的__proto__属性设为构造函数的原型对象
    
    // 以上两行可以用 ES6 方法一行实现,同时也避免了模拟 new 却又使用了 new 的情况,如下:
    var obj = Object.create(Constructor.prototype)
    
    // 3.利用 apply() 将 this 指向这个新对象
    // 4.传入剩余参数,执行该构造函数
    var ret = Constructor.apply(obj, arguments);
    
    // 5.判断构造函数的返回类型
    // 若无返回值,则直接返回新实例 obj
    // 若有返回值则检查其返回值是否为引用类型。
    // - 是引用类型:返回该引用类型 ret
    // - 非引用类型:仍然返回新实例 obj
    
    // ret || obj 这里这么写考虑了构造函数显示返回 null 的情况
    // typeof null == 'object' 是一个历史遗留问题,可参考之前的文章~
    return typeof ret === 'object' ? ret || obj : obj;
};

纯享版与使用方法如下:

function objectFactory() {
    Constructor = [].shift.call(arguments);
    var obj = Object.create(Constructor.prototype)
    var ret = Constructor.apply(obj, arguments);
    return typeof ret === 'object' ? ret || obj : obj;
};

function Person(name, age) {
    this.name = name
    this.age = age
}
let p = objectFactory(Person, '布兰', 12)  // new Person('布兰', 12)
console.log(p)  // { name: '布兰', age: 12 }

实现 Object.create()✍

基于前文,简单来说 Object.create() 的功能就是绕过 new 运算符调用构造函数这一过程,可以让一个空对象直接成为某一构造函数的实例,即使用现有的对象来提供新创建的对象的 __proto__,且不会执行该构造函数内的初始化代码。

而实际上的 Object.create() 还可选地传入第二个参数,为这个新实例添加属性。

针对这一思路出发,我们可以实现如下代码:

Object.myCreate = function(proto, propertyObject = undefined) {
    // 参数校验
    if (typeof proto !== 'object' && typeof proto !== 'function') {
        throw new TypeError('Object prototype may only be an Object or null.')
    // 不能传一个 null 值给实例作为属性
    if (propertyObject == null) {
        new TypeError('Cannot convert undefined or null to object')
    }
    // 原型式继承的思想:用一个空函数(即忽略掉原有构造函数的初始化代码)创建一个干净的实例    
    function F() {}
    F.prototype = proto // 确定后续的继承关系
    const obj = new F()
    
    // 如果有传入第二个参数,将其设为 obj 的属性
    if (propertyObject != undefined) {
        Object.defineProperties(obj, propertyObject)
    }
        
    // 即 Object.create(null)  创建一个没有原型对象的对象
    if (proto === null) {
        obj.__proto__ = null
    }
    return obj
}

总结💬

本文从一张原型链图的剖析出发,引申出继承的概念,又总结了几种不同的实现继承方法。并比较了它们的优劣点,如下表:

思想优点缺点
盗用构造函数继承在子类构造函数中调用父类构造函数实现属性绑定。实现了各实例之间的数据隔离。实例继承的方法不可重用;并且子类不可以访问父类上定义的方法。
组合继承基于上一方法,重写子类的原型为父类实例。解决了方法不可重用的问题。效率不高,调用了两次父类构造函数;需要手动补充 constructor 属性。
原型式继承创建一个空函数改写自身的原型对象为父类构造函数的原型,并通过该空函数生成一个新实例,其 __proto__ 指向了父类构造函数的原型。适合不需要单独创建构造函数的场景。属性中包含的引用值始终会在相关对象间共享。
寄生式组合继承基于组合继承,利用 Object.create() 方法改写子类原型,该方法可以避免直接调用父类构造函数。提高创建对象的效率代码冗长
Class 继承基于 ES5 实现继承的思路提出的语法糖结构。简洁优雅

实际开发中,更倾向于利用 ES6 class 实现继承,其本质上是 ES5 实现继承的语法糖,只要你搞懂了实现继承的思路,就可以很快上手该特性。

最后参考了 @大海我来了 大佬的文章,在此感谢大佬做了一个整合,在此笔者只是对 new 运算符和 Object.create() 的实现进行了补充说明,加深印象的同时,也方便大家更直观地了解各步骤的意义。

最后,很感谢你可以看到这里,也期待你可以留下一个印记!👍

若文章出现了纰漏或者你有更好的建议,欢迎评论区留言或是私信指出。✨

参考资料📜

死磕 36 个 JS 手写题(搞懂后,提升真的大)

MDN: New 运算符

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