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

484 阅读6分钟

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

相信不少人知道,面向对象的思想,就是将现实世界的事物抽象成对象,现实世界中的关系抽象成类、继承,以此实现对现实世界的抽象与数字建模。

而在 ECMAScript 6 规范发布之前,JavaScript 并没有正式支持面向对象的结构,比如类和继承,但在那时侯开发人员还是通过原型继承成功地模拟了相关行为。

那么问题来了,什么是原型?为什么它可以实现继承呢?你可以写出几种继承的方式呢?

🙌懂了当复习,不懂也没关系!本专题将从创建对象的方式出发,探讨 JavaScript 中的原型与继承的关系,并手写实现面试常考的 new 运算符与相关实现继承的方法。

🚗接下来就让我们带着以上问题进行阅读吧!

从创建对象说起🔎

既然要迎合面向对象的思想,那我们从最基本的对象开始讲起。

JavaScript 中,简单直观的创建对象方法便是通过字面量创建,即 const foo = {},但这种方式也有明显不足:创建具有同样接口的多个对象需要重复编写很多代码

所以在以往的实际开发中,通常采用以下的方式进行对象的的创建👇。

构造函数模式

我们可以用构造函数来创建特定类型对象。构造函数与普通函数具体差异体现在调用方式不同,任何函数只要使用 new 运算符调用就属于构造函数。

而如果对一个构造函数进行普通调用,结果会将函数体内的属性和方法添加到 window 对象。究其原因,是函数体内的 this 指向了 window 对象。

function Person() {
  this.name = "Jake"; 
  this.sayName = function() {     
      console.log(this.name); 
};

// 作为函数调用
Person("Greg", 27, "Doctor");   // 添加到 window 对象 
window.sayName();    // "Greg" 
    
let p = new Person("Greg", 27, "Doctor") // 利用 new 运算符实例化

从上述代码可见,构造函数将实例对象需要的属性与方法封装在一个构造方法体内,改善了字面量创建对象时代码冗余的缺点。

但同时又引出了一个新的问题,其方法体内定义的实例方法会在每个实例上都创建一遍,尽管实际上二者执行的逻辑是一样的,即不同实例上的函数虽然同名但却不相等(具体体现在内存地址)。

也许聪明的你会想到,把构造函数内的方法抽离出来,在外部定义不就解决了上述问题了吗?即:

function Person(name, age, job){ 
 this.name = name; 
 this.sayName = sayName; 
} 
// 将方法定义在构造函数外部
function sayName() { 
 console.log(this.name); 
}

没错,这样的确可以解决相同逻辑的函数重复定义的问题,但全局作用域也因此被搞乱了,不便于后续项目的维护。

面对这个新问题,我们可以通过原型模式来解决,这个模式也涉及到本文要介绍的主角:原型

原型模式

要理解原型模式,首先得搞懂一些概念。

prototype 是什么?

JavaScript 中,无论何时,只要创建一个函数,引擎内部就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象),所以 prototype 就是原型,它本质上是一个对象。那么它具体又是什么?👇

prototype 作为构造函数上的属性,给所有由该构造函数创建的实例提供了一个公共区域,这些实例可以访问这个公共区域,并共享使用这个区域内的属性和方法。

同时,所有原型对象 prototype 自动获得一个名为 constructor 的属性,指回与之关联的构造函数,即两者循环引用

function Person() {} 
Person.prototype.name = "Nicholas"; 
Person.prototype.age = 29; 
Person.prototype.job = "Software Engineer"; 
Person.prototype.sayName = function() { 
 console.log(this.name); 
};

let person1 = new Person(); 

let person2 = new Person(); 

console.log(person1.sayName == person2.sayName); // true
console.log(person1.__proto__ == person2.__proto__); // true
console.log(Person.prototype.constructor);  // ƒ Person() {}

上述代码中,向构造函数的 prototype 属性添加自定义的属性和方法,便可被对象实例共享使用。利用这一机制,解决了构造函数方法体内定义的实例方法被重复创建的问题。

__proto__ 又是什么?

当我们每次调用 new 运算符调用构造函数创建一个新实例时,这个被创建出来的新实例的内部 [[Prototype]] 指针就会被赋值为构造函数的原型对象 prototype实际上,脚本中没有访问这个 [[Prototype]] 特性的标准方式,但 FirefoxSafariChrome 会在每个对象上暴露__proto__属性,通过这个属性可以访问实例的原型 prototype

__proto__prototype 的联系

简而言之,构造函数的 prototype 属性指向了一个公共区域,这个区域包含了定义好的方法与属性,所有由构造函数创建的实例对象都可以通过 __proto__ 访问到该公共区域,这个公共区域就是原型对象 prototype ,简称原型。

而原型对象 prototype 作为对象,也存在一个 __proto__ 属性可以访问到上一层公共区域,读到这里,有点要素察觉的意味对吧😆,没错这就是原型链的概念,也是实现继承的实现基础。

📌需要注意的是:

  1. 不一定所有对象都有原型。 通过 Object.create(null,{}) 生成的对象便没有原型。
  2. 留意共享的弊端。 原型上的所有属性是在实例间共享的,而对于这个公共区域内的引用数据类型来说,一处改动(比如往数组内添加元素)将会影响所有实例。但一般来说,不同的实例应该有属于自己的属性副本。所以原型模式也存在弊端。 该如何解决呢?且看下文分解😉。

总结💬

为了更好地介绍原型对于实现继承的作用,笔者将该部分内容分为上下两篇进行讲解,🥺望各位读者理解包涵。在本文中,笔者从创建对象的方法讲起,对比了各种方式的优势与不足,并引出了原型这一概念,为后文的继承概念作了铺垫。

下表是对本文介绍的创建对象方法的总结:

字面量创建构造函数模式原型模式
优点简单直观解决了代码冗余的问题解决了方法不可重用的问题
缺点创建同样接口的多个对象需要重复编写很多代码定义的方法不可重用原型上的引用数据将被所有实例共享,容易出错。

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

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

预告🔊

  • 原型链是什么?
  • 有什么实现继承的方式?
  • new 运算符到底干了什么?
  • 明天见😉