图解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
引入了new
,this
等关键字。
在语法设计上, 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
所创建的对象, 都共享了所有定义在原型对象上的属性和方法。
正如我们在文章开始所介绍了原型链模型那样: 当我们访问对象属性时, 首先在对象自身属性和方法中查找, 如果没有则沿着原型链查找。
在例子中, 我们依旧创建了cat
和dog
两个对象。
访问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
;
为了规避这两个缺点, 最好的方式是将I
和II
两种方案组合。
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对象的继承》 .