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

问题:

  1. 如何理解面向对象编程?

  2. 简述你对类的理解

  3. 简述继承的几种方式

8.1 理解对象

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

//Object()构造函数创建对象
let person = new Object();
person.name = "Tom";
person.sayName = function(){}

//对象字面量创建对象
let person={
    name:"Tom".
    sayName(){}
}

8.1.1 属性的类型

​ ECMA-262 使用一些内部特性来描述属性的特性。开发者不能在 JavaScript 开发中直接访问这些内部特性。为了标识内部特性,规范要求用两个中括号把特性的名称括起来,如[[Enumerable]]。对象的属性分为两种:数据属性和访问器属性。

  • 数据属性

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

[[Configurable]]://表示属性是否可以通过delete删除并重新定义,是否可以修改为访问器属性。默认情况下,所有直接定义在对象上的属性的该特性都为true。
[[Enumerable]]://表示属性是否可以通过for-of循环返回。默认情况下,所有直接定义在对象上的属性的该特性都为true。
[[Writable]]://表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的该特性都为true。
[[Value]]://包含属性实际的值,即写入和读取属性值的位置。默认值为undefined。

​ 要修改属性的默认特性,就必须使用Object.defineProperty()方法。接收3个参数,要修改属性的对象,属性的名称,一个描述符对象。描述符对象用于描述该属性的特性设置,可以设置一个或多个属性的特性。

let person = {};
Object.defineProperty(person,"name",{
    writable:false,
    value:"Jack",
    Configurable:false;
})

​ 上述代码表示属性name的值不能被修改了,尝试修改其值的操作在非严格模式下会被忽略,而在严格模式下会抛出错误。此外,由于Configurable:false;说明该属性不能从对象上删除,并且不能再变回可配置的了。再次调用Object.defineProperty()方法将其设置为Configurable:true;会导致错误。

​ 在调用Object.defineProperty()方法时,configurable、enumerable、writable的值如果不指定,则都默认为fasle。

  • 访问器属性

访问器属性不包含数据值。它包含一个 get 函数和一个 set 函数,但非必需。在读取访问器属性时,会调用 getter 函数,返回一个有效值。在写入访问器属性时,会调用 setter 函数并传入新值,修改原来的数据。访问器属性也有4特性描述它们的行为:

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

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

let book ={
    yeat_:2017,
    edition:1
};

Object.defineProperty(book,"year",{
    get(){
        return this.year_;//year_后的下划线用于表示该属性并不希望被在对象方法的外部被访问。
    },
    set(newValue){
        if(newValue>2017){
            this.year_ = newValue;
            this.edition += newValue-2017;
        }
    }
})

// book.year.setter(2018);//不是这样设置,会报错
book.year = 2018; //不用显式调用setter函数,直接设置
console.log(book.year);//2018,不用显式调用getter
console.log(book.year_);//2018
console.log(book.edition);//2
console.log(book); //{ year_: 2018, edition: 2 },原来的值被覆盖,不会呈现访问器属性

//可以自定义访问器属性的名称
let myObj = {
    get a(){
        return this._a_;
    },
    set a(val){
        this._a_ = val;
    }
};

console.log(myObj);//{ a: [Getter/Setter] }
console.log(myObj.a);//undefined
myObj.a = 2;
console.log(myObj.a);//2

8.1.2 定义多个属性

​ ECMAScript提供了Object.defineProperties()方法,用于一次定义多个属性。两个参数,一个需要添加属性的对象,一个属性描述符。

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

​ 在调用Object.defineProperties()方法时,configurable、enumerable、writable的值如果不指定,则都默认为fasle。上述各属性的这几个特性值都是false。

8.1.3 读取属性的特性

​ 使用Object.getOwnPropertyDescriptor()方法可以取得指定属性的属性描述符,接收两个参数,属性所在的对象,属性名。

//8.1.2中的代码创建对象属性
let descriptor = Object.getOwnPropertyDescriptor(book,"year_");
console.log(descriptor);
// {
//     value: 2017,
//     writable: false,
//     enumerable: false,
//     configurable: false
// }

let descriptor = Object.getOwnPropertyDescriptor(book,"year");
console.log(descriptor);
//{
//  get: [Function: get], //此处是一个指向获取函数的指针
//  set: [Function: set],
//  enumerable: false,
//  configurable: false
//}

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

//接上文代码
console.log(Object.getOwnPropertyDescriptors(book));
// {
//     year_: {
//         value: 2017,
//             writable: false,
//             enumerable: false,
//             configurable: false
//     },
//     edition: { value: 1, writable: false, enumerable: false, configurable: false },
//     year: {
//         get: [Function: get],
//         set: [Function: set],
//         enumerable: false,
//             configurable: false
//     }
// }
//

8.1.4 合并对象

​ 合并(merge)对象有时被称作混入(mixin)对象,即将源对象的所有本地属性一起复制到目标对象上。ECMAScript 6 为对象合并提供了Object.assign()方法。这个方法接收一个目标对象和一个或多个源对象作为参数,然后将每个源对象的可枚举属性和自有属性复制到目标对象。

let dest,src,result;

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

console.log(dest === result);//true
console.log(dest !== src);//true
console.log(dest);//{id:'src'},修改了目标对象
console.log(result);//{id:'src'},也返回了结果对象

//多个源对象
dest = {};
result = Object.assign(dest,{a:'foo'},{b:'bar'});
console.log(result);//{ a: 'foo', b: 'bar' }

//获取函数和设置函数
dest ={
    set a(val){
        console.log(`Invoked dest setter with param ${val}`);
    }
};
src = {
    get a(){
        console.log('Invoked src getter');
        return 'foo';
    }
};
myObj = Object.assign(dest,src);//调用src的get方法,获取参数'foo',调用dest的设置方法,并传入参数,这里设置函数不执行赋值操作,因此并没有将值转移过来
//Invoked src getter
//Invoked dest setter with param foo
console.log(dest);//{ a: [Setter] }
console.log(myObj);//{ a: [Setter] }

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

//覆盖属性
let dest,src,result;
dest = {id:'dest'};

result = Object.assign(dest,{id:'src1',a:'foo'},{id:'src2',b:'bar'});
console.log(result);//{ id: 'src2', a: 'foo', b: 'bar' }

//关于设置函数的理解
dest = {
    //将属性id的值设置为x时会执行的函数
    set id(x){
        console.log(x);
    }
}
Object.assign(dest,{id:'first'},{id:'second'},{id:'third'});
// first
// second
// third

//对象引用
dest = {};
src = {a:{}};
Object.assign(dest,src);

console.log(dest);//{ a: {} }
console.log(dest.a === src.a)//true,说明是浅复制,即只会复制对象的引用

​ 赋值期间出错,操作会中止并退出,同时抛出错误。Obhect.assign()没有回滚之前赋值的概念,因此它是一个尽力而为、可能只会完成部分赋值的方法。

let dest,src,result;

dest ={};
src = {
    a:'foo',
    get b(){
        throw new Error();
    },
    c:'bar'
};

try{
    Object.assign(dest,src);
} catch (e){}

console.log(dest);//{ a: 'foo' }

8.1.5 对象标识及相等判定

​ 在ECMAScript 6 之前,有些特殊情况即使全等符号也不能按预期返回,如:

//符合预期的===
true === 1;//false
{} === {};//false
"2" === 2;//false

//不应该相等的情况
+0 === -0;//true
+0 === 0;//true
-0 === 0;//true

//NaN
NaN === NaN;//false
isNaN(NaN);// true

​ 为改善上述情况,ECMAScript 6 新增了 Object.is(),这个方法和 === 很像,但也同时考虑了上述边界情况。

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

//能正确判断+0、-0、0的相等关系
Object.is(+0,-0); //false
Object.is(+0,0); //true
Object.is(-0,0); //false
    
Object.is(NaN,NaN);//true

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

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

8.1.6 增强的对象语法

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

  • 属性值简写
//原来的代码
let name = 'Matt';
let person = {
    name:name
};

//新写法
let name = 'Matt';
let person = {
    name
};

//原来的代码
function makePerson(name){
    return{
        name
    };
}

let person = makePerson("Matt");
console.log(pweson.name);//Matt,虽然name是函数的参数,只限于函数作用域,编译器也会保留person对象上作为属性名的的name

//Google Closure编译器压缩,使函数参数名缩短
function makePerson(a){
    return{
        name:a
    };
}
let person = makePerson("Matt");
console.log(pweson.name);//Matt
  • 可计算属性
//原来的代码
const nameKey = 'name';
let person = {};
person[nameKey] = 'Matt';//不能直接在对象字面量中直接动态命名属性,必须先声明对象,再添加属性

//新写法
const nameKey = 'name';
let person = {
    [nameKey]:'Matt';//动态属性赋值,边创建对象,边添加属性
}

//属性键可以是复杂的表达式,在实例化时再求值
const nameKey = 'name';
const ageKey = 'age';
let uniqueToken = 0;

function getUniqueKey(key){
    return `$(key)_${uniqueToken++}`;
}

let person = {
    [getUniqueKey(nameKey)]:'Matt',
    [getUniqueKey(ageKey)]:'27'
}

console.log(person);//{ '$(key)_0': 'Matt', '$(key)_1': '27' }
  • 简写方法名
//原来的写法
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 Matt

//简写方法名与可计算属性键相互兼容
const methodKey = 'sayName';

let person = {
    [methodKey](name){
        console.log(`my name is ${name}`);
    }
}

person.sayName('Matt');//my name is Matt

8.1.7 对象解构

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

let person ={
    name:'Matt',
    age:27
}

//不使用对象解构
let personname = person.name,//Matt
    personage = person.age;//27

//使用对象解构
let {name:personName,age:personAge} = person;
console.log(personAge);//27
console.log(personName);//Matt

//可以进一步简写
let {name,age} = person;
let {name,job} = person;//对象不存在的属性,值为undefined,即job = undefined
let {name.job = 'software engineer'} = person;//解构过程中能够自定义默认值

​ 解构在内部使用函数ToObject(),将源数据解构转换为对象。这意味着在对象解构的上下文中,原始值会被当做对象,这也意味着null和 undefined 不能被解构。

let {length} = 'foobar';
console.log(length);//6

let {constructor:c} = 4;
console.log(c);//[Function: Number]
console.log(c === Number);//true

let {_} = null;//TypeError
let {_} = undefined;//TypeError

​ 解构并不要求变量必须在解构表达式中声明。不过,如果是给事先声明的变量赋值,则赋值表达式必须包含在一对括号里。

let personName,personAge;
let person ={
    name:'Matt',
    age:27
};

({name:personName,age:personAge} = person);
console.log(personName,personAge);//Matt 27
  • 嵌套解构

​ 解构对于引用嵌套的属性或赋值目标没有限制。因此可用解构来复制对象属性。

let person ={
    name:'Matt',
    age:27,
    job:{
        title:'software engineer'
    }
};

let personCopy = {};

({
    name:personCopy.name,
    age:personCopy.age,
    job:personCopy.job
}=person);

person.job.title = 'Hacker';
console.log(person);//{ name: 'Matt', age: 27, job: { title: 'Hacker' } }
console.log(personCopy);//{ name: 'Matt', age: 27, job: { title: 'Hacker' } }
//person.job是一个对象引用,当它被复制给personCopy.job时,复制的是引用,因此person.job对象的属性被修改后,personCopy.job也会受影响,而name、age则复制的是值,当person.age或person.name变化后不会影响personCopy。

​ 解构赋值可以使用嵌套解构,以匹配嵌套的属性:

let person ={
    name:'Matt',
    age:27,
    job:{
        title:'software engineer'
    }
};

let {job:{title}} = person;
console.log(title);//software engineer

​ 在外层属性没有定义的情况下不能使用嵌套解构,无论是源对象还是目标对象:

let person ={
    name:'Matt',
    age:27,
    job:{
        title:'software engineer'
    }
};

let personCopy = {};

//foo在源对象上是undefined
({
    foo:{
        bar:personCopy.bar
    }
}=person) //TypeError: Cannot read property 'bar' of undefined

//job在目标对象是undefined
({
  job:{
      title: personCopy.job.title
  }
}=person)//TypeError: Cannot set property 'title' of undefined
  • 部分解构

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

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

let personName,personBar,personAge;
try{
    ({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');
//[Arguments] { '0': '1st', '1': { name: 'Matt', age: 27 }, '2': '2nd' }
// Matt 27
printPerson2('1st',person,'2nd');
// [Arguments] { '0': '1st', '1': { name: 'Matt', age: 27 }, '2': '2nd' }
// Matt 27

8.2 创建对象

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

8.2.1 概述

​ ES 5.1 并没有正式支持面向对象的结构,比如类和继承。但是可以巧妙地运用原型式继承可以成功地模拟同样的行为。

​ ES 6 开始正式支持类和继承。ES6 的类旨在完全涵盖之前规范设计的基于原型的继承模式。不过,无论从哪方面看,ES 6 的类都仅仅是封装了 ES5.1构造函数和原型继承的语法糖而已。

​ 在介绍ES6 的类之前,本书将循序渐进地介绍被类取代的那些底层概念。

8.2.2 工厂模式

工厂模式广泛应用于软件工厂领域,用于抽象创建特定对象的过程

function creatPerson(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 = creatPerson("Nicholas",29,"software engineer");
let person2 = creatPerson("Greg",27,"Doctor");

​ 这种工厂模式虽然可以解决创建多个类似对象的问题,但是没有解决对象标识问题(即创建的对象是什么类型)。

8.2.3 构造函数模式

​ ES 中的构造函数用于创建特定类型的对象。Object和Array这样的原生构造函数,运行时可以直接在执行环境中使用,也可以自定义构造函数,以函数的形式为自己的对象类型定义属性和方法。

​ 如上面的例子用构造函数模式可以这样写:

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

​ 相对于creatPerson(),构造函数Person()没有显式创建对象,属性和方法直接赋值给了this,没有return,同时首字母是大写的,按照惯例,构造函数名称的首字母都是要大写的。非构造函数则以小写字母开头。

​ 创建Person的实例,要使用new操作符。这种方式调用构造函数会执行如下操作:

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

​ 上个例子中的 person1 和 person2 分别保存着 Person 的不同实例。这两个对象都有一个 constructor属性指向 Person:

console.log(person1.constructor == Person);//true
console.log(person2.constructor == Person);//true

constructor 本来是用于标识对象类型的,不过,一般认为 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

定义自定义函数可以确保实例被标识为特定类型,相比于工厂模式,这是一个很大的好处。上述例子中 person1 和 person2 之所以也被认为是 Object 的实例,是因为所有自定义对象都继承自 Object。

​ 构造函数不一定要写成函数声明的形式,赋值给变量的函数表达式也可以表示构造函数:

let Person = function(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");

let person3 = new Person;//不想传参数时,括号可以不加
  • 构造函数也是函数

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

//构造函数调用
let person1 = new Person("Nicholas",29,"software engineer");

//普通函数调用
Person("Greg",27,"Doctor");
window.sayName();

//在另一个对象的作用域中调用
let o = new Object();
Person.call(o,"Kristen",25,"Nurse");//将Person函数里的this设置为对象o,即为对象o添加属性
o.sayName();//Kristen
  • 构造函数的问题

构造函数虽然有用,但是也有问题。其主要问题在于,其定义的方法会在每个实例上都创建一遍。前面的例子中,person1 和 person2 都有名为sayName() 的方法,但这两个方法不是同一个 Function 实例。

​ 在ES中,函数是对象,因此每次定义函数时,都会初始化一个对象。上面的构造函数在逻辑上等价于:

let Person = function(name,age,job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = new Function ("console.log(this.name)");
}

let person1 = new Person("Nicholas",29,"software engineer");
let person2 = new Person("Nicholas",29,"software engineer");

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

​ 因为是做一样的事,所以没必要定义两个不同的Function实例。况且,this 对象可以把函数与对象的绑定推迟到运行时(?)。要解决这个问题,可以把函数定义转移到构造函数外部:

let Person = function(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("Nicholas",29,"software engineer");

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

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

8.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

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

1.理解原型

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

​ 在自定义构造函数时,原型对象默认只会获得 constructor属性,其他所有的方法都继承自Object。每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象。脚本中没有访问这个[[Prototype]]特性的标准方式,但Firefox、Safari、Chrome会在每个实例对象上暴露__proto__属性,通过这个属性可以访问对象的原型。实例与构造函数原型之间有直接的关系,但实例与构造函数之间没有。

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

console.log(typeof Person.prototype);//Object
console.log(Person.prototype);//在构造函数没有构造新对象时被当做普通函数,此时返回空对象{},当构造函数构造新对象后,才返回和person1.__proto__一样的对象原型
console.log(person1.__proto__);
// {
//     name: 'Nicholas',
//     age: 29,
//     job: 'software engineer',
//     sayName: [Function (anonymous)]
// }

console.log(Person.prototype.constructor);//[Function: Person]
console.log(person1.constructor);//[Function: Person],可以通过实例访问构造函数

//原型链
console.log(Person.prototype);
console.log(Person.prototype.__proto__);//[Object: null prototype] {}
console.log(Person.prototype.__proto__.__proto__);//null

构造函数、原型对象和实例是3个完全不同的对象。实例通过__proto__链接到原型对象,构造函数通过prototype属性链接到原型对象,实例与构造函数没有直接联系,与原型对象有直接联系。

console.log(Person.prototype.isPrototypeOf(person1));//true
console.log(Object.getPrototypeOf(person1).name);//Nicholas

​ Object 类型还有一个 setPrototypeOf()方法,可以向私有特性[[Prototype]]写入一个新值。这样就可以重写一个对象的原型继承关系。

let biped = {
    numLegs:2
};

let person = {
    name:'Matt'
};

Object.setPrototypeOf(person,biped);//person继承biped

console.log(person);//{ name: 'Matt' }
console.log(person.numLegs);//2
console.log(Object.getPrototypeOf(person) === biped);//true

Object.setPropertypeOf()可能会造成性能下降,可以通过Object.create()来创建一个新对象,同时为其指定原型:

let biped = {
    numLegs:2
};

let person = Object.create(biped);//指定biped为person的原型,此时person为实例对象
person.name = 'Matt';

console.log(person);//{ name: 'Matt' }
console.log(person.numLegs);//2
console.log(Object.getPrototypeOf(person) === biped);//true
2. 原型层级

​ 在通过对象访问属性时,会按照这个属性名称开始搜索。搜索开始于对象实例本身。如果在这个实例上发现了给定的名称,则返回该名称对应的值。如果没有,则搜素会沿着指针进入原型对象,然后在原型对象上找到属性后,再返回对应的值。

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

person1.name = "Greg";
console.log(person1.name);//Greg,来自实例
console.log(person1.hasOwnProperty("name"));//true

console.log(person2.name);//Nicholas,来自原型
console.log(person2.hasOwnProperty("name"));//false

delete person1.name;
console.log(person1.name);//Nicholas,来自原型
console.log(person1.hasOwnProperty("name"));//false

注意:ES 的Object.getOwnPropertyDescriptor()方法只对实例属性有效。要取得原型属性的描述符,就必须在原型对象上调用该方法。

3. 原型和 in 操作符

​ 有两种方式使用in操作符:单独使用和在for-in循环中使用。在单独使用时,in操作符可以通过对象访问指定属性时返回true,无论该属性是在实例上还是在原型上。

console.log("name" in person1);//true

​ 在 for-in 循环中使用 in 操作符时,可以通过对象访问且可以被枚举的属性都会返回,包括实例属性和原型属性。遮蔽了原型中的不可枚举属性([[Enumerable]]特性被设置为 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 p1 = new Person();

let keys = Object.keys(Person.prototype);
console.log(keys);//[ 'name', 'age', 'job', 'sayName' ]

p1.name = "Rob";
p1.age = 31;
let p1keys = Object.keys(p1);
console.log(p1keys);//[ 'name', 'age' ]

​ 要列出所有实例属性,无论是否可枚举,都可以使用Object.getOwnPropertyNames()

let keys = Object.getOwnPropertyNames(Person.prototype);
console.log(keys);//[ 'constructor', 'name', 'age', 'job', 'sayName' ]

​ ES 6 新增符号类型之后,因为以符号为键的属性没有名称的概念,出现了一个Object.getOwnPropertyNames()的兄弟方法Object.getOwnPropertySymbols(),这个方法与Object.getOwnPropertyNames()类似,只是针对符号而已:

let k1 = Symbol('k1'),
    k2 = Symbol('k2');
let o = {
    [k1]:'k1',
    [k2]:'k2'
};

console.log(Object.getOwnPropertySymbols(o));//[ Symbol(k1), Symbol(k2) ]
4. 属性枚举顺序

​ 【个人觉得不重要,需要再补】

8.2.5 对象迭代

​ 有两个静态方法用于将对象内容转化为序列化的可迭代的格式。分别是Object.values()Object.entries(),这两个方法都接收一个对象,返回该对象内容的数组:

const sym = Symbol();
const o ={
    foo:'bar',
    baz:1,
    qux:{},
    [sym]:'foo'
}

console.log(Object.values(o));//[ 'bar', 1, {} ],符号属性被忽略
console.log(Object.entries(o));//[ [ 'foo', 'bar' ], [ 'baz', 1 ], [ 'qux', {} ] ]

console.log(Object.values(o)[0]);//bar
console.log(Object.values(o)[2] === o.qux);//true,{},同一个对象,说明执行的是浅复制
console.log(Object.entries(o)[2][1] === o.qux);//true
1. 其他原型语法

​ 前面的例子中,每次定义一个属性或方法都会把Person.prototype重写一遍。为了减少代码冗余,可以直接将原型写成一个对象字面量:

function Person(){}

Person.prototype = {
    name:"Nicholas",
    age:27,
    job:"Software Engineer",
    sayName(){
        console.log(this.name);
    }
};

let friend = new Person();
console.log(friend instanceof Object);//true
console.log(friend instanceof Person);//true

console.log(friend.constructor ==  Object);//true
console.log(friend.constructor ==  Person);//false
//由于用对象字面量重写了Person的原型,其原型的构造函数不再是Person,而是Object

​ 如果Constructor的值很重要,可以在重写原型对象时专门设置一下它的值:

function Person(){}

Person.prototype = {
    name:"Nicholas",
    age:27,
    job:"Software Engineer",
    sayName(){
        console.log(this.name);
    }
};

Object.defineProperties(Person.prototype,"constructor",{
    enumerable:false,//直接写入字面量里这个属性将是true,与原生属性的设置矛盾
    value:Person
})
2. 原型的动态性
let friend = new Person();

Person.prototype.sayHi = function (){
    console.log("hi");
}

friend.sayHi();//"hi"

​ 当重写原型字面量在创建新实例之后时:

function Person(){}

let friend = new Person();
Person.prototype = {
    name:"Nicholas",
    age:27,
    job:"Software Engineer",
    sayName(){
        console.log(this.name);
    }
};

friend.sayName();//TypeError,因为friend用的是原来的原型,重写后,friend并没有及时与重写后的原型建立联系
3. 原生对象原型

​ 原型模式之所以重要,不仅体现在自定义类型上,而且还因为它也是实现所有原生引用类型的模式。所有原生引用类型的构造函数(包括Object、Array、String等)都在原型上定义了实例方法,如:

console.log(typeof Array.prototype.sort);//"function"
console.log(typeof String.prototype.substring);//"function"

​ 通过原生对象的原型可以取得所有默认方法的引用,也可以给原生类型的实例定义新的方法。可以像修改自定义对象一样修改原生对象原型,因此随时可以添加方法:

String.prototype.startsWith = function (text){
    return this.indexOf(text) === 0;
}

let msg = "Hello world";
console.log(msg.startsWith("Hell"));//true

注意:并不推荐在产品环境中修改原生对象原型。可能重写了原生对象原型的方法,推荐的做法是创建一个自定义的类,继承原生类型。

4. 原型的问题

​ 原型模式也有自己的问题。一是弱化了向构造函数传递初始化参数的能力,导致所有实例都默认会取得相同的属性值。二是(主要的)原型上的所有属性是实例间共享的,这对于包含原始值的属性也还好,但对于包含引用值的属性,可能造成混乱:

function Person(){}

Person.prototype = {
    name:"Nicholas",
    age:27,
    job:"Software Engineer",
    sayName(){
        console.log(this.name);
    },
    friends:["Shelby","Court"]
};

let person1 = new Person();
let person2 = new Person();

person1.friends.push("Van");//多个实例共享一个对象

console.log(person1.friends);//[ 'Shelby', 'Court', 'Van' ]
console.log(person2.friends);//[ 'Shelby', 'Court', 'Van' ],并非期待的结果

8.3 继承

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

8.3.1 原型链

​ ECMA-262把原型链定义为 ECMAScript 的主要继承方式。其基本思想就是通过原型继承多个引用类型的属性和方法。

​ 每个构造函数都有一个原型对象,原型有一个属性指回构造函数,实例有一个内部指针指向原型。实际上,原型是另一个类型的实例,即这个原型本身有一个内部指针指向另一个原型,相应另一个原型也有一个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链。这就是原型链的基本思想。

function SuperType(){
    this.property = true;
}

SuperType.prototype.getSuperValue = function (){
    return this.property;
}

function SubType(){
    this.subproperty = false;
}

SubType.prototype = new SuperType();

SubType.prototype.getSubvalue = function (){
    return this.subproperty;
}

let instance = new SubType();
console.log(instance.getSuperValue());//true

​ 在读取实例的属性时,首先会在实例上搜素这个属性,如果没找到,则会继承搜索实例的原型。在通过原型链实现继承后,搜索就可以继承向上,搜索原型的原型。

1. 默认原型

​ 实际上,默认情况下,所有引用类型都继承自Object,这也是通过原型链实现的。任何函数的默认原型都是一个 Object 的实例。这也是为什么自定义类型能够继承包括 toString()、valueOf()在内的所有默认方法的原因。Object.prototype 对象拥有 toString()、valueOf()、isPrototypeOf()等所有默认方法。

2. 原型与继承关系

​ 原型与实例的关系可以通过两种方式来确定。第一种方式是使用 instanceof 操作符,如果一个实例的原型链中出现过相应的构造函数,则返回 true。第二种方式是使用 ispropertyOf() 方法,原型链中的每个原型都可以调用该方法,只要原型链中包含这个原型,就返回 true。

3. 关于方法

​ 子类有时候需要覆盖原型的方法,或者增加原型的方法。为此,这些方法必须在原型赋值之后再添加到原型上。此外,以对象字面量方式创建原型方法会破坏之前的原型链,因为这相当于重写了原型链。

function SuperType(){
    this.property = true;
}

SuperType.prototype.getSuperValue = function (){
    return this.property;
}

function SubType(){
    this.subproperty = false;
}

SubType.prototype = new SuperType();

//通过对象字面量添加新方法,会导致上一行无效
SubType.prototype = {
    getSubValue(){
        return this.subproperty;
    },
    someOtherMethod(){
        return false;
    }
}

let instance = new SubType();
console.log(instance.getSuperValue());//出错!
4. 原型链的问题

​ 原型链的主要问题出现在原型中包含引用值的时候。即原型中包含的引用值会在所有实例间共享:

function SuperType(){
    this.color = ["red","blue","green"];
}

function SubType(){}

SubType.prototype = new SuperType();

let instance1 = new SubType();
instance1.color.push("black");

let instance2 = new SubType();
console.log(instance2.color);//[ 'red', 'blue', 'green', 'black' ]

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

8.3.2 盗用构造函数

​ 为了解决原型包含引用值导致的继承问题,出现了一种“盗用构造函数”(constructor stealing,又称“对象伪装”、“经典继承”)。基本思路很简单:在子类构造函数中调用父类构造函数。

function SuperType(){
    this.color = ["red","blue","green"];
}

function SubType(){
    SuperType.call(this);//将SuperType函数中有关this的部分调用至此
}

SubType.prototype = new SuperType();

let instance1 = new SubType();
instance1.color.push("black");
console.log(instance1.color);//[ 'red', 'blue', 'green', 'black' ]

let instance2 = new SubType();
console.log(instance2.color);//[ 'red', 'blue', 'green']
1. 传递参数

​ 相比于原型链,盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参。

function SuperType(name){
    this.name = name;
}

function SubType(){
    SuperType.call(this,"Nocholas");//将SuperType函数中有关this的部分调用至此,并将"Nocholas"作为参数传入
    this.age = 29;
}

let instance1 = new SubType();
console.log(instance1.age);//29
console.log(instance1.name);//"Nocholas"
2. 盗用构造函数的问题

​ 盗用构造函数的主要缺点,也是使用构造函数模式自定义类型的问题:必须在构造函数中定义方法,因此函数不能重用(即不能传入实例自己的参数)。此外,子类不能访问原型上定义的方法,因此所有类型只能使用构造函数模式。由于这些问题,盗用构造函数也不能单独使用。

8.3.3 组合继承

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

function SuperType(name){
    this.name = name;
    this.color = ["red","blue","green"];
}

SuperType.prototype.sayName = function (){
    console.log(this.name);
}

function SubType(name,age){
    SuperType.call(this,name);//盗用构造函数
    this.age = age;
}

SubType.prototype = new SuperType();//继承原型中的方法

SubType.prototype.sayAge = function (){
    console.log(this.age);
}

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

let instance2 = new SubType("Greg",27);
console.log(instance2.color);//[ 'red', 'blue', 'green' ]
console.log(instance2.sayName());//Greg
console.log(instance2.sayAge());//27

​ 组合继承弥补了原型链和盗用构造函数的不足,是JavaScript中使用最多的继承模式。而且组合继承保留了 instanceof 操作符和 isPropertyOf()方法识别合成对象的能力。

8.3.4 原型式继承

​ 为了实现即使不自定义类型也可以通过原型实现对象之间的信息共享,Douglas Crockford在2006年给出了一个函数:

function object(o){
    function F(){}
    F.prototype = o;
    return new F();
}

​ 本质上,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("Barable");

console.log(person.friends);//[ 'Shelby', 'Court', 'Van', 'Rob', 'Barable' ]
console.log(anotherPerson);//{ name: 'Greg' }
console.log(yetAnotherPerson);//{ name: 'Linda' }

​ 这种原型式继承适用于这种情况:你有一个对象,想在它的基础上再创建一个新对象。你需要把这个对象先传给 object(),然后再对返回的对象进行适当修改。

​ ES 5 通过增加 Object.creat()方法将原型式继承的概念规范化了。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个参数可选):

let person = {
    name:"Nicholas",
    friends:["Shelby","Court","Van"]
}

let anotherPerson = Object.create(person,{
    name:{
        value:"Greg"
    }
});

console.log(anotherPerson.name);//Greg

​ 原型式继承非常适合不用单独创建构造函数,但需要在对象间共享信息的场合。但要记住,属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。

8.3.5 寄生式继承

​ 与原型式继承比较接近的一种继承方式是寄生式继承(parasitic inheritance),也是 Crockford 首倡的一种模式。寄生式继承背后的思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种方式增强对象,再返回这个对象。基本的寄生继承模式如下:

function createAnother(original){
    let clone = object(original);
    clone.sayHi = function (){
        console.log("hi");
    };
    return clone;
}

​ 使用过程如下:

let person = {
    name:"Nicholas",
    friends:["Shelby","Court","Van"]
}

let anotherPerson = createAnother(person)
console.log(anotherPerson);//{ sayHi: [Function (anonymous)] }

​ 寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。object()函数不是寄生式继承所必须的,任何返回新对象的函数都可以在这里使用。

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

8.3.6 寄生式组合继承

​ 组合继承其实也存在问题。最主要的效率问题就是父类构造函数始终会被调用两次:

function SuperType(name){
    this.name = name;
    this.color = ["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);
}

​ 本质上,子类原型最终是要包含超类对象的所有实例属性,子类构造函数只要在执行时重写自己的原型就行了。但现在在继承时,SubType.prototype作为SuperType()的实例,有namecolor两个属性,而在创建SubType实例时,又会在实例上创建这两个属性,相当于实例上和其原型上都出现了这两个属性,从而导致效率问题。

​ 好在有办法解决这个问题:

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

function SuperType(name){
    this.name = name;
    this.color = ["red","blue","green"];
}

SuperType.prototype.sayName = function (){
    console.log(this.name);
}

function SubType(name,age){
    SuperType.call(this,name);
    this.age = age;
}

inheritPrototype(SubType,SuperType);//只调用了一次SuperType构造函数

SubType.prototype.sayAge = function (){
    console.log(this.age);
}

这种做法效率更高,原型链仍然保持不变,instanceof 操作符和 isPrototypeOf()方法正常有效。寄生式组合继承可以算是引用类型继承的最佳模式。

8.4 类

​ ES 6 新引进的class关键字具有正式定义类的能力。类(class)是ECMAScript中新的基础性语法糖结构。虽然ES 6表面看起来可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念。

8.4.1 类定义

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

//类声明
class Person()

//类表达式
const Animal = class {}

​ 类必须先声明才能引用,同时类的声明不能提升(函数可以):

console.log(ClassDeclaration);//ReferenceError: Cannot access 'ClassDeclaration' before initialization
class ClassDeclaration{}
console.log(ClassDeclaration);//[class ClassDeclaration]

​ 另一个跟函数声明不同的地方是,函数受函数作用域限制,而类受块作用域限制:

{
    class ClassDeclaration{}
    function FunctionDeclaration(){}
}

// console.log(ClassDeclaration);//ReferenceError: ClassDeclaration is not defined
console.log(FunctionDeclaration);//[Function: FunctionDeclaration]

​ 与函数构造函数一样,多数编程风格都建议类名的首字母要大写,以区别通过它创建的实例:

//空类,有效
calss Foo{};

//有构造函数的类,有效
class Bar{
    constructor() {}
}

//有获取函数的类,有效
class Baz{
    get myBaz(){}
}

//有静态方法的类,有效
class Qux{
    static myQux(){}
}

​ 类表达式的名称是可选的。把类表达式

类名和变量名共用一个标识符
class PersonName{
    identify(){
        console.log(PersonName.name,PersonName.name);
    }
}

let p = new PersonName();
p.identify();//PersonName PersonName

console.log(PersonName.name);//PersonName
console.log(PersonName);//[class PersonName]

//类名和变量名用不同的标识符
let Person = class PersonName{
    identify(){
        console.log(Person.name,Person.name);
    }
}

let p1 = new Person();
p1.identify();//PersonName PersonName

console.log(Person.name);//PersonName
console.log(PersonName);//ReferenceError: PersonName is not defined

8.4.2 类构造函数

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

1. 实例化

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

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

class Person{
    constructor() {
        console.log("person ctor");
    }
}

class Vegetable{
    constructor() {
        this.color = 'orange';
    }
}

let a = new Animal();
let p = new Person();//person ctor
let v = new Vegetable();
console.log(v.color);//orange

​ 类实例化时传入的参数会用作构造函数的参数。如果不需要参数,则类名后面的括号可选。

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('jake');//1
console.log(p2.name);//jake

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

class Person{
    constructor(override) {
        this.foo = 'foo';
        if (override){
            return {
                bar:'bar'
            }
        }
    }
}

let p1 = new Person();
console.log(p1 instanceof Person);//true

let p2 = new Person(true);
console.log(p2 instanceof Person);//false,返回的这个对象的原型不是Person.prototype

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

function Person(){}

class Animal{}

let p = Person();//把window作为this来创建实例
console.log(p);//undefined

let a = Animal();//TypeError: Class constructor Animal cannot be invoked without 'new'

​ 类构造函数没什么特殊之处,在实例化后会成为普通的实例方法。实例化之后可以在实例上引用它:

let a = class Animal{};

let b = new a();
let c = new a.constructor();

console.log(a);//[class Animal]
console.log(b);//Animal {}
console.log(c);//[Function: anonymous]

console.log(a.constructor);//[Function: Function]
console.log(b.constructor);//[class Animal]
console.log(c.constructor);//[Function: Function]
2. 把类当成特殊函数

​ ES中没有正式的类这个类型。从各方面看,ECMAScript类就是一种特殊函数。声明一个类后,通过typeof操作符检测类标识符,表明它是一个函数:

let a = class Animal{};

console.log(a);//[class Animal]
console.log(typeof a);//function

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

class a{};

console.log(a.prototype);//{}
console.log(a === a.prototype.constructor);//true

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

class a{};

let b = new a();
console.log(b instanceof a);//true

​ 由此可知,类本身具有和普通构造函数一样的行为。在类的上下文中,类本身使用new调用时就会被当做构造函数。重点在于,类中定义的 constructor()不会被当做构造函数

class a{};

let b = new a();
console.log(b.constructor === a);//true
console.log(b instanceof a);//true
console.log(b instanceof a.constructor);//false

let c = new a.constructor();
console.log(c.constructor === a);//false
console.log(c instanceof a);//false
console.log(c instanceof a.constructor);//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],2324);//instance 2324

let p = new class Foo{
    constructor(x) {
        console.log(x);
    }
}('bar');//bar

console.log(p);//Foo {}

8.4.3 实例、原型和类成员

1. 实例成员

​ 每次通过new调用类标识符时,都会执行类构造函数。在这个函数内部,可以为新创建的实例(this)添加自有属性。每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享:

class Person{
    constructor() {
        this.name = new String('jake');
        this.sayName = ()=> console.log(this.name);
        this.nicknames = ['Jake','J-Dog']
    }
}

let p1 = new Person(),
    p2 = new Person();

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 p1 = new Person();

p1.locate();//instance
Person.prototype.locate();//prototype

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

class Person{
    name:'Jake'
}//SyntaxError: Unexpected identifier

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

const symbolKey = Symbol('symbolKey');

class Person{
    stringKey(){
        console.log('invokedd stringKey');
    }
    [symbolKey](){
        console.log('invoked symbolKey');
    }
    ['computed'+'Key'](){
        console.log('invoked computeKey');
    }
}

let p = new Person();

p.stringKey();//invokedd stringKey
p[symbolKey]();//invoked symbolKey
p.computedKey();//invoked computeKey

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

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 p1 = new Person();

p1.locate();//instance Person { locate: [Function (anonymous)] }
Person.prototype.locate();//prototype {}
Person.locate();//class [class Person]

​ 静态类方法非常适合作为实例工厂函数:

class Person{
    constructor(age) {
        this.age_ = age;
    }
    sayAge(){
        console.log(this.age_);
    }
    static create(){
        return new Person(Math.random()*100)
    }
}

console.log(Person.create());//Person { age_: 38.154767468236074 }
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
console.log(p.greeting);//undefined
console.log(p.name);//Jake
5. 迭代器与生成器方法

​ 类定义方法支持在原型和类本身上定义生成器方法:

class Person{
    //在原型上定义生成器方法
    *createNicknameIterator(){
        yield 'jack';
        yield 'kake';
        yield 'J-Dog';
    }

    //在类上定义生成器方法
    static *createJobIterator(){
        yield 'Butcher';
        yield 'Baker';
        yield 'Candlestick maker';
    }
}

let jobIter = Person.createJobIterator();
console.log(jobIter.next().value);//Butcher
console.log(jobIter.next().value);//Baker
console.log(jobIter.next().value);//Candlestick maker

let p = new Person();
let nicknameIter = p.createNicknameIterator();
console.log(nicknameIter.next().value);//jack
console.log(nicknameIter.next().value);//kake
console.log(nicknameIter.next().value);//J-Dog

​ 因为支持生成器方法,所以可以通过添加一个默认的迭代器,把类实例变成可迭代对象:

class Person{
   constructor() {
       this.nicknames = ['Jake','Jake','J-Dog'];
   }
   *[Symbol.iterator](){
       yield *this.nicknames.entries();
       //也可以不加星号
   }
}

let p = new Person();
for (let [idx,nickname] of p){
    console.log(nickname);
}
// Jake
// Jake
// J-Dog

8.4.4 继承

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

1. 继承基础

​ ES 6 类支持单继承。使用 extends 关键字,就可以继承任何拥有[[Construct]]和原型的对象。很大程度上,这意味着不仅可以继承一个雷,也可以继承普通的构造函数。

class Vehicle{}
class Bus extends Vehicle{}
let b = new Bus();
console.log(b instanceof Bus);//true
console.log(b instanceof Vehicle);//true

function Person(){}
class Engineer extends Person{};
let e = new Engineer();
console.log(e instanceof Engineer);//true
console.log(e instanceof Person);//true

​ 派生类都会通过原型链访问到类和原型上定义的方法。

class Vehicle{
    identifyPrototype(id){
        console.log(id,this);
    }
    static identifyClass(id){
        console.log(id,this);
    }
}

class Bus extends Vehicle{}
let v = new Vehicle();
let b = new Bus();

b.identifyPrototype('bus');//bus Bus {}
v.identifyPrototype('vehicle');//vehicle Vehicle {}

Bus.identifyClass('bus');//bus [class Bus extends Vehicle]
Vehicle.identifyClass('vehicle');//vehicle [class Vehicle]

b.identifyClass('bus');//TypeError: b.identifyClass is not a function
2. 构造函数、HomeObject和super()

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

class Vehicle{
    constructor() {
        this.hasEngine = true;
    }
}

class Bus extends Vehicle{
    constructor() {
        super();//相当于super.constructor()
        console.log(this instanceof Vehicle);//true
        console.log(this);//Bus { hasEngine: true }
    }
}

new Bus();

​ 在静态方法中可以通过super调用继承的类上定义的静态方法:

class Vehicle{
    static identify(){
        console.log('vehicle');
    }
}

class Bus extends Vehicle{
    static identify() {
        super.identify();
    }
}

Bus.identify();//vehicle

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

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

//super只能在派生构造函数和静态方法中使用
class Vehicle{
    constructor() {
        super();
    }//SyntaxError: 'super' keyword unexpected here
}

//不能单独引用super关键字,要么用它调用构造函数,要么用它引用静态方法。
class Vehicle{}
class Bus extends Vehicle{
    constructor() {
        super();
        console.log(this instanceof Vehicle);
    }
}
new Bus();//true

//super()的行为如图调用构造函数,如需给父类构造函数传参,则需要手动传入
class Vehicle{
    constructor(licensePlate) {
        this.licensePlate = licensePlate;
    }
}

class Bus extends Vehicle{
    constructor(licensePlate) {
        super(licensePlate);
    }
}

console.log(new Bus('182123'));//Bus { licensePlate: '182123' }

//如果没有定义类的构造函数,在实例化派生类时会调用super(),而且会传入所有传给派生类的参数
class Vehicle{
    constructor(licensePlate) {
        this.licensePlate = licensePlate;
    }
}

class Bus extends Vehicle{}
console.log(new Bus('182123'));//Bus { licensePlate: '182123' }

//在类构造函数中,不能在调用super()前引用this
class Vehicle{}

class Bus extends Vehicle{
    constructor() {
        console.log(this);
    }
}
//ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
new Bus();

//如果在派生类中显式定义了构造函数,则要么必须在其中调用super(),要么必须在其中返回一个对象。
class Vehicle{}
class Car extends Vehicle{}
class Bus extends Vehicle{
    constructor() {
        super();
    }
}
class Van extends Vehicle{
    constructor() {
        return {};
    }
}

console.log(new Car());//Car {}
console.log(new Bus());//Bus {}
console.log(new Van());//{}
3. 抽象基类

​ 有时候可能需要定义这样一个类,它可供其他类继承,但本身不会被实例化。虽然ECMAScript没有专门支持这种类的语法,但通过new.target也很容易实现。

//抽象基类
class Vehicle{
    constructor() {
        console.log(new.target);
        if (new.target === Vehicle){
            throw new Error('Veehicle cannot be directly instanciated');
        }
    }
}

//派生类
class Bus extends Vehicle{}

new Bus();//[class Bus extends Vehicle]
new Vehicle();//Error: Veehicle cannot be directly instanciated

​ 此外,可以要求派生类必须定义某个方法,这可以通过在抽象基类构造函数中进行检查。

//抽象基类
class Vehicle{
    constructor() {
        if (new.target === Vehicle){
            throw new Error('Veehicle cannot be directly instanciated');
        }
        //constructor里面的this在创建新实例时都指向新实例对象
        if(!this.foo){
            throw new Error('Inheriting class must define foo()');
        }
        console.log('success!');
    }
}

//派生类
class Bus extends Vehicle{
    foo(){}
}

class Van extends Vehicle {}

new Bus();//success!
// new Van();//Error: Inheriting class must define foo()
4. 继承内置类型

​ ES6 类为继承内置引用类型提供了顺畅的机制,可以方便地扩展内置类型:

class SuperArray extends Array{
    shuffle(){
        for (let i=this.length-1;i>0; i--){
            const j = Math.floor(Math.random()*i);
            [this[i],this[j]] = [this[j],this[i]];//值的获取同Pythonlist
        }
    }
}

let a = new SuperArray(1,2,3,4,5);

console.log(a instanceof Array);//true
console.log(a instanceof SuperArray);//true
console.log(a);// [ 1, 2, 3, 4, 5 ]
a.shuffle();
console.log(a);// [ 4, 1, 5, 3, 2 ]

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

class SuperArray extends Array{}

let a = new SuperArray(1,2,3,4,5);
let b = a.filter(x => !!(x%2))//x%2 = 0,转换为false,x%2=1,转换为true,输出结果为true的x组成的数组

console.log(a);// [ 1, 2, 3, 4, 5 ]
console.log(b);//  [ 1, 3, 5 ]
console.log(b instanceof SuperArray);//true,和原始实例类型一致

​ 如果想覆盖这个行为,则可以覆盖Symbol.species访问器,这个访问器决定在创建返回的实例时使用的类。

class SuperArray extends Array{
    static get [Symbol.species](){
        return Array;
    }
}

let a = new SuperArray(1,2,3,4,5);
let b = a.filter(x => !!(x%2))//x%2 = 0,转换为false,x%2=1,转换为true,输出结果为true的x组成的数组

console.log(a);// [ 1, 2, 3, 4, 5 ]
console.log(b);//  [ 1, 3, 5 ]
console.log(a instanceof SuperArray);//true
console.log(b instanceof SuperArray);//false

console.log(a instanceof Array);//true
console.log(b instanceof Array);//true
5. 类混入

​ 可以将不同类的行为集中到一个类。方式不是多类继承(ES6没有显式支持),而是使用Object.assign()方法。该方法在需要混入多个对象的属性时比较适合,但需要混入类的行为时需要自己实现混入表达式。

class Vehicle{}

function getParentClass(){
    console.log('evaluated expression');
    return Vehicle;
}

//extends 后可以跟类或函数或其他任何可以解析为一个类或一个构造函数的表达式
class BUs extends getParentClass(){}

​ 混入模式可以通过在一个表达式中连缀多个混入元素来实现:

class Vehicle{}

let FooMixin = (Superclass) => class extends Superclass{
    foo(){
        console.log('foo');
    }
}

let BarMixin = (Superclass) => class extends Superclass{
    bar(){
        console.log('bar');
    }
}

let BazMixin = (Superclass) => class extends Superclass{
    baz(){
        console.log('baz');
    }
}

class Bus extends FooMixin(BarMixin(BazMixin(Vehicle))) {}

let b = new Bus();
b.foo();//foo
b.bar();//bar
b.baz();//baz

​ 可以添加一个辅助函数,将嵌套调用展开:

function mix(BaseClass,...Mixins){
    return Mixins.reduce((accumulator,current) => current(accumulator),BaseClass);
}
class Bus extends mix(Vehicle,FooMixin,BarMixin,BazMixin){}
posted @ 2021-09-01 16:47  unuliha  阅读(69)  评论(0)    收藏  举报