JavaScript 的原型和原型链的前世今生 (一)

2,066 阅读7分钟
原文链接: blog.5udou.cn

大家不要被这个感觉高大上的名字给吓着,我没有打算把原型的历史给说一遍,本文只是想帮助大家理解为什么要有原型和原型链这个独一无二的语言特性,别的语言(或者说是我学过的编程语言中)没有见过这一个概念的,这也是我从C语言转来学习JavaScript的时候最为困惑不解的地方。

1、先从JavaScript创建对象说起

大家都知道JavaScript是一门面向对象的语言,但是没有类的概念(除非现在的ES6标准)。个人觉得ES6是在ES5上再封装的一个新标准,本质的实现还是ES5,所以掌握了ES5才算是掌握了精髓。没有类的概念,但是肯定有对象的概念,而JS的对象就与其他面向对象的语言(比如C++)不一样了。每个对象都是基于一个引用类型(比如Array/Date/Function等都属于引用类型,具体参考《JavaScript高级程序设计(第三版)》的第五章)或者一个自定义的类型来实现。

以前最常见的创建对象方法是(通过创建一个Object实例):


var animal = new Object();
animal.name = 'WangWang';
animal.type = 'dog';

animal.say = function(){
  console.log('I am a ' + this.type);
}

之后出现了对象字面量的创建方法:

var Animal= {
   name: 'WangWang',
   type: 'dog',
   say: function(){
      console.log('I am a ' + this.type); 
  }
}

首先清楚的是一个对象肯定包含属性和方法:name和type肯定属于属性,say肯定属于方法。其次属性在浏览器的内部有对应的特性,这些特性是内部JS引擎使用的。

1.1、谈谈JS对象中的属性(Property)

按照ES5标准,属性除了我们在印象中知道的一个名字,一个值,类似于键值对的形式,其实在浏览器内部是有一大篇文章在里面的。

属性分为数据属性(Data Property)和访问器属性(Accessor Property)。刚才定义的name和type都是数据属性,区别数据属性和访问器数据的依据便是访问器属性有[[Get]][[Set]]方法并且它不包含[[value]]特性(Attribute)。

数据属性包含4个特性:[[configurable]][[Enumerable]][[Writable]][[Value]]

访问器属性包含4个特性:[[Configurable]][[Enumerable]][[Get]][[Set]]

尽管这些特性是浏览器内部使用,但是ES5仍然提供了接口供我们去调用:

Object.defineProperty(obj, prop, descriptor);
Object.defineProperties(obj, props);
Object.getOwnPropertyDescriptor(obj, prop);
Object.getOwnPropertyDescriptors(obj);

随便举个例子(在Chrome控制台中):

> Object.getOwnPropertyDescriptor(Person, "name")
> Object {value: "WangWang", writable: true, enumerable: true, configurable: true}

这4个API更多细节(比如兼容性)可以参考:MDN

2、创建JS对象的进阶

虽然Object构造函数或对象字面量都可以用来创建单个对象,但是很明显都有一个明显的缺陷:这种将对象创建以及对象实例化糅合在一起的做法直接导致代码无法复用接着就会产生一大堆重复的代码,所以为了解决这个问题就产生了新的创建对象的方法--工厂模式。这种形式开始慢慢地向C++语言的类和对象实例化靠近,更加贴近实际的代码开发。

2.1、工厂模式

很形象的称呼,一听到这个名字我们就知道届时有一个工厂,只要我们提供原料就可以使用工厂的模子帮我们创建出我们想要的对象(也就是实例化的过程)。

因为ES5中无法创建类,所以就只能用函数来封装以特定接口创建对象的细节。比如:

function createAnimal(name, type){
   var o = new Object();
   o.name = name;
   o.type = type;
   o.say = function(){
       console.log('I am a ' + this.type); 
   }
   return o;
}

var dog = createAnimal('WangWang', 'dog');

这种方式虽然解决了对象实例化代码重复度的问题,但却没有解决对象识别的问题(也就是说这种方式创建的对象无法知道它的类型,比如说之前的第二种方法创建的对象,可以知道其对象的类型是Person)。所以演变着又有另外一种方式来创建对象。

2.2、构造函数模式

构造函数在C++语言中是一个基本概念,其作用是在实例化一个对象之后会初始化调用,并执行某些复制操作,可以看做是一个初始化函数。同理,JS在这里使用的构造函数虽然形式和C++的不一样,但是本质是一样的。JS提供了一些原生的构造函数有:Object/Array/String等,也可以创建自定义的。比如:

function Animal(name, type){
   this.name = name;
   this.type = type;
   this.say = function(){
       console.log('I am a ' + this.type); 
   }
}

var dog = new Animal('WangWang', 'dog');

此构造函数有以下三个特征:

  • 没有显式地创建对象
  • 直接将属性和方法赋给了this对象
  • 没有return语句

在执行new操作的时候会经历以下4个步骤:

  • 创建一个对象
  • 将构造函数的作用域赋给新对象(因此this指针就指向了新的对象)
  • 执行构造函数中的代码
  • 返回新对象

这个时候的dog是一个Animal实例,按照C++语言的传统,每个实例肯定都有一个叫做constructor的属性,JS也是一样的,JS中的实例的constructor属性指向了Animal这个构造函数。

前面三种方法创建的对象都是一样的,所以任取一个,就拿工厂模式来对比:

可以看出构造函数方法的确是多了一个属性的,至于为什么这些属性集中在__proto__下,正是我们后面要提及的。

所以我们可以通过constructor属性来标识对象的类型(比如本例中的Animal类型),也就是使用instanceof来验证的。

当然使用构造函数并不是完美无缺,使用构造函数的主要问题就是每个方法都要在每个实例中创建一遍,也就是说当我们创建Animal对象的时候,里面的方法其实是Function对象的实例,也就是等同于:

this.say = new Function( console.log('I am a ' + this.type);)

这样就导致创建多个实例会实例很多个函数对象,这样明显会增加内存消耗,为了解决这个问题,我们便引入了原型模式。

3、原型模式

我们创建的每个函数都有一个prototype原型)属性,这个属性是一个指针,指向一个对象, 而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。它与下面__proto__是不一样的,具体会在3.1节中细说。

刚才的图1中我们已经发现每个对象无论是用什么方法创建的,都有一个__proto__属性,这个__proto__属性便是连接原型链的关键地方,在上一节中我们说到的执行new的操作时会执行4个步骤,其中第二个步骤如果按照代码来说便是执行赋值操作,也就是:dog.__proto__ = Animal.prototype,可以通过控制台打印看出来:

原型模式创建的方法采用是诸如这样的形式:

function Animal(){}

Animal.prototype.name = 'WangWang';
Animal.prototype.type = 'dog';
Animal.prototype.say = function(){
       console.log('I am a ' + this.type); 
   };

var dog = new Animal();

原型模式和构造函数模式的区别可以通过下面这张图看出来:

构造函数模式:

原型模式:

从上面两张图片可以看出原型的优缺点,如何改进呢?将二者融合是不是可以充分利用二者的优点呢?你要是这么想,那说明你是对的^_^ ! 这个便是3.2小节要讲的内容。

除了原型使用上面的赋值操作,我们目前更喜欢使用对象字面量或者是new关键词来操作原型。但是使用对象字面量或者new关键词有一个很重要的知识点:无论是使用对象字面量还是new关键词,都是创建一个新的对象来替换掉原来的原型对象

很是抽象?一张图来告诉你真相:

代码:

function Animal(){}

Animal.prototype = {
     name: 'WangWang',
     type:  'dog',
     say : function(){
       console.log('I am a ' + this.type); 
   }
}
var dog = new Animal();

或者:

function Species(){
  this.name =  'WangWang';
  this.type =  'dog';
  this.say = function(){
       console.log('I am a ' + this.type); 
   };
}

function Animal(){}

Animal.prototype = new Species();
var dog = new Animal();

二者对应的原型模式图示:

所以正是因为这种overridden的效果,当你使用这种方法的时候一定要注意原型对象是否还是原来的原型对象

那二者都有优点,组合使用如何?原型链又与原型有什么关系?

请看下一篇:《JavaScript的原型和原型链的前世今生(二)》


本文对你有帮助?欢迎扫码加入前端学习小组微信群: