不知不觉跳入前端「大坑」也已经有大半年了,学到了很多知识。为了让知识更好地沉淀,我打算写一系列的知识总结,希望能在回顾知识的同时也能帮到别的同学。
忘记在哪里看到过,有人说鉴别一个人是否 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 klaus1 = new Person('Klaus', 22, 'developer');
var klaus2 = new Person('Klaus', 22, 'developer');
console.log(klaus1.friends); //['person0', 'person1', 'person2']
klaus1.friends.push('person3');
console.log(klaus1.friends); //['person0', 'person1', 'person2', 'person3']
console.log(klaus2.friends); //['person0', 'person1', 'person2', 'person3']
for(var key in klaus1){
console.log(key); //name, age, job, introduce, friends
}
ES6 class
如果你有关注最新的 ES6 的话,你会发现里面提出了一个关键字 class 的用法,难道 JavaScript 要有自己类的概念了吗?
tan90°,不存在的,这只是一个语法糖而已,上面定义的 Person 构造函数可以用 class 来改写。
class Person{
constructor(name, age, job){
this.name = name;
this.age = age;
this.job = job;
}
introduce(){
console.log('My name is ' + this.name + ', I\'m ' + this.age + ' years old.');
}
}
Person.prototype.friends = ['person0', 'person1', 'person2'];
var klaus = new Person('Klaus', 22, 'developer');
很遗憾,ES6 明确规定 class 里只能有方法而不能有属性,所以像 friends 这样的属性可能只能在外面单独定义了。
下面简单举几个差异点,如果想详细了解可以去看阮一峰的《ECMAScript 6 入门》或者 Nicholas C. Zakas 的《Understanding ECMAScript 6》。
class 里的静态方法(类似于 introduce)是不可枚举的,而用 prototype 定义的是可枚举的。
class 里面默认使用严格模式。
class 已经不属于普通的函数了,所以不使用 new 调用会报错。
class 不存在变量提升。
class 里的方法可以加 static 关键字定义静态方法,这种静态方法就不是定义在 Person.prototype 上而是直接定义在 Person 上了,只能通过 Person.method() 调用而不会被实例共享。
作用域安全的构造函数
不管是高程还是其他的一些资料都提到过作用域安全的构造函数这个概念,因为构造函数如果不用 new 来调用就只是一个普通的函数而已,这样在函数调用的时候 this 会指向全局(严格模式为 undefined),这样如果错误调用构造函数就会把属性和方法定义在 window 上。为了避免这种情况,可以将构造函数稍加改造,先用 instanceof 检测 this 然后决定调用方法。
function Person(name, age, job){
if(this instanceof Person){
this.name = name;
this.age = age;
this.job = job;
}else{
return new Person(name, age, job);
}
}
var klaus1 = Person('Klaus', 22, 'developer');
var klaus2 = new Person('Klaus', 22, 'developer'); //两种方法结果一样
不过个人认为这种没什么必要,构造函数已经首字母大写来加以区分了,如果还错误调用的话那也没啥好说的了。。。
结语
以上就是我眼中的 JavaScript 原型,可能解释的不够清楚,大家如果还想看更详细的内容可以去看高程三的第六章或者你不知道的 JavaScript(上卷)的第二部分关于原型的内容,下一次我可能会写一些关于 JavaScript 继承的内容。