第 8 章 对象、类与面向对象编程



前言

虽然之前在课堂上已经学过 JavaScript 的相关用法了,但是现在要开始找工作的时候才发现好多知识点都忘记了(正所谓基础不牢,地动山摇😂),而且有些知识点学校教材上都没有,便有了要写这个 JavaScript 学习系列文章的想法。

由于博主只是做知识点总结与学习笔记分享,导致本系列文章对于 JavaScript 的使用方面介绍较少,还望读者能够自己多勤学苦练。废话不多说,让我们开始第 8 章 对象、类与面向对象编程 的学习吧。


一、理解对象

ECMA-262 将对象定义为一组属性的无序集合。严格来说,这意味着对象就是一组没有特定顺序的值。对象的每个属性或方法都由一个名称来标识,这个名称映射到一个值。

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

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

现在,对象字面量变成了更流行的方式:

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

1.属性的类型

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

属性分两种:数据属性和访问器属性。

  1. 数据属性
    数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。数据属性有 4 个特性描述它们的行为。

    • [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true,如前面的例子所示。
    • [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true,如前面的例子所示。
    • [[Writable]]:表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的这个特性都是 true,如前面的例子所示。
    • [[Value]]:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。这个特性的默认值为 undefined。

    要修改属性的默认特性,就必须使用 Object.defineProperty()方法。这个方法接收 3 个参数:要给其添加属性的对象属性的名称一个描述符对象。比如:

    let person = {}; 
    Object.defineProperty(person, "name", {	// 最后一个参数,可以根据要修改的特性,可以设置其中一个或多个值
      writable: false, 
      value: "Nicholas" 
    }); 
    console.log(person.name); // "Nicholas" 
    person.name = "Greg"; 
    console.log(person.name); // "Nicholas"
    
  2. 访问器属性
    访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数,不过这两个函数不是必需的。在读取访问器属性时,会调用获取函数,这个函数的责任就是返回一个有效的值。在写入访问器属性时,会调用设置函数并传入新值,这个函数必须决定对数据做出什么修改。访问器属性有 4 个特性描述它们的行为。

    • [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为数据属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
    • [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
    • [[Get]]:获取函数,在读取属性时调用。默认值为 undefined。
    • [[Set]]:设置函数,在写入属性时调用。默认值为 undefined。

    访问器属性是不能直接定义的,必须使用 Object.defineProperty()。在不支持 Object.defineProperty()的浏览器中没有办法修改[[Configurable]]或[[Enumerable]]。

2.定义多个属性

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

3.读取属性的特性

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

ECMAScript 2017 新增了 Object.getOwnPropertyDescriptors()静态方法。这个方法实际上会在每个自有属性上调用 Object.getOwnPropertyDescriptor()并在一个新对象中返回它们。

4.合并对象

ECMAScript 6 专门为合并对象提供了 Object.assign()方法。这个方法接收一个目标对象和一个或多个源对象作为参数,然后将每个源对象中可枚举(Object.propertyIsEnumerable()返回 true)和自有(Object.hasOwnProperty()返回 true)属性复制到目标对象。以字符串和符号为键的属性会被复制。对每个符合条件的属性,这个方法会使用源对象上的[[Get]]取得属性的值,然后使用目标对象上的[[Set]]设置属性的值。

Object.assign()实际上对每个源对象执行的是浅复制。如果多个源对象都有相同的属性,则使用最后一个复制的值。此外,从源对象访问器属性取得的值,比如获取函数,会作为一个静态值赋给目标对象。换句话说,不能在两个对象间转移获取函数和设置函数

5.对象标识及相等判定

ECMAScript 6 规范新增了 Object.is(),这个方法与===很像,但同时也考虑到了===的边界情形。这个方法必须接收两个参数:

console.log(Object.is(true, 1)); // false 
console.log(Object.is({}, {})); // false 
console.log(Object.is("2", 2)); // false 

// 这些情况在不同 JavaScript 引擎中表现不同,使用===操作符会被认为相等
// 正确的 0、-0、+0 相等/不等判定
console.log(Object.is(+0, -0)); // false 
console.log(Object.is(+0, 0)); // true 
console.log(Object.is(-0, 0)); // false 

// 在 ECMAScript 6 之前,要确定 NaN 的相等性,必须使用极为讨厌的 isNaN()
// 正确的 NaN 相等判定
console.log(Object.is(NaN, NaN)); // true 

要检查超过两个值,递归地利用相等性传递即可:

function recursivelyCheckEqual(x, ...rest) { 
 return Object.is(x, rest[0]) && 
 (rest.length < 2 || recursivelyCheckEqual(...rest)); 
} 

6.增强的对象语法

  1. 属性值简写
    在给对象添加变量的时候,如果属性名和变量名是一样的,这时候可以简写。

    let name = 'Matt'; 
    let person = { 
     name // 等价== name: name 
    }; 
    
  2. 可计算属性
    有了可计算属性,就可以在对象字面量中完成动态属性赋值。中括号包围的对象属性键告诉运行时将其作为 JavaScript 表达式而不是字符串来求值:

    const nameKey = 'name'; 
    const ageKey = 'age'; 
    const jobKey = 'job'; 
    
    let person = { 
      [nameKey]: 'Matt', 
      [ageKey]: 27, 
      [jobKey]: 'Software engineer' 
    }; 
    
    console.log(person); // { name: 'Matt', age: 27, job: 'Software engineer' } 
    
  3. 简写方法名
    在给对象定义方法时,通常都要写一个方法名、冒号,然后再引用一个匿名函数表达式,如下所示:

    let person = { 
      sayName: function(name) { 	// sayName: function(name) 可简写为 sayName(name)
      console.log(`My name is ${name}`); 
      } 
    }; 
    
    person.sayName('Matt'); // My name is Matt 
    

7.对象解构

ECMAScript 6 新增了对象解构语法,可以在一条语句中使用嵌套数据实现一个或多个赋值操作。简单地说,对象解构就是使用与对象匹配的结构来实现对象属性赋值。

下面的例子展示了两段等价的代码,首先是不使用对象解构的:

// 不使用对象解构
let person = { 
 name: 'Matt', 
 age: 27 
};

let personName = person.name, 
    personAge = person.age; 
 
console.log(personName); // Matt 
console.log(personAge); // 27

然后,是使用对象解构的:

// 使用对象解构
let person = { 
 name: 'Matt', 
 age: 27 
}; 

// 可简写为 let { name, age } = person;
let { name: personName, age: personAge } = person; 	

console.log(personName); // Matt 
console.log(personAge); // 27
  1. 嵌套解构
    解构对于引用嵌套的属性或赋值目标没有限制。为此,可以通过解构来复制对象属性。

  2. 部分解构
    需要注意的是,涉及多个属性的解构赋值是一个输出无关的顺序化操作。如果一个解构表达式涉及多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分。

  3. 参数上下文匹配
    在函数参数列表中也可以进行解构赋值。对参数的解构赋值不会影响 arguments 对象,但可以在函数签名中声明在函数体内使用局部变量。

二、创建对象

1.概述

ECMAScript 6 开始正式支持类和继承。ES6 的类旨在完全涵盖之前规范设计的基于原型的继承模式。

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

3.构造函数模式

使用构造函数模式可以自定义引用类型,可以使用 new 关键字像创建内置类型实例一样创建自定义类型的实例。当然也可以自定义构造函数,以函数的形式为自己的对象类型定义属性和方法。

比如,前面的例子使用构造函数模式可以这样写:

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()基本是一样的,只是有如下区别。

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

按照惯例,构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头。

  1. 构造函数也是函数
    构造函数与普通函数唯一的区别就是调用方式不同。

  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
  1. 理解原型
    无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象)。默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数。

  2. 原型层级

  3. 原型和 in 操作符
    有两种方式使用 in 操作符:单独使用和在 for-in 循环中使用。

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

    在 for-in 循环中使用 in 操作符时,可以通过对象访问且可以被枚举的属性都会返回,包括实例属性和原型属性。遮蔽原型中不可枚举([[Enumerable]]特性被设置为 false)属性的实例属性也会在 for-in 循环中返回,因为默认情况下开发者定义的属性都是可枚举的。

  4. 属性枚举顺序
    Object.getOwnPropertyNames()Object.getOwnPropertySymbols()Object.assign()的枚举顺序是确定性的。先以升序枚举数值键,然后以插入顺序枚举字符串和符号键。在对象字面量中定义的键以它们逗号分隔的顺序插入。for-in 循环和 Object.keys()的枚举顺序是不确定的,取决于 JavaScript 引擎,可能因浏览器而异。

5.对象迭代

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", {}]]

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

  1. 其他原型语法
    为了从视觉上更好地封装原型功能,直接通过一个包含所有属性和方法的对象字面量来重写原型成为了一种常见的做法。

  2. 原型的动态性

  3. 原生对象原型

  4. 原型的问题
    它弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。虽然这会带来不便,但还不是原型的最大问题。原型的最主要问题源自它的共享特性。

三、继承

继承是面向对象编程中讨论最多的话题。很多面向对象语言都支持两种继承:接口继承实现继承。前者只继承方法签名,后者继承实际的方法。接口继承在 ECMAScript 中是不可能的,因为函数没有签名。实现继承是 ECMAScript 唯一支持的继承方式,而这主要是通过原型链实现的。

1.原型链

ECMA-262 把原型链定义为 ECMAScript 的主要继承方式。其基本思想就是通过原型继承多个引用类型的属性和方法。重温一下构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。如果原型是另一个类型的实例呢?那就意味着这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链。这就是原型链的基本构想。

  1. 默认原型

  2. 原型与继承关系
    原型与实例的关系可以通过两种方式来确定。第一种方式是使用 instanceof 操作符,如果一个实例的原型链中出现过相应的构造函数,则 instanceof 返回 true。

    第二种方式是使用 isPrototypeOf()方法。原型链中的每个原型都可以调用这个方法,如下例所示,只要原型链中包含这个原型,这个方法就返回 true。

  3. 关于方法
    子类有时候需要覆盖父类的方法,或者增加父类没有的方法。为此,这些方法必须在原型赋值之后再添加到原型上。

    另一个要理解的重点是,以对象字面量方式创建原型方法会破坏之前的原型链,因为这相当于重写
    了原型链。

  4. 原型链的问题
    主要问题出现在原型中包含引用值的时候。

    原型链的第二个问题是,子类型在实例化时不能给父类型的构造函数传参。事实上,我们无法在不影响所有对象实例的情况下把参数传进父类的构造函数。

2.盗用构造函数

为了解决原型包含引用值导致的继承问题,一种叫作“盗用构造函数”(constructor stealing)的技术在开发社区流行起来,基本思路就是子类构造函数中调用父类构造函数。

  1. 传递参数
  2. 盗用构造函数的问题

3.组合继承

组合继承(有时候也叫伪经典继承)综合了原型链和盗用构造函数,将两者的优点集中了起来。基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。

4.原型式继承

ECMAScript 5 通过增加 Object.create()方法将原型式继承的概念规范化了。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。第二个参数与 Object.defineProperties()的第二个参数一样:每个新增属性都通过各自的描述符来描述。以这种方式添加的属性会遮蔽原型对象上的同名属性。

原型式继承可以无须明确定义构造函数而实现继承,本质上是对给定对象执行浅复制。这种操作的结果之后还可以再进一步增强。

5.寄生式继承

与原型式继承比较接近的一种继承方式是寄生式继承(parasitic inheritance)。寄生式继承背后的思路类似于寄生构造函数和工厂模式:即先基于一个对象创建一个新对象,然后再增强这个新对象,最后返回新对象。这个模式也被用在组合继承中,用于避免重复调用父类构造函数导致的浪费。

注意:通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类似。

6.寄生组合式继承

寄生组合继承被认为是实现基于类型继承的最有效方式。

四、类

1.类定义

与函数类型相似,定义类也有两种主要方式:类声明和类表达式。这两种方式都使用 class 关键字加大括号:

// 类声明
class Person {}
 
// 类表达式
const Animal = class {}; 

2.类构造函数

constructor 关键字用于在类定义块内部创建类的构造函数。方法名 constructor 会告诉解释器在使用 new 操作符创建类的新实例时,应该调用这个函数。构造函数的定义不是必需的,不定义构造函数相当于将构造函数定义为空函数。

  1. 实例化
  2. 把类当成特殊函数

3.实例、原型和类成员

  1. 实例成员
  2. 原型方法与访问器
  3. 静态类方法
  4. 非函数原型和类成员
  5. 迭代器与生成器方法

4.继承

ECMAScript 6 新增特性中最出色的一个就是原生支持了类继承机制。虽然类继承使用的是新语法,但背后依旧使用的是原型链。

  1. 继承基础
    ES6 类支持单继承。使用 extends 关键字,就可以继承任何拥有[[Construct]]和原型的对象。很大程度上,这意味着不仅可以继承一个类,也可以继承普通的构造函数(保持向后兼容)。

  2. 构造函数、HomeObject 和 super()

  3. 抽象基类
    虽然 ECMAScript 没有专门支持这种类的语法 ,但通过 new.target 也很容易实现。new.target 保存通过 new 关键字调用的类或函数。通过在实例化时检测 new.target 是不是抽象基类,可以阻止对抽象基类的实例化

  4. 继承内置类型

  5. 类混入
    注意,很多 JavaScript 框架(特别是 React)已经抛弃混入模式,转向了组合模式(把方法提取到独立的类和辅助对象中,然后把它们组合起来,但不使用继承)。这反映了那个众所周知的软件设计原则:“组合胜过继承(composition over inheritance)。”这个设计原则被很多人遵循,在代码设计中能提供极大的灵活性。


总结

对象在代码执行过程中的任何时候都可以被创建和增强,具有极大的动态性,并不是严格定义的实体。

JavaScript 的继承主要通过原型链来实现。原型链涉及把构造函数的原型赋值为另一个类型的实例。这样一来,子类就可以访问父类的所有属性和方法,就像基于类的继承那样。原型链的问题是所有继承的属性和方法都会在对象实例间共享,无法做到实例私有。盗用构造函数模式通过在子类构造函数中调用父类构造函数,可以避免这个问题。这样可以让每个实例继承的属性都是私有的,但要求类型只能通过构造函数模式来定义(因为子类不能访问父类原型上的方法)。目前最流行的继承模式是组合继承,即通过原型链继承共享的属性和方法,通过盗用构造函数继承实例属性。

ECMAScript 6 新增的类很大程度上是基于既有原型机制的语法糖。类的语法让开发者可以优雅地定义向后兼容的类,既可以继承内置类型,也可以继承自定义类型。类有效地跨越了对象实例、对象原型和对象类之间的鸿沟。

以上就是本篇文章的全部内容了,下一篇文章我们将学习“第 9 章 代理与反射”,如果想要第一时间获取相关文章,欢迎关注博主,并订阅专栏。您的支持与鼓励将成为我不断前行地动力!

最后,如果本篇文章对正在阅读的您有所帮助或启发,请不要吝啬您的点赞收藏评论及分享,这样就有可能帮助到更多的人了。同时也欢迎留下您遇到的问题,让我们一起探讨学习,共同进步!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值