创建对象

2022-1-6 JavaScriptES6

创建对象有工厂模式、构造函数模式、原型模式等方式

# 创建对象

创建具有同样接口的多个对象需要重复编写很多代码。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");
1
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 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

区别:

  • 没有显式地创建对象
  • 属性和方法直接赋值给了this
  • 没有return

其中构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头,有助于在ES中区分构造函数和普通函数。如果构造函数没有参数,在new 构造函数的时候,后面的括号可加可不加,只要有new操作符,就可以调用相应的构造函数。通过 new 操作符调用构造函数会执行如下操作

  1. 在内存中创建一个新对象
  2. 这个新对象内部的[[Prototype]]特性被复制为构造函数的 prototype 属性
  3. 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)
  4. 执行构造函数内部的代码(给新对象添加属性)
  5. 如果构造函数返回非空对象,则返回该对象,否则,返回刚创建的新对象

相比于工厂模式,定义自定义构造函数可以确保实例被标识为特定类型,constructor是用来标识对象类型的,不过一般认为instanceof操作符是确定对象类型更可靠的方式。

console.log(person1.constructor == Person); // true
console.log(person1 instanceof Object); // true 
console.log(person1 instanceof Person); // true 
1
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); 
} 
1
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 
1
2
3
4
5
6
7
8
9
10
11
12

# 理解原型

只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象)。默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数。因构造函数而异,可能会给原型对象添加其他属性和方法。

console.log(Person.prototype.constructor === Person); // true
1

在自定义构造函数时,原型对象默认只会获得 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
1
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 两个实例之间的关系如下图:

image-20211129192544437

由上图可以看到,虽然 person1 和 person2 这两个实例都没有属性和方法,但 person1.sayName() 可以正常调用,这是由于对象属性查找机制的原因。

  • isPrototypeOf():这个方法在传入参数的[[Prototype]]指向调用它的对象时返回true

    console.log(Person.prototype.isPrototypeOf(person1)); // true 
    console.log(Person.prototype.isPrototypeOf(person2)); // true 
    
    1
    2
  • Object.getPrototypeOf():取得一个对象的原型,这在通过原型实现继承时很重要

    console.log(Object.getPrototypeOf(person1) == Person.prototype); // true 
    console.log(Object.getPrototypeOf(person1).name); // "Nicholas"
    
    1
    2
  • Object.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是实例属性
1
2
3
4
5
  • hasOwnProperty()用于确定某个属性是在实例上还是在原型对象上,如果在实例上返回true,否则为false。
  • Object.getOwnPropertyDescriptor()只对实例属性有效。要取得原型属性的描述符,就必须直接在原型对象上调用Object.getOwnPropertyDescriptor()
  • hasPrototypeProperty(对象实例,对象上的属性)判断属性是否只存在于原型上,如果这个属性只存在于原型则返回true,如果在实例上也定义了同名属性,则该方法会返回false,因为实例上的属性遮蔽了它,所以不会用到。

# 原型和 in 操作符

in操作符的两种使用方式:

  1. 单独使用:in操作符在可以通过对象访问指定属性时返回true,无论该属性是在实例上还是在原型上。

    function Person() {} 
    Person.prototype.name = "Nicholas"; 
    let person1 = new Person(); 
    console.log("name" in person1); // true 
    
    1
    2
    3
    4
  2. 在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)] 
1
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)] 
1
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); 
  } 
};
1
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(); // 错误
1
2
3
4
5
6
7
8
9
10
11
12

在这个例子中,Person 的新实例是在重写原型对象之前创建的。在调用 friend.sayName()的时 候,会导致错误。这是因为 firend 指向的原型还是最初的原型,而这个原型上并没有 sayName 属性。重写构造函数上的原型之后再创建的实例才会引用新的原型。而在此之前创建的实例仍然会引用最 初的原型。

image-20211206205659224

# 原生对象原型

不仅在自定义类型上,所有原生引用类型的构造函数(包括Object、Array、String等)都在原型上定义了实例方法,所以原型模式很重要。可以像自定义类型一样,给原生类型的实例也定义新的方法,但并推荐在产品环境中修改原生对象原型,这可能会引发命名冲突,还可能意外重写原生的方法。推荐创建一个自定义类,继承原生类型

# 原型的问题

  1. 弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。
  2. 原型上的所有属性是在实例间共享的,可以在实例上添加同名属性来简单地遮蔽原型上的属性,如果在原型上定义引用值属性(如数组),那所有实例将共享该引用值属性,但一般来说,不同实例应该有属于自己的属性副本。这就是实际开发中通常不单独使用原型模式的原因。