彻底搞懂js中原型、原型链(一)

jeckson

共 7550字,需浏览 16分钟

 · 2021-04-30

Javascript继承机制的设计思想

***下面这部分说的都是ES6之前的***

首先我们先了解继承从何而来,借助阮一峰大神的文章一起来看看。

首先我们得知道,JavaScript最开始是Brendan Eich为网景浏览器开发。因为最开始,Brendan Eich设计javascript的目的就只是为了解决网景公司Navigator浏览器和用户交互的问题,当时C++、java这些已经算是流行起来了,觉得没有必要设计很复杂,只要能够完成简单的一些操作就可以啦。

为什么javascript使用new命令用来从原型对象生成一个实例对象呢,因为当时C++、java这些都是用的这种写法,也算是为了统一吧。但是,Javascript没有"类",怎么来表示原型对象呢?想到C++和Java使用new命令时,都会调用"类"的构造函数(constructor)。就做了一个简化的设计,在Javascript语言中,new命令后面跟的不是类,而是构造函数。


举例


举例来说,现在有一个叫做Animal的构造函数,表示动物对象的原型。

function Animal(name) {  this.name = name;}

对这个构造函数使用new,就会生成一个兔子对象的实例。

let rabbit = new Animal('兔子');
console.log("rabbit",rabbit); //兔子

注意构造函数中的this关键字,它就代表了新创建的实例对象。

new运算符的缺点

SPRING

用构造函数生成实例对象,有一个缺点,那就是无法共享属性和方法。

比如,在Animal对象的构造函数中,设置一个实例对象的共有属性type。

function Animal(name) {  this.name = name;  this.type = '狗';}

然后,生成两个实例对象:

let rabbit = new Animal('兔子');
let dog = new Animal('二狗子');

这两个对象的species属性是独立的,修改其中一个,不会影响到另一个。

rabbit.type = '兔';
console.log("rabbit",rabbit.type); //兔
console.log("dog",dog.type); //狗,不受rabbit的影响


虽然一方面可能会觉得自己的属性只归自己用挺好,但是这也不仅无法做到数据共享,也是极大的资源浪费。

prototype属性的引入

SPRING

考虑到数据共享这一点,Brendan Eich决定为构造函数设置一个prototype属性。

这个属性包含一个对象(以下简称"prototype对象"),所有实例对象需要共享的属性和方法,都放在这个对象里面;那些不需要共享的属性和方法,就放在构造函数里面。

实例对象一旦创建,将自动引用prototype对象的属性和方法。也就是说,实例对象的属性和方法,分成两种,一种是本地的,另一种是引用的。


还是以Animal构造函数为例,现在用prototype属性进行改写:


function Animal(name) {  this.name = name;}
Animal.prototype = { type: '兔子' };let rabbitA = new Animal('大兔子');let rabbitB = new Animal('小兔子');
console.log("rabbitA",rabbitA.type);//兔子console.log("rabbitB",rabbitB.type);//兔子

现在,type属性放在prototype对象里,是两个实例对象共享的。只要修改了prototype对象,就会同时影响到两个实例对象。

Animal.prototype.type = '狗子';
console.log("rabbitA",rabbitA.type);//狗子
console.log("rabbitB",rabbitB.type);//狗子

由于所有的实例对象共享同一个prototype对象,那么从外界看起来,prototype对象就好像是实例对象的原型,而实例对象则好像"继承"了prototype对象一样。

这就是Javascript继承机制的设计思想。

对象

创建对象

SPRING

虽然 Object 构造函数或对象字面量都可以用来创建单个对象,但这些方式有个明显的缺点:使用同一个接口创建很多对象,会产生大量的重复代码。为解决这个问题,人们开始使用工厂模式的一种变体。

工厂模式

function createPerson(name, age, job){    let o = new Object();    o.name = name;    o.age = age;    o.job = job;    o.sayName = function(){      alert(this.name);    };   return o; } let person1 = createPerson("Nicholas", 29, "Software Engineer"); let person2 = createPerson("Greg", 27, "Doctor");

函数 createPerson()能够根据接受的参数来构建一个包含所有必要信息的 Person 对象。可以无数次地调用这个函数,而每次它都会返回一个包含三个属性一个方法的对象。工厂模式虽然解决了创建 多个相似对象的问题,但却没有解决对象识别的问题(即怎样知道一个对象的类型)。

构造函数模式

function Person(name, age, job){   this.name = name;   this.age = age;   this.job = job;   this.sayName = function(){     alert(this.name);   }; } let person1 = new Person("Nicholas", 29, "Software Engineer"); let person2 = new Person("Greg", 27, "Doctor");


在这个例子中,Person()函数取代了 createPerson()函数。我们注意到,Person()中的代码 除了与 createPerson()中相同的部分外,还存在以下不同之处: 

1、没有显式地创建对象;

2、直接将属性和方法赋给了 this 对象; 

3、没有 return 语句。

要创建 Person 的新实例,必须使用 new 操作符。以这种方式调用构造函数实际上会经历以下 4 个步骤:

1、创建一个新对象;

2、 将构造函数的作用域赋给新对象(因此 this 就指向了这个新对象); 

3、执行构造函数中的代码(为这个新对象添加属性);

4、返回新对象。

在前面例子的最后,person1 和 person2 分别保存着 Person 的一个不同的实例。这两个对象都 有一个 constructor(构造函数)属性,该属性指向 Person,如下所示。

alert(person1.constructor==Person); //true  
alert(person2.constructor==Person); //true

对象的 constructor 属性最初是用来标识对象类型的。但是,提到检测对象类型,还是 instanceof 操作符要更可靠一些。

alert(person1 instanceof Object); //true  
alert(person1 instanceof Person); //true  
alert(person2 instanceof Object); //true  
alert(person2 instanceof Person); //true  

创建自定义的构造函数意味着将来可以将它的实例标识为一种特定的类型;而这正是构造函数模式胜过工厂模式的地方。在这个例子中,person1 和 person2 之所以同时是 Object 的实例,是因为所有对象均继承自 Object。

构造函数的问题

构造函数模式虽然好用,但也并非没有缺点。使用构造函数的主要问题,就是每个方法都要在每个 实例上重新创建一遍。在前面的例子中,person1 和 person2 都有一个名为 sayName()的方法,但那 两个方法不是同一个 Function 的实例。不要忘了——ECMAScript 中的函数是对象,因此每定义一个函数,也就是实例化了一个对象。从逻辑角度讲,此时的构造函数也可以这样定义。

function Person(name, age, job){  this.name = name;  this.age = age;  this.job = job;  this.sayName = new Function("alert(this.name)"); // 与声明函数在逻辑上是等价的}

从这个角度上来看构造函数,更容易明白每个 Person 实例都包含一个不同的 Function 实例(以 显示 name 属性)的本质。说明白些,以这种方式创建函数,会导致不同的作用域链和标识符解析,但创建 Function 新实例的机制仍然是相同的。因此,不同实例上的同名函数是不相等的

alert(person1.sayName == person2.sayName); //false

然而,创建两个完成同样任务的 Function 实例的确没有必要;况且有 this 对象在,根本不用在执行代码前就把函数绑定到特定对象上面。因此,大可像下面这样,通过把函数定义转移到构造函数外部来解决这个问题。

function Person(name, age, job){  this.name = name;  this.age = age;  this.job = job;  this.sayName = sayName; } function sayName(){  alert(this.name); } var person1 = new Person("Nicholas", 29, "Software Engineer"); var person2 = new Person("Greg", 27, "Doctor");

在这个例子中,我们把 sayName()函数的定义转移到了构造函数外部。而在构造函数内部,我们将sayName 属性设置成等于全局的 sayName 函数。这样一来,由于 sayName 包含的是一个指向函数 的指针,因此 person1 和 person2 对象就共享了在全局作用域中定义的同一个 sayName()函数。这 样做确实解决了两个函数做同一件事的问题,可是新问题又来了:在全局作用域中定义的函数实际上只 能被某个对象调用,这让全局作用域有点名不副实。而更让人无法接受的是:如果对象需要定义很多方 法,那么就要定义很多个全局函数,于是我们这个自定义的引用类型就丝毫没有封装性可言了。好在, 这些问题可以通过使用原型模式来解决。

原型模式

SPRING

无论什么时候,只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个 prototype属性,这个属性指向函数的原型对象。在默认情况下,所有原型对象都会自动获得一个 constructor(构造函数)属性,这个属性包含一个指向 prototype 属性所在函数的指针。

322152a453e361f5fa3d51afb1b31dc1.webp


展示了 Person 构造函数、Person 的原型属性以及 Person 现有的两个实例之间的关系。在此,Person.prototype 指向了原型对象,而 Person.prototype.constructor 又指回了 Person。原型对象中除了包含 constructor 属性之外,还包括后来添加的其他属性。Person 的每个实例——person1 和 person2 都包含一个内部属性,该属性仅仅指向了 Person.prototype;换句话说,它们与构造函数没有直接的关系。

虽然可以通过对象实例访问保存在原型中的值,但却不能通过对象实例重写原型中的值。如果我们在实例中添加了一个属性,而该属性与实例原型中的一个属性同名,那我们就在实例中创建该属性,该属性将会屏蔽原型中的那个属性。来看下面的例子。

function Person(){ } Person.prototype.name = "Nicholas"; Person.prototype.age = 29; Person.prototype.job = "Software Engineer"; Person.prototype.sayName = function(){  alert(this.name); }; var person1 = new Person(); var person2 = new Person(); person1.name = "Greg"; alert(person1.name); //"Greg"——来自实例alert(person2.name); //"Nicholas"——来自原型

当为对象实例添加一个属性时,这个属性就会屏蔽原型对象中保存的同名属性;换句话说,添加这个属性只会阻止我们访问原型中的那个属性,但不会修改那个属性。即使将这个属性设置为 null,也只会在实例中设置这个属性,而不会恢复其指向原型的连接。不过,使用 delete 操作符则可以完全删除实例属性,从而让我们能够重新访问原型中的属性,如下所示。

function Person(){ } Person.prototype.name = "Nicholas"; Person.prototype.age = 29; Person.prototype.job = "Software Engineer"; Person.prototype.sayName = function(){  alert(this.name); }; var person1 = new Person(); var person2 = new Person(); person1.name = "Greg"; alert(person1.name); //"Greg"——来自实例alert(person2.name); //"Nicholas"——来自原型delete person1.name; alert(person1.name); //"Nicholas"——来自原型

在这个修改后的例子中,我们使用 delete 操作符删除了 person1.name,之前它保存的"Greg"值屏蔽了同名的原型属性。把它删除以后,就恢复了对原型中 name 属性的连接。因此,接下来再调用person1.name 时,返回的就是原型中 name 属性的值了。

使用 hasOwnProperty()方法可以检测一个属性是存在于实例中,还是存在于原型中。这个方法(不要忘了它是从 Object 继承来的)只在给定属性存在于对象实例中时,才会返回 true。来看下面这个例子。

function Person(){ } Person.prototype.name = "Nicholas"; Person.prototype.age = 29; Person.prototype.job = "Software Engineer"; Person.prototype.sayName = function(){ alert(this.name); }; var person1 = new Person(); var person2 = new Person(); alert(person1.hasOwnProperty("name")); //false person1.name = "Greg"; alert(person1.name); //"Greg"——来自实例alert(person1.hasOwnProperty("name")); //true alert(person2.name); //"Nicholas"——来自原型alert(person2.hasOwnProperty("name")); //false delete person1.name; alert(person1.name); //"Nicholas"——来自原型alert(person1.hasOwnProperty("name")); //false

通过使用 hasOwnProperty()方法,什么时候访问的是实例属性,什么时候访问的是原型属性就一清二楚了。调用 person1.hasOwnProperty( "name")时,只有当 person1 重写 name 属性后才会返回 true,因为只有这时候 name 才是一个实例属性,而非原型属性。

图展示了上面例子在不同情况下的实现与原型的关系(为了简单起见,图中省略了与 Person 构造函数的关系)。

2e86d913edc3725630f91511ca7e9164.webp


总结一下

1、通过new之后的实例无法数据共享

2、通过prototype实现数据共享

3、因为js的大多数数据类型都是对象(函数、数组、正则、时间。。。),所以如何优雅的创建对象成为了关键问题。

4、工厂模式的缺陷是无法解决识别问题

5、

    5.1、构造函数中有很多的方法那么就会开辟很多的空间,浪费内存资源;

    5.2、如果在全局情况下声明函数,虽然解决了内存资源浪费的问题,但是又会出现全局变量污染的问题; 

    5.3、如果重新声明一个对象专门存放这些方法,但是新的问题时,如果有很多个构造函数,就要声明很多个这样的对象。

6、

    6.1、原型模式中通过实例添加属性会屏蔽原型中的那个属性,如果去prototype获取,则为nuhll,如果想取消屏蔽,则使用delete删除即可;

    6.2、判断是实例还是原型对象上的属性,通过hasOwnProperty去识别,如果是实例则为true。

避免一次消化太多。这一篇先到这里,接下来会讲到原型对象、继承、原型链;

浏览 21
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报