创建对象有工厂模式、构造函数模式、原型模式等方式
# 创建对象
创建具有同样接口的多个对象需要重复编写很多代码。ES5.1并没有正式支持面向对象的结构,比如类或继承,但可以巧妙地运用原型式继承可以成功地模拟同样的行为。
ES6开始正式支持类和继承。不过ES6的类仅是封装了ES5.1构造函数加原型继承的语法糖而已。
# 工厂模式
用于抽象创建特定对象。
function createPerson(name, age, job) {
let o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.sayName = function() {
console.log(this.name);
};
return o;
}
let person1 = createPerson("Nicholas", 29, "Software Engineer");
let person2 = createPerson("Greg", 27, "Doctor");
2
3
4
5
6
7
8
9
10
11
12
函数 createPerson()接收 3 个参数,根据这几个参数构建了一个包含 Person 信息的对象。 可以用不同的参数多次调用这个函数,每次都会返回包含 3 个属性和 1 个方法的对象。
这种工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)。
# 构造函数模式
ES中的构造函数是用于创建特定类型对象的,比如Object、Array之类的,当然也可以自定义构造函数。前面的例子使用构造函数模式可以写成:
/* 构造函数可以是函数表达式,也可以是函数声明:
function Person() {}
let Person = function() {}
*/
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = function() {
console.log(this.name);
};
}
let person1 = new Person("Nicholas", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");
person1.sayName(); // Nicholas
person2.sayName(); // Greg
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
区别:
- 没有显式地创建对象
- 属性和方法直接赋值给了this
- 没有return
其中构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头,有助于在ES中区分构造函数和普通函数。如果构造函数没有参数,在new 构造函数
的时候,后面的括号可加可不加,只要有new操作符,就可以调用相应的构造函数。通过 new 操作符调用构造函数会执行如下操作:
- 在内存中创建一个新对象
- 这个新对象内部的
[[Prototype]]
特性被复制为构造函数的 prototype 属性 - 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)
- 执行构造函数内部的代码(给新对象添加属性)
- 如果构造函数返回非空对象,则返回该对象,否则,返回刚创建的新对象
相比于工厂模式,定义自定义构造函数可以确保实例被标识为特定类型,constructor是用来标识对象类型的,不过一般认为instanceof操作符是确定对象类型更可靠的方式。
console.log(person1.constructor == Person); // true
console.log(person1 instanceof Object); // true
console.log(person1 instanceof Person); // true
2
3
注:
- 构造函数与普通函数唯一的区别就是调用方式不同。任何函数只要使用 new 操作符调用就是构造函数,而不使用 new 操作符调用的函数就是普通函数
- 在调用一个函数而没有明确设置 this 值的情况下(即没有作为对象的方法调用,或者没有使用
call()/apply()
调用), this 始终指向 Global 对象(在浏览器中就是 window 对象)
构造函数的主要问题在于,其定义的方法会在每个实例上都创建一遍,因为在ES中的函数是对象,所以每次定义函数时都会初始化一个对象,即不同实例上的函数虽然同名却不相等,以这种方式创建函数会带来不同的作用域链和标识符解析。要解决这个问题可以把函数定义转移到构造函数外部:
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = sayName;
}
function sayName() {
console.log(this.name);
}
2
3
4
5
6
7
8
9
这样不同Person实例共享了定义在全局作用域上的 sayName() 函数,但这虽然解决了相同逻辑的函数重复定义的问题,全局作用域也因此被搞乱了,因为那个函数实际上只能在一个对象上调用。如果这个对象需要多个方法,那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好地聚集在一起。这个问题可以通过原型模式来解决。
# 原型模式
每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。原来在构造函数中直接赋给对象实例的值,可以直接赋值给它们的原型。
function Person() {}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
console.log(this.name);
};
let person1 = new Person();
person1.sayName(); // "Nicholas"
let person2 = new Person();
person2.sayName(); // "Nicholas"
console.log(person1.sayName == person2.sayName); // true
2
3
4
5
6
7
8
9
10
11
12
# 理解原型
只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象)。默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数。因构造函数而异,可能会给原型对象添加其他属性和方法。
console.log(Person.prototype.constructor === Person); // true
在自定义构造函数时,原型对象默认只会获得 constructor 属性,其他的所有方法都继承自 Object。每次调用构造函数创建一个新实例,这个实例的内部 [[Prototype]] 指针就会被赋值为构造函数的原型对象。脚本中没有提供访问 [[Prototype]] 特性的标准方式,但 Firefox、Safari 和 Chrome 会在每个对象上暴露 __proto__
属性,通过这个属性可以访问对象的原型。关键的是:实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。
/**
* 正常的原型链都会终止于 Object 的原型对象
* Object 原型的原型是 null
* 构造函数 Person
* 构造函数的原型对象 Person.prototype
* 实例的原型对象 person1.__proto__
* 原型对象指回构造函数 Person.prototype.constructor===Person
* Person.prototype===person1.__proto__
*/
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Person.prototype.__proto__.constructor === Object); // true
console.log(Person.prototype.__proto__.__proto__ === null); // true
console.log(Person.prototype.__proto__);
// {
// constructor: f Object(),
// toString: ...
// hasOwnProperty: ...
// isPrototypeOf: ...
// ...
// }
/**
* 实例通过__proto__链接到原型对象,
* 它实际上指向隐藏特性[[Prototype]]
*
* 构造函数通过 prototype 属性链接到原型对象
*
* 实例与构造函数没有直接联系,与原型对象有直接联系
*/
console.log(person1.__proto__ === Person.prototype); // true
conosle.log(Person.prototype.constructor === Person); // true
/**
* 同一个构造函数创建的两个实例
* 共享同一个原型对象:
*/
console.log(person1.__proto__ === person2.__proto__); // true
/**
* instanceof 检查实例的原型链中
* 是否包含指定构造函数的原型:
*/
console.log(person1 instanceof Person); // true
console.log(person1 instanceof Object); // true
console.log(Person.prototype instanceof Object); // true
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
在 Person 这个例子中,Person构造函数、Person的原型对象和 Person 两个实例之间的关系如下图:
由上图可以看到,虽然 person1 和 person2 这两个实例都没有属性和方法,但 person1.sayName() 可以正常调用,这是由于对象属性查找机制的原因。
isPrototypeOf():这个方法在传入参数的
[[Prototype]]
指向调用它的对象时返回trueconsole.log(Person.prototype.isPrototypeOf(person1)); // true console.log(Person.prototype.isPrototypeOf(person2)); // true
1
2Object.getPrototypeOf():取得一个对象的原型,这在通过原型实现继承时很重要
console.log(Object.getPrototypeOf(person1) == Person.prototype); // true console.log(Object.getPrototypeOf(person1).name); // "Nicholas"
1
2Object.setPrototypeOf():向对象原型写入新值,这样就可以重写一个对象的原型继承关系,但这可能会严重影响代码性能,不建议使用
Object.create():创建一个新对象,同时为其指定原型,这种方法可以代替setPrototypeOf()
let biped = { numLegs: 2 }; // 使用Object.setPrototypeOf() let person = { name: 'Matt' }; Object.setPrototypeOf(person, biped); // 使用Object.create() let person = Object.create(biped); person.name = 'Matt'; console.log(person.name); // Matt console.log(person.numLegs); // 2 console.log(Object.getPrototypeOf(person) === biped); // true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 原型层级
在通过对象访问属性时,会先查找对象实例上有没有直接定义这个属性,如果没有则沿着指针进入原型对象上查找。只要给对象实例添加一个属性,这个属性就会遮蔽原型对象上的同名属性,也就是虽然不会修改它,但会屏蔽对它的访问。通过 delete 操作符可以完全删除实例上的这个属性。
function Person() {}
Person.prototype.name = "Nicholas"; // 这个name是原型属性
let person1 = new Person();
person1.name = "Greg";// 这个name是实例属性
2
3
4
5
hasOwnProperty()
用于确定某个属性是在实例上还是在原型对象上,如果在实例上返回true,否则为false。Object.getOwnPropertyDescriptor()
只对实例属性有效。要取得原型属性的描述符,就必须直接在原型对象上调用Object.getOwnPropertyDescriptor()
。hasPrototypeProperty(对象实例,对象上的属性)
判断属性是否只存在于原型上,如果这个属性只存在于原型则返回true,如果在实例上也定义了同名属性,则该方法会返回false,因为实例上的属性遮蔽了它,所以不会用到。
# 原型和 in 操作符
in操作符的两种使用方式:
单独使用:in操作符在可以通过对象访问指定属性时返回true,无论该属性是在实例上还是在原型上。
function Person() {} Person.prototype.name = "Nicholas"; let person1 = new Person(); console.log("name" in person1); // true
1
2
3
4在for-in循环中使用:返回可以通过对象访问且可以被枚举的属性,包括实例属性和原型属性,其中遮蔽原型中不可枚举属性的实例属性也会返回(不可枚举指的是[[Enumerable]]特性被设置为false),因为默认情况下开发者定义的属性都是可枚举的。
要获得对象上所有可枚举的实例属性,可以使用Object.keys()
方法。这个方法接收一个对象作为参数,返回包含该对象所有可枚举属性名称的字符串数组。如果想列出所有实例属性,无论是否可以枚举,都可以使用Object.getOwnPropertyNames()
。
在ES6新增符号类型之后,相应的新增了一个Object.getOwnPropertySymbols()
方法,因为以符号为键的属性没有名称的概念。
let k1 = Symbol('k1'),
k2 = Symbol('k2');
let o = {
[k1]: 'k1',
[k2]: 'k2'
};
console.log(Object.getOwnPropertySymbols(o));
// [Symbol(k1), Symbol(k2)]
2
3
4
5
6
7
8
# 属性枚举顺序
不同方法在属性枚举顺序方面有很大区别。
- for-in、Object.keys() 的枚举顺序是不确定的,取决于JavaScript引擎。
- Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()和 Object.assign() 的枚举顺序是确定性的。**先以升序枚举数值键,然后以插入顺序枚举字符串和符号键。**在对象字面量中定义的键以它们逗号分隔的顺序插入。
let k1 = Symbol('k1'),
k2 = Symbol('k2');
let o = {
1: 1,
first: 'first',
[k1]: 'sym2',
second: 'second',
0: 0
};
o[k2] = 'sym2';
o[3] = 3;
o.third = 'third';
o[2] = 2;
console.log(Object.getOwnPropertyNames(o));
// ["0", "1", "2", "3", "first", "second", "third"]
console.log(Object.getOwnPropertySymbols(o));
// [Symbol(k1), Symbol(k2)]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 对象迭代
ES2017新增了两个静态方法:Object.values() 和 Object.entries(),用于将对象内容转换为序列化的、可迭代的格式。
- 它们都接收一个对象
- Object.values() 返回对象值的数组,Object.entries() 返回键/值对的数组
- 这两个方法执行对象的浅复制,符号属性会被忽略
- 其中非字符串属性会被转换为字符串输出
# 其他原型语法
在前面的例子中,每定义一个属性或方法都回重写一遍Person.prototype,可以用以下方法封装原型功能。
function Person() {}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
console.log(this.name);
};
//也可以写成:
Person.prototype = {
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
但这样重写之后,Person.prototype 的 constructor 属性就不指向 Person 了。虽然 instanceof 操作符还能可靠地返回值,但不能再依靠 constructor 属性来识别类型了。恢复 constructor 有两种方法:
在 Person.prototype 中加入
constructor: Person
,但这种写法会导致其[[Enumerable]]
为true,而原生的 constructor 属性默认是不可枚举的如果使用的是兼容 ECMAScript 的 JavaScript 引擎,可以通过以下方式来恢复
// 恢复 constructor 属性 Object.defineProperty(Person.prototype, "constructor", { enumerable: false, value: Person });
1
2
3
4
5
# 原型的动态性
从原型上搜索值的过程是动态的,实例和原型之间的链接就是简单的指针,而不是保存的副本,所以对原型的修改也会反映到实例中。
虽然随时能给原型添加属性和方法,并能够立即反映在所有对象实例上,但这跟重写整个原型是两回事。实例的[[Prototype]]指针是在调用构造函数时自动赋值的,这个指针即使把原型修改为不同的对象也不会变。重写整个原型会切断最初原型与构造函数的联系,但实例引用的仍然是最初的原型,而不是重写后的原型。实例只有指向原型的指针,没有指向构造函数的指针。
function Person() {}
let friend = new Person();
Person.prototype = {
constructor: Person,
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
friend.sayName(); // 错误
2
3
4
5
6
7
8
9
10
11
12
在这个例子中,Person 的新实例是在重写原型对象之前创建的。在调用 friend.sayName()的时 候,会导致错误。这是因为 firend 指向的原型还是最初的原型,而这个原型上并没有 sayName 属性。重写构造函数上的原型之后再创建的实例才会引用新的原型。而在此之前创建的实例仍然会引用最 初的原型。
# 原生对象原型
不仅在自定义类型上,所有原生引用类型的构造函数(包括Object、Array、String等)都在原型上定义了实例方法,所以原型模式很重要。可以像自定义类型一样,给原生类型的实例也定义新的方法,但并推荐在产品环境中修改原生对象原型,这可能会引发命名冲突,还可能意外重写原生的方法。推荐创建一个自定义类,继承原生类型。
# 原型的问题
- 弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。
- 原型上的所有属性是在实例间共享的,可以在实例上添加同名属性来简单地遮蔽原型上的属性,如果在原型上定义引用值属性(如数组),那所有实例将共享该引用值属性,但一般来说,不同实例应该有属于自己的属性副本。这就是实际开发中通常不单独使用原型模式的原因。