前言
不知不觉跳入前端“大坑”也已经有大半年了,学到了很多知识。为了让知识更好地沉淀,我打算写一系列的知识总结,希望能在回顾知识的同时也能帮到别的同学。
忘记在哪里看到过,有人说鉴别一个人是否js入门的标准就是看他有没有理解js原型,所以第一篇总结就从这里出发。
对象
JavaScript是一种基于对象的编程语言,但它与一般面向对象的编程语言不同,因为他没有类(class)的概念。
对象是什么?ECMA-262把对象定义为:“无序属性的集合,其属性可以包含基本值、对象或者函数。”简单来说,对象就是一系列的键值对(key-value),我习惯把键值对分为两种,属性(property)和方法(method)。
面向对象编程,在我的理解里是一种编程思想。这种思想的核心就是把万物都抽象成一个个对象,它并不在乎数据的类型以及内容,它在乎的是某个或者某种数据能够做什么,并且把数据和数据的行为封装在一起,构建出一个对象,而程序世界就是由这样的一个个对象构成。而类是一种设计模式,用来更好地创建对象。
举个例子,把我自己封装成一个简单的对象,这个对象拥有我的一些属性和方法。
//构造函数创建
var klaus = new Object();
klaus.name = 'Klaus';
klaus.age = 22;
klaus.job = 'developer';
klaus.introduce = function(){
console.log('My name is ' + this.name + ', I\'m ' + this.age + ' years old.');};
//字面量语法创建,与上面效果相同
var klaus = {
name: 'Klaus',
age: 22,
job: 'developer',
introduce: function(){
console.log('My name is ' + this.name + ', I\'m ' + this.age + ' years old.');
}};
这个对象中,name、age和job是数据部分,introduce是数据行为部分,把这些东西都封装在一起就构成了一个完整的对象。这种思想不在乎数据(name、age和job)是什么,它只在乎这些数据能做什么(introduce),并且把它们封装在了一起(klaus对象)。
跑一下题,与面向对象编程相对应的编程思想是面向过程编程,它把数据和数据行为分离,分别封装成数据库和方法库。方法用来操作数据,根据输入的不同返回不同的结果,并且不会对输入数据之外的内容产生影响。与之相对应的设计模式就是函数式编程。
工厂模式创建对象
如果创建一个简单的对象,像上面用到的两种方法就已经够了。但是如果想要创建一系列相似的对象,这种方法就太过麻烦了。所以,就顺势产生了工厂模式。
function createPerson(name, age, job){
var o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.introduce = function(){
console.log('My name is ' + this.name + ', I\'m ' + this.age + ' years old.');
};
return o;}var klaus = createPerson('Klaus', 22, 'developer');
随着JavaScript的发展,这种模式渐渐被更简洁的构造函数模式取代了。(高程三中提到工厂模式无法解决对象识别问题,我觉得完全可以加一个_type属性来标记对象类型)
构造函数模式创建对象
我们可以通过创建自定义的构造函数,然后利用构造函数来创建相似的对象。
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.introduce = function(){
console.log('My name is ' + this.name + ', I\'m ' + this.age + ' years old.');
};
}
var klaus = new Person('Klaus', 22, 'developer');
console.log(klaus instanceof Person); //true
console.log(klaus instanceof Object); //true
现在我们来看一下构造函数模式与工厂模式对比有什么不同:
-
函数名首字母大写:这只是一种约定,写小写也完全没问题,但是为了区别构造函数和一般函数,默认构造函数首字母都是大写。
-
不需要创建对象,函数最后也不需要返回创建的对象:new操作符帮你创建对象并返回。
-
添加属性和方法的时候用this:new操作符帮你把this指向创建的对象。
-
创建的时候需要用new操作符来调用构造函数。
-
可以获取原型上的属性和方法。(下面会说)
-
可以用instanceof判断创建出的对象的类型。
new
这么看来,构造函数模式的精髓就在于这个new操作符上,所以这个new到底做了些什么呢?
-
创建一个空对象。
-
在这个空对象上调用构造函数。(所以this指向这个空对象)
-
将创建对象的内部属性__proto__指向构造函数的原型(原型,后面讲到原型会解释)。
-
检测调用构造函数后的返回值,如果返回值为对象(不包括null)则new返回该对象,否则返回这个新创建的对象。
用代码来模仿大概是这样的:
function _new(fn){
return function(){
var o = new Object();
var result = fn.apply(o, arguments);
o.__proto__ = fn.prototype;
if(result && (typeof result === 'object' || typeof result === 'function')){
return result;
}else{
return o;
}
}
}
var klaus = _new(Person)('Klaus', 22, 'developer');
组合使用构造函数模式和原型模式
构造函数虽然很好,但是他有一个问题,那就是创建出的每个实例对象里的方法都是一个独立的函数,哪怕他们的内容完全相同,这就违背了函数的复用原则,而且不能统一修改已创建实例对象里的方法,所以,原型模式应运而生。
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.introduce = function(){
console.log('My name is ' + this.name + ', I\'m ' + this.age + ' years old.');
};
}
var klaus1 = new Person('Klaus', 22, 'developer');
var klaus2 = new Person('Klaus', 22, 'developer');
console.log(klaus1.introduce === klaus2.introduce); //false
什么是原型?我们每创建一个函数,他就会自带一个原型对象,这个原型对象你可以理解为函数的一个属性(函数也是对象),这个属性的key为prototype,所以你可以通过fn.prototype来访问它。这个原型对象除了自带一个不可枚举的指向函数本身的constructor属性外,和其他空对象并无不同。
那这个原型对象到底有什么用呢?我们知道构造函数也是一个函数,既然是函数那它也就有自己的原型对象,既然是对象你也就可以给它添加一些属性和方法,而这个原型对象是被该构造函数所有实例所共享的,所以你就可以把这个原型对象当做一个共享仓库。下面来说说他具体是如何共享的。
上面讲new操作符的时候讲过有一步,将创建对象的内部属性__proto__指向构造函数的原型,这一步才是原型共享的关键。这样你就可以在新建的实例对象里访问构造函数原型对象里的数据。
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.introduce = this.__proto__.introduce; //这句可以省略,后面会介绍
}
Person.prototype.introduce = function(){
console.log('My name is ' + this.name + ', I\'m ' + this.age + ' years old.');
};
var klaus1 = new Person('Klaus', 22, 'developer');
var klaus2 = new Person('Klaus', 22, 'developer');
console.log(klaus1.introduce === klaus2.introduce); //true
这样,我们就达到了函数复用的目的,而且如果你修改了原型对象里的introduce函数后,所有实例的introduce方法都会同时更新,是不是很方便呢?但是原型绝对不止是为了这么简单的目的所创建的。
我们首先明确一点,当创建一个最简单的对象的时候,其实默认用new调用了JavaScript内置的Objcet构造函数,所以每个对象都是Object的一个实例(用Object.create(null)等特殊方法创建的暂不讨论)。所以根据上面的介绍,每个对象都有一个__proto__的属性指向Object.prototype。这是理解下面属性查找机制的前提。
var klaus = {
name: 'Klaus',
age: 22,
job: 'developer',
introduce: function(){
console.log('My name is ' + this.name + ', I\'m ' + this.age + ' years old.');
}
};
console.log(klaus.friend); //undefined
console.log(klaus.toString); //ƒ toString() { [native code] }
上面代码可以看出,如果我们访问klaus对象上没有定义的属性friend,结果返回undefined,这个可以理解。但是同样访问没定义的toString方法却返回了一个函数,这是不是很奇怪呢?其实一点不奇怪,这就是JavaScript对象的属性查找机制。
属性查找机制:当访问某对象的某个属性的时候,如果存在该属性,则返回该属性的值,如果该对象不存在该属性,则自动查找该对象的__proto__指向的对象的此属性。如果在这个对象上找到此属性,则返回此属性的值,如果__proto__指向的对象也不存在此属性,则继续寻找__proto__指向的对象的__proto__指向的对象的此属性。这样一直查下去,直到找到Object.prototype对象,如果还没找到此属性,则返回undefined。(原型链查找,讲继承时会详细讲)
理解了上面的查找机制以后,也就不难理解klaus.toString其实也就是klaus.__proto__.toString,也就是Object.prototype.toString,所以就算你没有定义依然也可以拿到一个函数。
理解了这一点以后,也就理解了上面Person构造函数里的那一句我为什么注释了可以省略,因为访问实例的introduce找不到时会自动找到实例__proto__指向的对象的introduce,也就是Person.prototype.introduce。
这也就是原型模式的强大之处,因为你可以在每个实例上访问到构造函数的原型对象上的属性和方法,而且可以实时修改,是不是很方便呢。
除了给原型对象添加属性和方法之外,也可以直接重写原型对象(因为原型对象本质也是一个对象),只是别忘记添加constructor属性。
还需要注意一点,如果原型对象共享的某属性是个引用类型值,一个实例修改该属性后,其他实例也会因此受到影响。
以及,如果用for-in循环来遍历属性的key的时候,会遍历到原型对象里的可枚举属性。
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;}Person.prototype = {
introduce: function(){
console.log('My name is ' + this.name + ', I\'m ' + this.age + ' years old.');
},
friends: ['person0', 'person1', 'person2']
};
Object.defineProperty(Person.prototype, 'constructor', {
enumerable: false,
value: Person
});
var