图解Javascript对象的创建

关于JavaScript创建对象和实现继承的各种方法,很多文章和书籍有着很详细的介绍。 为了更好地理解JavaScript中原型系统的设计, 本文使用图示的方式对这些方法进行了总结和梳理。

对象的原型

首先我们需要了解一下JavaScript中对象的原型模型是如何设计的。

在JavaScript中, 所有的对象都有一个私有字段[[prototype]]指向自身的原型对象,而原型对象本身也有指向自己原型对象的[[prototype]], 于是一条原型链就产生了。

当访问该对象的属性时, 如果没有找到,则沿着原型链依次访问查找,直到找到或字段指向为空。

因为[[prototype]]是一个私有字段, 我们无法通过对象调用直接访问该属性, ES5 后通过内置方法 Object.getPrototypeOf(object) 可以获取到object的原型。

有些浏览器环境提供了__proto__属性, 有些则不支持.

如果我们想通过修改对象的[[prototype]]字段的值来改变一个对象的原型对象应该怎么做呢? 在对象创建时期,ES5中提供了 Object.create(proto, [propertiesObject]) 方法, 可以根据一个原型对象创建新对象,也就是将指定的proto 赋值给新对象的[[prototype]]属性。

而尽管在对象创建以后去修改[[prototype]]字段危险又低效, 仍然可以通过ES6之后才引入的 Object.setPrototypeOf(obj, prototype) 方法来设置一个指定的对象的原型。

另外, 如果我们想知道一个对象obj是否存在于另一个对象的原型链上, 则可以通过prototypeObj.isPrototypeOf(obj)方法来测试.

构造函数

在引入class关键字之前,为了原型的基础上能像Java那样通过构造函数创建对象并实现的封装性和继承性, JavaScript引入了newthis等关键字。

在语法设计上, JavaScript并没有单独定义构造函数, 它与普通函数的区别在于调用方式:

任何函数, 只要通过new关键字来调用, 就可以作为构造函数来使用。

每个函数都有一个属性 —— prototype(与对象的私有字段[[prototype]]区分), 它所指向的对象,就是该函数的原型对象。 而这个原型对象上,也有一个属性constructor,指向对应的构造函数。

在使用new关键字调用构造函数的方式创建对象时,事实上完成了以下操作:

  • 以构造函数prototype属性为原型,新创建一个对象(新创建一个对象,将其[[prototype]]字段指向构造函数prototype属性)
  • 执行构造函数,将this和相关参数传给构造函数, 并将this指向新创建的对象。
  • 构造函数执行完毕后, 如果没有主动return指定对象, 则返回新创建的对象。

当我们理解了JavaScript的对象原型以及构造函数,那么如何通在原型系统的基础上完成对象的创建呢?

通过new关键字和构造函数, JavaScript让对象的创建在语法上和基于类的编程语言很相似, 但在原型系统的具体操作上却有很多种选择和玩法了。 下面我们依次列举并图示。

I. 构造函数内初始化对象

在利用构造函数创建对象时, 可以选择在构造函数为对象(this指向了新创建的对象)的属性赋值, 如下例:

function Animal(name, age){
  this.name=name;
  this.age=age;
  this.sayHi= function (){
    console.log(`Hi, I'm ${this.name}`);
  }
}

var cat = new Animal("kitty", 2);
var dog = new Animal("doggy", 1);

可以看到, 直接利用构造函数创建了两个对象, 每个对象都有各自的内存地址并存放着对应的数据, 每个对象的[[prototype]]都指向了原型对象Animal Prototype

然而这种方式有一个问题在于, 每次执行构造函数创建对象的时候, 会在构造函数内部重新创建并初始化函数sayHi的对象, 不同Animal对象之间无法对sayHi进行复用。

II. 将属性挂载到原型对象中

前面提到, 我们在利用构造函数创建对象的时候, 会以构造函数的prototype所指向的对象为原型来创建新的对象, 如下例:

function Animal(){
}
Animal.prototype.name="kitty";
Animal.prototype.age=1;
Animal.prototype.sayHi= function (){
    console.log(`Hi, I'm ${this.name}`);
};

var cat = new Animal();
var dog = new Animal();
dog.name = "doggy";

在上面的例子里, 我们把属性和方法都挂载到Animal.prototype所指的原型对象上, 而构造函数Animal成为了一个空函数。

当通过构造函数创建对象的时候, 会将所创建对象的[[prototype]]属性指向原型对象:

cat.[[prototype]]=Animal.prototype

这样所有通过构造函数Animal所创建的对象, 都共享了所有定义在原型对象上的属性和方法。

正如我们在文章开始所介绍了原型链模型那样: 当我们访问对象属性时, 首先在对象自身属性和方法中查找, 如果没有则沿着原型链查找。

在例子中, 我们依旧创建了catdog两个对象。 访问cat.name时会沿原型链查找到Animal.prototype.name属性并返回; 而访问dog.name时则会发现自身已经定义了name属性,便直接返回了。 两者同样也在原型链上共享了sayHi()这个方法。

我们通过in操作符, 可以判断是否能够通过对象obj来访问属性“prop”, 无论该属性是否存在于对象本身还原型链上:

if("prop" in obj){
}

那么我们如何判断所访问的属性来自哪里呢? 通过 obj.hasOwnProperty(prop) 方法可以查看对象obj自身是否具有属性“prop”

在上面的例子中, 我们采用了依次将属性挂载在Animal.prototype上方式。 其实也可以直接定义一个新的原型对象,直接将Animal.prototype指向改对象:

function Animal(){
}
Animal.prototype={
  name:"kitty",
  age:1,
  sayHi:function (){
    console.log(`Hi, I'm ${this.name}`);
  }
}

这样的方式虽然更简洁美观了, 但也丢掉了原型对象中constructor属性, 如果有需要的话还是要额外手动加上, 这里就不再赘述了。

通过将属性挂载在原型对象上的方式来创建对象, 大大提升了基本属性和函数的复用性, 但有两个比较明显的缺点:

  • 无法通过构造函数动态传参;
  • 如果将引用类型的属性也挂载到原型对象上, 每个对象对该属性的修改都会影响到其他的对象, 效果类似于static

为了规避这两个缺点, 最好的方式是将III两种方案组合。

III. 构造函数+原型对象

将方案I 和方案II相结合的好处在于, 将希望被复用和共享的方法挂载到原型对象上, 而对于那些希望不希望被所有对象共享的引用类型属性, 则直接在构造函数中进行初始化; 与此同时, 还可以利用构造函数来动态传参, 如下例:

function Animal(name, age){
  this.name=name;
  this.age=age;
  this.favorFood=[];
}
Animal.prototype.sayHi= function (){
    console.log(`Hi, I'm ${this.name}`);
};
var cat = new Animal("kitty", 2);
var dog = new Animal("doggy", 1);

cat.favorFood.push("fish");
dog.favorFood.push("bone");

除了上面介绍的三种创建对象的方案, 很多文章中也介绍了其他创建对象的方式, 都是为了在ES6引入class之前, 根据应用场景的不同,提高构造函数封装性和复用性。

定义Class

ES6标准中引入了class关键字, 我们也终于可以正大光明地在JavaScript中使用类来定义和创建对象了(尽管事实上也是由原型运行时来承载的, 实质上还是语法糖), 如下例:

class Animal{
  constructor(name, age){
    this.name=name;
    this.age=age;
  }
  sayHi(){
    console.log(`Hi, I'm ${this.name}`);
  }
}
let cat = new Animal("kitty", 2);
let dog = new Animal("doggy", 1);

除了提供基于的编程范式, class相关机制还提供了继承的能力, 相关内容见《图解Javascript对象的继承》 .