每个对象都是基于一个引用类型创建,
这个引用类型可以是原生类型,如 Object 类型
、Array 类型
、Date 类型
、RegExp 类型
、基本包装类型
,也可以是 自定义类型
。
理解对象
ok,首先我们来看下用 对象字面量
的方式创建自定义对象:
var person = {
name: 'Fly_001',
age: 22,
sayName: function() {
alert(this.name);
}
};
复制代码
其中这些属性在创建的时候都带有一些 特征值
,JavaScript 通过这些 特征值
来定义它们的行为。
ECMAScript 定义只有内部才使用的特性,描述了属性的各种特征,为了表示特性是内部值,该规范把它们放在了两对方括号里,例如 [[Enumerable]],并且在 JavaScript 中不能直接访问它们。
ECMAScript 中有两种属性,数据属性
和 访问器属性
:
-
数据属性
数据属性有 4 个描述其行为的特性:
名称 描述 [[Configurable]] 表示能否通过 delete 删除属性从而重新定义属性,默认为 true。 [[Enumerable]] 表示能否通过 for-in 循环 ♻️ 返回属性,默认为 true。 [[Writable]] 表示能否修改属性的值,默认为 true。 [[Value]] 包含这个属性的数据值。读取和写入属性值都是在这个位置,默认值为 undefined。 要修改属性的特性,可使用
Object.defineProperty()
方法 ( 话说 Vue 就是通过这个方法实现双向数据绑定的 ),该方法接受三个参数:属性所在的对象、属性的名字和一个描述符对象
,其中描述符对象必须是configurable
、enumerable
、writable
和value
的一个或多个:var person = {}; Object.defineProperty(person, 'name', { writable: false, value: 'Fly_001' }); alert(person.name); // 'Fly_001'; person.name = 'juejin'; // 修改 person 的 name 属性; alert(person.name); // 'Fly_001'; 复制代码
上面代码创建了一个 name 属性,它的值是只读、不可修改的,如果尝试为它指定新值将会被忽略。( 在严格模式下会抛出 Cannot assign to read only property 'name' of object 的错误 )
另外要注意的是如果把
configurable
设置为 false,就不能从对象中删除属性,同时也不能再把它变回可配置了( 此时调用 Object.defineProperty() 方法只能修改writable
特性 )多数情况下可能用不到 Object.defineProperty() 方法提供的这些高级功能,不过理解这些概念对我们理解 JavaScript 对象非常有用。
-
访问器属性
访问器属性包含一对 getter 和 setter 函数,有如下 4 个特性:
特性名 描述 [[Configurable]] 表示能否通过 delete 删除属性从而重新定义属性、能否修改属性的特性。 [[Enumerable]] 表示能否通过 for-in 循环 ♻️ 返回属性。 [[Get]] 在读取属性时调用的函数,默认值为 undefined。 [[Set]] 在写入属性时调用的函数,默认值为 undefined。 同样滴~ 访问器属性不能直接定义,必须使用
Object.defineProperty()
来定义:var book = { _year = 2018, edition: 1 } Object.defineProperty(book, 'year', { get: function() { return this._year; }, set: function(newYear) { if (newYear > 2018) { this._year = newYear; this.edition += newYear - 2018; } } }); book.year = 2020; alert(book.edition); // 3; 复制代码
_year 前面的下划线是一种常用的记号,表示只能通过对象方法访问的属性。
另外,不一定要同时指定 getter 和 setter。
只指定 getter 意味着属性是不可写?,而只指定 setter 意味着不可读 ?~
定义多个属性
由于为对象定义多个属性的可能性很大,ECMAScript 又定义了一个 Object.defineProperties()
方法,可以一次定义多个属性,这个方法接受两个对象参数:目标对象
和 要添加或修改的属性
:
var book = {};
Object.defineProerties(book, {
_year: {
value: 2018
},
edition: {
value: 1
},
year: {
get: function() {
return this._year;
},
set: function(newYear) {
if (newYear > 2018) {
this._year = newYear;
this.edition += newYear - 2018;
}
}
}
});
复制代码
上述代码在 book 上定义了两个数据属性 ( _year 和 edition ) 和一个访问其属性 ( year ),值得一提的是这里的属性都是在同一时间创建的。
读取属性的特性
既然能修改属性的特性,那就应该能获取属性的特性,所以
ECMAScript 又给我们提供了 Object.getOwnPropertyDescriptior()
方法,该方法接受两个参数:属性所在的对象
和 要读取的属性名称
。
它的返回值是一个对象。
如果是数据属性,则对象的属性有 configurable
、enumerable
、writable
和 value
;
如果是访问器属性,这个对象的属性有 configurable
、enumerable
、get
和 set
。
举个栗子 ? :
var descriptior = Object.getOwnPropertyDescriptior(book, '_year');
alert(descriptior.value); // 2018;
alert(descriptior.configurable); // false;
复制代码
Tips: 在 JavaScript 中,可以针对任何对象 —— 包括 DOM
和 BOM
对象,使用 Object.getOwnPropertyDescriptior()
方法。
创建对象
虽然使用 Object 构造函数或对象字面量都可以创建单个对象,但如果使用同一接口创建很多对象就会产生大量的重复代码。
为解决这个问题,我们可以使用工厂模式的一种变体。
-
工厂模式
工厂模式是用函数来封装 ? 接口创建对象的细节:
function createPerson(name, age) { var o = new Object(); o.name = name; o.age = age; o.sayName = function() { alert(this.name); }; return o; } var person1 = createPerson('Fly_001', 22); var person1 = createPerson('juejin', 24); 复制代码
工厂模式虽然解决了创建多个相似对象的问题,但却没有解决
对象识别
的问题 ( 即怎样知道一个对象的类型 ),所以又出现了另一个模式 ~构造函数模式
。 -
构造函数模式
ECMAScript 的构造函数可用来创建特定类型的对象,从而定义对象的属性和方法:
function Person(name, age) { this.name = name; this.age = age; this.sayName = function() { alert(this.name); }; } var person1 = new Person('Fly_001', 22); var person1 = new Person('juejin', 24); 复制代码
我们注意到,Person() 中的代码与前面 createPerson() 不同之处在于:
没有显示地创建对象
;直接将属性和方法赋给了 this 对象
;没有 return 语句
。
Tips: 按照惯例,构造函数始终都应该以一个大写字母开头。
要创建 Person 的新实例,必须使用
new
操作符,以这种方式调用构造函数会经历以下 4 个步骤:- 创建一个新对象;
- 将构造函数的作用域赋给新对象( 因此 this 就指向这个新对象 );
- 执行构造函数中的代码(为新对象添加属性);
- 返回新对象。
这样,person1 和 person2 分别保存着 Perosn 的一个不同实例:
alert(person1.constructor == Person); // true; alert(person2.constructor == Person); // true; alert(person1 instanceof Person); // true; alert(person2 instanceof Person); // true; 复制代码
使用构造函数模式可以将它的实例标识为一种特定的类型,这也正是构造函数模式胜过工厂模式的地方。
构造函数模式虽然好用,但它的缺点是每个方法在每个实例上都要重新创建一遍,从逻辑角度来讲,此时的构造函数也可以这样定义:
function Person(name, age) { this.name = name; this.age = age; this.sayName = new function('alert(this.name)'); } 复制代码
同时不同实例上的同名函数是
不相等
的:alert(person1.sayName == person2.sayName); // false; 复制代码
所以创建两个完成同样的任务的 Function 实例确实没有必要,好在这些问题又可以通过
原型模式
来解决。 -
原型模式
我们创建的每个函数都有一个 prototype ( 原型 ) 属性,这个属性是一个指针,指向一个对象,这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。
是不是很绕,如果按照字面意思来理解,那么 prototype 就是通过调用构造函数而创建的那个对象实例的原型对象。(好吧,还是很迷 ?),那就放码出来吧~
function Person() {} Person.prototype.name = 'Fly_001'; Person.prototype.age = 22; Person.prototype.sex = 'male'; Person.property.sayName = function() { alert(this.name); }; var person1 = new Person(); person1.sayName(); // 'Fly_001'; var person2 = new Person(); alert(person1.sayName == person2.sayName); // true; 复制代码
使用原型对象的好处是可以让所有对象实例共享它所包含的属性和方法。
与构造函数模式不同的是,新对象的属性和方法是由所有实例共享的,换句话说,person1 和 person2 访问的都是同一组属性和同一个 sayName() 方法。
要理解原型模式的工作原理,就必须先理解 ECMAScript 中
原型对象
的性质。 -
理解原型对象
无论什么时候,只要创建了一个新函数,就会根据一组特定的规则为该函数创建一个 prototype 属性,这个属性指向函数的原型对象。在默认情况下,所有原型对象都会自动获得一个 constructor(构造函数)属性,这个属性包含一个指向 prototype 属性所在函数的指针。
就拿前面的栗子 ? 来说,Person.prototype.constructor 指向 Person,而通过这个构造函数,我们还可继续为原型对象添加其它属性和方法。
下图展示了各个对象之间的关系:
在此,Person.prototype 指向了原型对象,而 Person.prototype.constructor 又指回了 Person。
原型对象中除了包含 constructor 属性外,还包括后来添加的其它属性。
Person 的每一个实例 —— person1 和 person2 都包含一个内部属性,该属性仅仅指向 Person.prototype, 换句话说,它们与构造函数没有直接的关系。此外,要格外注意的是,虽然这两个实例都不包含属性和方法,但却可以调用 sayName() 方法,这是通过 查找对象属性的过程 来实现的。
同时我们可以通过 isPrototypeOf() 方法来确定对象之间是否存在这种关系:
alert(Person.prototype.isPrototypeOf(person1)); // true;
alert(Person.prototype.isPrototypeOf(person2)); // true;
复制代码
这里因为 person1 和 person2 内部都有一个指向 Person.prototype 的指针,因此都返回了 true。
每当代码读取某个对象的某个属性时,都会执行一次搜索,目标是具有给定的属性名。搜索首先从对象实例本身开始,如果找到对应的属性,则返回属性值并停止搜索;如果没找到,则继续搜索指针指向的原型对象。
也就是说,在我们调用 person1.sayName() 的时候,会先后执行两次搜索,先从 person1 实例本身上找,没找到后再从 person1 的原型上寻找,最后发现了 sayName() 方法定义并返回。
另外可以使用 hasOwnProperty() 方法来检测一个属性是存在于实例中,还是存在于原型中,只有当属性存在于对象实例中才会返回 true
:
alert(person1.hasOwnProperty('name')); // false;
复制代码
-
更简单的原型语法
在前面的例子里,每添加一个属性和方法就要敲一遍 Person.prototype,显得有些繁琐,所以更常见的做法是使用对象字面量来进行封装 ?:
function Person() {} Person.prototype = { name: 'Fly_001', age: 22, sex: 'male', sayName: function() { alert(this.name); } }; 复制代码
在上面的代码中,我们将 Person.prototype 设置为等于一个以对象字面量形式创建的新对象,最终结果相同,但是有一个例外:constructor 属性不再指向 Person 了。
前面介绍过,每创建一个函数,就会同时创建它的 prototype 对象,这个对象也会自动获得 constructor 属性。
而我们刚才的代码,本质上完全重写了默认的 prototype 对象,因此 constructor 属性也就变成了新对象的 constructor 属性 ( 指向 Object 构造函数 ),不再指向 Person 函数。
此时,尽管 instanceof 操作符还能返回正确的结果,但通过 constructor 已经无法确定对象的类型了:
var friend = new Person(); alert(friend instanceof Person); // true; alert(friend.constructor == Person); //false; alert(friend.constructor == Object); // true; 复制代码
如果 constructor 的值真的很重要,可以像下面这样特意将它设置回适当的值:
function Person () {}; Person.prototype = { constructor: Person, // 设置其它属性和方法; } 复制代码
但要注意一点,以这种方式重设 constructor 属性会导致它的 [[enumerable]] 特性被设置为 true。而默认情况下,原生的 constructor 属性是不可枚举的,因此咱们可以使用 Object.defineProperty() 方法:
// 重设构造函数; Object.defineProperty(Person.prototype, 'constructor', { enumerable: false, value: Person }); 复制代码
-
原型的动态性
由于在原型中查找值的过程是一次搜索,因此我们对原型对象所做的任何修改都能够立即从实例上反映出来 —— 即使是先创建了实例后修改原型也照样如此:
var friend = new Person(); Person.prototype.sayName = function() { alert('hi'); }; friend.sayName(); // 'hi', 木有问题~ 复制代码
其原因可以归结为实例与原型之间的松散连接关系。
当我们调用 friend.sayName() 时,首先会在实例中搜索名为 sayName 的方法,在没找到的情况下会继续搜索原型。因为实例与原型之间的连接是一个指针而非副本,因此可以在原型中找到并返回保存在那里的函数。
尽管可以随时为原型添加属性和方法,并且能够立即在所有对象实例中反映出来,但如果是重写整个对象,那么情况就不一样了:
function Person() {} var friend = new Person(); Person.prototype = { constructor: Person, name: 'Fly_001', age: 22, sex: 'male', sayName: function() { alert(this.name); } }; friend.sayName(); // error! 复制代码
因为 friend 指向的原型中不包含以该名字命名的属性,下图展示了这个过程的内幕:
-
原生对象的原型
原型模式的重要性不仅体现在创建自定义类型方面,就连所有原生的引用类型,都是采用这种模式创建的。所有原生引用类型 ( Object、Array、String,等等 )都在其构造函数的原型上定义了方法。
例如,在 Array.prototype 中可以找到 sort() 方法,而在 String.prototype 中可以找到 substring() 方法:
alert(typeof Array.prototype.sort); // 'function'; alert(typeof String.prototype.substring); // 'function'; 复制代码
通过原生对象的原型,不仅可以取得所有默认方法的引用,还可以定义新方法:
String.prototype.startWith = function(text) { return this.indexOf(text) == 0; }; var msg = 'Hello World'; alert(msg.startWith('Hello')); // true; 复制代码
上述代码 ? 就给基本包装类型 String 添加了一个 startWith() 方法。既然方法被添加给了 String.prototype,那么当前环境中的所有字符串都可以调用这个方法。
-
原型对象的问题
原型模式也不是没有缺点,它的最大问题是由其
共享性
的本性导致的。原型中所有属性是被很多实例共享的,这种共享对于函数非常合适,对于那些包含基本值的属性也说得过去,毕竟通过在实例上添加一个同名属性,可以隐藏原型中的对应属性。
然鹅,对于包含引用类型值的属性来说,问题就比较突出了:
function Person() {} Person.prototype = { constructor: Person, name: 'Fly_001', age: 22, friends: ['Jack', 'Tom'], sayName: function() { alert(this.name); } }; var person1 = new Person(); var person2 = new Person(); person1.friends.push('Daniel'); alert(person1.friends); // 'Jack, Tom, Daniel'; alert(person2.friends); // 'Jack, Tom, Daniel'; alert(person1.friends === person2.friends); // true; 复制代码
由于 friends 数组存在于 Person.prototype 而非 person1 中,所以刚刚的修改也会通过 person2.friends 反映出来。
所以正是这个问题,我们不能单独使用原型模式~
-
组合使用构造函数模式和原型模式 ( 闪亮登场 ✨ )
创建自定义类型的最常见方式,就是组合使用构造函数模式与原型模式。
构造函数模式用于定义实例属性,而原型模式用于定义方法和共享的属性。
这样,每个实例都会有自己的一份实例属性的副本,但同时又共享着对方法的引用,最大限度地节省了内存。另外,这种混合模式还支持向构造函数传递参数;可谓是集两种模式之长呀:
funciton Person(name, age, sex) { this.name = name; this.age = age; this.sex = sex; this.friends = ['Jack', 'Tom']; } Person.prototype = { constructor: Person, sayName: function() { alert(this.name); } } var person1 = new Perosn('Fly_001', 22, 'male'); var person2 = new Person('juejin', 24, 'unknown'); person1.friends.push('Daniel'); alert(person1.friends); // 'Jack, Tom, Daniel'; alert(person2.friends); // 'Jack, Tom'; alert(person1.friends === person2.friends); // false; alert(person1.sayName === person2.sayName); // true; 复制代码
这里修改了 person1.friends 不会影响到 person2.friends,因为它们分别引用了不同的数组。
这种构造函数与原型混成的模式,是使用最广泛、认同度最高的一种创建自定义类型的方式,可以说,这是用来定义引用类型的一种默认模式。
关于 JS 中对象的一些浅薄知识,就先讲到这里,下一篇会谈谈 JS 中几种继承方式,敬请期待~ ❤️