💙《JavaScript高级程序设计》 | 对象、类与面向对象编程

22 阅读14分钟

8.1 理解对象

创建自定义对象的通常方式是创建 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); 
 } 
};

属性的类型

  • 数据属性

    数据属性有 4 个特性描述它们的行为:

    • [[Configurable]] :表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认值为 true
    • [[Enumerable]] :表示属性是否可以通过 for-in 循环返回。默认值为 true
    • [[Writable]] :表示属性的值是否可以被修改。默认值为 true
    • [[Value]] :包含属性实际的值。
    • [[Configurable]][[Enumerable]][[Writable]] 都会被设置为 true,而**[[Value]]**特性会被设置为指定的值。

    Object.defineProperty():可以修改属性的默认特性。这个方法接收 3 个参数:要给其添加属性的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包含:configurableenumerablewritablevalue,跟相关特性的名称一一对应。根据要修改的特性,可以设置其中一个或多个值。比如:

    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

    访问器属性是不能直接定义的,必须使用 Object.defineProperty()

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

合并对象

“合并”(merge)两个对象,就是把源对象所有的本地属性一起复制到目标对象上。

ECMAScript 6 专门为合并对象提供了 Object.assign() 方法。这个方法接收一个目标对象和一个或多个源对象作为参数,然后将每个源对象中可枚举(Object.propertyIsEnumerable()返回 true)和自有(Object.hasOwnProperty()返回 true)属性复制到目标对象。

let dest, src, result; 
/** d
 * 简单复制
 */ 
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 } 
/** 
 * 多个源对象
 */ 
dest = {}; 
result = Object.assign(dest, { a: 'foo' }, { b: 'bar' }); 
console.log(result); // { a: foo, b: bar }

Object.assign() 实际上对每个源对象执行的是浅复制。如果多个源对象都有相同的属性,则使用最后一个复制的值。

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

对象解构

对象解构语法,可以在一条语句中使用嵌套数据实现一个或多个赋值操作。使用解构,可以在一个类似对象字面量的结构中,声明多个变量,同时执行多个赋值操作。

// 使用对象解构
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

解构赋值不一定与对象的属性匹配。赋值的时候可以忽略某些属性,而如果引用的属性不存在,则该变量的值就是 undefined

let person = { 
 name: 'Matt', 
 age: 27 
}; 
let { name, job } = person; 
console.log(name); // Matt 
console.log(job); // undefined

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

构造函数模式

构造函数是用于创建特定类型对象的。

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

工厂模式和构造函数模式的区别:

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

new 操作符的执行过程:

  1. 在内存中创建一个新对象。
  2. 设置原型,将对象的原型设置为函数的 prototype 对象。
  3. 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。
  4. 执行构造函数内部的代码(给新对象添加属性)。
  5. 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。

构造函数与普通函数唯一的区别就是调用方式不同。任何函数只要使用 new 操作符调用就是构造函数。

原型模式

每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数。

在自定义构造函数时,原型对象默认只会获得 constructor 属性,其他的所有方法都继承自 ObjectFirefoxSafariChrome 会在每个对象上暴露 proto 属性,通过这个属性可以访问对象的原型。

isPrototypeOf():用于测试一个对象是否存在于另一个对象的原型链上。

Object.getPrototypeOf():返回给定对象的原型。

hasOwnProperty():用于确定某个属性是在实例上还是在原型对象上。这个方法是继承自 Object 的,会在属性存在于调用它的对象实例上时返回 true。

原型和 in 操作符:

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

    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(); 
    let person2 = new Person(); 
    console.log(person1.hasOwnProperty("name")); // false 
    console.log("name" in person1); // true
    

    只要 in 操作符返回 truehasOwnProperty() 返回 false,就说明该属性是一个原型属性。

  • for-in 循环中使用

要获得对象上所有可枚举的实例属性,可以使用 Object.keys() 方法。这个方法接收一个对象作为参数,返回包含该对象所有可枚举属性名称的字符串数组。

function Person() {} 
Person.prototype.name = "Nicholas"; 
Person.prototype.age = 29; 
Person.prototype.job = "Software Engineer"; 
Person.prototype.sayName = function() { 
 console.log(this.name); 
}; 
let keys = Object.keys(Person.prototype); 
console.log(keys); // "name,age,job,sayName" 
let p1 = new Person(); 
p1.name = "Rob"; 
p1.age = 31; 
let p1keys = Object.keys(p1); 
console.log(p1keys); // "[name,age]"

hasPrototypeProperty() : 确认属性是否在实例上,属性只存在于原型上,hasPrototypeProperty() 返回 true

Object.keys() :获得对象上所有可枚举的实例属性,接收一个对象作为参数,返回包含该对象所有可枚举属性名称的字符串数组。

Object.getOwnPropertyNames() :返回一个由指定对象的所有自身属性的属性名(包括不可枚举属性但不包括 Symbol 值作为名称的属性)组成的数组。

Object.getOwnPropertyNames() :返回值 一个包含给定对象所有自有的 Symbol 值的属性的数组。

Object.keys()Object.getOwnPropertyNames() 在适当的时候都可用来代替 for-in 循环。

for-in 循环和 Object.keys() 的枚举顺序是不确定的,取决于 JavaScript 引擎,可能因浏览器而异。

Object.getOwnPropertyNames()Object.getOwnPropertySymbols()Object.assign() 的枚举顺序是确定性的。

将对象内容转换为序列化的——更重要的是可迭代的——格式

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

8.3 继承

很多面向对象语言都支持两种继承:接口继承实现继承。前者只继承方法签名,后者继承实际的方法。

8.3.1 原型链

ECMA-262 把原型链定义为 ECMAScript 的主要继承方式。通过原型继承多个引用类型的属性和方法。

默认情况下,所有引用类型都继承自 Object。任何函数的默认原型都是一个 Object 的实例,这意味着这个实例有一个内部指针指向 Object.prototype

原型与继承关系通过两种方式来确定

  • 使用 instanceof 操作符
  • 使用 isPrototypeOf() 方法

原型链的问题

主要问题出现在原型中包含引用值的时候。原型中包含的引用值会在所有实例间共享。在使用原型实现继承时,原型实际上变成了另一个类型的实例。这意味着原先的实例属性摇身一变成为了原型属性。

function SuperType() { 
 this.colors = ["red", "blue", "green"]; 
} 
function SubType() {} 
// 继承 SuperType 
SubType.prototype = new 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,black"

8.3.2 盗用构造函数

为了解决原型包含引用值导致的继承问题,在子类构造函数中调用父类构造函数被称为“盗用构造函数“。函数就是在特定上下文中执行代码的简单对象,所以可以使用 apply()call() 方法以新创建的对象为上下文执行构造函数。

function SuperType() { 
 this.colors = ["red", "blue", "green"]; 
} 
function SubType() { 
 // 继承 SuperType 
 SuperType.call(this); 
} 
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"

盗用构造函数的主要缺点:必须在构造函数中定义方法,因此函数不能重用。此外,子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。由于存在这些问题,盗用构造函数基本上也不能单独使用

8.3.3 组合继承

组合继承(有时候也叫伪经典继承)综合了原型链和盗用构造函数,将两者的优点集中了起来。基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。组合继承弥补了原型链和盗用构造函数的不足,是 JavaScript 中使用最多的继承模式。而且组合继承也保留了 instanceof 操作符和 isPrototypeOf() 方法识别合成对象的能力。

8.3.4 原型式继承

object() 函数会创建一个临时构造函数,将传入的对象赋值给这个构造函数的原型,然后返回这个临时类型的一个实例。本质上,object() 是对传入的对象执行了一次浅复制。来看下面的例子:

let person = { 
 name: "Nicholas", 
 friends: ["Shelby", "Court", "Van"] 
}; 
let anotherPerson = object(person); 
anotherPerson.name = "Greg"; 
anotherPerson.friends.push("Rob"); 
let yetAnotherPerson = object(person); 
yetAnotherPerson.name = "Linda"; 
yetAnotherPerson.friends.push("Barbie"); 
console.log(person.friends); // "Shelby,Court,Van,Rob,Barbie" 
Crockford 推荐的原型式继承适用于这种情况:你有一个对象,想在它的基础

ECMAScript 5 通过增加 Object.create() 方法将原型式继承的概念规范化了。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。在只有一个参数时,Object.create() 与这里的 object() 方法效果相同。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"

8.3.5 寄生式继承

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

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

8.3.6 寄生式组合继承

寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。

使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型。

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

8.4 类

定义类有两种主要方式:

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

类定义不能提升,类受块作用域限制,类表达式的名称是可选的。

类的构成

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

类构造函数

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

构造函数的定义不是必需的,不定义构造函数相当于将构造函数定义为空函数。

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

类构造函数与构造函数的主要区别是:调用类构造函数必须使用 new 操作符。

普通构造函数如果不使用 new 调用,那么就会以全局的 this(通常是 window)作为内部对象。

类标识符有 prototype 属性,而这个原型也有一个 constructor 属性指向类自身:

class Person{} 
console.log(Person.prototype); // { constructor: f() } 
console.log(Person === Person.prototype.constructor); // true

与普通构造函数一样,可以使用 instanceof 操作符检查构造函数原型是否存在于实例的原型链中:

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

8.4.3 实例、原型和类成员

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

  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
    

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

    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. 静态类方法

    静态类成员在类定义中使用 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 {}
    
  4. 非函数原型和类成员

    虽然类定义并不显式支持在原型或类上添加成员数据,但在类定义外部,可以手动添加:

    class Person { 
     sayName() { 
     console.log(`${Person.greeting} ${this.name}`); 
     } 
    } 
    // 在类上定义数据成员
    Person.greeting = 'My name is';
    // 在原型上定义数据成员
    Person.prototype.name = 'Jake'; 
    let p = new Person(); 
    p.sayName(); // My name is Jake
    
  5. 迭代器与生成器方法

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

    class Person { 
     constructor() { 
     this.nicknames = ['Jack', 'Jake', 'J-Dog']; 
     } 
     *[Symbol.iterator]() { 
     yield *this.nicknames.entries(); 
     } 
    } 
    let p = new Person(); 
    for (let [idx, nickname] of p) { 
     console.log(nickname); 
    }
    

8.4.4 继承

虽然类继承使用的是新语法,但背后依旧使用的是原型链。

  1. 继承基础

    ES6 类支持单继承。使用 extends 关键字,就可以继承任何拥有 [[Construct]] 和原型的对象。

    class Vehicle {} 
    // 继承类
    class Bus extends Vehicle {}
    
    function Person() {} 
    // 继承普通构造函数
    class Engineer extends Person {}
    
  2. 构造函数、HomeObject 和 super()

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

    class Vehicle { 
     constructor() { 
     this.hasEngine = true; 
     } 
    } 
    class Bus extends Vehicle { 
     constructor() { 
     // 不要在调用 super()之前引用 this,否则会抛出 ReferenceError 
     super(); // 相当于 super.constructor() 
     console.log(this instanceof Vehicle); // true 
     console.log(this); // Bus { hasEngine: true } 
     } 
    } 
    new Bus();
    

    注意 ES6 给类构造函数和静态方法添加了内部特性 [[HomeObject]] ,这个特性是一个指针,指向定义该方法的对象。这个指针是自动赋值的,而且只能在 JavaScript 引擎内部访问。super 始终会定义为 [[HomeObject]] 的原型。

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

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

  4. 继承内置类型

  5. 类混入

    把不同类的行为集中到一个类是一种常见的 JavaScript 模式。混入模式可以通过在一个表达式中连缀多个混入元素来实现,这个表达式最终会解析为一个可以被继承的类。