JavaScript语言与传统的面向对象语言(如Java)有点不一样,js语言设计的简单灵活,没有class、namespace等相关概念,而是万物皆对象。虽然js不是一个纯正的面向对象语言,但依然可以对js面向对象编程。java语言面向对象编程的基础是类,而js语言面向对象编程的基础是原型
。
原型是学习js的基础之一,由它衍生出许多像原型链、this指向、继承等问题。所以深入掌握js原型,才能对其衍生的问题有很好的理解。网上有很多文章解释原型里的等式关系,那样有些晦涩难懂,这里笔者从js设计历史来逐步解释js原型。
在ES6前,js语法是没有class
的。倒不是js语言作者Brendan Eich忘记引入class语法,而是因为当初设计js语言时,只想解决表单验证等简单问题(估计js作者没想到后来js成为最流行的语言之一),没必要引入class这种重型武器,不然就跟Java这种基于class的面向对象语言一样了。具体可以看下阮一峰老师的Javascript继承机制的设计思想 (opens new window)。
虽然设计js语言时,更多的考虑轻量级灵活,但依然要在语言层面考虑对象封装以及多个对象之间复用的问题。先看下使用传统方式进行封装:
function Person(name) {
return {
name: name,
sleep: function() {
console.log( 'go to sleep' )
}
}
}
var person1 = Person('tom')
var person2 = Person('lucy')
... personN
传统方式有以下两个弊端:
既然无意引入class语法,同时需要满足对象的封装以及复用问题,那就需要在js语言层面引入一种机制来处理class问题。
js作者使用了原型概念来解决class问题
。那什么是原型?原型是如何在语法层实现的?会涉及到哪些概念?
js原型概念通俗讲有点像Java中的类概念,多个实例是基于共同的类型定义,就像tom、lucy这些真实的人(占据空间)基于Person概念(不占空间,只是定义)。java中类是基于class关键字的,但js中没有class关键字,有的是function。而java类定义中都有个构造函数,实例化对象时会执行该构造函数,所以js作者简化把构造函数constructor作为原型(代替class)的定义
。同时规定构造函数需要满足以下条件:
// java定义类
class Person {
// java类中都有构造函数
constructor(name) {
this.name = name
}
public void sleep() {
....
}
}
// js使用构造函数代替类的作用
function Person(name) {
this.name = name
this.sleep = function() { ... }
}
以上通过构造函数定义了Person这个类。但如何区分一个function定义是构造函数还是普通函数?难道是看定义的函数里面是否有this来判断?
当然不是,js作者引入new关键字来解决该问题
。因为构造函数只是定义原型(不占据内存),最终还是需要产生实例(占据内存)来处理流程,所以使用new关键字来产生实例。同时规定new后面跟的是构造函数,而不是普通函数。这样就区分出定义的function,是构造函数还是普通函数。
// new 关键字后跟上构造函数,生成实例
// 语法层面上也和Java实例类一致
var tom = new Person('tom')
var lucy = new Person('lucy')
你肯定注意到构造函数中this的疑问
,它到底是在哪定义的?this又代表什么?其实在执行new的过程中,它会发生以下一些事:
// 模拟new的实现
function objectFactory() {
var obj = new Object(),
Constructor = [].shift.call(arguments); // 取出第一个参数,即构造函数
obj.__proto__ = Constructor.prototype;
var ret = Constructor.apply(obj, arguments);
return typeof ret === 'object' ? ret : obj;
};
var tom = objectFactory(Person, 'tom')
// 赋值的this === tom
console.log(tom.name) // tom
console.log(tom.sleep) // Function
new 和 constructor 解决了模拟class类的概念,使得产生的多个实例对象有共同的原型,同类型对象内在有了一些联系。看上去很完美,但还有个问题:每个实例对象本质上还是拷贝了构造函数对象里的属性和方法。tom和lucy实例的sleep方法依然创建了两个内存空间进行存储,而不是一个。这样不仅无法数据共享,对内存的浪费也是极大的(想象下再生成10000个tom)。那js作者是如何解决这个问题的?
Brendan Eich为构造函数设置一个prototype属性来保存这些公用的方法或属性
。prototype属性是一个对象,你可以扩展该对象,也可以覆写该对象。当你通过new constructor() 生成实例时,这些实例的公用方法(如:tom.sleep方法)并不会在内存中创建多份,而是通过指针都指向构造函数的prototype属性(如:Person.prototype)。
注意:Person构造函数和Person.prototype都是对象,拥有诸多属性。并且对象的属性依然可以是对象,万物皆对象核心。
function Person(name) {
this.name = name
}
// 构造函数都有一个非空的prototype对象
// 可以扩展该对象,也可以覆写该对象,以下在原型上扩展sleep方法
Person.prototype.sleep = function() { ... }
var tom = new Person('tom')
var lucy = new Person('lucy')
tom.sleep === lucy.sleep // true
由于所有实例对象共享同一个prototype对象(构造函数的prototype属性),那么从外界看起来,prototype对象就好像是实例对象的原型,而实例对象则好像"继承"了prototype对象一样。这就是我们通俗讲的:js面向对象编程是基于原型
。
Javascript规定,每一个构造函数都有一个prototype属性,指向另一个对象。这个对象的所有属性和方法,都会被构造函数的实例继承。
我们再深入思考下,js是如何把各个实例跟构造函数的prototype对象(以下称原型对象)联系起来的?它们之间的通道是如何建立起来的?答案是使用new关键字。在上面我们模拟new关键字流程中,有个步骤是: 添加__proto__属性到tom实例上,并且把tom.__proto__指向Person.prototype。所以可以得到结论:实例与原型对象间关联起来是通过__proto__属性
。
__proto__属性有什么用?当访问实例对象的属性或方法时,如果没有在实例上找到,js会顺着__proto__去查找它的原型,如果找到就返回。由于原型对象(如Person.prototype)也是一个对象,它也可以有自己的原型对象(比如覆写它),这样层层上溯,就形成了一个类似链表的结构,这就是原型链(prototype chain)
。而通过覆写子类原型对象,再根据js原型链机制,可以让子类拥有父类的内容,就像继承一样,所以原型链是js继承的基础
。
tom.__proto__ === lucy.__proto__ === Person.prototype // true
tom.sleep() // sleep方法是在原型链上找到的
注意new关键字以及原型链查找都是js语言内置的