「这是我参与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]] 特性的标准方式,但 Firefox、Safari 和 Chrome 会在每个对象上暴露__proto__属性,通过这个属性可以访问实例的原型 prototype。
__proto__ 与 prototype 的联系
简而言之,构造函数的 prototype 属性指向了一个公共区域,这个区域包含了定义好的方法与属性,所有由构造函数创建的实例对象都可以通过 __proto__ 访问到该公共区域,这个公共区域就是原型对象 prototype ,简称原型。
而原型对象 prototype 作为对象,也存在一个 __proto__ 属性可以访问到上一层公共区域,读到这里,有点要素察觉的意味对吧😆,没错这就是原型链的概念,也是实现继承的实现基础。
📌需要注意的是:
- 不一定所有对象都有原型。 通过
Object.create(null,{})生成的对象便没有原型。 - 留意共享的弊端。 原型上的所有属性是在实例间共享的,而对于这个公共区域内的引用数据类型来说,一处改动(比如往数组内添加元素)将会影响所有实例。但一般来说,不同的实例应该有属于自己的属性副本。所以原型模式也存在弊端。 该如何解决呢?且看下文分解😉。
总结💬
为了更好地介绍原型对于实现继承的作用,笔者将该部分内容分为上下两篇进行讲解,🥺望各位读者理解包涵。在本文中,笔者从创建对象的方法讲起,对比了各种方式的优势与不足,并引出了原型这一概念,为后文的继承概念作了铺垫。
下表是对本文介绍的创建对象方法的总结:
| 字面量创建 | 构造函数模式 | 原型模式 | |
|---|---|---|---|
| 优点 | 简单直观 | 解决了代码冗余的问题 | 解决了方法不可重用的问题 |
| 缺点 | 创建同样接口的多个对象需要重复编写很多代码 | 定义的方法不可重用 | 原型上的引用数据将被所有实例共享,容易出错。 |
很感谢你可以看到这里,也期待你可以留下一个印记👍
若文章出现了纰漏或者你有更好的建议,欢迎评论区留言或是私信指出✨
预告🔊
- 原型链是什么?
- 有什么实现继承的方式?
new运算符到底干了什么?- 明天见😉