JavaScript高级程序设计学习笔记(一)【第八章 对象、类与面向对象编程】


前言

ECMA-262 将对象定义为一组属性的无序集合。严格来说,这意味着对象就是一组没有特定顺序的值。对象的每个属性或方法都由一个名称来标识,这个名称映射到一个值。正因为如此,可以把 ECMAScript 的对象想象成一张散列表,其中的内容就是一组名/值对,值可以是数据或者函数。
在这里插入图片描述

8.1 理解对象

创建自定义对象的通常方式是创建 Object 的一个新实例,然后再给它添加属性和方法,如下例所示:

let person = new Object(); 
person.name = "Nicholas"; 
person.age = 29; 
person.job = "Software Engineer"; 
person.sayName = function() { 
 console.log(this.name); 
}; 

这个例子创建了一个名为 person 的对象,而且有三个属性(name、age 和 job)和一个方法(sayName())。sayName()方法会显示 this.name 的值,这个属性会解析为 person.name。JavaScript 开发者频繁使用这种方式创建新对象。几年后,对象字面量变成了更流行的方式。前面的例子如果使用对象字面量则可以这样写:

let person = { 
 name: "Nicholas", 
 age: 29, 
 job: "Software Engineer", 
 sayName() { 
 console.log(this.name); 
 } 
}; 

这个例子中的 person 对象跟前面例子中的 person 对象是等价的,它们的属性和方法都一样。这些属性都有自己的特征,而这些特征决定了它们在 JavaScript 中的行为。

8.1.1 属性的类型

ECMA-262 使用一些内部特性来描述属性的特征。这些特性是由为 JavaScript 实现引擎的规范定义的。因此,开发者不能在 JavaScript 中直接访问这些特性。为了将某个特性标识为内部特性,规范会用两个中括号把特性的名称括起来,比如[[Enumerable]]。
属性分两种:数据属性和访问器属性。

  • 数据属性
    数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。数据属性有 4个特性描述它们的行为。
  • [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true,如前面的例子所示。
  • [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true,如前面的例子所示。
  • [[Writable]]:表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的这个特性都是 true,如前面的例子所示。
  • [[Value]]:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。这个特性的默认值为 undefined。在像前面例子中那样将属性显式添加到对象之后,[[Configurable]]、[[Enumerable]]和[[Writable]]都会被设置为 true,而[[Value]]特性会被设置为指定的值。比如:
let person = { 
 name: "Nicholas" 
}; 

这里,我们创建了一个名为 name 的属性,并给它赋予了一个值"Nicholas"。这意味着[[Value]]特性会被设置为"Nicholas",之后对这个值的任何修改都会保存这个位置。要修改属性的默认特性,就必须使用 Object.defineProperty()方法。这个方法接收 3 个参数:要给其添加属性的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包含:configurable、enumerable、writable 和 value,跟相关特性的名称一一对应。根据要修改的特性,可以设置其中一个或多个值。

  • 访问器属性
    访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数,不过这两个函数不是必需的。在读取访问器属性时,会调用获取函数,这个函数的责任就是返回一个有效的值。在写入访问器属性时,会调用设置函数并传入新值,这个函数必须决定对数据做出什么修改。访问器属性有 4 个特性描述它们的行为。
  • [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为数据属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
  • [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
  • [[Get]]:获取函数,在读取属性时调用。默认值为 undefined。  [[Set]]:设置函数,在写入属性时调用。默认值为 undefined。

8.1.2 定义多个属性

在一个对象上同时定义多个属性的可能性是非常大的。为此,ECMAScript 提供了 Object.defineProperties()方法。这个方法可以通过多个描述符一次性定义多个属性。它接收两个参数:要为之添加或修改属性的对象和另一个描述符对象,其属性与要添加或修改的属性一一对应。比如:

let book = {}; 
Object.defineProperties(book, { 
 year_: { 
 value: 2017 
 }, 
 edition: { 
 value: 1 
 }, 
 year: { 
 get() { 
 return this.year_; 
 }, 
 set(newValue) { 
 if (newValue > 2017) { 
 this.year_ = newValue; 
 this.edition += newValue - 2017; 
 } 
 } 
 } 
}); 

这段代码在 book 对象上定义了两个数据属性 year_和 edition,还有一个访问器属性 year。最终的对象跟上一节示例中的一样。唯一的区别是所有属性都是同时定义的,并且数据属性的configurable、enumerable 和 writable 特性值都是 false。

8.1.3 读取属性的特性

使用 Object.getOwnPropertyDescriptor()方法可以取得指定属性的属性描述符。这个方法接收两个参数:属性所在的对象和要取得其描述符的属性名。返回值是一个对象,对于访问器属性包含configurable、enumerable、get 和 set 属性,对于数据属性包含 configurable、enumerable、writable 和 value 属性。比如:

let book = {}; 
Object.defineProperties(book, { 
 year_: { 
 value: 2017 
 }, 
 edition: { 
 value: 1 
 }, 
 year: { 
 get: function() { 
 return this.year_; 
 }, 
 set: function(newValue){ 
 if (newValue > 2017) { 
 this.year_ = newValue; 
 this.edition += newValue - 2017; 
 } 
 } 
 } 
}); 
let descriptor = Object.getOwnPropertyDescriptor(book, "year_"); 
console.log(descriptor.value); // 2017 
console.log(descriptor.configurable); // false 
console.log(typeof descriptor.get); // "undefined" 
let descriptor = Object.getOwnPropertyDescriptor(book, "year"); 
console.log(descriptor.value); // undefined 
console.log(descriptor.enumerable); // false 
console.log(typeof descriptor.get); // "function" 

对于数据属性 year_,value 等于原来的值,configurable 是 false,get 是 undefined。对于访问器属性 year,value 是 undefined,enumerable 是 false,get 是一个指向获取函数的指针。

8.1.4 合并对象

JavaScript 开发者经常觉得“合并”(merge)两个对象很有用。更具体地说,就是把源对象所有的本地属性一起复制到目标对象上。有时候这种操作也被称为“混入”(mixin),因为目标对象通过混入源对象的属性得到了增强。
ECMAScript 6 专门为合并对象提供了 Object.assign()方法。这个方法接收一个目标对象和一个或多个源对象作为参数,然后将每个源对象中可枚举(Object.propertyIsEnumerable()返回 true)和自有(Object.hasOwnProperty()返回 true)属性复制到目标对象。以字符串和符号为键的属性会被复制。对每个符合条件的属性,这个方法会使用源对象上的[[Get]]取得属性的值,然后使用目标
对象上的[[Set]]设置属性的值。

8.1.5 对象标识及相等判定

在 ECMAScript 6 之前,有些特殊情况即使是===操作符也无能为力:

// 这些是===符合预期的情况
console.log(true === 1); // false 
console.log({} === {}); // false 
console.log("2" === 2); // false 
// 这些情况在不同 JavaScript 引擎中表现不同,但仍被认为相等
console.log(+0 === -0); // true 
console.log(+0 === 0); // true 
console.log(-0 === 0); // true 
// 要确定 NaN 的相等性,必须使用极为讨厌的 isNaN() 
console.log(NaN === NaN); // false 
console.log(isNaN(NaN)); // true 
为改善这类情况,ECMAScript 6 规范新增了 Object.is(),这个方法与===很像,但同时也考虑到了上述边界情形。这个方法必须接收两个参数:
console.log(Object.is(true, 1)); // false 
console.log(Object.is({}, {})); // false 
console.log(Object.is("2", 2)); // false
// 正确的 0、-0、+0 相等/不等判定
console.log(Object.is(+0, -0)); // false 
console.log(Object.is(+0, 0)); // true 
console.log(Object.is(-0, 0)); // false 
// 正确的 NaN 相等判定
console.log(Object.is(NaN, NaN)); // true 
要检查超过两个值,递归地利用相等性传递即可:
function recursivelyCheckEqual(x, ...rest) { 
 return Object.is(x, rest[0]) && 
 (rest.length < 2 || recursivelyCheckEqual(...rest)); 
}

8.2 创建对象

虽然使用 Object 构造函数或对象字面量可以方便地创建对象,但这些方式也有明显不足:创建具有同样接口的多个对象需要重复编写很多代码。

8.2.1 概述

综观ECMAScript 规范的历次发布,每个版本的特性似乎都出人意料。ECMAScript 5.1 并没有正式支持面向对象的结构,比如类或继承。但是,正如接下来几节会介绍的,巧妙地运用原型式继承可以成功地模拟同样的行为。
ECMAScript 6 开始正式支持类和继承。ES6 的类旨在完全涵盖之前规范设计的基于原型的继承模式。不过,无论从哪方面看,ES6 的类都仅仅是封装了 ES5.1 构造函数加原型继承的语法糖而已。

8.2.2 工厂模式

工厂模式是一种众所周知的设计模式,广泛应用于软件工程领域,用于抽象创建特定对象的过程

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"); 

这里,函数 createPerson()接收 3 个参数,根据这几个参数构建了一个包含 Person 信息的对象。可以用不同的参数多次调用这个函数,每次都会返回包含 3 个属性和 1 个方法的对象。这种工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)。

8.2.3 构造函数模式

前面几章提到过,ECMAScript 中的构造函数是用于创建特定类型对象的。像 Object 和 Array 这样的原生构造函数,运行时可以直接在执行环境中使用。当然也可以自定义构造函数,以函数的形式为自己的对象类型定义属性和方法。
比如,前面的例子使用构造函数模式可以这样写:

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 

在这个例子中,Person()构造函数代替了 createPerson()工厂函数。实际上,Person()内部的代码跟 createPerson()基本是一样的,只是有如下区别。

  • 没有显式地创建对象
  • 属性和方法直接赋值给了 this
  • 没有 return
    另外,要注意函数名 Person 的首字母大写了。按照惯例,构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头。这是从面向对象编程语言那里借鉴的,有助于在 ECMAScript 中区分构造函数和普通函数。毕竟 ECMAScript 的构造函数就是能创建对象的函数。
    要创建 Person 的实例,应使用 new 操作符。以这种方式调用构造函数会执行如下操作。
    (1) 在内存中创建一个新对象。
    (2) 这个新对象内部的[[Prototype]]特性被赋值为构造函数的 prototype 属性。
    (3) 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。
    (4) 执行构造函数内部的代码(给新对象添加属性)。
    (5) 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
  1. 构造函数也是函数
    构造函数与普通函数唯一的区别就是调用方式不同。除此之外,构造函数也是函数。并没有把某个函数定义为构造函数的特殊语法。任何函数只要使用 new 操作符调用就是构造函数,而不使用 new 操作符调用的函数就是普通函数。比如,前面的例子中定义的 Person()可以像下面这样调用:
// 作为构造函数 
let person = new Person("Nicholas", 29, "Software Engineer"); 
person.sayName(); // "Nicholas" 
// 作为函数调用
Person("Greg", 27, "Doctor"); // 添加到 window 对象
window.sayName(); // "Greg" 
// 在另一个对象的作用域中调用
let o = new Object(); 
Person.call(o, "Kristen", 25, "Nurse"); 
o.sayName(); // "Kristen" 
  1. 构造函数的问题
    构造函数虽然有用,但也不是没有问题。构造函数的主要问题在于,其定义的方法会在每个实例上都创建一遍。因此对前面的例子而言,person1 和 person2 都有名为 sayName()的方法,但这两个方法不是同一个 Function 实例。我们知道,ECMAScript 中的函数是对象,因此每次定义函数时,都会初始化一个对象。逻辑上讲,这个构造函数实际上是这样的:
function Person(name, age, job){ 
 this.name = name; 
 this.age = age; 
 this.job = job; 
 this.sayName = new Function("console.log(this.name)"); // 逻辑等价
} 

这样理解这个构造函数可以更清楚地知道,每个 Person 实例都会有自己的 Function 实例用于显示 name 属性。当然了,以这种方式创建函数会带来不同的作用域链和标识符解析。但创建新 Function实例的机制是一样的。因此不同实例上的函数虽然同名却不相等,如下所示:

console.log(person1.sayName == person2.sayName); // false 
因为都是做一样的事,所以没必要定义两个不同的 Function 实例。况且,this 对象可以把函数与对象的绑定推迟到运行时。要解决这个问题,可以把函数定义转移到构造函数外部:
function Person(name, age, job){ 
 this.name = name; 
 this.age = age; 
 this.job = job; 
 this.sayName = sayName; 
} 
function sayName() { 
 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

8.2.4 原型模式

每个函数都会创建一个 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 

使用函数表达式也可以:

let Person = function() {}; 
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

8.2.5 对象迭代

在 JavaScript 有史以来的大部分时间内,迭代对象属性都是一个难题。ECMAScript 2017 新增了两个静态方法,用于将对象内容转换为序列化的——更重要的是可迭代的——格式。这两个静态方法Object.values()和 Object.entries()接收一个对象,返回它们内容的数组。Object.values()返回对象值的数组,Object.entries()返回键/值对的数组。
下面的示例展示了这两个方法:

const o = { 
 foo: 'bar', 
 baz: 1, 
 qux: {} 
}; 
console.log(Object.values(o));
// ["bar", 1, {}] 
console.log(Object.entries((o))); 
// [["foo", "bar"], ["baz", 1], ["qux", {}]] 

注意,非字符串属性会被转换为字符串输出。另外,这两个方法执行对象的浅复制:

const o = { 
 qux: {} 
}; 
console.log(Object.values(o)[0] === o.qux); 
// true 
console.log(Object.entries(o)[0][1] === o.qux); 
// true 
符号属性会被忽略:
const sym = Symbol(); 
const o = { 
 [sym]: 'foo' 
}; 
console.log(Object.values(o)); 
// [] 
console.log(Object.entries((o))); 
// []

总结

以上就是今天学习的内容,下次继续努力!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值