JavaScript语言的原型是前端开发者必须掌握的要点之一,但在使用原型时往往只关注了语法,其深层的原理并未理解透彻。本文结合笔者开发工作中遇到的问题详细讲解JavaScript原型的几个关键概念,如有错误,欢迎指正。

1. JavaScript原型继承

提到JavaScript原型,用处最多的场景便是实现继承。然而在实现继承时总有一些细节处理不到位,引起一些看起来莫名其妙的问题。比如使用下述代码:

function Animal(){}
Animal.prototype = {}; function Cat(){}
Cat.prototype = new Animal(); var cat_1 = new Cat();

上述代码首先定义了Animal类的构造函数,随后改变了其原型指向。Cat类将其原型指向Animal类的一个实例对象。以上写法可以满足大部分简单需求,比如创建一个Cat类的实例对象cat_1,此时如果使用instanceof判断会得到以下结果:

cat_1 instanceof Cat; // true
cat_1 instanceof Animal; // true

以上的实现方式有什么不妥之处呢?这个问题先不解答,我们首先讲解以下原型的几个关键属性:prototype,__proto__和constructor。理解了它们之后,再进一步完善上述代码。

2. prototype和__proto__

许多初学者容易混淆prototype和__proto__。简单来说:prototype属性是可以作为构造函数的函数对象才具备的属性,__proto__属性是任何对象(除了null)都具备的属性,两者的指向都是其所属类的原型对象,也就是下文提到的内部属性[[Prototype]]

JavaScript语言中并没有严格意义上的类,本文中提到的类可以理解为一个抽象的概念,原型对象可以理解为类暴露出来的接口。

2.1 prototype

首先解释一下为什么说只有可以作为构造函数的函数对象才具备prototype属性。这种说法是为了区分ES6中新增的箭头函数,箭头函数不能作为构造函数使用,没有prototype属性。某种程度上讲,箭头函数的引入增强了构造函数的语义化。

熟悉其他OO语言的开发者对于构造函数的概念并不陌生,以Java为例,不论一个类的构造函数被显式或者隐式定义,在创建实例时都会调用构造函数。所以,以功能来讲,构造函数是“用来构造新对象的函数”;以语义来讲,构造函数是类的公共标识,或者叫做外在表现。比如前文例子中的构造函数Animal(),它的函数名便是其所属类Animal的类名。

构造函数的prototype指向其所属类的原型对象,一个类的原型对象初始值是与类同名的,比如:

function Animal(){}

Console.log(Animal.prototype);

输出结果为:

Animal{
constructor: function Animal(),
__proto__: Object
}

在输出结果中可以看到,Animal类的原型对象有两个属性:constructor和__proto__。constructor属性便是构造函数Animal()。__proto__属性指向的是Animal类的父类原型对象。

2.2 __proto__

上一节提到的prototype属性是构造函数特有的属性,指向其归属类的原型对象。__proto__属性除了null以外的对象都具备的一个属性,其指向与构造函数的prototype相同。

并非所有JavaScript引擎都支持__proto__属性的访问和修改,通过修改__proto__改变原型并不是一种兼容性方案。最新的ES6规范中,__proto__被规范为一个存储器属性。它的getter方法为Object.getPrototypeOf(),这个方法在ES5中就已经有了;setter方法为Object.setPrototypeOf()。使用这两个方法获取和修改一个对象的原型实际上是操作内部隐藏属性[[Prototype]],下文将详细讲解这个属性。

3. constructor

3.1 构造函数是什么?

前文提到,构造函数是一个类的外在表现,声明一个构造函数实际上就声明了一个类。基于这条准则,再回顾一下文章最初实现继承的例子,我们可以发现以下问题:

  1. 在修改Animal类的prototype时,直接使用赋值操作符将其prototype指向一个空对象,此时Animal类的构造函数是什么?
  2. Cat类继承Animal类时,只是将Cat类的prototype指向一个Animal类的实例,此时Cat类的构造函数是什么?

我们可以用代码验证上面两个问题:

Console.log(Animal.prototype.constructor);

Console.log(Cat.prototype.constructor);

输出结果为:

function Object() { [native code] };

function Object() { [native code] };

两者的构造函数都是function Object() { [native code] };。为什么会得到这样的结果?

在改变Animal和Cat的原型时,使用赋值操作符直接将一个空对象赋值给两者的prototype,constructor属性同时也被这个空对象的constructor属性覆盖了,也就是function Object() { [native code] };

这是很多开发者容易忽略和不解的一个细节,在使用赋值操作符改变一个类的原型时,要注意同时将其原型的constructor属性指向本身,也就是:

Animal.prototype.constructor = Animal;

Cat.prototype.constructor = Cat;

笔者曾在面试一位应聘者的时候提出这个细节,应聘者说了一句“知道有这么回事,但一直没弄明白原理,所以平时工作中也不是很在意”。网上也有很多博客中提到“修改constructor是为了保证语义上的一致性”,这是不准确的。下面通过具体实例讲解为何要保证constructor指向的正确性。

3.2 instanceof

我们通常使用instanceof判断一个对象是否是一个类的实例。但是instanceof并不能得到准确的结果。首先要明白instanceof的工作机制,比如以下代码:

obj instanceod Obj;

使用instanceof判断obj是否为Obj的实例时,并不是判断obj继承自Obj,而是判断obj是否继承自Obj.prototype。这是一个很容易忽略的细节,不注意区分的话很容易出现问题。请思考以下代码:

function Father(){}

function ChildA(){}
function ChildB(){} var father = new Father(); ChildA.prototype = father;
ChildB.prototype = father; var childA = new ChildA();
var childB = new ChildB(); Conosle.log(childA instanceof ChildA); //true
Conosle.log(childA instanceof ChildB); //true
Conosle.log(childB instanceof ChildA); //true
Conosle.log(childB instanceof ChildB); //true
Conosle.log(childA instanceof Father); //true
Conosle.log(childA instanceof Father); //true

上述代码将派生类ChildA和ChildB的prototype指向同一个Father类的实例,然后分别创建两个实例childA和childB。但是在判断继承关系时发现,得到的结果令人困惑,为什么(childA instanceof ChildB返回true呢?

这个问题根据上文提到的instanceof的工作原理很容易解答,派生类ChildA和ChildB的prototype是同一个对象,使用instanceof判断各自实例继承归属时,得到的结果自然是相同的。

明白了instanceof的工作原理后,我们研究一下JavaScript实现继承的另一种方式,如下:

function Animal(){}
Animal.prototype = {}; function Cat(){}
Cat.prototype = Object.create(Animal.prototype); function Dog(){}
Dog.prototype = Object.create(Animal.prototype);

有些书籍将上述方式成为寄生式继承,笔者强烈建议不要使用!

根据instanceof工作原理,我们可以预估到以下结果:

var cat = new Cat();
var dog = new Dog(); Console.log(cat instanceof Cat); //true
Console.log(cat instanceof Dog); //true
Console.log(dog instanceof Dog); //true
Console.log(dog instanceof Cat); //true

这样,instanceof判断继承关系便没有任何意义了。

现在,我们明白了instanceof的缺陷,那么跟constructor有什么关系呢?

3.3 使用constructor判断继承关系

如上文所述,在某些场景下instanceof并不能正确验证继承关系。使用constructor属性可以一定程度上弥补instanceof的不足。仍然使用上一个例子,添加以下验证代码:

Console.log( cat.constructor === Cat); //false
Console.log( cat.constructor === Dog); //false
Console.log( cat.constructor === Animal); //false
Console.log( cat.constructor === Object); //true

可能你会疑惑,结果也是不正确的啊?别急,前文提到,在实现原型继承时要保证constructor指向的正确性。基于这条原则,我们修改代码如下:

function Animal(){}
Animal.prototype = {};
Animal.prototype.constructor = Animal; function Cat(){}
Cat.prototype = Object.create(Animal.prototype);
Cat.prototype.constructor = Cat; function Dog(){}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

然后再分别使用instanceof和constructor的方法判断继承关系如下:

// instanceof
Console.log(cat instanceof Cat); //true
Console.log(cat instanceof Dog); //true
Console.log(dog instanceof Dog); //true
Console.log(dog instanceof Cat); //true
Console.log(cat instanceof Animal); //true
Console.log(dog instanceof Animal); //true //constrcutor
Console.log( cat.constructor === Cat); //true
Console.log( cat.constructor === Dog); //false
Console.log( dog.constructor === Dog); //true
Console.log( dog.constructor === Cat); //false
Console.log( cat.constructor === Animal); //false
Console.log( cat.constructor === Object); //false

可见,修正后的代码使用constructor可以正确判断继承关系,instanceof仍然没有改善。

3.4 小结

通过以上的论述我们知道了实现继承时保证constructor指向正确的必要性,以及判断继承关系时和constructor和instanceof各自的工作原理及不足。有以下结论:

  1. 实现原型继承时请务必保证constructor指向的正确性;
  2. instanceof可以判断递归向上的继承关系,但是并不能应对全部场景;
  3. constructor可以判断直属的继承关系,但是并不能判断递归向上的连续继承关系;
  4. 具体使用场景应综合使用instanceof和constructor,互补互缺;
  5. 不建议使用寄生式继承。

4. 原型到底是什么?

JavaScript的诞生只用了10天,但是需要10年甚至更久的时间去完善。JavaScript语言是基于原型的,那么原型到底是什么呢?

ES6新增了内部属性[[Prototype]],对象的原型便储存在这个属性内,上文提到的各种对原型的操作本质上都是对[[Prototype]]的操作。

JavaScript并没有类的概念,即使ES6规范了class关键字,本质上仍然是基于原型的。类可以作为一个抽象的概念,是为了便于理解构造函数和原型。原型可以理解为类暴露出来的一个接口或者属性。前文提到,创建了构造函数便是创建了同名类,随后在改变一个对象的原型时,只是改变了类的这个属性,而构造函数是类的静态成员,保持不变。

另外,在修改对象原型时,不建议使用直接赋值的方式。我们应该遵守一个原则:扩展利于赋值。

5. 改善后的代码

长篇大论的一通,我们可以基于上述的基本原则改善文章最初的例子。如下:

function Animal(){}
Animal.prototype.getName = function(){}; function Cat(){
Animal.apply(this,arguments);
}
Cat.prototype = Object.create(Animal.prototype,{
constructor: Cat
}); var cat_1 = new Cat();

结合其他OO语言的继承方式和JavaScript原型理解上述代码:

  1. 扩展Animal原型而不是赋值修改;
  2. 保证派生类构造函数向上递归调用;
  3. 使用Object.create()方法而不是寄生式继承;
  4. 保证constructor指向的正确性。

有些书籍把以上方式称为组合式继承,可以说是最接近传统OO语言类式继承的一种方式了。

深入理解JavaScript原型:prototype,__proto__和constructor的更多相关文章

  1. 再次理解JS的prototype,__proto__和constructor

    个人总结: 下面这篇文章很好的讲解了js原型,原型链,个人的总结是要记住这三个属性 prototype.__proto__和constructor 首先明确,js中一切都是对象object(A). ( ...

  2. 深入理解javascript原型和闭包(3)——prototype原型

    既typeof之后的另一位老朋友! prototype也是我们的老朋友,即使不了解的人,也应该都听过它的大名.如果它还是您的新朋友,我估计您也是javascript的新朋友. 在咱们的第一节(深入理解 ...

  3. 深入理解javascript原型和闭包(3)——prototype原型 (转载)

    深入理解javascript原型和闭包(3)——prototype原型   既typeof之后的另一位老朋友! prototype也是我们的老朋友,即使不了解的人,也应该都听过它的大名.如果它还是您的 ...

  4. 《深入理解javascript原型和闭包系列》 知识点整理(转)

    深入理解javascript原型和闭包系列 对原型和闭包等相关知识的讲解,由浅入深,通俗易懂,每个字都值得细细研究. 一.一切都是对象 1. typeof操作符输出6种类型:string boolea ...

  5. 《深入理解javascript原型和闭包系列》 知识点整理

    深入理解javascript原型和闭包系列 对原型和闭包等相关知识的讲解,由浅入深,通俗易懂,每个字都值得细细研究. 一.一切都是对象 1. typeof操作符输出6种类型:string boolea ...

  6. 深入理解javascript原型和闭包(4)——隐式原型

    注意:本文不是javascript基础教程,如果你没有接触过原型的基本知识,应该先去了解一下,推荐看<javascript高级程序设计(第三版)>第6章:面向对象的程序设计. 上节已经提到 ...

  7. 深入理解javascript原型和闭包(5)——instanceof

    又介绍一个老朋友——instanceof. 对于值类型,你可以通过typeof判断,string/number/boolean都很清楚,但是typeof在判断到引用类型的时候,返回值只有object/ ...

  8. 深入理解javascript原型和闭包(6)——继承

    为何用“继承”为标题,而不用“原型链”? 原型链如果解释清楚了很容易理解,不会与常用的java/C#产生混淆.而“继承”确实常用面向对象语言中最基本的概念,但是java中的继承与javascript中 ...

  9. 深入理解javascript原型和闭包(4)——隐式原型 (转载)

    深入理解javascript原型和闭包(4)——隐式原型   注意:本文不是javascript基础教程,如果你没有接触过原型的基本知识,应该先去了解一下,推荐看<javascript高级程序设 ...

随机推荐

  1. zabbix监控配置与邮件告警

    添加主机与主机组 进入web页面,在 配置-主机群组,创建主机群组 在 配置-主机,新建主机 在可见的名称中建议填写为类似 主机类型-主机名-IP或域名 的格式,如Web-Hyrule001-192. ...

  2. JQUERY-修改-API-事件绑定

    正课: 1. 修改: 2. 按节点间关系查找: 3. 添加,删除,克隆,替换: 4. 事件绑定: 1. 修改: 内容: html片段: .html(["html片段"])      ...

  3. Libgdx slg游戏进程记录

    2月16日缩放居中,stage确定点击坐标,背景处理为actor 2月17日地图多次点击 2月19日stage确定点击位置(贝塞尔曲线六边形) 2月24日格式长度,读取xml属性解析btl保存 3月1 ...

  4. 2019.03.28 bzoj3597: [Scoi2014]方伯伯运椰子(01分数规划)

    传送门 题意咕咕咕有点麻烦不想写 思路: 考虑加了多少一定要压缩多少,这样可以改造边. 于是可以通过分数规划+spfaspfaspfa解决. 代码: #include<bits/stdc++.h ...

  5. hbase常用操纵操作——增删改查

    查询某个资金账户的信息 get 'dmp:hbase_tags','资金账号' 创建表 create 'emp', 'personal data', 'professional data' 在HBas ...

  6. tp5自定义分页参数

    代码示例: $data = db('activity') -> where($condition1)-> order('startline desc') -> paginate(2, ...

  7. Monkey测试结果分析

    Monkey测试结果分析 什么是monkey Monkey 测试是 Android 自动化测试的手段之一,它通过模拟用户的按键输入.触摸屏输入等,测试设备多长时间出现异常.Monkey 是一个命令行工 ...

  8. VS中Debug与Release、_WIN32与_WIN64的区别

    一.Debug与Release 1.  区别 Debug——调试版,生成的.exe中包含很多调试信息,若直接发包,比较大: Release——发布版 2.  如何区分是Debug编译还是Release ...

  9. Git Gui for Windows的建库、克隆(clone)、上传(push)、下载(pull)、合并(转)

    Git Gui for Windows的建库.克隆(clone).上传(push).下载(pull).合并(转) from:http://hi.baidu.com/mvp_xuan/blog/item ...

  10. spark入门

    这一两年Spark技术很火,自己也凑热闹,反复的试验.研究,有痛苦万分也有欣喜若狂,抽空把这些整理成文章共享给大家.这个系列基本上围绕了Spark生态圈进行介绍,从Spark的简介.编译.部署,再到编 ...