理解对象
第五讲里面讲了对象声明的两种方式:
- 使用Object构造函数
- 对象字面量
属性类型
ES5描述对象属性(property)的特征, 称为特性(attribute), 定义特性是为了实现js引擎用的, 所以在JS中不能直接访问它们(特性). 为了表示特性是内部值, 规范把它们放在两个方括号之中.
ES5有两种属性:
1. 数据属性:
数据属性包含一个数据值的位置。在这个位置可以读取和写入值。数据属性有 4 个描述其行为的特性。
- [[Configurable]]: 表示能否通过 delete 删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。默认值: true
- [[Enumerable]]: 可枚举, 即能否for-in循环出这个属性, 默认值: true
- [[Writable]]: 是否能改属性的值, 默认值:true
- [[Value]]: 默认值: undefined
为了帮助理解, 我们来看一个例子:
var person = {};
Object.defineProperty(person, "name", {
writable: false,
value: "Nicholas",
configurable: false, //禁止删除
});
// 注意这个配置只能用一次,否则会报错
Object.defineProperty(person, "name", {
writable: true, // Cannot redefine property:
})
alert(person.name); //"Nicholas"
person.name = "Greg"; //非严格模式下赋值被忽略, 严格模式下报错
alert(person.name); //"Nicholas"
delete person.name; //严格模式下报错
alert(person.name); //"Nicholas"
如果运行Object.defineProperty方法时, 不明确指定的话, configurable, enumerable, writable的值都会变成false(要记住原本他们都是true).
注意: 笔者写本文的时候(2019年), 亲自写示例测试, 书上的这部分话已经不可信.
2. 访问器属性
访问器属性不包含数据值;它们包含一对儿 getter 和 setter 函数(不过,这两个函数都不是必需的)。在读取访问器属性时,会调用 getter 函数,这个函数负责返回有效的值;在写入访问器属性时,会调用
setter 函数并传入新值,这个函数负责决定如何处理数据。访问器属性有如下 4 个特性:
- [[Configurable]]:同数据属性
- [[Enumerable]]: 同数据属性
- [[Get]]: 读取属性时候调用的函数, 默认undefined
- [[Set]]: 写入属性时候调用的函数, 默认undefined
要修改访问器属性的特性, 同样是用Object.defineProperty方法
我们看这个例子:
var demo = {};
Object.defineProperty(demo, 'name', {
configurable:true,
enumerable:true,
get:function(){
console.log('我读取了属性')
},
set:function(){
console.log('我设置了属性')
}
})
定义多个属性
定义多个属性可以用Object.defineProperties方法, 用法和Object.defineProperty类似, 只不过第二个参数为复合对象
读取属性的特性
用Object.getOwnPropertyDescriptor读取属性的特性
var book = {};
Object.defineProperties(book, {
_year: {
value: 2004
},
edition: {
value: 1
},
year: {
get: function(){ return this._year;
},
set: function(newValue){
if (newValue > 2004) {
this._year = newValue;
this.edition += newValue - 2004;
}
}
}
});
var descriptor = Object.getOwnPropertyDescriptor(book, "_year");
alert(descriptor.value); //2004
alert(descriptor.configurable); //false
alert(typeof descriptor.get); //"undefined"
var descriptor = Object.getOwnPropertyDescriptor(book, "year");
alert(descriptor.value); //undefined
alert(descriptor.enumerable); //false
alert(typeof descriptor.get); //"function"
创建对象
为了解决多次生成对象的问题, 形成了以下常见的接种封装模式
工厂模式
function createPerson(name, age, job){
var o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.sayName = function(){
alert(this.name);
};
return o;
}
var person1 = createPerson("Nicholas", 29, "Software Engineer");
var person2 = createPerson("Greg", 27, "Doctor");
工厂模式解决了生成多个类似对象的问题, 但是还没有解决对象属于什么”类”的问题
构造函数模式
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = function(){
alert(this.name);
};
}
var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");
我们通常把构造函数的函数名首字母大写, 这样它看起来想一个”类”, 区别于工厂模式, 构造函数模式有三个不同点:
- 没有显示地创建对象
- 直接将属性和方法赋给this
- 没有return
创建一个实例的方式变成了 new Person, 背后产生了以下几个步骤:
- 创建一个新对象
- 将构造函数的作用域赋给新对象(因此 this 就指向了这个新对象);
- 执行构造函数中的代码(为这个新对象添加属性);
- 返回新对象
构造函数的优点:
构造函数的好处在于, 它可以作为一种自定义的”类”, 也容易识别实例是否为某一种类型:
alert(person1.constructor == Person); //true
alert(person2.constructor == Person); //true
注意, 创建Person的实例的时候必须用new关键字, 否则创建的示例会挂载到window上.
构造函数的缺点:
还是用上一个例子, 但是我们稍作修改:
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = new Function('alert(this.name)'); //为了方便理解我们使用new Function
//这样每次创建Person实例的时候, sayName都是重复创建具有一样功能的方法(即资源浪费)
}
var p1 = new Person('k',18,'fe');
var p2 = new Person('x',19,'bd');
p1.sayName == p2.sayName; //false
前面我知道了函数名其实就是一个指针, 那么我们可以稍微改造一下:
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = sayName;
}
function sayName(){
alert(this.name)
}
var p1 = new Person('k',18,'fe');
var p2 = new Person('x',19,'bd');
p1.sayName == p2.sayName; //true
这样, 看起来也没什么问题了. 不过sayName作为一个全局函数, 只能给Person的示例调用, 好像又对不起它作为全局函数的称号, 如果要定义很多个方法, 那就需要很多个全局函数, 这样看起来又不像”封装好”的样子.
原型模式
为了解决构造函数模式的问题, 诞生了原型模式, 第五章我们提到了Function的每个实例都有两个属性, 一个是length
, 另一个是prototype
, 我们现在来着重讲这个prototype
. prototype
其实是一个指针, 指向一个对象
, 这个对象用于存储所有实例共享的属性和方法.
我们先看一张图:
可以看到我随便创造的一个空函数demo, 它的prototype指向了一个对象, 这个对象包含了constructor
和__proto__
两个属性, 而constructor
又指回了demo本身, __proto__
指向的是Object, 实际上这个Object, 就是我们常常看到的 new Object里面的那个Object构造函数, 这侧面反映了几个事实:
- 所有对象都是Object的实例(这个讲原型链的时候会继续深入讲解)
- 一个对象可以通过
__proto__
访问生成这个对象的构造函数 的 原型 - 原型(prototype)的
constructor
属性,指向的是构造函数本身
我们再看这个例子:
function Person(){ }
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function(){
alert(this.name);
};
var person1 = new Person();
person1.sayName(); //"Nicholas"
var person2 = new Person();
person2.sayName(); //"Nicholas"
alert(person1.sayName == person2.sayName); //true
实际上它的原理如下:
图中实例的[[prototype]]
, 其实就是__proto__
;
除了用这个属性确定对象实例和原型的关系, 还可以通过下列方式查看:
Person.prototype.isPrototypeOf(person1) //true
Object.getPrototypeOf(person1) == Person.prototype //true
前面给出的例子, 构造函数都是一个空函数, 不存在任何的属性和方法. 基于上一个代码示例, 我们尝试通过实例重写原型的属性.
alert(person1.name); // "Nicholas"
person1.name = "kkk"; //注意我这里只是改了实例的属性, 并没有改构造函数
alert(person1.name); //"kkk"
alert(person2.name); // "Nicholas"
可以看出, 通过实例去改变原型, 是没办法在原型中改变对应的属性或者方法.
从上面的代码能反映出, 对象实例查找某一个属性(或者方法), 是先查找构造函数中的同名属性 , 如果找到则停止, 否则继续在原型中查找同名属性.
解释: 上面的代码是显式地person1.name = “kkk”. 假如构造函数中存在this.name=”kkk”, 那么person1.name的值毫无疑问就是”kkk”;
由于构造函数和原型的这种特性, 我们要查找一个对象实例的属性究竟来自自身还是来自原型, 需要用hasOwnProperty方法:
alert(person1.hasOwnProperty('name')); //true
in
:
通常我们会在for-in(当然ES5可以用Object.keys)中看到in操作符, 用于遍历一个对象的所有课枚举属性, 然而单独使用in操作符的时候, 是用于检测某个对象的某个属性(或方法)是否存在于原型链中. 只要原型中存在需要查找的属性, 假设这个属性为name
, 那么不管构造函数是否存在name
, in操作符依然能查找出来.
为了查找只存在原型上的属性(或方法), 我们可以将in和hasOwnProperty写成一个函数用于检测:
function hasPrototypeProperty(object, name){
return !object.hasOwnProperty(name) && (name in object);
}
简写原型
:
前面的代码可以看到, 每添加一个属性, 就要多书写Person.prototype一次, 实际上们可以这样:
function Person(){}
Person.prototype = {
constructor:Person, //注意这里要手动绑定构造函数,因为此时的原型相当于一个新领养的小孩, 要重新让他认爹. 虽然我们大多数情况下用不到constructor属性, 但是建议养成这个习惯
}
原型的动态
:
由于在原型中查找值的过程是一次搜索,因此我们对原型对象所做的任何修改都能够立即从实例上反映出来 即使是先创建了实例后修改原型也照样如此。
请看例子:
var friend = new Person();
Person.prototype.sayHi = function(){
alert("hi");
};
friend.sayHi(); //"hi"(没有问题!)
如果生成实例之后用字面量的形式修改了原型对象, 那么就会报错:
function Person(){
}
var friend = new Person();
Person.prototype = {
constructor: Person,
name : "Nicholas",
age : 29,
job : "Software Engineer",
sayName : function () {
alert(this.name);
}
};
friend.sayName(); //error. 因为friend是原本那个原型衍生而来的
原生对象的原型: Array, Object, Function同样可以通过上述方式修改原型对象, 不过除非必要, 不建议修改.
原型模式的缺点:
所有属性和方法都写在原型, 看上去实现了共享. 但是如果原型对象中的属性是引用类型的话, 实例对改属性的修改, 也会立刻反映到所有实例上.
看例子:
function Person(){ }
Person.prototype = {
constructor: Person,
name : "Nicholas",
age : 29,
job : "Software Engineer",
friends : ["Shelby","Court"],
sayName : function () {
alert(this.name);
}
};
var person1 = new Person();
var person2 = new Person();
person1.friends.push("Van");
alert(person1.friends); //"Shelby,Court,Van"
alert(person2.friends); //"Shelby,Court,Van"
alert(person1.friends === person2.friends); //true
构造函数+原型模式
仅仅只用构造函数, 那么在生成对象方法的时候会造成资源浪费. 如果只是用原型模式的话, 那么会产生属性为引用类型时候的弊端. 所以我们可以把这两张方式结合起来:
function Person(name,age, job){
this.name = name;
this.age = age;
this.job = job;
this.friends = ["Shelby","Court"],
}
Person.prototype = {
constructor: Person,
sayName : function () {
alert(this.name);
}
};
var person1 = new Person("Nicholas", 29, "Software Engineer"); var person2 = new Person("Greg", 27, "Doctor");
person1.friends.push("Van");
alert(person1.friends); //"Shelby,Count,Van"
alert(person2.friends); //"Shelby,Count"
这样, 组合模式基本解决所有的需求.
动态原型模式
组合模式的优化:
function Person(name,age, job){
this.name = name;
this.age = age;
this.job = job;
this.friends = ["Shelby","Court"];
// 看起来更像是一个'类'
if (typeof this.sayName != "function"){
Person.prototype.sayName = function(){
alert(this.name);
};
}
}
寄生构造函数
假如你有这么一个这样的需求: 批量生产某一种数据类型, 比如数组实例, 但是这个数组实例又有一个toPipedString
方法, 那你可以考虑用寄生构造函数.
它和工厂模式长得很像(实际上就是一样的, 该有的缺点都有):
function _Array(){
var values = new Array();
values.push.apply(values, arguments);
values.toPipedString = function(){
return this.join("|");
}
return values;
}
var a = new _Array(2,6,8,9,4);
a.toPipedString();
var b = _Array(2,6,8,9,4);// 这里没有用new , 返回的结果依旧一样(就是工厂模式)
b.toPipedString();
在上面的_Array构造函数, 并没有使用到this, 而且它还有显式的return(引用类型), 也就是说new了也是白new. 虽然书上给出了这个寄生构造函数模式, 但笔者认为实在没有必要使用这种方式.
稳妥构造函数
就是寄生构造函数模式下, 只暴露方法, 不允许通过对象实例直接访问对象的属性值
继承
JS只有实现继承, 并且主要依靠原型链实现的.
原型链
前面我们讲了原型, 至于原型链. 实际就是用父类的实例, 作为子类的原型对象(即用父类实例重写子类原型对象), 这样父类拥有的属性和方法, 子类实例自然也能访问到.
原型链需要注意:
function SuperType(){
this.property = true;
}
SuperType.prototype.getSuperValue = function(){
return this.property;
};
function SubType(){
this.subproperty = false;
}
//继承了 SuperType
SubType.prototype = new SuperType();
//注意以下操作一点要在继承完之后才能执行, 并且不能用字面量添加方法, 否则会导致继承失效.
//添加新方法
SubType.prototype.getSubValue = function (){
return this.subproperty;
};
//重写超类型中的方法
SubType.prototype.getSuperValue = function (){
return false;
};
var instance = new SubType();
alert(instance.getSuperValue()); //false
除了以上要注意的地方, 原型链继承还有两个问题:
- 第一个问题就是, 当父类的实例包含了引用类型属性时, 子类的原型对象就同样包含了这个引用类型的属性.
我们来看示例:
function SuperType(){
this.colors = ["red", "blue", "green"];
}
function SubType(){ }
SubType.prototype = new SuperType();
var instance1 = new SubType();
instance1.colors.push("black");
alert(instance1.colors); //"red,blue,green,black"
var instance2 = new SubType();
alert(instance2.colors); //"red,blue,green,black", 已经发生修改
- 第二个问题: 不能向父类传参.
借用构造函数
为了解决原型链继承问题, 我们可以借用父类构造函数, 而不是将父类实力重写子类原型对象 , 通常这种方式叫做经典继承或者伪造继承.
function SuperType(name){
this.name = name;
this.colors = ["red", "blue", "green"];
}
function SubType(name){
var name = name || 'testdog'
SuperType.call(this,name); //想起call和apply的作用吗?
}
var instance1 = new SubType();
instance1.colors.push("black");
alert(instance1.colors); //"red,blue,green,black"
var instance2 = new SubType();
alert(instance2.colors); //"red,blue,green"
var instance = new subType('kkk')
instance.name; //'kkk'
不过这样的话, 就子类实例无法用instanceof来检查是否是也是父类的实例了.
当然, 不可能把所有属性和方法都放在父类构造函数中, 肯定还有写在父类原型对象的情况. 所以只用call/apply借用父类构造函数实现继承, 也是不够的.
组合继承
顾名思义, 原型链+构造函数组合而成.
function SuperType(name){
this.name = name;
this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function(){
alert(this.name);
};
function SubType(name, age){
//继承属性
SuperType.call(this, name);
this.age = age;
}
//继承方法
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function(){
alert(this.age);
};
var instance1 = new SubType("Nicholas", 29);
instance1.colors.push("black");
alert(instance1.colors); //"red,blue,green,black"
instance1.sayName(); //"Nicholas";
instance1.sayAge(); //29
var instance2 = new SubType("Greg", 27);
alert(instance2.colors); //"red,blue,green"
instance2.sayName(); //"Greg";
instance2.sayAge(); //27
注意: 创建一个子类实例, SuperType其实是运行了两次的 , 一次在于原型链继承, 另一次在于借用以覆盖引用类型属性共享的问题.
原型式继承
如果只是基于现有的对象实现继承, 那可不比兴师动众写那么多函数:
var k = {
name:'k',
age:18,
friends:['a','b','c'],
};
var object = function(o){
function F(){};
F.prototype = o; //浅复制,
return new F();
};
var obj = object(k);
obj.friends.push('d');
var obj2 = object(k);
obj2.friends; //['a','b','c','d'];
ES5规范化了这种继承方式, 于是有了Object.create方法, 用法同上, 但是它可以多一个参数.这个参数的格式和Object.defineProperties的第二个参数格式一致.
var person = {
name: "Nicholas",
friends: ["Shelby", "Court", "Van"]
};
var anotherPerson = Object.create(person, {
name: {
value: "Greg"
}
});
alert(anotherPerson.name); //"Greg"
寄生式继承
其实是原型式继承的基础上再包装一层, 用于添加需要的方法, 类似于前面讲的寄生构造函数模式或者工厂模式.
寄生组合继承
前面讲了组合继承, 它还存在一个两次调用父类的问题. 既然组合类型是通过借用父类函数实现属性继承, 通过原型链实现方法的继承. 那么我们可以在原型链这里动一次手脚.
function inheritPrototype(subType, superType){
var prototype = Object.create(superType.prototype);
prototype.constructor = subType;
subType.prototype = prototype;
}
function SuperType(name){
this.name = name;
this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function(){
alert(this.name);
};
function SubType(name, age){
SuperType.call(this, name);
this.age = age;
}
inheritPrototype(SubType, SuperType);
SubType.prototype.sayAge = function(){
alert(this.age);
};
本章完