3w字长文带你从JS的对象一路通关到类

ECMA-262将对象定义为:一组属性的无序集合。 我们可以把JS中的对象想象成一张散列表,其中的内容就是一组键值对,值的类型可以是数据或者函数。

一、理解对象

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

let person = new Object();
person.name = 'Macc';
person.age = 18;
person.sayHi = function(){
    console.log('hi');
}

⭐现在更流行的是对象字面量方式定义对象

//对象字面量
let person = {
    name:'Macc',
    age:16,
    sayHi:function(){
        console.log('hi');
    }
}

上面两个对象的属性和方法都一样,可视为两者是等价的(注意,是等价而不是相同或者说是同一个对象)。

对象的属性都有自己的特征,而这些特征决定了它们在JS中的行为。

(一)属性的类型

ECMA-262用一些内部特性来描述属性的特征。既然是内部特性,也就是说开发者在JS中是不能直接访问这些特性的。

规范使用两个中括号括住特性的名称,以此来标识其为内部特性。例如:[[Enumberable]]

对象的属性分为两种:数据属性和访问器属性。

1.数据属性

数据属性有4个特性描述其行为

特性名称作用默认值
[[Configurable]]表示属性是否可通过 delete 删除并重新定义,是否可以修改属性的特性,是否可以把属性类型改为访问器属性。true
[[Enumberable]]属性是否可以通过 for-in 循环返回。true
[[Writable]]属性的值是否可以被修改。true
[[value]]属性实际值的存放位置,读取和写入属性值的都是操作这个位置。undefined
修改默认(内部)特性

上面提到过,开发者在js中是无法直接访问内部特性的。所以要修改属性的内部特性,必须要使用 Object.defineProperty() 方法。

Object.defineProperty(obj,‘propertyName’,decriptionObj)方法
参数名参数类型描述
objObject要添加/修改属性的对象
propertyNameString要添加或修改的属性的名称
decriptionObjObject描述符对象

描述符对象上的属性可以包含4个内部特性名称(即以内部特性的名称为属性名)。

let person = {};//定义一个person对象
//给对象添加属性
Object.defineProperty(person,'name',{
    writable:false, //属性值不可修改
    value:'Macc'  //属性的实际值
});
console.log(person.name);//访问属性值,输出'Macc'
person.name = 'GaGa';//尝试修改属性的值
console.log(person.name);//输出'Macc'

因为person对象的name属性的内部特性writable被改为false了,表示属性的值不可以被修改,因此后面尝试将其改为GaGa的时候,修改行为被忽略(在严格模式下会报错),依旧输出原值。

若把属性的configurable属性设为false,则不能再改回true,此时再调用Object.defineProperty()方法并修改任何非writable属性都会报错。

调用Object.defineProperty()时,configurable、enumerable、writable的值,若不指定,默认为false

let person = {
    name: 'Macc',
    age: 18
}
Object.defineProperty(person, 'hair', {
    value: 'black',
    //未指定其他值,其他值默认为false
});

let _p = Object.getOwnPropertyDescriptor(person, 'hair');
console.log(_p);

image.png

2.访问器属性

访问器属性不包含数据值。

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

  • 在读取访问器属性时,会调用gettergetter的责任是return一个有效值。
  • 在写入访问器属性时,会调用setter并传入新值,setter决定对数据(属性的值)做什么修改。

访问器属性也有4个特性描述其行为

特性名称作用默认值
[[Configurable]]表示属性是否可通过 delete 删除并重新定义,是否可以修改属性的特性,是否可以把属性类型改为数据属性。true
[[Enumberable]]属性是否可以通过 for-in 循环返回。true
[[Get]]getter,在读取属性时调用。undefined
[[Set]]setter,在写入属性时调用。undefined

⭐修改访问器属性也要使用Object.defineProperty()方法。

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

上面代码就是访问器属性的典型使用场景:设置一个属性的值会导致一些其他变化的发生

gettersetter 不一定都要定义:

  • 只定义getter 意味着属性是只读的,尝试修改属性会被忽略;
  • 只定义setter 在非严格模式下读取属性会返回 undefined

ES5之前没有 Object.defineProperty()方法。

3.同时定义多个属性

使用 Object.defineProperties(obj,descriptionObj)方法

参数名参数类型描述
objObject要添加/修改属性的对象
decriptionObjObject描述符对象
let person = {}
Object.defineProperties(person, {
    name: {
        value: 'Macc'
    },
    age: {
        value: 18
    },
    hair: {
        get() {
            return 'black';
        },
        set(newValue) {
            //....
        }
    }
});

4.读取属性的特性

4.1 读取属性的某个特性

使用方法 Object.getOwnPropertyDescriptor(属性所在对象,属性名称),该方法 return 一个对象。

4.2 读取对象的全部自有属性的特性

使用方法 Object.getOwnPropertyDescriptors(对象),该方法也 return 一个对象,对象包括指定对象的所有自有属性的特性,若对象没有属性,则返回一个空对象。

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

(二)合并对象

把源对象的所欲本地属性一起复制到目标对象上,这种操作叫做合并(merge),也叫做混入(mixin)

合并对象使用的方法是 Object.assign(目标对象,源对象1, ... ,源对象n)
该方法会将源对象中的所有可枚举属性和自有属性复制到目标对象上。

所谓的可枚举属性指的是调用Object.prpertyIsEnumerable()返回true的属性;
所谓的自有属性指的是调用Object.hasOwnProperty()返回true的属性;

复制过程中,会使用源对象上的 [[Get]] 取得属性值,再使用目标对象上的 [[Set]] 设置属性的值。

Object.assign() 执行的是浅复制,只复制对象的引用。

若多个源对象有相同属性,则使用最后一个复制的值(即哪个源对象靠后就使用哪个的值)(覆盖

不能在两个对象之间转移getter函数和setter函数。 从源对象访问器属性中取得的值比如getter函数,会作为一个静态的值赋值给目标对象。

若是在赋值期间出错,则操作中止并退出,抛错。但是该方法不会回滚,它是一个尽力而为,可能只完成部分复制的方法。

(三)对象标识及相等判定

在ES6之前,存在使用全等符(===)也无能为力的情况:

  1. 符合预期的情况如下
表达式结果
true === 1false
{} === {}false
"2" === 2false

2.不同js引擎表现不同,但仍被认为相等

表达式结果
+0 === -0true
+0 === 0true
-0 === 0true
  1. 要确定 NaN 的相等性必须使用 isNaN 函数
表达式结果
NaN === NaNfalse
isNaN(NaN)true

在ES6中新增了方法 Object.is() ,该方法与全等符相似,但是考虑了上述的边界条件。该方法必须接收两个参数。

表达式结果
Object.is(+0,-0)false
Object.is(+0,0)true
Object.is(-0,0)false
Object.is(NaN,NaN)true

如果要使用 Object.is() 检查超多两个值,可以递归的利用相等性实现:

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

console.log(recursivelyCheckEqual(1, 2, 3, 4)); //false

(四)增强的对象语法(语法糖)

1.属性值的简写

简写属性值只要使用变量名就会自动被解释为同名属性键,若是未找到同名的变量,则抛错。

let name = 'Macc',
    age = 18;
let person = {
    name,  //简写
    //下面是以前的写法
    age:age
};
console.log(person);//{name:"Macc",age:18}

2.可计算属性

在引入可计算属性前,如果想使用变量值作为属性(名),必须先声明对象,再使用中括号语法来添加属性。也即是说,不可以在对象字面量中直接动态命名属性。

const nameKey = 'name';
let person = {};//先声明对象
person[nameKey] = 'Macc';//使用中括号语法添加属性

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

let person = {
    [nameKey]:'Macc'
}

可计算属性表达式中抛出任何错误都会中断对象的创建,且不回滚。

const nameKey = 'Macc';
const ageKey = 'age';

let person = {
    [nameKey]: 'Macc',
    [jobKey]: '码农', //这里会出错
    [ageKey]: 18
}
console.log(person);//这里是打印不出来的,因为对象的创建被中断了。

3.简写方法名

在此之前,给对象定义方法的时候,是以下格式:

let person = {
    //方法名 冒号 匿名函数表达式
    sayHi:function(){
        //...
    }
}

现在则是:

let person = {
    sayHi(){
        //...
    }
}

而且,简写方法名与可计算属性相互兼容。

const methodKey = 'sayHi';
let person = {
    [methodKey](name){
        //...
    }
}

(五)对象解构

在一条语句中使用嵌套数据实现一个或多个赋值操作。
简而言之就是,使用与对象 匹配的结构来实现对象属性的赋值。

匹配的结构:就有点对号入座的味道。

  1. 可以使用简写语法
let person = {
    name:'Macc',
    job:'码农'
};
let {name,job} = person;
console.log(name,job);//Macc,码农
  1. 解构赋值不一定与对象的属性匹配(赋值时可以忽略某些属性,无需一一对应)
let {name,age} = person;//无需一一对应
console.log(name,age);//"Macc",undefined
  1. 可在解构赋值的同时设定默认值
let {name,age:18} = person;
console.log(name,age);//"Macc",18

解构在内部使用了 ToObject() 方法把源数据解构转换为对象,也就是说在对象解构上下文中,原始值会被当成对象null和undefined不能被解构,否则报错。

let { _ } = null;//报错
let { _ } = undefined;//报错

image.png

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

let personName,personAge;//事先声明变量
({name:personName,age:personAge} = person);

1.嵌套解构

首先是可以使用解构来复制对象的属性:

let person = {
    name: 'Macc',
    age: 18,
};

let personCopy = {}; //这里这个分号一定要记得加,不然报错

({ name: personCopy.name, age: personCopy.age } = person);
console.log(personCopy);
//{name: 'Macc', age: 18}

然后想一想,假如被复制的属性是个嵌套结构呢?解构还能用吗?答案是可以的,解构赋值可以使用嵌套结构,但是外层属性未定义时,不能使用。

let person = {
    job: {
        title: '码农'
    }
};

let personCopy = {}; //这里这个分号一定要记得加,不然报错

let { job: { title } } = person;
console.log(title); //码农

//foo在源对象上undefined未定义,报错
({ foo: { bar: person.bar } } = person);
//job在源对象上undefined未定义,报错
({ job: { title: person.job.title } } = person);

2.部分解构

如果一个解构表达式设计多个赋值操作,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分

3.参数上下文匹配

在函数参数列表中也可以进行解构赋值,且不会影响到 arguments 对象。

let person = {
    name: 'Macc',
    age: 18
};

function printPerson(foo, { name, age }, bar) {
    console.log(arguments);
}
printPerson('1st', person, '2nd');

image.png

二、创建对象

使用Object构造函数和对象字面量的方式创建对象的不足之处:
创建具有同样接口的多个对象需要重复编写很多代码。

let person1 = {
    name:'Macc',
    age:18,
    sayHi(){
        console.log('hi');
    }
};

let person2 = {
    name:'Deing',
    age:16,
    sayHi(){
        console.log('hi');
    }
}

(一)工厂模式

这是一种设计模式,用于抽象创建特定对象的过程。这个模式后面再详细说它。

function PersonFactory(){
    //创建一个新对象
    let o = new Object();
    //给新对象添加大家共有的属性和方法
    o.sayHi = function (){
        console.log('hi');
    }
    //return 这个对象
    return o;
}
let person1 = PersonFactory();
let person2 = PersonFactory();

person1.sayHi();//'hi'
person2.sayHi();//'hi'

(二)构造函数模式

JS中的构造函数是用于创建特定类型对象的。比如我希望得到一个Person类型的对象person1,那么就是用Person构造函数。

构造函数模式的特点:

  1. 没有显式的创建对象
  2. 属性和方法直接赋值给 this
  3. 没有 return

按照惯例,构造函数的名称的首字母要大写。

//Person类型构造函数
function Person(){
    this.sayHi = function(){
        console.log('hi');
    }
}
//创建Person类型的实例对象
let p1 = new Person();
let p2 = new Person;  //无需传参可以省略括号,new操作符则必不可少。

创建实例要使用 new 操作符,使用 new 调用构造函数会执行如下操作:

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

上面的代码中,p1和p2分别保存着Person的不同实例。p1和p2都有一个 constructor 属性指向Person。

console.log(p1.constructor == Person); //true

constructor 属性是用来标识对象类型的,但是一般认为 instanceof 操作符更可靠。

console.log(p1 instanceof Person);//true
console.log(p1 instanceof Object);//true

所有的自定义对象都是Object的实例,因为所有自定义对象都继承自Object。

构造函数也可以写成函数表达式的形式:

let Person = function(){
    this.sayHi = function(){
        console.log('hi');
    }
}

1.构造函数也是函数

构造函数和普通函数的唯一区别就是调用的方式不同。

任何函数只要使用 new 操作符调用就是构造函数。

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

//这里是全局作用域
var name = 'Macc';
function sayHi(){
    console.log('hi,' + this.name);
}
//调用sayHi函数,注意,此处的sayHi函数是直接调用的,没有作为某个对象的方法被调用(即:没有通过 xxx.sayHi() 这种方式被调用)
sayHi();// 'hi,Macc'

2.构造函数模式的弊端

构造函数的主要问题在于:其定义的方法会在每个实例上都创建一遍。 因此,不同实例上的函数虽然同名但是不相等。

function Person() {
    this.hair = 'black';
    this.sayHi = function() {
        console.log('hi');
    }
}

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

console.log(p1.sayHi === p2.sayHi);//false

但是正常来说,同名函数做的事情是一样的,所以没必要定义两个不同的Function实例。

就像小明和小红(两个实例),他俩的钥匙都丢了,去找开锁师傅,正常来说只需要一个开锁师傅就足够了,没必要给小明专门培养一个开锁师傅,给小红专门培养一个开锁师傅。

要解决这个问题,其中一种思路是:把函数的定义转移到构造函数外部。 然后实例中的方法属性只包含一个指向外部函数的指针,所以实例共享了定义在外部(一般是全局作用域)上的函数。

function Person() {
    this.hair = 'black';
    this.sayHi = sayHi;
}

function sayHi() {
    console.log('hi');
}

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

console.log(p1.sayHi === p2.sayHi);//true

上面的这种思路,虽然解决了相同逻辑的函数重复定义的问题,但是却污染了全局作用域。 因此,我们可以引入原型模式来更好的解决这个问题。

(三)原型模式

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

我们称该对象( prototype 属性)为我们通过调用构造函数创建的对象(实例)的原型。

在原型对象上定义的属性和方法被对象的实例所共享。

1.理解原型

(1)只要创建函数,就会为该函数创建一个 prototype 属性,该属性指向原型对象;

(2)默认情况下,所有的原型对象会自动获得一个 constructor 属性,该属性指回与之关联的构造函数;

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

//创建一个函数
function Person() {}
/*就会为该函数创建一个prototype属性,
 *该属性是个对象,我们称这个属性为原型,这个对象为原型对象
 */
console.log(Person.prototype); //{constructor: ƒ}
//默认情况下,原型对象自动获取一个constructor属性
//该属性指回与之关联的构造函数
console.log(Person.prototype.constructor === Person); //true

image.png

(4)每次调用构造函数创建一个新的实例,实例内部[[Prototype]] 指针会被赋值为构造函数的原型对象。

JS中没有访问 [[Prototype]] 特性的标准方式,但是Firefox、Safari、Chrome会在每个对象上暴露 __proto__ 属性,可以通过该属性来访问对象的原型。

同一构造函数创建的不同实例,共享同一个原型对象。

//创建一个构造函数
function Person() {
    this.hair = 'black';
}

let p1 = new Person(); //创建实例
let p2 = new Person();

//实例内部的[[pPrototype]]指针被赋值为构造函数的原型对象
console.log(p1.__proto__ === Person.prototype); //true

console.log(p1 === p2); //false 说明是不同的实例
//同一构造函数创建的不同实例共享同一个原型
console.log(p1.__proto__ === p2.__proto__); //true

(5)正常的原型链都终止于Object的原型对象,而Object的原型的原型是null。

(6)核心需要理解的一点是:实例和构造函数原型之间有直接关系,但是实例与构造函数之间没有。

这句话我没有理解,实例不是通过调用构造函数产生的吗?为什么说实例与构造函数之间没有关系呢。

想一下前面说到的,通过new操作符调用构造函数的时候执行了什么操作,就理解了。

(7)可以使用 isPrototypeOf() 方法确定两个对象之间的关系(实例和构造函数的关系吧)。

本质上,该方法会在传入参数的 [[Prototype]] 指针指向调用它的对象时返回 true

//构造函数
function Person() {}
//创建实例
let p1 = new Person;
//使用isPrototypeOf方法
console.log(Person.prototype.isPrototypeOf(p1)); //true

⭐(8)JS中Object有一个方法叫做 Object.getPrototypeOf() 会返回参数内部特性 [[Prototype]] 的值。

console.log(Object.getPrototypeOf(p1) === Person.prototype); //true

⭐(9)get和set经常成对出现,所以还有个方法叫 Object.setPrototypeOf(),该方法可以向实例的私有特性 [[Prototype]] 写入一个新的值。这样就就重写了一个对象的原型链关系。

let biped = {
    numLegs: 2
};
let person = {
    name: 'Macc'
};
Object.setPrototypeOf(person, biped);
console.log(person);
console.log(person.numLegs);

image.png

上面的截图可以看出,person对象是没有numLegs属性的,但是通过Object.setPrototypeOf()方法改变了person的原型对象为biped对象,然后顺着原型链,找到了numLegs属性,输出为2。

这个方法可能会严重影响代码的性能, 而且它造成的影响是很深层次的,所以一般不建议使用。

⭐(10)有问题就要解决问题,为了避免使用 Object.setPrototypeOf() 可能造成的性能下降,可以通过使用 Object.create() 方法创建新对象并为其指定原型对象。

let biped = {
    numLegs: 2
};
let person = Object.create(biped)
console.log(person); //{}
console.log(person.numLegs); //2

2.原型层级

这部分字比较多,但是挺好理解的。

原型用于在多个对象实例间共享属性和方法的原理: 在通过对象访问属性时,会按照这个属性的名称开始搜索。

  • 搜索开始于对象实例本身,若在自身发现了对应属性,则返回对应属性的值,停止搜索;
  • 若未发现,则搜索会沿着 [[Prototype]] 指针进入原型对象中搜索,找到则返回值;

上面的过程我们可以得知,我们可以通过实例读取原型对象上的值,但是另外一点就是,我们不能通过实例修改/重写这些值。( 可读不可写

如果在实例上添加了一个与原型对象中同名的属性,则会在实例上创建这个属性,这个属性会遮蔽住原型对象上的属性。(即:只要给对象实例添加一个属性,这个属性就会遮蔽shadow原型对象上的同名属性。虽然不会修改它,但是会屏蔽对它的访问。

即使把实例上的这个属性设置为 null,也恢复不了它和原型的联系,但是,使用 delete 操作符可以完全删除实例上的这个属性,进而恢复搜索过程。

2.1 hasOwnProperty()

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

function Person(){}
Person.prototype.name = 'Macc';

let p1 = new Person();
p1.age = 18;

p1.hasOwnProperty('name');//false
p1.hasOwnProperty('age');//true

这里补充一点前面的知识点:

Object.getOwnPropertyDescriptor() 方法只对实例属性生效。 要取得原型属性的描述符,必须直接在原型对象上调用该方法。

3.原型和in操作符

in 操作符有两种使用方式:单独使用和在 for - in循环中使用。

3.1 单独使用

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

let person = {
    name:'Macc'
};

console.log('name' in person);//true

像下面这样,同时使用 hasOwnProperty()in 操作符可以确定某个属性是否存在于原型上。

function hasPrototypeProperty(obj,name){
    return !obj.hasOwnProperty(name) && (name in obj);
}
//只要 hasOwnProperty 返回 false;
//in 返回 true
//说明该属性是个原型属性

不是很明白这个函数的意义是啥,单独使用 hasOwnProperty() 不是就已经区分出该属性是实例属性还是原型属性了吗?

3.2 for-in循环

for-in 循环中使用时,可通过对象访问 可被枚举的属性都会返回,包括实例属性和原型属性。

遮蔽原型中不可枚举属性([[Enumerable]]特性为false)的实例属性也会返回,因为默认情况下,开发者自定义的属性都是可枚举属性。

(1)要获得对象上所有可枚举实例属性,使用 Object.keys(obj) 方法。

  • 该方法接收一个对象做参数;
  • 返回一个数组, 包含对象所有可枚举属性名称的字符串数组。

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

不可枚举属性:constructor 属性

(3)ES6新增Symbol类型后,因为以Symbol为键的属性没有名称的概念,因此,Object.getOwnPropertySymbols() 方法就出现了,该方法只针对Symbol。

let k1 = Symbol('k1'),
    k2 = Symbol('k2');

let o = {
   //这里就用到了前面说的可计算属性
   [k1]: 'k1',
   [k2]: 'k2',
}

console.log(Object.getOwnPropertySymbols(o)); //[Symbol(k1), Symbol(k2)]

4.属性枚举顺序

(1)for-in 循环、Object.keys()枚举顺序是不确定的

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

  • 先以升序枚举数值键
  • 然后以插入顺序枚举字符串和符号键;

(3)对象字面量中定义的键以它们的逗号分隔的顺序插入;

这个枚举顺序咋体现?不是遍历的先后顺序吗?我用for-in和keys方法也是确定的呀,先数值键再插入顺序,为啥?

(四)对象迭代

ES6新增两个静态方法,用于将对象内容转换为序列化的格式。 这两个方法是 Object.values(obj)Object.entries(obj)。两者都接收一个对象做参数,返回对象内容的 数组

  • Object.values(obj) 返回的是 对象值 的(一维)数组;
  • Object.entries(obj) 返回的是键值对 的(二维)数组;
const o = {
    foo:'bar',
    qux:{},
    baz:1,
};

console.log(Object.values(o)); //['bar',{},1]
console.log(Object.entries(o));
//[['foo','bar'],['qux',{}],['baz',1]];

(1)这两个方法执行的都是浅复制;

const o = {
    foo: 'bar',
    obj: {
        name: 'Macc'
    }
};

let o1 = Object.values(o);
let o2 = Object.entries(o);

console.log(o1[1] === o2[1][1]); //true 浅复制

(2)非字符串属性会被转换为字符串输出;

(3)符号属性会被忽略

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

console.log(Object.values(o)); //[]
console.log(Object.entries(o)); //[]

1.其他原型语法

给原型对象添加属性和方法有2种方式

1.1 直接添加
function Person() {}

Person.prototype.name = 'Macc';
Person.prototype.sayHi = function() {
    console.log('hi');
}
1.2 重写原型

直接通过一个包含所有属性和方法的对象字面量来重写原型。

function Person() {}

Person.prototype = {
    name: 'Macc',
    sayHi: function() {
        console.log('hi');
    }
}

在这种写法中,Person.prototype 被设置为一个新对象,这样的作法有一个问题:Person.prototypeconstructor 属性不再指向构造函数 Person 了。

console.log(Person.prototype.constructor === Person); //false

因为这种写法,完全重写了默认的 prototype 对象,因此其 constructor 属性也指向了完全不同的新对象(Object构造函数)。

虽然使用 instanceof 操作符依然能返回可靠的值,但是却不能再依靠 constructor 属性来识别类型了。

如果 constructor 属性的值很重要,则我们可以在对象字面量中专门设置一下它的值。

function Person() {}

Person.prototype = {
    constructor: Person, //专门设置一下,但是这样也有问题
    name: 'Macc',
    sayHi: function() {
        console.log('hi');
    }
}

用上面的这种方法虽然恢复了 constructor 属性的问题,但是这样仍然有一个问题,那就是现在恢复后的 constructor 属性的枚举特性 [[Enumerable]]true

但是原生的 constructor 属性是不可枚举的,因此我们更可能会在重写原型对象后,再使用 Object.defineProperty() 方法恢复 constructor 属性。

function Person() {}

Person.prototype = {
    name: 'Macc',
    sayHi: function() {
        console.log('hi');
    }
}

Object.defineProperty(Person.prototype, 'constructor', {
    enumerable: false,
    value: Person
})

console.log(Person.prototype.constructor === Person); //true

2.原型的动态性

因为从原型上搜索值的过程是动态的,所以即使实例在修改原型之前就已经存在了,任何时候对原型对象所做的修改也会在实例上反映出来。

function Person() {}
//在修改原型前创建一个实例
let p1 = new Person();
//修改原型
Person.prototype.sayHi = function() {
    console.log('hi');
};
//修改会在实例上反映出来
p1.sayHi(); //hi

虽然随时可以给原型添加属性和方法,并且立即能在所有实例上反映出来,但是修改原型对象和重写原型对象这是两码事。

重写整个原型会切断最初原型与构造函数的联系(因为 constructor 属性变了),但是实例仍然引用的是最初的原型,而最初的原型上不一定有我们定义的方法,进而会引发报错。

function Person() {}
//在修改原型前创建一个实例
let p1 = new Person();
//重写原型
Person.prototype = {
    name: 'Macc',
    sayHi: function() {
        console.log('hi');
    }
};
//这里就会报错
p1.sayHi();

image.png

实例只有指向原型的指针,而没有指向构造函数的指针。

重写原型之后再创建的实例才会引用新的原型。

function Person() {}
//在修改原型前创建一个实例
let p1 = new Person();
//重写原型
Person.prototype = {
    name: 'Macc',
    sayHi: function() {
        console.log('hi');
    }
};
//重写后创建的p2
let p2 = new Person();
p2.sayHi();//'hi'

3.原生对象的原型

所有原生引用类型的构造函数(Array、Object、String等)都在原型上定义了实例方法,通过原生对象的原型可以取得所有默认方法的引用,也可以给原生类型的实例定义新的方法。

但是不推荐这么做:

  • 可能造成命名冲突(因为各浏览器的不同);
  • 可能意外的重写原生的方法;

推荐的做法是:创建一个自定义的类,然后继承原生类型。

4.原型的问题(弊端)

(1) 弱化了向构造函数传初始参数的能力,导致所有实例默认都取得相同的属性值;

(2)原型的最主要问题源于它的共享特性。主要是共享引用值的属性。

function Person() {}

Person.prototype = {
    arrName: ['Macc', 'Deing']
}

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

p1.arrName.push('Gaga');  //修改实例p1的属性,会在p2上反映出来
console.log(p2.arrName); //['Macc', 'Deing', 'Gaga']

但是一般来说,不同的实例应该有属于自己的属性副本。

三、继承

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

接口继承只继承方法签名,这种继承在ECMAScript中是不可能的,因为函数没有签名。

实现继承继承实际的方法,这种继承是ECMAScript唯一支持的继承方式,主要通过原型链实现。

image.png

单看定义可能有点懵,可以对比下面的JS和Java的代码来意会:

image.png

image.png

(一)原型链

ECMA-262把原型链定义为ECMAScript的主要继承方式。

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

1.原型链的基本构想

这里要从构造函数、原型、实例三者的关系出发,每个构造函数都有一个原型对象(prototype),原型有一个属性(constructor)指回构造函数。而实例则有一个内部指针([[Prototype]],__proto__)指向构造函数的原型。

那么,如果原型是另一个类的实例呢? 这就会意味着这个原型本身就有一个内部指针指向另一个原型,相应的另一个原型也有一个指针指向另一个构造函数的原型。

这样就在实例和原型之间构造了一条原型链。

2.默认原型

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

3.原型与继承的关系

原型与实例之间的关系有2种确定方法

3.1 instance 操作符

如果一个实例的原型链中出现过相应的构造函数,则 instance 操作符就会返回 true。

//父类构造函数
function Father() {}
//子类构造函数
function Child() {}
//子类继承父类
Child.prototype = new Father();
//创建一个实例
let Macc = new Child();
//判断实例与原型的关系
console.log(Macc instanceof Child); //true
console.log(Macc instanceof Father); //true
console.log(Macc instanceof Object); //true

分析一下实例 Macc 的原型链:

  • 首先,因为Macc是Child的实例,所以 Macc instanceof Child 返回 true;
  • 然后,Child的原型是Father的实例,也就是说 Child.prototype.constructor === Father 可以找到Father的构造函数,因此 Macc instanceof Father 返回 true
  • 最后,任何函数的默认原型都是Object的实例,所以 Macc instanceof Objecttrue;
3.2 isPrototypeOf 方法

原型链中的每个原型都可以调用这个方法,只要原型链中包含这个原型,该方法就会返回 true

4.关于方法

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

function Father() {
    this.name = 'Macc';
}
Father.prototype.getName = function() {
    return this.name;
}

function Child() {
    this.age = 18;
}
//继承父类
Child.prototype = new Father();
//改写父类的方法
Child.prototype.getName = function() {
    console.log('我没有名字');
};
//添加父类没有的新方法
Child.prototype.getAge = function() {
    console.log(this.age);
};
//创建实例
let Macc = new Child();
Macc.getName(); //我没有名字
Macc.getAge(); //18

上面代码的重点在于:上述的两个方法都是在Child的原型赋值为Father的实例之后才定义修改的。

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

(二)盗用构造函数

盗用构造函数也称为对象伪装、经典继承。

作用:为解决原型包含引用值导致的继承问题。

基本思路:在子类的构造函数中调用父类的构造函数。

function Father() {
    this.colors = ['red', 'blue', 'green'];
}

function Child() {
    Father.call(this); //盗用构造函数
}

let Macc = new Child();
let Deing = new Child();

console.log(Macc.colors === Deing.colors); //false

因为毕竟函数就是在特定上下文中执行代码的简单对象, 所以可以使用 apply或call 方法以新创建的对象(new的执行过程) 为上下文执行构造函数。

上面的代码中,通过使用 call 方法,Father构造函数在为Child的实例创建的新对象的上下文中执行了,这就相当于新的Child对象上运行了Father函数中的所有初始化代码。

结果就是:每个实例都会有自己的属性副本(可以看到Macc和Deing的colors属性已经不相等了)。

1.传递参数

盗用构造函数的一个优点就是:可以在子类的构造函数中向父类构造函数传递参数。

这里只要了解过abc函数 apply\bind\call\ 的使用方法就明白了。

Father.call(this,'Macc'); //传递参数

为了确保父类构造函数不会覆盖子类定义的属性,可以在调用父类构造函数再给实例添加额外的属性。

2.盗用构造函数的问题

盗用构造函数的问题和构造函数模式的问题一样:必须在构造函数中定义方法,因此函数不能被重用。

此外,还有就是子类不能访问父类原型上定义的方法。

3.复习一下

文章有点长,到这里复习一下前面的知识再继续看下面的组合继承效果会更好。

首先,第一节中我们学习了什么是对象,也就是一些与对象有关的概念;

然后,第二节中我们学习了如何创建对象,这里讲了3种模式,分别是

  • 工厂模式
  • 构造函数模式
  • 原型模式

工厂模式我们没有详细讲,略过了,而在构造函数模式中,我们把属性和方法都赋值给 this,然后通过 new 操作符创建实例,但是这种模式的弊端就是:其中定义的方法会在每个实例中都创建一遍,导致不同实例上的函数虽然同名但是不相等。

为了解决这个问题,我们引入了原型模式,我们将一些需要在实例之间共享的属性或方法定义在构造函数的原型上,这样解决了函数重用的问题,但是同时又带来了新的问题,假如某个定义在原型上的属性是引用类型的,那么就会在A实例修改该属性的时候在B实例上也反映出来,可是正常来说,不同的实例应该有属于自己属性副本。

为了让不同实例拥有属于自己的属性副本,又引入了盗用构造函数技术,但是盗用构造函数技术又带来了和构造函数模式一样的问题,函数不能被重用,因此,我们后面会将前面学到的原型模式和盗用构造函数技术的结合起来,也就是下面我们要学习的组合继承。

(三)组合继承

也叫伪经典继承,综合了原型链和盗用构造函数两者的优点。

基本思想:使用原型链继承原型上的属性和方法,通过盗用构造函数继承实例的属性。

function SuperType(name) {
    this.name = name;
    this.colors = ['red', 'blue', 'yellow'];
}

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('Macc', 18);
let instance2 = new SubType('Deing', 16);

console.log(instance1.colors === instance2.colors); //false
console.log(instance1.sayName === instance2.sayName); //true
console.log(instance1.sayAge === instance2.sayAge); //true
instance1.sayAge(); //18
instance2.sayAge(); //16
instance1.sayName(); //Macc
instance2.sayName(); //Deing

这样 既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。

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

(四)原型式继承

原型式继承的出发点是:即使不自定义类型,也可以通过原型实现对象之间的信息共享。

前面的学习可以知道,如果我们想在不同对象之间进行信息共享(属性、方法)的话,要创建一个类型的构造函数,然后把想要共享的信息放在这个构造函数的原型上,通过new操作符调用这个构造函数,创建实例,这样这些实例就可以共享这些属性和方法了。

但是,现在我们不想再额外的创建一个新的构造函数了(类型),却又想在对象之间共享信息,那么这种情况就要用原型式继承了。
下面这个函数就是实现原型式继承的基本思路

function object(o) {
    //临时构造函数
    function F() {}
    F.prototype = o;
    return new F();
}

上面的object函数会创建一个临时构造函数,将传入的对象赋值给这个临时构造函数的原型,然后返回这个临时类型的一个实例。

本质上:object函数是对传入的对象执行了一次浅复制。

let person = {
    name: 'Macc',
    friendsList: ['Deing', 'GTR', 'GaGa'],
};

let anotherPerson = object(person);
anotherPerson.name = 'Greg';
anotherPerson.friendsList.push('Rob');

let yetAnotherPerson = object(person);
yetAnotherPerson.name = 'Linda';
yetAnotherPerson.friendsList.push('Barbie');

console.log(person.friendsList); // ['Deing', 'GTR', 'GaGa', 'Rob', 'Barbie']

上面的代码,person对象定义了另一个对象也应该共享的信息。把它传递给object函数之后会返回一个新对象,这个新对象的原型是person,意味着它的原型上既有原始值属性也有引用值属性,也就是说person的friendsList属性不仅仅是它自己的属性,也会跟
anotherPerson和yetAnotherPerson共享。

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

ES5通过增加 Object.create() 方法将原型式继承的概念规范化。

1.Object.create

该方法接收 2 个参数。

  • 第一个参数:作为新对象原型的对象;
  • 第二个参数:给新对象定义额外属性的对象(可选)

只有一个参数的时候,该方法与上面的object函数效果相同。

let person = {
    name: 'Macc',
    friendsList: ['Deing', 'GTR', 'GaGa'],
};

let anotherPerson = Object.create(person);
anotherPerson.name = 'Greg';
anotherPerson.friendsList.push('Rob');

let yetAnotherPerson = Object.create(person);
yetAnotherPerson.name = 'Linda';
yetAnotherPerson.friendsList.push('Barbie');

console.log(person.friendsList); // ['Deing', 'GTR', 'GaGa', 'Rob', 'Barbie']

Object.create的第二个参数和Object.defineProperties的第二个参数一样:每个新增属性都通过各自的描述符来描述。

let person = {
    name: 'Macc',
    friendsList: ['Deing', 'GTR', 'GaGa'],
};

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

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

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

(五)寄生式继承

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

基本的寄生继承模式如下

//原型式继承
function object(o) {
    function F() {}
    F.prototype = o;
    return new F();
}

function createAnother(original) {
    let clone = object(original); //通过调用函数创建一个新对象
    clone.sayHi = function() { //以某种方式增强这个对象:比如添加方法或属性
        console.log('hi');
    };
    return clone; //返回这个对象
}

上面这段代码中,createAnother函数(创建一个实现继承的函数)接收一个参数,就是新对象的基准对象。这个对象original会被传给object函数,然后将object函数返回的新对象赋值给clone。接着给clone对象添加一个新方法saiHi(以某种方式增强对象),最后返回这个对象(然后返回这个对象)。

object函数不是寄生式继承所必需的,任何返回新对象的函数都可以在这里使用。

下面就是寄生式继承的使用场景

let person = {
    name: 'Macc',
    friends: ['Shelly', 'Court', 'Deing'],
};

let anotherPerson = createAnother(person);
anotherPerson.sayHi(); //hi

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

let p1 = createAnother(person);
let p2 = createAnother(person);

console.log(p1.sayHi === p2.sayHi); //false

(六)寄生组合式继承

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

  • 一次在创建子类原型时调用
  • 一次在子类构造函数中调用

本质上,子类原型最终是要包含超类对象的所有实例属性,子类构造函数只要在执行的时候重写自己的原型就可以了。

寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法

组合继承:使用原型链继承原型上的属性和方法,通过盗用构造函数继承实例的属性。

基本思路就是:不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。

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

下面的代码就是寄生式组合继承的核心逻辑

function inheritPrototype(subType, superType) {
    let prototype = object(superType.prototype); //创建对象
    prototype.constructor = subType; //增强对象,解决由于重写原型导致constructor属性丢失的问题。
    subType.prototype = prototype; //赋值对象
}

下面是寄生式组合继承的使用

//原型式继承
function object(o) {
    function F() {}
    F.prototype = o;
    return new F();
}
//寄生组合式继承
function inheritPrototype(subType, superType) {
    let prototype = object(superType.prototype); //创建对象
    prototype.constructor = subType; //增强对象
    subType.prototype = prototype; //赋值对象
}
//父类
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);
    this.age = age;
}

inheritPrototype(SubType, SuperType);

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

上面的例子中,只调用了一次SuperType的构造函数,避免了子类原型上不必要也用不到的属性,因此可以说这个例子的效率更高。而且,原型链仍然保持不变,因此 instanceofisPrototypeOf 依然正常有效。

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

四、类

ES6新引入的 class 关键字具有正式定义的能力。类是ES6中新的基础性语法糖结构。

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

(一)类的定义

定义类的方式有2种:类声明和类表达式。

两种方式都是使用 class 关键字加大括号。

//类声明
class Person {}
//类表达式
let Animal = class {}

与函数表达式类似,类表达式在它们被求值前也不能被引用。 但是与函数定义不同的是,函数声明可以提升,类定义不能提升。

还有一点与函数不同的是:函数受函数作用域限制,类则受块作用域限制。

1.类的构成

类可以包含 构造函数方法、实例方法、获取函数、设置函数、静态类方法。但是这些都不是必需的, 空的类定义照样有效。

默认情况下,类定义中的代码都在严格模式下执行。

类表达式的名称是可选的。 在把类表达式赋值给变量后,可以通过 name 属性取得类表达式的名称字符串。 但是不能在类表达式作用域外部访问这个标识符。

let Animal = class {};//这里类表达式的名称为空,所以说是可选的。
let Person = class PersonName { //PersonName就是类表达式的名称
    identify() {
        console.log(Person.name, PersonName.name);
    }
}

let p = new Person();

p.identify(); //PersonName PersonName

console.log(Person.name);  //PersonName PersonName
console.log(PersonName); //报错,undefined,类表达式作用域外不能访问PersonName这个标识。

(二)类构造函数

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

方法名 constructor 会告诉解释器在使用 new 操作符创建类的实例的时候,应该调用这个函数。

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

1.实例化

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

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

let p1 = new Person; //0
let p2 = new Person('Macc'); //1

console.log(p1.name); //null
console.log(p2.name); //Macc

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

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

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

class Person {
    constructor(name) {
        console.log(arguments.length);
        this.name = name || null;
        return {
            name: 'GaGa',
            age: 36
        };
    }
}

let p1 = new Person('Macc');

console.log(p1 instanceof Person); //false

类构造函数与构造函数的区别:调用类构造函数必须使用 new 操作符。而普通构造函数如果不使用 new 操作符调用,则以全局的 this (通常是 window) 作为内部对象。

调用类构造函数时如果忘记使用 new 操作符则会抛错

类的构造函数没有什么特殊之处,实例化后,它会成为普通的实例方法。(但是作为类的构造函数,哪怕作为实例方法调用时仍然要使用 new 操作符)。

实例化后可以在实例上引用它(构造函数)

class Person {
    constructor() {
        console.log('调用构造函数');
    }
}
//使用类创建一个实例
let p1 = new Person();  //输出:调用构造函数
//使用对类的构造函数的引用创建一个新的实例
let p2 = new p1.constructor(); //输出:调用构造函数
//未使用new操作符,报错了
let p3 = Person.constructor(); //报错

2.把类当成特殊函数

从各方面看,ECMAScript中的类就是一种特殊的函数。

声明一个类之后,通过 typeof 操作符检测类标识符,表明它是一个函数。

class Person {
    constructor() {
        console.log('调用构造函数');
    }
}
console.log(typeof Person); //function

类标签符也有 prototype 属性,原型上也有一个 constructor 属性指向类自身;

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

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

如前所述,类本身具有与普通构造函数一样的行为。 在类的上下文中,类本身在使用 new 调用时就会被当成构造函数。重点在于,类中定义的 constructor 方法不会被当成构造函数,在对它使用 instanceof 操作符时会返回 false

但是,如果在创建实例时直接将类的构造函数当成普通构造函数来使用,那么 instanceof 操作符的结果会反转。

class Person {}

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

let p2 = new Person.constructor(); //直接将类构造函数当普通构造函数用
console.log(p2.constructor === Person); //false
console.log(p2 instanceof Person); //fasle
console.log(p2 instanceof Person.constructor); //true

上面的例子其实对应的是再上面一点说到的一句话:类本身在使用 new 调用时会被当成构造函数。类中定义的 constructor 方法不会被当成构造函数。 这里要注意的是,在使用 new 的时候,被当作构造函数的是类本身,而不是类中定义的 constructor 方法。

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

类可以像函数一样,在任何地方定义,比如说在数组中。

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], 1433223);

与立即调用函数表达式相似,类也可以立即实例化。

let p = new class Foo {
    constructor(x) {
        console.log(x);
    }
}('bar'); //立即实例化

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

(三)实例、原型、类成员

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

1.实例成员

在类构造函数内部,可以为新创建的实例(this)添加自有属性。在构造函数执行完之后,仍然可以给实例继续添加新成员。

每个实例都对应唯一的成员对象,这一味着所有成员都不会在原型上共享。

class Person {
    constructor() {
        this.name = new String('Macc');
        this.sayName = () => console.log(this.name);
        this.nickname = ['mac', 'MC'];
    }
}

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

console.log(p1.name === p2.name); //false
console.log(p1.sayName === p2.sayName); //false
console.log(p1.nickname === p2.nickname); //false

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 Animal {
    name: 'GTR'
    //报错
}

像上面的这种写法就会报错。

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

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

class Person {
    set name(newName) {
        this.name_ = newName;
    }

    get name() {
        return this.name_;
    }
}

let p1 = new Person();
p1.name = 'Macc';

3.静态类方法

可以在类上定义静态方法。这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。

每个类上只能有一个静态成员。

静态类成员在类定义中使用 static 关键字作为前缀。在静态成员中,this 引用类自身。 其他所有约定跟原型成员一样。

class Person {
    constructor() {
            this.locate = () => console.log('instance');
        }
        //定义在类的原型对象上
    locate() {
            console.log('prototype', this);
        }
        //定义在类本身上
    static locate() {
        console.log('class', this);
    }
}

let p = new Person();
p.locate();
Person.prototype.locate();
Person.locate(); //class, Person {}

静态方法非常适合作为实例工厂。

class Person {
    constructor(age) {
        this.age = age;
    }

    static create() {
        //使用随机年龄创建并返回一个Person实例
        return new Person(Math.floor(Math.random() * 100));
    }
}

console.log(Person.create()); //Person {age: 65}

4.非函数原型和类成员

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

class Person {
    sayName() {
        console.log(`${Person.greeting} ${this.name}`);
    }

    // name:'Macc',  上面也说过这样定义属性是错误的
}
//在类定义外部手动添加
Person.greeting = 'My name is'; //在类上定义数据成员
Person.prototype.name = 'Macc'; //在类原型上定义数据成员

let p = new Person();
p.sayName(); //My name is Macc

为什么类定义中没有显式支持添加数据成员呢?

因为在共享目标(原型和类)上添加可变(可改)的数据成员是一种反模式。

一般来说,对象实例应该独自拥有通过 this 引用的数据。

5.迭代器与生成器方法

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

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

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

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

let p = new Person();
let nicknameIter = p.createNickIterator();
console.log(nicknameIter.next().value);
console.log(nicknameIter.next().value);
console.log(nicknameIter.next().value);

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

class Person {
    constructor() {
        this.nickname = ['Jack', 'Jake', 'J-Dog'];
    }

    *[Symbol.iterator]() {
        yield *this.nickname.entries();
    }
}

let p = new Person();
//把类的实例p变成了可迭代对象
for (let [idx, nickname] of p) {
    console.log(nickname);
}

也可以只返回迭代器实例

class Person {
    constructor() {
        this.nickname = ['Jack', 'Jake', 'J-Dog'];
    }


    [Symbol.iterator]() {
        //只返回迭代器实例
        return this.nickname.entries();
    }
}

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

(四)继承

1.继承基础

ES6支持单继承。 使用 extends 关键字,就可以继承任何拥有 [[Constructor]] 和原型的对象。这意味着不仅可以继承一个类,也可以继承普通的构造函数(保持向后兼容)

//类
class Vehicle {}
//继承类
class Bus extends Vehicle {}

let bus = new Bus();
console.log(bus instanceof Bus); //true
console.log(bus instanceof Vehicle); //true

//构造函数
function Person() {}
//继承普通构造函数
class Chinese extends Person {}

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

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

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');
v.identifyPrototype('vehicle');

Bus.identifyClass('Bus');
Vehicle.identifyClass('Vehicle');

image.png

extends 关键字也可以再类表达式中使用,如 let Bar = class extends Foo{}

2.构造函数、HomeObjectSuper()

派生类的方法可以通过 super 关键字引用它们的原型。

这个关键字只在派生类中使用,而且仅限于类构造函数、实例方法和静态方法内部。

在类构造函数中使用 super 可以调用父类构造函数。

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

class Bus extends Vehicle {
    constructor() {
        //不要再调用super前引用this,否则会报错!!!
        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

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

2.1 super 的使用注意事项

(1) super 只能在派生类的构造函数和静态方法中使用。

(2)不能单独引用 super 关键字,要么用它调用构造函数,要么用它调用静态方法。

(3)调用 super() 会调用父类构造函数,并将返回的实例赋值给 this

(4)super() 的行为如同调用构造函数,如果需要给父类的构造函数传递参数,需要手动传入。

class Vehicle {
    constructor(color) {
        this.color = color;
    }
}

class Bus extends Vehicle {
    constructor(color) {
        super(color); //向父类构造函数传递参数
    }
}

console.log(new Bus('black')); //Bus {color: 'black'}

(5)如果没有定义(子)类构造函数,在实例化派生类时会调用 super(),而且会传入所有传给派生类的参数。

class Vehicle {
    constructor(color) {
        this.color = color;
    }
}

//子类没有定义构造函数
class Bus extends Vehicle {}

console.log(new Bus('black')); //Bus {color: 'black'}

(6)在类构造函数中,不能在调用 super() 前调用 this

(7)如果在派生类中显式定义了构造函数,则要么必须在其中调用 super() ,要么必须在其中返回一个对象。

class Vehicle {}
//car没有定义构造函数
class Car extends Vehicle {}

class Bus extends Vehicle {
    constructor() {
        super(); //要么调用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 实现,new.target 保存通过new关键字调用的类或函数。 通过在实例化时检查 new.target 是不是抽象基类,可以阻止对抽象基类的实例化。

//抽象基类
class Vehicle {
    constructor() {
        console.log(new.target);
        if (new.target === Vehicle) {
            throw new Error('抽象基类不可以实例化');
        }
    }
}

//派生类
class Bus extends Vehicle {}

//实例化派生类
new Bus();
//实例化抽象基类
new Vehicle();

image.png

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

//抽象基类
class Vehicle {
    constructor() {
        if (new.target === Vehicle) {
            throw new Error('抽象基类不可以实例化');
        }

        if (!this.foo) {
            throw new Error('派生类必须定义foo方法');
        }

        console.log('success');
    }
}

//定义了foo方法的正常
class Van extends Vehicle {
    foo() {}
}
//派生类:未定义foo方法,会报错
class Bus extends Vehicle {}

new Van();
new Bus(); //报错

image.png

4.继承内置类型

开发者可以很方便地扩展内置类型。

class SuperArray extends Array {
    shuffle() {
        //洗牌算法
        for (let i = this.length - 1; i > 0; i--) {
            const j = Math.floor(Math.random() * (i + 1));
            [this[i], this[j]] = [this[j], this[i]];
        }
    }
}

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

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

class SuperArray extends Array {}

let a1 = new SuperArray(1, 2, 3, 4, 5);
let a2 = a1.filter(x => !!(x % 2));

console.log(a1);
console.log(a2);
console.log(a1 instanceof SuperArray); //true
console.log(a2 instanceof SuperArray); //true

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

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

let a1 = new SuperArray(1, 2, 3, 4, 5);
let a2 = a1.filter(x => !!(x % 2));

console.log(a1);
console.log(a2);
console.log(a1 instanceof SuperArray); //true
console.log(a2 instanceof SuperArray); //false 注意这里的结果与上面的结果不同

5.类混入

把不同类的行为集中到一个类时一种常见的JS模式。

Object.assign() 方法就是为了混入对象行为而设计的。如果只是需要混入多个对象的属性,那么使用这个方法就可以了。

在下面的代码中,extends 关键字后面是一个JS表达式。 任何可以解析为一个类或一个构造函数的表达式都是有效的。这个表达式会在求值类定义时被求值。

class Vehicle {}

function getParentClass() {
    console.log('求值表达式');
    return Vehicle; //返回一个类
}

class Bus extends getParentClass() {}

混入模式可以通过在一个表达式中连缀多个混入元素来实现,这个表达式最终会被解析为一个可以被继承的类。

如果Person类需要组合A、B、C,则需要某种机制来实现B继承A,C继承B,而Person再继承C,从而把ABC组合到这个超类

一种策略是:定义一组“可嵌套”函数,每个函数分别接收一个超类作为参数,而将混入类定义为这个参数的子类,并返回这个类。 这些组合函数可以连缀调用,最后组合成超类表达式。

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 bus = new Bus();
bus.foo();
bus.bar();
bus.baz();

通过写一个辅助函数,可以把嵌套调用展开。

//辅助函数
function mix(BaseClass, ...Mixins) {
    return Mixins.reduce((accumulator, current) => current(accumulator), BaseClass);
}

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

很多JS框架已经抛弃混入模式, 转向了复合模式。 复合模式就是把方法提取到独立的类和辅助对象中,然后把它们组合起来,但是不使用继承。 这反映了软件设计原则:复合胜过继承。


希望看到这里的你,能有所收获。

完结,撒花…

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值