《JS高级程序设计》第8章 对象、类与面向对象编程

93 阅读19分钟

前言

  1. 对象是一组属性的无序集合。

一、理解对象

  1. 创建自定义对象的通常方式是创建Object的一个新实例,然后再给它添加属性和方法。

  2. ECMA-262 使用一些内部特性来描述属性的特征,内部特性会用两个中括号把特性的名称括起来,如[[Enumerable]]

  3. 属性分为数据属性和访问器属性。

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

    1. [[Configurable]]:可以配置,如删除、修改操作。
    2. [[Enumerable]]:可以通过for-in循环返回。
    3. [[Writable]]:可以被修改。
    4. [[Value]]:包含属性实际的值,默认值为undefined
  5. 要修改属性的默认特性,就必须使用Object.defineProperty()方法。

let person = {};
Object.defineProperty(person, "name", {
  writable: false, // 只读
  value: "Nicholas"
});

console.log(person.name); // "Nicholas"
person.name = "Greg";
console.log(person.name); // "Nicholas"
  1. 在调用Object.defineProperty()时,configurableenumerablewritable的值如果不指定,则都默认为false
  2. 访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数,不过这两个函数不是必需的。访问器属性是不能直接定义的,必须使用Object.defineProperty()。访问器属性的典型使用场景是设置一个属性值会导致一些其他变化发生。
// 定义一个对象,包含伪私有成员 year_ 和公共成员 edition  
// year_ 中的下划线常用来表示该属性并不希望在对象方法的外部被访问
let book = {
  year_: 2017,
  edition: 1
};

Object.defineProperty(book, "year", {
  get() {
    return this.year_
  },
  set(newValue) {
    if (newValue > 2017) {
      this.year_ = newValue;
      this.edition += newValue - 2017;
    }
  }
});

book.year = 2018;
console.log(book.edition); // 2
  1. Object.defineProperties()方法可以通过多个描述符一次性定义多个属性。
let book = {};
Object.defineProperties(book, {
  year: {
    value: 2017,
  },
  edition: {
    value: 1,
  },
});
console.log(book); // {year: 2017, edition: 1}
  1. 使用Object.getOwnPropertyDescriptor()方法可以取得指定属性的属性描述符,接收两个参数:属性所在的对象要取得其描述符的属性名Object.getOwnPropertyDescriptors()会在每个自有属性上调用Object.getOwnPropertyDescriptor()并在一个新对象中返回它们。
console.log(Object.getOwnPropertyDescriptor(book, 'year'));
// {value: 2017, writable: false, enumerable: false, configurable: false}

console.log(Object.getOwnPropertyDescriptors(book));
// {year: {}, edition: {}}
  1. Object.assign()方法接收一个目标对象和一个或多个源对象作为参数,然后将每个源对象中可枚举(Object.propertyIsEnumerable()返回true) 和自有(Object.hasOwnProperty()返回true)属性复制到目标对象。
let dest = {};
let src = { id: 'src' };
let res = Object.assign(dest, src);
console.log(res, dest, src);
// {id: 'src'} {id: 'src'} {id: 'src'}
  1. Object.assign()对每个源对象执行的是浅复制,如果多个源对象都有相同的属性,则使用最后一个复制的值。
  2. 从源对象访问器属性取得的值,比如获取函数,会作为一个静态值赋给目标对象。不能在两个对象间转移获取函数和设置函数。
let dest = {
  set id(x) {
    console.log(x);
  }
}
Object.assign(dest, { id: 1 }, { id: 2 }, { id: 3 });
// 1 2 3
  1. 如果赋值期间出错,则操作会中止并退出,同时抛出错误。Object.assign()没有“回滚”之前赋值的概念,因此它是一个尽力而为、可能只会完成部分复制的方法没有“回滚”之前赋值的概念。
  2. Object.is()能处理NaN0-0+0的相等判定:
// 正确的 0、-0、+0 相等/不等判定 
console.log(Object.is(+0, -0));   // false 
console.log(Object.is(+0, 0));    // false
console.log(Object.is(-0, 0));    // true

// 正确的 NaN 相等判定 
console.log(Object.is(NaN, NaN)); // true

二、创建对象

  1. 使用Object构造函数或对象字面量的不足是:创建具有同样接口的多个对象需要重复编写很多代码。
  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('Emma', 18, 'Cook');
let Person2 = createPerson('Bob', 20, 'Chef');
Person1.sayName(); // Emma
Person2.sayName(); // Bob
  1. 自定义构造函数以函数的形式为自己的对象类型定义属性和方法:
// 赋值给变量的函数表达式也可以表示构造函数
// let Person = function(name, age, job) {
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. 构造函数模式和工厂模式的区别是:

    1. 没有显式地创建对象。
    2. 属性和方法直接赋值给了this
    3. 没有return
  2. 按照惯例,构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头。

  3. 使用new操作符调用构造函数会执行如下操作:

    1. 在内存中创建一个新对象。
    2. 这个新对象内部的[[Prototype]]特性被赋值为构造函数的prototype属性。
    3. 构造函数内部的this被赋值为这个新对象(即this指向新对象)。
    4. 执行构造函数内部的代码(给新对象添加属性)。
    5. 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
  4. 定义自定义构造函数可以确保实例被标识为特定类型。

  5. 构造函数与普通函数唯一的区别就是调用方式不同。除此之外,构造函数也是函数。

  6. 任何函数只要使用new操作符调用就是构造函数,而不使用new操作符调用的函数就是普通函数。

  7. 构造函数的主要问题是定义的方法会在每个实例上都创建一遍。解决这个问题可以把函数定义转移到构造函数外部,但在全局作用域中定义多个函数会导致自定义类型引用的代码不能很好地聚集一起。

  8. 每个函数都会创建一个prototype属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。

  9. 使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。

  10. 与构造函数模式不同,使用原型模式定义的属性和方法是由所有实例共享的。

  11. 无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个prototype属性(指向原型对象)。

  12. 默认情况下,所有原型对象自动获得一个名为constructor的属性,指回与之关联的构造函数。

  13. 在自定义构造函数时,原型对象默认只会获得constructor属性,其他的所有方法都继承自Object

function Per() {}
console.log(Per.prototype);  // {constructor: ƒ}

// 构造函数有一个 prototype 属性引用其原型对象,而这个原型对象也有一个 constructor 属性引用这个构造函数,两者循环引用
console.log(Per.prototype.constructor === Per); // true

// 正常的原型链都会终止于 Object 的原型对象,Object 原型的原型是 null
console.log(Per.prototype.__proto__ === Object.prototype); // true
console.log(Per.prototype.__proto__.constructor === Object); // true.
console.log(Per.prototype.__proto__.__proto__ === null); // true
console.log(Object.prototype.__proto__ === null); // true
  1. 每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象。
  2. 浏览器会在每个对象上暴露__proto__属性,通过这个属性可以访问对象的原型。换句话说,实例通过__proto__链接到原型对象,它实际上指向隐藏特性[[Prototype]]
  3. 实例与构造函数原型(原型对象)之间有直接的联系,但实例与构造函数之间没有直接联系。
let person1 = new Per();
let person2 = new Per();
// 构造函数通过 prototype 属性链接到原型对象,构造函数通过 prototype 属性链接到原型对象
// 只有一个内部属性指回 Per.prototype
console.log(person1.__proto__ === Per.prototype); // true
console.log(person1.__proto__.constructor === Per); // true

// 同一个构造函数创建的两个实例,共享同一个原型对象:
console.log(person1.__proto__ === person2.__proto__); // true
  1. instanceof检查实例的原型链中是否包含指定构造函数的原型。
console.log(person1 instanceof Per); // true
console.log(person2 instanceof Object); // true

// Per.prototype 指向原型对象
console.log(Per.prototype instanceof Object); // true
  1. Person.prototype指向原型对象,而Person.prototype.contructor指回Person构造函数。
  2. isPrototypeOf()会在传入参数的[[Prototype]]指向调用它的对象时返回true
console.log(Per.prototype.isPrototypeOf(person1)); // true
  1. Object.getPrototypeOf()返回参数的内部特性[[Prototype]]的值。使用Object.getPrototypeOf()可以方便地取得一个对象的原型,而这在通过原型实现继承时显得尤为重要。
console.log(Object.getPrototypeOf(person1) === Per.prototype); // true
  1. 可以通过Object.create()来创建一个新对象,同时为其指定原型。
let p = {
  job: 'cook',
};
let o = Object.create(p);
o.name = 'sam';
console.log(o.job); // cook
console.log(Object.getPrototypeOf(o) === p); // true
  1. 只要给对象实例添加一个属性,这个属性就会遮蔽(shadow)原型对象上的同名属性,也就是虽然不会修改它,但会屏蔽对它的访问。
  2. hasOwnProperty()方法用于确定某个属性是在实例上还是在原型对象上,方法会在属性存在于调用它的对象实例上时返回true
person1.name = 'Greg';
console.log(person1.name); // "Greg",来自实例
console.log(person1.hasOwnProperty('name')); // true
  1. 有两种方式使用in操作符:单独使用和在for-in循环中使用。在单独使用时,in操作符会在可以通过对象访问指定属性时返回 true,无论该属性是在实例上还是在原型上。
  2. 如果要确定某个属性是否存在于原型上,可同时使用hasOwnProperty()in操作符。
function hasPrototypeProperty(object, name) {
  return !object.hasOwnProperty(name) && name in object;
}
  1. Object.values()Object.entries()接收一个对象,返回它们内容的数组。Object.values()返回对象值的数组,Object.entries()返回键/值对的数组。
  2. 原型模式不仅体现在自定义类型上,而且它也是实现所有原生引用类型的模式。
  3. 原型模式弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。
  4. 原型的最主要问题源自它的共享特性。不同的实例应该有属于自己的属性副本,这就是实际开发中通常不单独使用原型模式的原因。

三、继承

  1. 很多面向对象语言都支持两种继承:接口继承实现继承

  2. 接口继承只继承方法签名,实现继承继承实际的方法

  3. 接口继承在 ECMAScript 中是不可能的,因为函数没有签名。实现继承是 ECMAScript 唯一支持的继承方式,而这主要是通过原型链实现的。

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

  5. 构造函数、原型和实例的关系:

    1. 每个构造函数都有一个原型对象,
    2. 原型有 一个属性指回构造函数,
    3. 而实例有一个内部指针指向原型。
// 继承SuperType
SubType.prototype = new SuperType();
  1. 原型与实例的关系可以通过两种方式来确定,第一种是使用instanceof操作符,第二种是使用 isPrototypeOf()方法。
console.log(s instanceof subP);   // true
console.log(s instanceof superP); // true
console.log(s instanceof Object); // true

console.log(Object.prototype.isPrototypeOf(s)); // true
console.log(superP.prototype.isPrototypeOf(s)); // true
console.log(subP.prototype.isPrototypeOf(s));   // true
  1. 子类有时候需要覆盖父类的方法,或者增加父类没有的方法。为此,这些方法必须在原型赋值之后再添加到原型上。
  2. 以对象字面量方式创建原型方法会破坏之前的原型链,因为这相当于重写了原型链。
// 继承SuperType
SubType.prototype = new SuperType();

// 通过对象字面量添加新方法,这会导致上一行无效
SubType.prototype = {};
  1. 原型中包含引用值的话,引用值会在所有实例间共享,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。
  2. 原型链的第二个问题是子类型在实例化时不能给父类型的构造函数传参。

(一)盗用构造函数

  1. 盗用构造函数(constructor stealing)解决了原型包含引用值导致的继承问题,基本思路是在子类构造函数中调用父类构造函数
  2. 盗用构造函数有时也称作“对象伪装”或“经典继承”。
  3. 盗用构造函数的原理是:函数就是在特定上下文中执行代码的简单对象,所以可以使用apply()call()方法以新创建的对象为上下文执行构造函数。
function SuperType() {
  this.colors = ['red', 'blue', 'green'];
}
function SubType() {
  // 继承 SuperType        
  SuperType.call(this);
}

// 新的 SubType 对象上运行了 SuperType()函数中的所有初始化代码
let instance1 = new SubType();
instance1.colors.push('black');
console.log(instance1.colors); // "red,blue,green,black"

let instance2 = new SubType();
console.log(instance2.colors); // "red,blue,green"
  1. 盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参。
function SuperType(name) {
  this.name = name;
}
function SubType() {
  // 继承 SuperType 并传参
  SuperType.call(this, 'Nicholas');
  // 实例属性
  this.age = 29;
}
let instance = new SubType();
console.log(instance.name); // "Nicholas"
console.log(instance.age);  // 29
  1. 盗用构造函数的主要缺点:

    1. 必须在构造函数中定义方法,因此函数不能重用,这也是使用构造函数模式自定义类型的问题。
    2. 子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。

(二)组合继承

  1. 组合继承(有时候也叫伪经典继承)综合了原型链盗用构造函数,将两者的优点集中了起来。组合继承弥补了原型链和盗用构造函数的不足,是 JavaScript 中使用最多的继承模式。
  2. 基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性
  3. 组合继承既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性
function SuperType(name) {
  this.name = name;
  this.colors = ['red', 'blue', 'green'];
}
SuperType.prototype.sayName = function () {
  console.log(this.name);
};

function SubType(name, age) {
  // 继承属性 
  SuperType.call(this, name); // 第二次调用SuperType()
  this.age = age;
}
// 继承方法
SubType.prototype = new SuperType(); // 第一次调用SuperType()
SubType.prototype.sayAge = function () {
  console.log(this.age);
};

let instance1 = new SubType('Nicholas', 29);
instance1.colors.push('black');
console.log(instance1.colors); // "red,blue,green,black"
instance1.sayName(); // "Nicholas";
instance1.sayAge(); // 29

let instance2 = new SubType('Greg', 27);
console.log(instance2.colors); // "red,blue,green"
instance2.sayName(); // "Greg";
instance2.sayAge(); // 27
  1. 组合继承也保留了instanceof操作符和isPrototypeOf()方法识别合成对象的能力。
  2. 组合继承存在效率问题,父类构造函数始终会被调用两次:一次在是创建子类原型时调用,另一次是在子类构造函数中调用。

(三)原型式继承

  1. 原型式继承(Prototypal Inheritance)的出发点是:即使不自定义类型也可以通过原型实现对象之间的信息共享。
// object()是对传入的对象执行了一次浅复制
function object(o) {
  // 创建一个临时构造函数 F()
  function F() {}
  // 传入的对象 o 赋值给构造函数 F() 的原型
  F.prototype = o;
  // 返回这个临时类型的一个实例
  return new F();
}
  1. ECMAScript 5 通过增加Object.create()方法将原型式继承的概念规范化了。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。
let anotherPerson = Object.create(person);
anotherPerson.name = 'Greg';
anotherPerson.friends.push('Rob');

let yetAnotherPerson = Object.create(person);
yetAnotherPerson.name = 'Linda';
yetAnotherPerson.friends.push('Barbie');
console.log(person.friends); // "Shelby,Court,Van,Rob,Barbie"
  1. Object.create()的第二个参数与Object.defineProperties()的第二个参数一样:每个新增属性都通过各自的描述符来描述。以这种方式添加的属性会遮蔽原型对象上的同名属性。
let person = {
  name: 'Nicholas',
  friends: ['Shelby', 'Court', 'Van'],
};
let anotherPerson = Object.create(person, {
  name: {
    value: 'Greg',
  },
});
console.log(anotherPerson.name); // "Greg"
  1. 原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。
  2. 属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。

(四)寄生式继承

  1. 寄生式继承(parasitic inheritance)背后的思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象。
function createAnother(original) {
  // 通过调用函数创建一个新对象
  let clone = Object.create(original); 
  clone.sayHi = function () {
    // 以某种方式增强这个对象
    console.log('hi');
  };
  return clone; // 返回这个对象
}
  1. 寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。
  2. object()函数不是寄生式继承所必需的,任何返回新对象的函数都可以在这里使用。
  3. 通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类似。

(五)寄生式组合继承

  1. 寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法
  2. 基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。说到底就是使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型
// 子类构造函数和父类构造函数
function inheritPrototype(subType, superType) {
  // 创建父类原型的一个副本
  let prototype = Object.create(superType.prototype); // 创建对象

  // 解决由于重写原型导致默认 constructor 丢失的问题
  prototype.constructor = subType; // 增强对象

  // 将新创建的对象赋值给子类型的原型
  subType.prototype = prototype; // 赋值对象
}
  1. 寄生式组合继承只调用了一次父类构造函数,避免了子类原型 SubType.prototype上不必要也用不到的属性。而且,原型链仍然保持不变,因此instanceof 操作符和isPrototypeOf()方法正常有效。
  2. 寄生式组合继承可以算是引用类型继承的最佳模式。

四、类

  1. ES6 的类都仅仅是封装了 ES5.1 构造函数加原型继承的语法糖。

  2. 定义类有两种主要方式:类声明类表达式,这两种方式都使用class关键字加大括号。

  3. 类声明和函数声明的异同:

    1. 相同点:

      1. 都可以通过声明和表达式定义;
      2. 表达式被求值都不能引用。
    2. 不同点:

      1. 函数声明可以提升,类声明不能提升。
      2. 函数受函数作用域限制,而类受块作用域限制。
  4. 默认情况下,类定义中的代码都在严格模式下执行。

  5. 类名的首字母要大写,以区别于通过它创建的实例。

  6. JavaScript 解释器知道使用new和类意味着应该使用constructor函数进行实例化。

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

    1. 在内存中创建一个新对象。
    2. 这个新对象内部的[[Prototype]]指针被赋值为构造函数的prototype属性。
    3. 构造函数内部的this被赋值为这个新对象(即this指向新对象)。
    4. 执行构造函数内部的代码(给新对象添加属性)。
    5. 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
  8. 类实例化时传入的参数会用作构造函数的参数。

  9. 类构造函数会在执行之后返回this对象,如果返回的不是this对象,而是其他对象,那么这个对象不会通过instanceof操作符检测出跟类有关联,因为这个对象的原型指针并没有被修改。

  10. 类构造函数与构造函数的主要区别是,调用类构造函数必须使用new操作符,否则会抛出错误。

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

class Person {}
let p1 = new Person();
console.log(p1.constructor === Person);
console.log(p1 instanceof Person);
console.log(p1 instanceof Person.constructor);  // false

let p2 = new Person.constructor();
console.log(p2.constructor === Person);
console.log(p2 instanceof Person);
console.log(p2 instanceof Person.constructor);  // true
  1. 每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享。
  2. 为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法。
class Person {
  constructor() {
    // 添加到 this 的所有内容都会存在于不同的实例上
    this.locate = () => console.log('instance');
  }
    // 在类块中定义的所有内容都会定义在类的原型上
  locate() {
    console.log('prototype');
  }
}
let p = new Person();

p.locate();                // instance
Person.prototype.locate(); // prototype
  1. 可以把方法定义在类构造函数中或者类块中,但不能在类块中给原型添加原始值或对象作为成员数据。
  2. 可以在类上定义静态方法。这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。
  3. 与原型成员类似,静态成员每个类上只能有一个。
  4. 静态类成员在类定义中使用static关键字作为前缀。在静态成员中,this引用类自身。其他所有约定跟原型成员一样。静态类方法非常适合作为实例工厂。
class Person {
  constructor() {
    // 添加到 this 的所有内容都会存在于不同的实例上
    this.locate = () => console.log('instance', this);
  }
  // 定义在类的原型对象上
  locate() {
    console.log('prototype', this);
  }
  // 定义在类本身上
  static locate() {
    console.log('class', this);
  }
}
let p = new Person();
p.locate();                 // instance, Person {}
Person.prototype.locate();  // prototype, {constructor: ... }
Person.locate();            // class, class Person {}
  1. ES6 类支持单继承。使用extends关键字,就可以继承任何拥有[[Construct]]和原型的对象。

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

  3. 使用super时要注意几个问题:

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

  5. 通过在抽象基类构造函数中进行检查,可以要求派生类必须定义某个方法。因为原型方法在调用类构造函数之前就已经存在了,所以可以通过this关键字来检查相应的方法。

  6. 有些内置类型的方法会返回新实例。默认情况下,返回实例的类型与原始实例的类型是一致的。