五、原型链
原型对象其实也是普通的对象。几乎所有的对象都可能是原型对象,也可能是实例对象,而且还可以同时是原型对象与实例对象。这样的一个对象,正是构成原型链的一个节点。因此理解了原型,那么原型链并不是一个多么复杂的概念。
我们知道所有的函数都有一个叫做toString的方法。那么这个方法到底是在哪里的呢?
先随意声明一个函数:
function add() {}
那么我们可以用如下的图来表示这个函数的原型链。
其中add是Function对象的实例。而Function的原型对象同时又是Object的实例。这样就构成了一条原型链。原型链的访问,其实跟作用域链有很大的相似之处,他们都是一次单向的查找过程。因此实例对象能够通过原型链,访问到处于原型链上对象的所有属性与方法。这也是foo最终能够访问到处于Object原型对象上的toString方法的原因。
基于原型链的特性,我们可以很轻松的实现继承。
六、继承
我们常常结合构造函数与原型来创建一个对象。因为构造函数与原型的不同特性,分别解决了我们不同的困扰。因此当我们想要实现继承时,就必须得根据构造函数与原型的不同而采取不同的策略。
我们声明一个Person对象,该对象将作为父级,而子级cPerson将要继承Person的所有属性与方法。
function Person(name, age) { this.name = name; this.age = age; } Person.prototype.getName = function() { return this.name; }
首先我们来看构造函数的继承。在上面我们已经理解了构造函数的本质,它其实是在new内部实现的一个复制过程。而我们在继承时想要的,就是想父级构造函数中的操作在子级的构造函数中重现一遍即可。我们可以通过call方法来达到目的。
// 构造函数的继承 function cPerson(name, age, job) { Person.call(this, name, age); this.job = job; }
原型的继承,只需要将子级的原型对象设置为父级的一个实例,加入到原型链中即可。
// 继承原型 cPerson.prototype = new Person(name, age); // 添加更多方法 cPerson.prototype.getLive = function() {}
当然关于继承还有更好的方式。
七、更好的继承
假设原型链的终点Object.prototype
为原型链的E(end)端,原型链的起点为S(start)端。
通过前面原型链的学习我们知道,处于S端的对象,可以通过S -> E的单向查找,访问到原型链上的所有方法与属性。因此这给继承提供了理论基础。我们只需要在S端添加新的对象,那么新对象就能够通过原型链访问到父级的方法与属性。因此想要实现继承,是一件非常简单的事情。
因为封装一个对象由构造函数与原型共同组成,因此继承也会分别有构造函数的继承与原型的继承。
假设我们已经封装好了一个父类对象Person。如下。
var Person = function(name, age) { this.name = name; this.age = age; } Person.prototype.getName = function() { return this.name; } Person.prototype.getAge = function() { return this.age; }
构造函数的继承比较简单,我们可以借助call/apply来实现。假设我们要通过继承封装一个Student的子类对象。那么构造函数可以如下实现。
var Student = function(name, age, grade) { // 通过call方法还原Person构造函数中的所有处理逻辑 Person.call(this, name, age); this.grade = grade; } // 等价于 var Student = function(name, age, grade) { this.name = name; this.age = age; this.grade = grade; }
原型的继承则稍微需要一点思考。首先我们应该考虑,如何将子类对象的原型加入到原型链中?我们只需要让子类对象的原型,成为父类对象的一个实例,然后通过__proto__
就可以访问父类对象的原型。这样就继承了父类原型中的方法与属性了。
因此我们可以先封装一个方法,该方法根据父类对象的原型创建一个实例,该实例将会作为子类对象的原型。
function create(proto, options) { // 创建一个空对象 var tmp = {}; // 让这个新的空对象成为父类对象的实例 tmp.__proto__ = proto; // 传入的方法都挂载到新对象上,新的对象将作为子类对象的原型 Object.defineProperties(tmp, options); return tmp; }
简单封装了create
对象之后,我们就可以使用该方法来实现原型的继承了。
Student.prototype = create(Person.prototype, { // 不要忘了重新指定构造函数 constructor: { value: Student } getGrade: { value: function() { return this.grade } } })
那么我们来验证一下我们这里实现的继承是否正确。
var s1 = new Student('ming', 22, 5); console.log(s1.getName()); // ming console.log(s1.getAge()); // 22 console.log(s1.getGrade()); // 5
全部都能正常访问,没问题。在ECMAScript5中直接提供了一个Object.create
方法来完成我们上面自己封装的create
的功能。因此我们可以直接使用Object.create
.
Student.prototype = create(Person.prototype, { // 不要忘了重新指定构造函数 constructor: { value: Student } getGrade: { value: function() { return this.grade } } })
完整代码如下:
function Person(name, age) { this.name = name; this.age = age; } Person.prototype.getName = function() { return this.name } Person.prototype.getAge = function() { return this.age; } function Student(name, age, grade) { // 构造函数继承 Person.call(this, name, age); this.grade = grade; } // 原型继承 Student.prototype = Object.create(Person.prototype, { // 不要忘了重新指定构造函数 constructor: { value: Student } getGrade: { value: function() { return this.grade } } }) var s1 = new Student('ming', 22, 5); console.log(s1.getName()); // ming console.log(s1.getAge()); // 22 console.log(s1.getGrade()); // 5
八、属性类型
在上面的继承实现中,使用了一个大家可能不太熟悉的方法defineProperties
。并且在定义getGrade
时使用了一个很奇怪的方式。
getGrade: { value: function() { return this.grade } }
这其实是对象中的属性类型。在我们平常的使用中,给对象添加一个属性时,直接使用object.param
的方式就可以了,或者直接在对象中挂载。
var person = { name: 'TOM' }
在ECMAScript5中,对每个属性都添加了几个属性类型,来描述这些属性的特点。他们分别是
•configurable
: 表示该属性是否能被delete删除。当其值为false时,其他的特性也不能被改变。默认值为true•enumerable
: 是否能枚举。也就是是否能被for-in遍历。默认值为true•writable
: 是否能修改值。默认为true•value
: 该属性的具体值是多少。默认为undefined•get
: 当我们通过person.name
访问name的值时,get将被调用。该方法可以自定义返回的具体值是多少。get默认值为undefined•set
: 当我们通过
person.name = 'Jake'
设置name的值时,set方法将被调用。该方法可以自定义设置值的具体方式。set默认值为undefined
需要注意的是,不能同时设置value、writable 与 get、set的值。
我们可以通过Object.defineProperty
方法来修改这些属性类型。
下面我们用一些简单的例子来演示一下这些属性类型的具体表现。
configurable
// 用普通的方式给person对象添加一个name属性,值为TOM var person = { name: 'TOM' } // 使用delete删除该属性 delete person.name; // 返回true 表示删除成功 // 通过Object.defineProperty重新添加name属性 // 并设置name的属性类型的configurable为false,表示不能再用delete删除 Object.defineProperty(person, 'name', { configurable: false, value: 'Jake' // 设置name属性的值 }) // 再次delete,已经不能删除了 delete person.name // false console.log(person.name) // 值为Jake // 试图改变value person.name = "alex"; console.log(person.name) // Jake 改变失败
enumerable
var person = { name: 'TOM', age: 20 } // 使用for-in枚举person的属性 var params = []; for(var key in person) { params.push(key); } // 查看枚举结果 console.log(params); // ['name', 'age'] // 重新设置name属性的类型,让其不可被枚举 Object.defineProperty(person, 'name', { enumerable: false }) var params_ = []; for(var key in person) { params_.push(key) } // 再次查看枚举结果 console.log(params_); // ['age']
writable
var person = { name: 'TOM' } // 修改name的值 person.name = 'Jake'; // 查看修改结果 console.log(person.name); // Jake 修改成功 // 设置name的值不能被修改 Object.defineProperty(person, 'name', { writable: false }) // 再次试图修改name的值 person.name = 'alex'; console.log(person.name); // Jake 修改失败
value
var person = {} // 添加一个name属性 Object.defineProperty(person, 'name', { value: 'TOM' }) console.log(person.name) // TOM
get/set
var person = {} // 通过get与set自定义访问与设置name属性的方式 Object.defineProperty(person, 'name', { get: function() { // 一直返回TOM return 'TOM' }, set: function(value) { // 设置name属性时,返回该字符串,value为新值 console.log(value + ' in set'); } }) // 第一次访问name,调用get console.log(person.name) // TOM // 尝试修改name值,此时set方法被调用 person.name = 'alex' // alex in set // 第二次访问name,还是调用get console.log(person.name) // TOM
请尽量同时设置get、set。如果仅仅只设置了get,那么我们将无法设置该属性值。如果仅仅只设置了set,我们也无法读取该属性的值。
Object.defineProperty
只能设置一个属性的属性特性。当我们想要同时设置多个属性的特性时,需要使用我们之前提到过的Object.defineProperties
var person = {} Object.defineProperties(person, { name: { value: 'Jake', configurable: true }, age: { get: function() { return this.value || 22 }, set: function(value) { this.value = value } } }) person.name // Jake person.age // 22
读取属性的特性值
我们可以使用Object.getOwnPropertyDescriptor
方法读取某一个属性的特性值。
var person = {} Object.defineProperty(person, 'name', { value: 'alex', writable: false, configurable: false }) var descripter = Object.getOwnPropertyDescriptor(person, 'name'); console.log(descripter); // 返回结果如下 descripter = { configurable: false, enumerable: false, value: 'alex', writable: false }
九、总结
关于面向对象的基础知识大概就是这些了。我从最简单的创建一个对象开始,解释了为什么我们需要构造函数与原型,理解了这其中的细节,有助于我们在实际开发中灵活的组织自己的对象。因为我们并不是所有的场景都会使用构造函数或者原型来创建对象,也许我们需要的对象并不会声明多个实例,或者不用区分对象的类型,那么我们就可以选择更简单的方式。
我们还需要关注构造函数与原型的各自特性,有助于在创建对象时准确的判断我们的属性与方法到底是放在构造函数中还是放在原型中。如果没有理解清楚,这会给我们在实际开发中造成非常大的困扰。