Javascript高级程序设计 第4版 第8章

72 阅读25分钟

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

ECMA-262 将对象定义为一组属性的无序集合。对象的每个属性或方法都由一个名称来标识,这个名称映射到一个值。可以把 ECMAScript 的对象想象成一张散列表,其中的内容就是一组名/值对,值可以是数据或者函数。

8.1 理解对象

创建自定义对象有两种方式:创建 Object 的一个新实例 和 对象字面量

// 方式一:创建 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); 
 } 
};

8.1.1 属性的类型

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

注意:ECMA-262 使用一些内部特性来描述属性的特征。将某个特性标识为内部特性时,用两个中括号把特性的名称括起来,比如 [[Enumerable]].

数据属性

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

数据属性的 4 个特性

[[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,
以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。

[[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对
象上的属性的这个特性都是 true。

[[Writable]]:表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的
这个特性都是 true。

[[Value]]:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。这个特性
的默认值为 undefined

修改属性的默认特性:使用 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"

访问器属性

访问器属性包含一个获取(getter)函数和一个设置(setter)函数。

访问器属性有 4 个特性描述它们的行为。

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

访问器属性的典型使用场景,即设置一个属性值会导致一些其他变化发生。

// 定义一个对象,包含伪私有成员 year_和公共成员 edition 
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

// 注意:year_中的下划线常用来表示该属性并不希望在对象方法的外部被访问。

8.1.2 定义多个属性

使用 Object.define.Properties() 方法

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;
      }
    },
  },
});

8.1.3 读取属性的特性

使用 Object.getOwnPropertyDescriptor() 方法

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"

ECMAScript 2017 新增了 Object.getOwnPropertyDescriptors() 静态方法

console.log(Object.getOwnPropertyDescriptors(book));
// {
// edition: {
// configurable: false,
// enumerable: false,
// value: 1,
// writable: false
// },
// year: {
// configurable: false,
// enumerable: false,
// get: f(),
// set: f(newValue),
// },
// year_: {
// configurable: false,
// enumerable: false,
// value: 2017,
// writable: false
// }
// }

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

8.1.4 合并对象

JavaScript 开发者经常觉得“合并”(merge)两个对象很有用。更具体地说,就是把源对象所有的本地属性一起复制到目标对象上。

ECMAScript 6 专门为合并对象提供了 Object.assign() 方法。

let dest, src, result; 
/** 
 * 简单复制
 */ 
dest = {}; 
src = { id: 'src' }; 
result = Object.assign(dest, src); 

// Object.assign 修改目标对象,也会返回修改后的目标对象
console.log(dest === result); // true 
console.log(dest !== src); // true 
console.log(result); // { id: src } 
console.log(dest); // { id: src } 
// Object.assign()实际上对每个源对象执行的是浅复制。

/** 
 * 多个源对象
 */ 
 dest = {}; 
 result = Object.assign(dest, { a: 'foo' }, { b: 'bar' }); 
 console.log(result); // { a: foo, b: bar }
 
/** 
 * 覆盖属性
 */ 
 dest = { id: 'dest' }; 
 result = Object.assign(dest, { id: 'src1', a: 'foo' }, { id: 'src2', b: 'bar' }); 
 // Object.assign 会覆盖重复的属性
 console.log(result); // { id: src2, a: foo, b: bar }
 
/** 
 * 对象引用
 */ 
 dest = {}; 
 src = { a: {} }; 
 Object.assign(dest, src); 
 // 浅复制意味着只会复制对象的引用
 console.log(dest); // { a :{} } 
 console.log(dest.a === src.a); // true 

8.1.5 对象标识及相等判定

8.1.6 增强的对象语法

ECMAScript 6 为定义和操作对象新增了很多极其有用的语法糖特性。这些特性都没有改变现有引擎的行为,但极大地提升了处理对象的方便程度。

1. 属性值简写

let name = "Matt";
let person = {
  name: name,
};
console.log(person); // { name: 'Matt' }

// 简写形式如下
let name = 'Matt'; 
let person = { 
 name 
}; 
console.log(person); // { name: 'Matt' }

2. 可计算属性

有了可计算属性,可以在对象字面量中完成动态属性赋值。

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' }

复杂的表达式:因为被当作 JavaScript 表达式求值,所以可计算属性本身可以是复杂的表达式。

const nameKey = 'name'; 
const ageKey = 'age'; 
const jobKey = 'job'; 
let uniqueToken = 0; 
function getUniqueKey(key) { 
 return `${key}_${uniqueToken++}`; 
} 
let person = { 
 [getUniqueKey(nameKey)]: 'Matt', 
 [getUniqueKey(ageKey)]: 27, 
 [getUniqueKey(jobKey)]: 'Software engineer' 
}; 
console.log(person); // { name_0: 'Matt', age_1: 27, job_2: 'Software engineer' }

3. 简写方法名

let person = {
  sayName: function (name) {
    console.log(`My name is ${name}`);
  },
};
person.sayName("Matt"); // My name is Matt

// 以下代码和之前的代码在行为上是等价的:
let person = {
  sayName(name) {
    console.log(`My name is ${name}`);
  },
};
person.sayName("Matt"); // My name is Mat

8.1.7 对象解构

简单地说,对象解构就是使用与对象匹配的结构来实现对象属性赋值。

解构实例

// 不使用对象解构
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: personName, age: personAge } = person;
console.log(personName); // Matt
console.log(personAge); // 27

// 让变量直接使用属性的名称
let person = {
  name: "Matt",
  age: 27,
};
let { name, age } = person;
console.log(name); // Matt
console.log(age); // 27

// 也可以在解构赋值的同时定义默认值
let person = {
  name: "Matt",
  age: 27,
};
let { name, job = "Software engineer" } = person;
console.log(name); // Matt
console.log(job); // Software engineer

// 给事先声明的变量赋值,则赋值表达式必须包含在一对括号中
let personName, personAge;
let person = {
  name: "Matt",
  age: 27,
};
({ name: personName, age: personAge } = person);
console.log(personName, personAge); // Matt, 27

解构的本质

解构在内部使用函数 ToObject()(不能在运行时环境中直接访问)把源数据结构转换为对象。这意味着在对象解构的上下文中,原始值会被当成对象。这也意味着(根据 ToObject()的定义),null 和 undefined 不能被解构,否则会抛出错误。

let { length } = "foobar";
console.log(length); // 6
let { constructor: c } = 4;
console.log(c === Number); // true
let { _ } = null; // TypeError
let { _ } = undefined; // TypeError

嵌套解构

let person = {
  name: "Matt",
  age: 27,
  job: {
    title: "Software engineer",
  },
};
// 声明 title 变量并将 person.job.title 的值赋给它
let {
  job: { title },
} = person;
console.log(title); // Software engineer

部分解构

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

let person = {
  name: "Matt",
  age: 27,
};
let personName, personBar, personAge;
try {
  // person.foo 是 undefined,因此会抛出错误
  ({
    name: personName,
    foo: { bar: personBar },
    age: personAge,
  } = person);
} catch (e) {}
console.log(personName, personBar, personAge);
// Matt, undefined, undefined

参数上下文匹配

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

let person = {
  name: "Matt",
  age: 27,
};

function printPerson(foo, { name, age }, bar) {
  console.log(arguments);
  console.log(name, age);
}

function printPerson2(foo, { name: personName, age: personAge }, bar) {
  console.log(arguments);
  console.log(personName, personAge);
}

printPerson("1st", person, "2nd");
// ['1st', { name: 'Matt', age: 27 }, '2nd']
// 'Matt', 27

printPerson2("1st", person, "2nd");
// ['1st', { name: 'Matt', age: 27 }, '2nd']
// 'Matt', 27

8.2 创建对象

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

8.2.1 概述

ES6 的类仅仅是封装了 ES5.1 构造函数加原型继承的语法糖而已。因此,在介绍 ES6 的类之前,本书会循序渐进地介绍被类取代的那些底层概念。

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 构造函数模式

前面的例子用构造函数实现。

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 的构造函数就是能创建对象的函数。

new 操作实际做了以下操作:

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

确保实例被标识为特定类型

定义自定义构造函数可以确保实例被标识为特定类型,相比于工厂模式,这是一个很大的好处。

// constructor 可用于标识对象类型
console.log(person1.constructor == Person); // true 
console.log(person2.constructor == Person); // true 

// instanceof 操作符更可靠
console.log(person1 instanceof Object); // true 
console.log(person1 instanceof Person); // true 
console.log(person2 instanceof Object); // true 
console.log(person2 instanceof Person); // true

注意:person1person2 之所以也被认为是 Object 的实例,是因为所有自定义对象都继承自 Object(后面再详细讨论这一点)。

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"

注意:第三种调用将对象 o 指定为 Person() 内部的 this 值,因此执行完函数代码后,所有属性和 sayName() 方法都会添加到对象 o 上面。

2. 构造函数的问题

构造函数的主要问题在于,其定义的方法会在每个实例上都创建一遍。

因此对前面的例子而言,person1 和 person2 都有名为 sayName() 的方法,但这两个方法不是同一个 Function 实例。

console.log(person1.sayName == person2.sayName); // false

我们来尝试着解释一下,为什么会这样

我们知道,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 实例的机制是一样的。因此不同实例上的函数虽然同名却不相等。

要解决这个问题,可以把函数定义转移到构造函数外部:

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

在这里,sayName() 被定义在了构造函数外部。在构造函数内部,sayName 属性等于全局 sayName() 函数。因为这一次 sayName 属性中包含的只是一个指向外部函数的指针,所以 person1person2 共享了定义在全局作用域上的 sayName()函数。这样虽然解决了相同逻辑的函数重复定义的问题,但全局作用域也因此被搞乱了,因为那个函数实际上只能在一个对象上调用。如果这个对象需要多个方法,那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好地聚集一起。这个新问题可以通过原型模式来解决。

8.2.4 原型模式

每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。实际上,这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。原来在构造函数中直接赋给对象实例的值,可以直接赋值给它们的原型。如下所示:

  • 每个函数都会创建一个 prototype 属性,构造函数也不例外
  • prototype 属性叫什么?
// 声明以后,就会自动创建一个名为 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

理解:对上面代码的拆分和理解

  • 构造函数也是函数
  • 构造函数体中什么也没有
  • 调用构造函数创建的新对象仍然拥有相应的属性和方法
  • 与构造函数模式不同,使用这种原型模式定义的属性和方法是由所有实例共享的

8-1.jpg

  • 图 8-1 展示了 Person 构造函数、Person 的原型对象和 Person 现有两个实例之间的关系。
  • 无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象)。
  • 构造函数可以是函数表达式也可以是函数声明
  • Person.prototype 指向 Person 的原型对象
  • 默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数。
  • 在自定义构造函数时,原型对象默认只会获得 constructor 属性,其他的所有方法都继承自Object。
  • 实例的内部 [[Prototype]] 指针就会被赋值为构造函数的原型对象
  • 实例可以通过 __proto__ 属性访问对象的原型
  • 实例与构造函数没有直接联系,与原型对象有直接联系
// 如何确定二者之间的关系?
console.log(Person.prototype.isPrototypeOf(person1)); // true
console.log(Person.prototype.isPrototypeOf(person2)); // true

console.log(Object.getPrototypeOf(person1) == Person.prototype); // true
console.log(Object.getPrototypeOf(person1).name); // "Nicholas"
// 第一行代码简单确认了 Object.getPrototypeOf() 返回的对象就是传入对象的原型对象。
// 第二行代码则取得了原型对象上 name 属性的值,即"Nicholas"。
// 使用 Object.getPrototypeOf()可以方便地取得一个对象的原型,
// 而这在通过原型实现继承时显得尤为重要(本章后面会介绍)。

// 拓展:可以向实例的私有特性[[Prototype]]写入一个新值。这样就可以重写一个对象的原型继承关系:
let biped = { 
  numLegs: 2 
 }; 
 let person = { 
  name: 'Matt' 
 }; 
 Object.setPrototypeOf(person, biped); 
 console.log(person.name); // Matt 
 console.log(person.numLegs); // 2 
 console.log(Object.getPrototypeOf(person) === biped); // true
 
// 但是:Object.setPrototypeOf()可能会严重影响代码性能。可以用 Object.create() 来代替
let biped = {
  numLegs: 2,
};
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

  • 声明之后,构造函数就有了一个与之关联的原型对象:
function Person() {}
console.log(Person.prototype);
// {
//   constructor: f Person(),
//   __proto__: Object
// }
  • 正常的原型链都会终止于 Object 的原型对象 Object 原型的原型是 null
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Person.prototype.__proto__.__proto__ === null); // true
console.log(Person.prototype.__proto__);
// {
//   constructor: f Object(),
//   toString: ...
//   hasOwnProperty: ...
//   isPrototypeOf: ...
//   ...
// }
  • 构造函数、原型对象和实例是 3 个完全不同的对象:
console.log(person1 !== Person); // true 
console.log(person1 !== Person.prototype); // true 
console.log(Person.prototype !== 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
// 使用函数表达式也可以:
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

这里,所有属性和 sayName()方法都直接添加到了 Personprototype 属性上,构造函数体中 什么也没有。但这样定义之后,调用构造函数创建的新对象仍然拥有相应的属性和方法。与构造函数模 式不同,使用这种原型模式定义的属性和方法是由所有实例共享的。因此 person1person2 访问的都是相同的属性和相同的 sayName() 函数。要理解这个过程,就必须理解 ECMAScript 中原型的本质。

1.理解原型

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

关键字:函数,prototype 属性,原型对象,constructor 属性,构造函数,其他属性和方法

函数创建一个 prototype 属性

原型对象自动获得 constructor 属性

Person.prototype.constructor 指向 Person

实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。

8.2.5 对象迭代

8.3 继承

8.3.1 原型链

8.3.2 盗用构造函数

8.3.3 组合继承

8.3.4 原型式继承

8.3.5 寄生式继承

8.3.6 寄生式组合继承

8.4 类

虽然 ECMAScript 6 类表面上看起来可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念。

8.4.1 类定义

定义

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

// 类声明(推荐)
class Person {} 

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

类的构成

类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必需的。空的类定义照样有效。默认情况下,类定义中的代码都在严格模式下执行。

8.4.2 类构造函数

constructor 关键字用于在类定义块内部创建类的构造函数。

constructor 会在 new 时自动执行。

1. 实例化

使用 new 操作符实例化 Person 的操作等于使用 new 调用其构造函数。

new 操作实际做了以下操作:

  • 在内存中创建一个新对象。
  • 这个新对象内部的 [[Prototype]] 特性被赋值为构造函数的 prototype 属性。
  • 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。
  • 执行构造函数内部的代码(给新对象添加属性)。
  • 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
class Person {
  constructor(name) {
    console.log(arguments.length);
    this.name = name || null;
  }
}
let p1 = new Person(); // 0
console.log(p1.name); // null
let p2 = new Person(); // 0
console.log(p2.name); // null
let p3 = new Person("Jake"); // 1
console.log(p3.name); // Jake

类构造函数返回的对象

默认情况下,类构造函数会在执行之后返回 this 对象。构造函数返回的对象会被用作实例化的对象,如果没有什么引用新创建的 this 对象,那么这个对象会被销毁。

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

class Person {
  constructor(override) {
    this.foo = "foo";
    if (override) {
      return {
        bar: "bar",
      };
    }
  }
}
let p1 = new Person(),
    p2 = new Person(true);
console.log(p1); // Person { foo: 'foo' }
console.log(p1 instanceof Person); // true

console.log(p2); // { bar: 'bar' }
console.log(p2 instanceof Person); // false

类构造函数与构造函数的区别

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

function Person() {} 
class Animal {} 
// 把 window 作为 this 来构建实例
let p = Person(); 
let a = Animal(); 
// TypeError: class constructor Animal cannot be invoked without 'new'

2. 把类当成特殊函数

ECMAScript 中没有正式的类这个类型。从各方面来看,ECMAScript 类就是一种特殊函数。

class Person {} 
console.log(Person); // class Person {} 
console.log(typeof Person); // function

// 类方式定义(与下面的函数方式等价)
class User {
  constructor(name) {
    this.name = name;
  }
  show() {
    console.log(this.name);
  }
}
let u = new User("江南");
console.dir(User);
console.log(u);

// 函数方式
function UserFun(name) {
  this.name = name;
}

UserFun.prototype.show = function () {
  console.log(this.name);
};

let h = new UserFun("江雪");
console.dir(UserFun);
console.log(h);

instanceof 操作符

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

class Person {}
let p = new Person();
console.log(p instanceof Person); // true

一等公民

类是 JavaScript 的一等公民,因此可以像其他对象或函数引用一样把类作为参数传递:

// 类可以像函数一样在任何地方定义,比如在数组中
let classList = [
  class {
    constructor(id) {
      this.id_ = id;
      console.log(`instance ${this.id_}`);
    }
  },
];
function createInstance(classDefinition, id) {
  return new classDefinition(id);
}
let foo = createInstance(classList[0], 3141); // instance 3141

函数差异

class 中定义的方法不能枚举

class User {
  constructor(name) {
    this.name = name;
  }
  show() {
    console.log(this.name);
  }
}
let xj = new User("江雪");
//不会枚举出show属性
for (const key in xj) {
  console.log(key);
}

严格模式

class 默认使用strict 严格模式执行

class User {
  constructor(name) {
    this.name = name;
  }
  show() {
    function test() {
      //严格模式下输出 undefined
      console.log(this);
    }
    test();
  }
}
let xj = new User("向军");
xj.show();

// 函数
function Hd(name) {
  this.name = name;
}
Hd.prototype.show = function () {
  function test() {
    //非严格模式输出 Window
    console.log(this);
  }
  test();
};
let obj = new Hd("后盾人");
obj.show();

// 注意: 在 show 中直接打印 this, 会输出调用的对象。

8.4.3 实例、原型和类成员

类的语法可以非常方便地定义应该存在于实例上的成员、应该存在于原型上的成员,以及应该存在于类本身的成员。

1. 实例成员

有两种方式添加实例成员:一是构造函数内部,二是构造函数执行完毕后。

class Person {
  constructor() {
    // 这个例子先使用对象包装类型定义一个字符串
    // 为的是在下面测试两个对象的相等性
    this.name = new String("Jack");
    this.sayName = () => console.log(this.name);
    this.nicknames = ["Jake", "J-Dog"];
  }
}
let p1 = new Person(),
  p2 = new Person();
p1.sayName(); // Jack
p2.sayName(); // Jack
console.log(p1.name === p2.name); // false
console.log(p1.sayName === p2.sayName); // false
console.log(p1.nicknames === p2.nicknames); // false
p1.name = p1.nicknames[0];
p2.name = p2.nicknames[1];
p1.sayName(); // Jake
p2.sayName(); // J-Dog

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

类定义也支持获取和设置访问器。语法与行为跟普通对象一样:

class Person {
  set name(newName) {
    this.name_ = newName;
  }
  get name() {
    return this.name_;
  }
}
let p = new Person();
p.name = "Jake";
console.log(p.name); // Jake

3. 静态类方法

静态方法通常用于执行不特定于实例的操作。

const data = [
  { name: "js", price: 100 },
  { name: "mysql", price: 212 },
  { name: "vue.js", price: 98 },
];
class Lesson {
  constructor(data) {
    this.model = data;
  }
  get price() {
    return this.model.price;
  }
  get name() {
    return this.model.name;
  }
  //批量生成对象 - 定义在类本身上
  static createBatch(data) {
    return data.map((item) => new Lesson(item));
  }
  //最贵的课程 - 定义在类本身上
  static MaxPrice(collection) {
    return collection.sort((a, b) => b.price - a.price)[0];
  }
}
const lessons = Lesson.createBatch(data);
console.log(lessons);
// [
//   {
//     model: {
//       name: "mysql",
//       price: 212,
//     },
//   },
//   {
//     model: {
//       name: "js",
//       price: 100,
//     },
//   },
//   {
//     model: {
//       name: "vue.js",
//       price: 98,
//     },
//   },
// ];
console.log(Lesson.MaxPrice(lessons).name);
// mysql

类属性

class User {
  job = "工程师";
  constructor(name) {
    this.name = name; // 可以通过外部修改
  }
  show() {
    console.log(this.job + ":" + this.name);
  }
}
let hd = new User("江南");
hd.show();

8.4.4 继承

属性继承

// 函数方式
function User(name) {
  this.name = name;
}
function Admin(name) {
  User.call(this, name);
}
let hd = new Admin("后盾人");
console.log(hd);

类实现继承:与上面的方式等价

class User {
  constructor(name) {
    this.name = name;
  }
}
class Admin extends User {
  constructor(name) {
    super(name);
  }
}
let hd = new Admin("后盾人");
console.log(hd);

8.5 小结

应用

用Class写一个记住用户离开位置的js插件

参考

进阶必读:深入理解 JavaScript 原型

原型基础

8.6 原型的理解

JS 原型其实是一个具有复杂背景的简单事物。

1、ES2019 规范里描述的 Prototype

1.1、prototype 的定义

4.3.5 prototype

object that provides shared properties for other objects

原型是一个对象,一个承担共享属性职责的对象。

当某个对象,承担了为其他对象共享属性的职责时,它就成了其他对象的原型。

当子对象的原型被设置为其他对象,它就失去了这个职能,它也不再是其他对象的原型。

抽象:prototype 描述的是两个对象之间的某种关系,其中一个为另一个提供属性访问权限的关系。

原型 <==> 原型对象

问题:一个对象是如何为另一个对象提供属性访问权限的呢?

1.1.1、所有 object 对象都有一个隐式引用

Every object has an implicit reference (called the object's prototype)

1.jpg

__proto__ 起着隐式引用的链接作用。而所谓的隐式,是指不是由开发者亲自创建/操作的。

1.1.2、历史问题:__proto__

ECMAScript 规范描述 prototype 是一个隐式引用,但之前的一些浏览器,已经私自实现了 __proto__ 这个属性,使得可以通过 obj.__proto__ 这个显式的属性访问,访问到被定义为隐式属性的 prototype。

2.jpg

小结:ECMAScript 规范说 prototype 应当是一个隐式引用:

  • 通过 Object.getPrototypeOf(obj) 间接访问指定对象的 prototype 对象。
  • 通过 Object.setPrototypeOf(obj, anotherObj) 间接设置指定对象的 prototype 对象。
  • 部分浏览器提前开了 __proto__ 的口子,使得可以通过 obj.__proto__ 直接访问原型,通过 obj.__proto__ = anotherObj 直接设置原型。
  • ECMAScript 2015 规范只好向事实低头,将 __proto__ 属性纳入了规范的一部分。

注意:__proto__ 属性既不能被 for in 遍历出来,也不能被 Object.keys(obj) 查找出来。

1.1.3、prototype chain 原型链

a prototype may have a non-null implicit reference to its prototype, and so on; this is called the prototype chain.

原型链的概念,仅仅是在原型这个概念基础上所作的直接推论。

既然 prototype 也是对象,也符合一个对象的基本特征,

也就是说,prototype 对象也有自己的隐式引用,有自己的 prototype 对象。

如此,构成了对象的原型 的原型 的原型 的原型 的链条,直到某个对象的隐式引用为 null,整个链条终止。

1.1.4、属性查找路径

1.2、对象的创建和关联原型

prototype 的概念,跟对象的构造方式和原型关联方式,其实是两个问题。

1.2.1、两类原型继承方式

原型继承就是指,设置某个对象为另一个对象的原型。

在 Javascript 中,有两类原型继承的方式:显示继承和隐式继承。

1.2.1.1、显式原型继承

显式原型继承有两种方式:Object.setPropertyOfObject.create

3.jpg

如上,通过调用 Object.setPrototypeOf 方法,我们将 obj_a 设置为 obj_b 的原型。访问 obj_b.a 时,lookupProperty 过程,先检查 obj_b 是否有 a 属性,没有就检查其原型 obj_a,可以找到 obj_a.a,最后返回 1

4.jpg

Object.setPropertyOfObject.create 的差别在于:

  • Object.setPropertyOf,给我两个对象,我把其中一个设置为另一个的原型。
  • Object.create,给我一个对象,它将作为我创建的新对象的原型。

二者如何选择?

  • 当我们已经拥有两个对象时,要构建原型关联,可以通过 Object.setPrototypeOf 来处理。
  • 当我们只有一个对象,想以它为原型,创建新对象,则通过 Object.create 来处理。

1.2.1.2、隐式原型继承

想要得到一个包含了数据、方法以及关联原型三个组成部分的丰满对象,一个相对具体的步骤如下:

  • 创建空对象
  • 设置该空对象的原型为另一个对象或者 null
  • 填充该对象,增加属性或方法。

不使用原型继承

假设没有隐式原型继承,创建一个普通的 js 对象,要向下面这样:

5.jpg

使用隐式原型继承

所有函数,都有 prototype 属性,它默认是以 Object.prototype 为原型的对象。

7.jpg

注意:prototype 对象的 constructor 属性,指向构造函数。

6.jpg

如上,我们通过 new 去创建 user 对象,可以通过 user.consturctor 访问到它的构造函数。

1.2.2 内置的构造函数和语法糖

2、隐式原型继承和显式原型继承的互操作性

2.1、从隐式原型继承中剥离出 Object.create 方法

2.2、用显式原型继承的方式完成 constructor 初始化过程

3、Prototype-based inheritance VS Class-based inheritance

3.1、从朴素的演化角度理解 class 的产生过程

3.2、从 class 角度理解 prototype

3.3、揭开语法糖包裹的实质

4、从数据结构和算法的角度理解 prototype 和 class

5、class 和 prototype 对 web 开发都不友好

5.1、隐式属性访问让程序更不可靠,也容易带来困惑

5.2、prototype 和 class 不利于体积优化

5.3、prototype/class 不利于代码复用

6、重新思考对象这个概念的必要性

7、真正的设计模式

8、新的概念营造:OOP VS COP

尾声