对象、类与面向对象编程

理解对象

一组属性的无序集合,每个属性或方法都由一个名称来标识,这个名称映射到一个值

可以想象成一张散列表,其中的内容就是一组名/值对,值可以是数据或者函数

创建方法

  1. new Object()然后再给它添加属性和方法
  2. {}对象字面量

内部属性,(不能在js直接访问)

  1. 数据属性
    • [[Configurable]]
      • 是否可以通过delete删除并重新定义
      • 是否可以修改它的特性
      • 是否可以改为访问器属性
      • 默认为true
    • [[Enumerable]]
      • 是否可以通过for-in循环返回
      • 默认为true
    • [[Writable]]
      • 是否可以被修改
      • 默认为true
    • [[Value]]
      • 包含属性实际的值
      • 默认undefined
  2. 访问器属性
    • [[Configurable]]
      • 是否可以通过delete删除并重新定义
      • 是否可以修改它的特性
      • 是否可以改为数据属性
      • 默认为true
    • [[Enumerable]]
      • 是否可以通过for-in循环返回
      • 默认为true
    • [[Get]]
      • 获取函数,在读取属性时调用。默认值为undefined。
    • [[Set]]
      • 设置函数,在写入属性时调用。默认值为undefined

API

  1. Object.defineProperty()
    • 接收3个参数:要给其添加属性的对象、属性的名称和一个描述符对象
    • configurable、enumerable和writable的值如果不指定,则都默认为false
  2. Object.defineProperties()
    • 这个方法可以通过多个描述符一次性定义多个属性
    • 接收两个参数:要为之添加或修改属性的对象和另一个描述符对象
  3. Object.getOwnPropertyDescriptor()
    • 取得指定属性的属性描述符
    • 接收两个参数:属性所在的对象和要取得其描述符的属性名。返回值是一个对象
  4. Object.getOwnPropertyDescriptors()
    • 实际上会在每个自有属性上调用Object.getOwnPropertyDescriptor()并在一个新对象中返回它们
    • 接受一个参数,参数为对象
  5. Object.assign()
    • 用于合并对象,是浅复制,接收一个目标对象和一个或多个源对象作为参数
    • 可枚举和自有属性才复制到目标对象
    • 这个方法会使用源对象上的[[Get]]取得属性的值,然后使用目标对象上的[[Set]]设置属性的值
  6. Object.is()
    • 与===很像,接受两个参数
  7. isPrototypeOf()
    • 确定两个对象之间的关系,只要原型链中包含这个原型,这个方法就返回true
  8. Object.getPrototypeOf()
    • 返回参数的内部特性[[Prototype]]的值
  9. Object.setPrototypeOf()
    • 可以向实例的私有特性[[Prototype]]写入一个新值。这样就可以重写一个对象的原型继承关系
    • 例子:Object.setPrototypeOf(A,B),A的原型就指向B
  10. Object.create()
  • 创建一个新对象,同时为其指定原型
  • 参数两个
    • 第一个:作为新对象原型的对象
    • 第二个可选:给新对象定义额外属性的对象,Object.defineProperties()的第二个参数一样:每个新增属性都通过各自的描述符来描述。以这种方式添加的属性会遮蔽原型对象上的同名属性
  1. hasOwnProperty()
    • 用于确定某个属性是在实例上还是在原型对象上。这个方法是继承自Object的,会在属性存在于调用它的对象实例上时返回true
  2. Object.getOwnPropertySymbol()
    • 取得以symbol为键的值,返回的是数组
    • 与Object.getOwnPropertyNames()类似,只是针对符号而已

增强的对象语法

  1. 属性简写
    • 只要使用变量名(不用再写冒号)就会自动被解释为同名的属性键
    • 如果没有找到同名变量,则会抛出ReferenceError
  2. 可计算属性
    • 可以在对象字面量中完成动态属性赋值。中括号内被当作JavaScript表达式求值
    • 抛出任何错误都会中断对象创建
  3. 简写方法名

对象解构

  • 就是使用与对象匹配的结构来实现对象属性赋值
  • 解构在内部使用函数ToObject()(不能在运行时环境中直接访问)把源数据结构转换为对象
  • null和undefined不能被解构,否则会抛出错误
  • 解构并不要求变量必须在解构表达式中声明。不过,如果是给事先声明的变量赋值,则赋值表达式必须包含在一对括号中
  • 解构赋值可以使用嵌套结构,以匹配嵌套的属性
  • 一个解构表达式涉及多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分

理解对象创建过程

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

工厂模式

用于抽象创建特定对象的过程

工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题

有一个函数,传入参数给它,它在里面新建对象。并用参数完成赋值,再把对象返回出去。这个函数就叫做工厂函数。

构造函数模式

有一个函数,传入参数给它,属性和方法直接赋值给了this

与工厂函数区别

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

new操作符创建实例,会发生以下事情

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

实例有constructor属性,指向构造函数

  • constructor本来是用于标识对象类型的
  • 定义自定义构造函数可以确保实例被标识为特定类型

在调用一个函数而没有明确设置this值的情况下(即没有作为对象的方法调用,或者没有使用call()/apply()调用),this始终指向Global对象。

构造函数问题:其定义的方法会在每个实例上都创建一遍,自定义类型引用的代码不能很好地聚集一起。

此问题可以通过原型模式解决。

原型模式

每个函数都会创建一个prototype属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。

理解原型

无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个prototype属性(指向原型对象)。所有原型对象自动获得一个名为constructor的属性,指回与之关联的构造函数。

每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象。脚本中没有访问这个[[Prototype]]特性的标准方式,但Firefox、Safari和Chrome会在每个对象上暴露__proto__属性,通过这个属性可以访问对象的原型。实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。

实例、构造函数、原型对象三者关系

  • 构造函数有一个prototype属性。指向原型对象
  • 原型对象有一个constructor的属性,指回构造函数
  • 实例有一个内部[[Prototype]]指针,指着原型对象,可以用__proto__属性可以访问原型对象
  • 实例还有一个constructor属性,指向构造函数
  • 实例可以获取原型对象的属性,但修改不来原型对象的属性

原型和in操作符

  • in操作符会在可以通过对象访问指定属性时返回true,无论该属性是在实例上还是在原型上
    • for-in循环中使用in操作符时,可以通过对象访问且可以被枚举的属性都会返回,包括实例属性和原型属性
  • Object.keys()方法。这个方法接收一个对象作为参数,返回包含该对象所有可枚举属性名称的字符串数组

属性枚举顺序

Object.getOwnPropertyNames()Object.getOwnPropertySymbols()Object.assign()的枚举顺序是确定性的。先以升序枚举数值键,然后以插入顺序枚举字符串和符号键

对象迭代

Object.values()Object.entries()接收一个对象,返回它们内容的数组。Object.values()返回对象值的数组,Object.entries()返回键/值对的数组。

直接用对象字面量重写原型对象,会丢失constructor属性,需要手动加上,并且要设置[[Enumerable]]false

原型的动态性

  • 任何时候对原型对象所做的修改也会在实例上反映出来
  • 因为实例和原型之间的链接就是简单的指针
  • 如果重写原型,会切断最初原型与构造函数的联系,但实例引用的仍然是最初的原型
  • 实例只有指向原型的指针,没有指向构造函数的指针

原型问题

  • 弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值
  • 最主要问题源自它的共享特性
    • 对函数没问题,但对属性就有问题了,例如引用值被共享,一个实例修改了,另一个实例也会被修改。

应该不同的实例应该有属于自己的属性副本

理解继承

JavaScript只有实现继承,继承实际方法,主要通过原型链实现

原型链

基本思想就是通过原型继承多个引用类型的属性和方法

原型链的由来,如果一个原型是另一个类型的实例,意味着这个原型本身有个一内部指针指着另一个原型,相应的另一个原型也有一个指针指向另一个构造函数,这样,实例和原型之间构造了一条原型链。

默认原型

默认情况下,所有引用类型都继承自Object,这也是通过原型链实现的。任何函数的默认原型都是一个Object的实例

原型与继承关系

  • instanceof操作符
  • isPrototypeOf()方法
    • 只要原型链中包含这个原型,这个方法就返回true

关于方法

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

以对象字面量方式创建原型方法会破坏之前的原型链,因为这相当于重写了原型链。

原型链问题

  • 原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因
  • 子类型在实例化时不能给父类型的构造函数传参

盗用构造函数

基本思路很简单:在子类构造函数中调用父类构造函数。所以可以使用apply()call()方法以新创建的对象为上下文执行构造函数

优点

  • 可以在子类构造函数中向父类构造函数传参

问题

  • 必须在构造函数中定义方法,因此函数不能重用
  • 子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式

组合继承

综合了原型链和盗用构造函数,将两者的优点集中了起来。基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性

优点

  • 实例都有自己的属性,并且还共享相同的方法
    • 属性定义在构造函数,方法放到原型上
  • 保留了instanceof操作符和isPrototypeOf()方法识别合成对象的能力

缺点

  • 最主要的效率问题就是父类构造函数始终会被调用两次
    • 一次在是创建子类原型时调用
    • 另一次是在子类构造函数中调用

原型式继承

即使不自定义类型也可以通过原型实现对象之间的信息共享

1
2
3
4
5
function object(o) {
function F() {}
F.prototype = o;
return new F();
}

使用场景:你有一个对象,想在它的基础上再创建一个新对象。你需要把这个对象先传给object(),然后再对返回的对象进行适当修改。

Object.create()方法在只有一个参数时,与这里的object()方法效果相同

原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合

寄生式继承

思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象

1
2
3
4
5
6
7
function createAnother(original) {
let clone = object(original); // 通过调用函数创建一个新对象
clone.sayHi = function () { // 以某种方式增强这个对象
console.log("hi");
};
return clone; // 返回这个对象
}

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

寄生式组合继承

寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。说到底就是使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型

1
2
3
4
5
function inheritPrototype(subType, superType) {
let prototype = object(superType.prototype); // 创建对象
prototype.constructor = subType; // 增强对象
subType.prototype = prototype; // 赋值对象
}

该函数核心逻辑

  • 接受两个参数
    • 子类构造函数
    • 父类构造函数
  • 创建父类原型的一个副本
  • 返回的prototype对象设置constructor属性,解决由于重写原型导致默认constructor丢失的问题
  • 将新创建的对象赋值给子类型的原型

寄生式组合继承可以算是引用类型继承的最佳模式

总结

(1)第一种是以原型链的方式来实现继承,但是这种实现方式存在的缺点是,在包含有引用类型的数据时,会被所有的实例对象所共享,容易造成修改的混乱。还有就是在创建子类型的时候不能向超类型传递参数。

(2)第二种方式是使用借用构造函数的方式,这种方式是通过在子类型的函数中调用超类型的构造函数来实现的,这一种方法解决了不能向超类型传递参数的缺点,但是它存在的一个问题就是无法实现函数方法的复用,并且超类型原型定义的方法子类型也没有办法访问到。

(3)第三种方式是组合继承,组合继承是将原型链和借用构造函数组合起来使用的一种方式。通过借用构造函数的方式来实现类型的属性的继承,通过将子类型的原型设置为超类型的实例来实现方法的继承。这种方式解决了上面的两种模式单独使用时的问题,但是由于我们是以超类型的实例来作为子类型的原型,所以调用了两次超类的构造函数,造成了子类型的原型中多了很多不必要的属性。

(4)第四种方式是原型式继承,原型式继承的主要思路就是基于已有的对象来创建新的对象,实现的原理是,向函数中传入一个对象,然后返回一个以这个对象为原型的对象。这种继承的思路主要不是为了实现创造一种新的类型,只是对某个对象实现一种简单继承,ES5中定义的Object.create()方法就是原型式继承的实现。缺点与原型链方式相同。

(5)第五种方式是寄生式继承,寄生式继承的思路是创建一个用于封装继承过程的函数,通过传入一个对象,然后复制一个对象的副本,然后对象进行扩展,最后返回这个对象。这个扩展的过程就可以理解是一种继承。这种继承的优点就是对一个简单对象实现继承,如果这个对象不是我们的自定义类型时。缺点是没有办法实现函数的复用。

(6)第六种方式是寄生式组合继承,组合继承的缺点就是使用超类型的实例做为子类型的原型,导致添加了不必要的原型属性。寄生式组合继承的方式是使用超类型的原型的副本来作为子类型的原型,这样就避免了创建不必要的属性。

理解类

定义类

  1. 类声明
  2. 类表达式

  • 表达式在它们被求值前也不能引用
  • 类声明不能提升
  • 受块级作用域限制
  • 可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必需的
  • 可以通过name属性取得类表达式的名称字符串。但不能在类表达式作用域外部访问这个标识符
  • 可以像函数一样在任何地方定义
  • 也可以立即实例化

类构造函数

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

使用new调用类的构造函数会执行如下操作:

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

默认情况下,类构造函数会在执行之后返回this对象。构造函数返回的对象会被用作实例化的对象,如果没有什么引用新创建的this对象,那么这个对象会被销毁。不过,如果返回的不是this对象,而是其他对象,那么这个对象不会通过instanceof操作符检测出跟类有关联,因为这个对象的原型指针并没有被修改。

类构造函数与构造函数的主要区别:

  • 调用类构造函数必须使用new操作符
  • 而普通构造函数如果不使用new调用,那么就会以全局的this(通常是window)作为内部对象
  • 调用类构造函数时如果忘了使用new则会抛出错误

可以使用instanceof操作符检查一个对象与类构造函数,以确定这个对象是不是类的实例

类中定义的constructor方法不会被当成构造函数,在对它使用instanceof操作符时会返回false。但是,如果在创建实例时直接将类构造函数当成普通构造函数来使用,那么instanceof操作符的返回值会反转

实例、原型和类成员

  1. 实例成员

    • 每次通过new调用类标识符时,都会执行类构造函数,在这个函数内部,可以为新创建的实例(this)添加“自有”属性

    • 放在构造函数的方法是各自独立的,放在构造函数外类块中的方法(即为原型方法)可以共享。

  2. 原型方法与访问器

    • 方法可以定义在类构造函数中或者类块中,但不能在类块中给原型添加原始值或对象作为成员数据

    • 类方法等同于对象属性,因此可以使用字符串、符号或计算的值作为键

    • 类定义也支持获取和设置访问器

  3. 静态类方法

    • 使用static关键字作为前缀,在静态成员中,**this引用类自身**。
    • 通常用于执行不特定于实例的操作,也不要求存在类的实例
    • 与原型成员类似,静态成员每个类上只能有一个。
    • 静态类方法非常适合作为实例工厂
  4. 非函数原型和类成员

    • 类定义并不显式支持在原型或类上添加成员数据,但在类定义外部,可以手动添加
  5. 迭代器与生成器方法

    • 类定义语法支持在原型和类本身上定义生成器方法
    • 因为支持生成器方法,所以可以通过添加一个默认的迭代器,把类实例变成可迭代对象

继承

背后依旧使用的原型链

  1. 继承基础

    支持单继承,使用extends关键字,可以继承任何拥有[[Construct]]和原型的对象,不仅可以继承一个类,也可以继承普通的构造函数

    派生类都会通过原型链访问到类和原型上定义的方法。this的值会反映调用相应方法的实例或者类

  2. 构造函数、HomeObjectsuper()

    派生类的方法可以通过super关键字引用它们的原型。这个关键字只能在派生类中使用,而且仅限于类构造函数、实例方法和静态方法内部。在类构造函数中使用super可以调用父类构造函数。

    ES6给类构造函数和静态方法添加了内部特性[[HomeObject]],这个特性是一个指针,指向定义该方法的对象。

    使用super()几个关键点:

    • super只能在派生类构造函数和静态方法中使用
    • 不能单独引用super关键字,要么用它调用构造函数,要么用它引用静态方法
    • 调用super()会调用父类构造函数,并将返回的实例赋值给this
    • super()的行为如同调用构造函数,如果需要给父类构造函数传参,则需要手动传入
    • 如果没有定义类构造函数,在实例化派生类时会调用super(),而且会传入所有传给派生类的参数。
    • 在类构造函数中,不能在调用super()之前引用this
    • 如果在派生类中显式定义了构造函数,则要么必须在其中调用super(),要么必须在其中返回一个对象
  3. 抽象基类

    • 可供其他类继承,但本身不会被实例化
    • new.target保存通过new关键字调用的类或函数
      • 通过在实例化时检测new.target是不是抽象基类,可以阻止对抽象基类的实例化
    • 因为原型方法在调用类构造函数之前就已经存在了,所以可以通过this关键字来检查相应的方法
  4. 继承内置类型

    • ES6类为继承内置引用类型提供了顺畅的机制,开发者可以方便地扩展内置类型
  5. 类混入