JavaScript学习笔记(6):面向对象

对象的属性

对象与属性

创建对象

1、创建一个Object的实例,为它添加属性:

1
2
3
4
5
6
let person = new Object();
person.name = "Dasen Sun";
person.age = 22;
person.sayName = function() {
console.log(this.name);
}

2、对象字面量:

1
2
3
4
5
6
7
let person = {
name: "Dasen Sun",
age: 22,
sayName() {
console.log(this.name);
}
}

以上两种方法创建的对象是等价的,它们有完全相同的属性和方法。

属性的类型

属性分为数据属性访问器属性

1、数据属性:

数据属性有四个特性:

  • [[Configurable]]:是否可配置。表示属性是否可以通过delete删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。
  • [[Enumerable]]:是否可枚举。表示属性是否可以通过for-in循环返回。
  • [[Writable]]:表示属性的值是否可以被修改。
  • [[Value]]:里面包含属性实际的值。它其实就是代表着属性值读取和写入的位置。这个特性的默认值为undefined。

默认情况下,我们通过上述两种创建对象和属性的方法将属性插入到对象之后,这些属性的[[Configurable]][[Enumerable]][[Writable]]都会被设置为true,而[[Value]]特性会被设置为我们指定的值。

如果不想用这些配置项的默认值,如我们可以使用Object.defineProperty()方法这样为对象定义属性:

1
2
3
4
5
let person = {};
Object.defineProperty(person, "name", {
writable: false,
value: "Dasen"
});

该方法的三个参数:需要定义属性的对象、属性名、配置项。

如果属性名已经存在,该方法会修改已有的属性,如:

1
2
3
4
5
6
7
8
9
10
11
let person = {
name: "Dasen"
};
console.log(person); // {name: 'Dasen'}
person.name = "Dasen Sun"; // 修改成功
console.log(person); // {name: 'Dasen Sun'}
Object.defineProperty(person, "name", {
writable: false
});
person.name = "dasen"; // 失败
console.log(person); // {name: 'Dasen Sun'}

注意:属性变为不可配置属性后,再进行删除、配置、修改为访问器属性会静默失败,在严格模式下会抛出错误。一旦不可配置了就再也改不回可配置了。包括尝试写设置为不可写的属性也会静默失败,在严格模式下抛出错误。

2、访问器属性:

访问器属性不包含数据值,反而包含一个getter和一个setter,它们也有四个配置项:

  • [[Configurable]]:同数据属性。
  • [[Enumerable]]:同数据属性。
  • [[Get]]:获取函数,在读取属性时调用。默认值为undefined。
  • [[Set]]:设置函数,在写入属性时调用。默认值为undefined。

访问器属性可以使用Object.defineProperty()来定义,就像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let person = {
name: "大森"
};
Object.defineProperty(person, "nickname", {
get() {
return "可爱的" + this.name;
},
set(newVal) {
if(newVal.slice(0,3) === "可爱的") {
this.name = newVal.slice(3);
} else {
this.name = newVal;
}
}
});
console.log(person.name); // 大森
console.log(person.nickname); // 可爱的大森
person.nickname = "可爱的Dasen";
console.log(person.name); // Dasen
console.log(person.nickname); // 可爱的Dasen
person.nickname = "Dasen Sun";
console.log(person.name); // Dasen Sun
console.log(person.nickname); // 可爱的Dasen Sun

对象字面量中也可以定义访问器属性,只需要把属性写成函数,然后前面标记set或get修饰符即可。

所以当一个属性值是由另一个属性值加工而来,或改变这个属性会引起其他的变化,可使用访问器属性。

注意:

  • 获取函数和设置函数不一定都要定义。只定义getter意味着属性是只读的,尝试修改属性会静默失败,在严格模式下会抛出错误。类似地,只有setter的属性是不能读取的,非严格模式下读取会返回undefined,严格模式下会抛出错误。
  • 上述的四个配置项的“默认值”都只是指上面两种显式定义对象和属性的方法下的默认值,对于Object.defineProperties()定义的属性,未设置的配置项的默认值都默认为undefined(解释为假值)。
  • 数据属性和访问器属性可以互相转换,对一个属性可以多次调用Object.defineProperties(),最后一次调用定义了writable或value中的任意一个,它就会成为数据属性,如果定义了set或get中的任意一个,就会变成访问器属性。

此外使用Object.defineProperties()可以批量定义多个属性,它接受两个参数,分别是要定义属性的对象,和一个描述对象,这个描述对象里每一个属性都是要添加的属性,每个属性值是该对象对应的配置对象。

还有两个方法是Object.getOwnPropertyDescriptor()Object.getOwnPropertyDescriptors(),前者接受两个参数:要查询的对象和属性名,返回这个属性的配置对象,后者只要对象作为参数,返回描述其所有自有属性的配置对象的对象。

合并对象

可枚举属性和自有属性:

  • 可枚举属性:即属性的[[Enumerable]]为true,对可枚举属性使用Object.propertyIsEnumerable()返回true。
  • 自有属性:在对象实例上而不是在原型上的属性,对自有属性使用Object.hasOwnProperty()返回true。

ECMAScript 6专门为合并对象提供了Object.assign()方法。这个方法接收一个目标对象和一个或多个源对象作为参数。

每个源对象中的可枚举和自有属性将会被复制到目标对象。对每个符合条件的属性,这个方法会使用源对象上的[[Get]]取得属性的值,然后使用目标对象上的[[Set]]设置属性的值。

如果目标对象上已有的属性源对象上也有,那么复制过来之后目标对象上的属性会被覆盖。

来看个特殊的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
dest = {
set a(val) {
console.log(`Invoked dest setter with param ${val}`);
}
};
src = {
get a() {
console.log('Invoked src getter');
return 'foo';
}
};
Object.assign(dest, src);
console.log(dest); // { set a(val) {...} }

由于对象合并会通过setter和getter来合并,这里src的内容并不会被合并到dest上是因为:遍历src上的属性,发现只有个访问器属性a,那么调用它的getter来取得值,取到了"foo",然后试图把这个值通过dest的a属性的setter设置到dest上,然后这个setter什么都没做,导致dest并没有发生什么变化。

那么假如:

1
2
3
dest2 = { a:'aaa' };
Object.assign(dest2, src);
console.log(dest2); // {a: 'foo'}

则会将dest2的a属性设置为"foo"

注意:Object.assign()执行的是浅复制。

对象相等

在ECMAScript 6之前,比较相等只能用===操作符,但有些特殊情况即使是===操作符也无法给出合理的结果:

1
2
3
4
5
console.log(+0 === -0); // true
console.log(+0 === 0); // true
console.log(-0 === 0); // true
console.log(NaN === NaN); // false
console.log(isNaN(NaN)); // true,要确定NaN的相等性,必须使用isNaN()

而ES6给出了Object.is()方法用来判断对象的相等性,它可以判断两个对象是否是同一个对象,如:

1
console.log(Object.is({}, {})); // false

此外还处理了正负零和NaN的特殊情况:

1
2
3
4
console.log(Object.is(+0, -0)); // false
console.log(Object.is(+0, 0)); // true
console.log(Object.is(-0, 0)); // false
console.log(Object.is(NaN, NaN)); // true

对象语法糖

属性值简写

如果属性引用同名变量的值,可以简写:

1
2
3
4
5
6
let name = "Dasen";
let age = 22;
let person = {
name,
age
};

可计算属性名

属性名使用中括号包含,可以让JS不把它当成字符串求值,而是表达式,如:

1
2
3
4
5
6
let nameKey = "name";
let ageKey = "age";
let person = {
[nameKey]: "Dasen",
[ageKey]: 22
};

甚至其中可以写更复杂一些的表达式,在对象实例化的时候再求值。

简写方法名

常规的写法:

1
2
3
4
5
6
let person = {
name: "Dasen",
sayName: function () {
console.log(this.name);
}
}

简写:

1
2
3
4
5
6
let person = {
name: "Dasen",
sayName: function () {
console.log(this.name);
}
}

其实之前的get和set函数就已经用过简写的方法了。

简写方法还可以和可计算属性一起用,使得函数名通过表达式计算得到。

这些语法糖都是ES6新增的。

对象解构

简单解构

对象解构是ES6新增的语法糖,用于便捷提取对象中的属性。在之前你可能需要这样:

1
2
3
4
5
let person = {
name: "Dasen",
age: 22
};
let personName = person.name, personAge = person.age;

使用对象解构你可以这样:

1
2
3
4
5
let person = {
name: "Dasen",
age: 22
};
let { name: personName, age: personAge } = person;

即使用

1
{ 属性名: 变量名 } = 对象;

即可从对象中提取出对应的属性值。如果属性名和变量名相同,还可以使用属性简写:

1
2
3
4
5
let person = {
name: "Dasen",
age: 22
};
let { name, age } = person;

可以直接把name和age属性值提取到两个同名变量中。

如果尝试提取不存在的属性:

1
2
3
4
5
let person = {
name: "Dasen",
age: 22
};
let { name, job } = person;

此时提取到的job值为undefined。为了避免有提不到的情况,可以为它加上默认值,当对象中没有该属性时,提取到的是预设的默认值:

1
let { name, job="Software engineer" } = person;

1
let { name: personName, job: personJob="Software engineer" } = person;

此外,对于原始值也可以使用对象解构,此时原始值会被包装为对象,如:

1
2
3
4
let { length } = "Dasen";
console.log(length); // 5
let { constructor: c } = 2;
console.log(c === Number); // true

对于null和undefined是无法解构的。

解构也不要求一定要立刻声明变量立刻解构,可以使用实现已有的变量解构,但此时赋值表达式要包含在一对括号里:

1
2
3
4
5
6
let personName, personAge;
let person = {
name: "Dasen",
age: 22
};
({ name: personName, age: personAge } = person);

嵌套解构

如果对象是嵌套的,要提取更深层的属性,可以使用嵌套解构,如:

1
2
3
4
5
6
7
8
let person = {
name: "Dasen",
age: 22,
job: {
title: "Software engineer"
}
};
let { job: { title } } = person;

这样即可将内层的属性提取到title变量中。

这也为我们复制对象提供了一种思路:

1
2
3
4
5
6
7
8
9
let person = {
name: "Dasen",
age: 22,
job: {
title: "Software engineer"
}
};
let personCopy = {};
({ name: personCopy.name, age: personCopy.age, job: personCopy.job } = person);

此时的复制同样为浅复制,改变person的job对象,personCopy的job对象也会一起改变。

部分解构

涉及多个属性的解构操作是一个从前往后的顺序操作,如果后面的属性解构赋值失败了,前面已经成功的解构赋值仍保留成功的结果,从失败的位置开始后面的解构就都失败了。即部分解构成功。

注意这里的失败是指产生了错误,如对undefined和null进行进一步解构,而非尝试获取不存在的属性值,前面说了尝试获取不存在的属性只是会返回undefined,不会抛出错误,只有对不能解构的undefined和null进行解构,才会抛出错误。

参数匹配

参数匹配其实是在形参处对对象进行解构,如:

1
2
3
4
5
6
7
8
9
function printPerson(info, {name, age}) {
console.log(info, name, age);
}
let person = {
name: "Dasen",
age: 22
};
printPerson("1st", {name:"Dasen", age:22}); // 1st Dasen 22
printPerson("2nd", person); // 2nd Dasen 22

这里使用了属性简写,如果使用名称与属性名不同的形参,不使用简写形式也是一样的。

创建对象

在ES6之前并没有真正支持面向对象特性,但其实是到了ES6,新增的面向对象的特性也只是封装了ES5的构造函数和原型继承的语法糖而已。面向对象的内容主要包括两个方面,一是对象的封装和创建,二是继承。这里讲ES5对象的封装和创建。

工厂模式

面向对象的出现是为了能够把具有类似接口的对象统称为一类,通过类来创建具有某些相同接口的对象。

工厂模式是创建对象的一种方式,它像一间工厂一样接受一些参数,并将其加工成为一个对象:

1
2
3
4
5
6
7
8
9
10
11
function createPerson(name, age) {
let o = new Object();
o.name = name;
o.age = age;
o.sayName = function () {
console.log(this.name);
};
return o;
}
person = createPerson("Dasen", 22);
console.log(person); // {name: 'Dasen', age: 22, sayName: ƒ}

这样可以看到,通过createPerson函数接收姓名和年龄的值,它返回我们一个具有姓名和年龄属性的对象,就可以认为我们通过这样的工厂函数,能够创建Person类的对象。

工厂模式的优点是:实现了能够创建出包含了指定属性、具有统一接口的某一类对象;但是它的弊端是:我们无法判断这样创建出的一个对象属于什么类。

构造函数模式则可以解决这一问题。

构造函数模式

ES中的构造函数是为了创建特定类型对象的,如Object、Array这样的原生构造函数。当然我们也可以定义自己的构造函数,以构造函数形式为我们的自定义类定义属性和方法:

1
2
3
4
5
6
7
8
9
function Person(name, age) {
this.name = name;
this.age = age;
this.sayName = function () {
console.log(this.name);
};
}
let person = new Person("Dasen", 22);
console.log(person); // {name: 'Dasen', age: 22, sayName: ƒ}

这样的函数就是构造函数,看起来和工厂模式中的createPerson函数很像,但是有如下区别:

  • 函数里没有显式创建对象;
  • 属性和方法直接赋值给了this;
  • 没有返回内容。

它之所以能够创建出对象就是因为它使用new操作符来调用了,一个函数使用new操作符来调用,那它就是构造函数,不使用new操作符而是直接调用,那它就是普通函数,也就是说构造函数的定义本身其实就没什么区别,区别只在于是否使用new来调用了,从而才表现出不同的行为,那么如果对这个构造函数不使用new操作符调用呢?如:

1
2
3
let person = Person("Dasen", 22);
console.log(person); // undefined
console.log(window.name, window.age); // Dasen 22

可以看到person接收函数Person的返回值接收了个寂寞,name和age的值被绑定到了全局对象上,是因为把Person当普通函数调用时,其this的值取调用环境的this,即全局的this。

所以new操作符做了什么呢?至少我们可以看出它至少处理了函数里this的指向,实际上,new调用构造函数的过程是这样的:

  • 在内存中新建一个对象;
  • 这个新对象内部的[[Prototype]]特性被赋值为构造函数的prototype属性,对象的constructor属性被赋值为它的构造函数;
  • 构造函数内的this被重置为这个新对象;
  • 执行构造函数内的代码;
  • 如果构造函数有返回非空对象,那么按返回语句返回,如果没有返回符合条件的对象,则返回刚刚创建的新对象。

这就解释了同一个函数通过new调用和不通过new调用的区别了。

由于这样产生的对象具有[[Prototype]]特性和constructor属性,也就是说对象与类(或者说构造函数)建立起了联系,那么判断一个对象属于哪个类就有了可能,有以下两种方法:

1
2
console.log(person.constructor === Person); // true
console.log(person instanceof Person); // true

构造函数模式的优点是:能够判断创建的对象属于哪个类;但是它的弊端是:其定义的方法在所有的对象上都创建一遍,浪费了内存的空间,而对于实际使用中,同一类的对象应当具有一些相同的方法,并且这些方法应当是公用的,没有必要为每个实例保留方法的副本。

要解决这一问题可以将方法定义在全局作用域上,所有的实例都去绑定这一个公共的方法:

1
2
3
4
5
6
7
8
9
10
function Person(name, age) {
this.name = name;
this.age = age;
this.sayName = sayName;
}
function sayName() {
console.log(this.name);
};
let person = new Person("Dasen", 22);
person.sayName();

这确实解决了问题但不够完美,因为这样会使得同一个类的代码无法聚拢在一起,反而分散在了全局作用域上。

原型模式则可以完美解决这一问题。

原型模式

每个函数都会拥有一个prototype属性,这个属性是一个对象,而在使用new操作符创建了新对象时,这个对象还会绑定在新对象的[[Prototype]]上。实际上,这个对象就是通过调用构造函数创建的对象的原型

使用原型模式创建对象的方法如下:

1
2
3
4
5
6
7
8
9
10
11
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.sayName = function () {
console.log(this.name);
};
let person1 = new Person("Dasen", 22);
let person2 = new Person("Three Zhang", 18);
person1.sayName();
person2.sayName();

这里的Person里根本没有sayName方法,反而是为它的原型对象上添加了这个方法,而原型对象上的属性和方法是这个类所有的对象实例所共有的,因此两个对象都能够调用这个方法。

原型模式不会使得方法污染全局作用域,很好地解决了类的实例间共享方法的问题。在实际使用中,我们只要在构造函数中创建每个实例自己的属性,然后在原型中定义各个实例公共的方法,就可以很好地使用原型来模拟出类的行为了。

原型

原型对象

原型是什么

关于原型:

  • 无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个prototype属性指向原型对象;
  • 默认情况下,所有原型对象自动获得一个名为constructor的属性,指回与之关联的构造函数,构成循环引用,即Person.prototype.constructor指向Person;
  • 可以给原型对象添加其他属性和方法,以实现所有实例间的共享;
  • 每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象,脚本中没有访问这个[[Prototype]]特性的标准方式,但Firefox、Safari和Chrome会在每个对象上暴露__proto__属性,通过这个属性可以访问对象的原型;
  • 在访问一个对象的某个名称时,如果在对象中找不到就会查找它原型上是否有这个名称,如果还找不到,就一直顺着原型链往上找,因此如果对象中已经有了这个名称,就不会找原型链上的了,我们说对象上的名称会遮蔽原型链上的同名名称,要解除遮蔽可以使用delete操作符删除掉对象上的这个名称;
  • 正常的原型链都会终止于Object的原型对象,Object的原型的原型是null;
  • instanceof本质上是检查实例的原型链中包不包含指定构造函数的原型。

关于原型的方法

对于没有暴露实例上__proto__属性的引擎,还可以使用isPrototypeOf()方法确定两个对象之间的原型关系,也可以使用Object.getPrototypeOf()方法来获取对象的原型

Object类型还有一个setPrototypeOf()方法,可以向实例的私有特性[[Prototype]]写入一个新值。这样就可以重写一个对象的原型继承关系,但是Object.setPrototypeOf()可能会严重影响代码性能,因此不要使用。

要判断一个属性是在自身还是在原型链上,可以通过对象的hasOwnProperty()方法和in操作符来判断:

  • 当属性可以通过对象访问到,in操作符返回true;
  • 当属性是自身的属性,hasOwnProperty()方法返回true。

那么通过这两个判断的组合条件就可以实现判断属性是在自身还是在原型链上。

迭代对象的属性

迭代属性的方法

在for-in循环中使用in操作符时,可以通过对象访问(包括实例属性和原型属性,但不包括被遮蔽的原型属性)且可以被枚举的属性都会被枚举。还有其他一些方法:

  • Object.keys()方法:获得对象上(不包括原型上)所有可枚举的属性;
  • Object.getOwnPropertyNames()方法:获得所有对象上(不包括原型上)属性,包括不可枚举的;
  • Object.getOwnPropertySymbols()方法:同上,但针对的是符号属性。

这些方法的返回结果都不包括原型,所以这么看来其实如果要枚举到原型和自身的所以属性,还是要使用for-in循环。

在ES2017中又添加了两个方法用于迭代对象的内容:

  • Object.values()返回对象值的数组;
  • Object.entries()返回键/值对的数组。

注意非字符串形式的属性名会被转换为字符串输出,符号键将会被直接忽略不输出。

属性的枚举顺序

常见方法的属性枚举顺序:

  • for-in循环和Object.keys()的枚举顺序是不确定的,取决于JavaScript引擎,可能因浏览器而异;
  • Object.getOwnPropertyNames()Object.getOwnPropertySymbols()Object.assign()的枚举顺序是确定性的:
    • 先以升序枚举数值键;
    • 然后以插入顺序枚举字符串和符号键(在对象字面量中定义的键的插入顺序看作根据逗号分隔的顺序)。

字面值重写原型

之前的修改原型的方法都是为原型对象上逐个加上属性和方法,为了在视觉上更好封装原型的功能,可以使用字面量对象来重写原型:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype = {
sayName() {
console.log("我的名字叫", this.name);
},
sayAge() {
console.log("我今年", this.age, "岁了");
}
}
let person = new Person("Dasen", 22);

但这里的问题是,这样重写之后,Person.prototype的constructor属性就不指向Person了,因为这个新的对象完全重写了Person.prototype。这样会导致的后果是instanceof操作符还能可靠地返回值(因为person的原型还是和Person的原型相同),但我们不能再依靠constructor属性来识别类型了:

1
2
console.log(person instanceof Person); // true
console.log(person.constructor === Person); // false

如果这个constructor非常重要的话,可以在原型对象的字面量里显式地指定这个属性为Person,但要注意这样会使得这个constructor属性可迭代,所以更符合它原本行为的方式是使用Object.defineProperty()来添加这个属性。

原型的动态性

由于JS中对象实际上是对象的引用,所以使用构造函数创建的对象,在构造函数将原型对象给创建的对象时,构造函数和创建的对象的原型对象实际上是同一个对象,所以原型是动态的,即:改变构造函数的原型对象的属性和方法,那么所有的改动都会反映到所有使用该构造函数创建的对象上(除非创建的对象或者构造函数又重写了其整个原型对象,使得原型对象不再是之前那个对象了)。

原生的对象如Array也是通过原型模式来定义的,因此我们可以像修改我们的自定义对象一样给原生的类型的原型对象上添加新的属性和方法,或者重写已有的属性和方法,那么这些原生对象就能获得我们赋予它的特性,但是实际使用中不应当这么做。

继承

原型链

原型链的继承方式

由于一个对象可以访问到它原型上的属性和方法,那么如果它的原型对象又是另一个构造函数创建的实例,那么如此连接起来,就构成了一个原型链,对象可以顺着原型链层层向上访问属性和方法,就实现了类的继承。

使用原型链实现继承的方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function SuperType() {
this.property = "SuperType";
}
SuperType.prototype.getSuperValue = function () {
return this.property
}
function SubType() {
this.subproperty = "SubType";
}
SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function () {
return this.subproperty;
}
let sub = new SubType();
console.log(sub.getSuperValue(), sub.getSubValue()); // SuperType SubType
console.log(sub.property, sub.subproperty); // SuperType SubType

可以看到原型链继承的实现方式为:

  • 创建一个父类的构造函数,设置父类的原型方法;
  • 创建一个子类的构造函数,创建一个父类的实例作为子类的原型对象,设置子类的原型方法;
  • 如果有更下一级的子类可以以同样的方式继续继承;
  • 完成继承,可实例化子类,看到子类实例拥有父类的方法和属性。

要注意为子类添加方法必须要在“将父类实例绑定为子类原型对象”之后进行,否则先设置子类方法再绑定父类实例的话,后绑定的父类实例会把之前添加的子类方法覆盖掉。

原型与继承关系

确定继承关系可以使用instanceof操作符,如对于上面的例子:

1
2
3
console.log(sub instanceof SuperType); // true
console.log(sub instanceof SubType); // true
console.log(sub instanceof Object); // true

或者使用原型对象的isPrototypeOf()方法:

1
2
3
console.log(SuperType.prototype.isPrototypeOf(sub)); // true
console.log(SubType.prototype.isPrototypeOf(sub)); // true
console.log(Object.prototype.isPrototypeOf(sub)); // true

由于所有的对象都默认继承于Object类,所以所有对象对于Object类测试继承关系都返回true,即Object是所有对象的超类。

原型链继承的弊端

原型链继承会直接把父类的一个实例作为原型对象,本来是一个对象的实例,这时候却成了子类的原型,它上面的方法和属性都成了子类的公共方法,这好吗?这不好。

我们希望的是方法会被继承过来,属性不继承,或者属性继承过来也行,但是不应该成为所有实例的公共属性,如果这个成为公共属性的属性为数组之类的对象,意味着其中一个实例修改了这个属性,其他的实例访问的也会是修改过的内容,这不符合逻辑,我们至少应该让这些实例都有一个对应属性的副本,而非公共属性。

盗用构造函数

盗用构造函数的继承方式

盗用构造函数就是不直接把父类实例作为原型对象了,因为这样的话实例上的属性就会成为原型属性被共享,这时候要盗用父类的构造函数,将父类构造函数的初始化过程在子类里执行一遍,把父类拥有的属性都初始化到子类对象上,使得每个子类的实例都有一份属性的副本,原型链继承的弊端就被解决了。

而这个盗用构造函数则是使用apply()call()方法以新创建的对象为上下文来执行父类的构造函数,具体如下:

1
2
3
4
5
6
7
8
9
10
11
function SuperType() {
this.names = ["Dasen", "Dasen Sun"];
}
function SubType() {
SuperType.call(this);
}
let names1 = new SubType();
let names2 = new SubType();
console.log(names1.names, names2.names); // ['Dasen', 'Dasen Sun'] ['Dasen', 'Dasen Sun']
names1.names.push("Sadose");
console.log(names1.names, names2.names); // ['Dasen', 'Dasen Sun', 'Sadose'] ['Dasen', 'Dasen Sun']

可以看到这样会使得每个实例有属于自己的父类属性的副本,父类实例的属性不会直接成为子类的公共属性了。

这样还有一个好处就是在盗用父类的构造函数时可以使用call来对父类的构造函数传递参数了。

盗用构造函数的弊端

其弊端主要为子类继承不了父类原型上的方法,因此如果要使用盗用构造函数的继承方式,就得一开始就使用这种方式,而不能先用原型链继承一级,再用盗用构造函数继承一级。

而如果从一开始就使用盗用构造函数,意味着方法无法重用了,每个子类对象都有父类的方法的副本,又开始浪费内存了。

还有一个问题就是这种方式继承下来的子类对象,在使用instanceof判断的时候,会被认为不是父类的实例。

组合继承

组合继承又叫伪经典继承,它综合了原型链和盗用构造函数,将两者的优点集中了起来。

它的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function SuperType(name) {
this.name = name;
if(arguments.length > 0) {
this.friends = [...arguments];
this.friends.shift();
} else {
this.friends = [];
}
}
SuperType.prototype.getFriends = function () {
console.log(this.friends.join(", "));
};
function SubType(name, age) {
let friends = Array.prototype.slice.call(arguments, 2);
SuperType.call(this, name, ...friends);
this.age = age;
}
SubType.prototype = new SuperType();
SubType.prototype.getAge = function () {
console.log(this.age);
};
let dasen = new SubType("Dasen", 22, "Xiaoyu", "Three");
let three = new SubType("Three", 22, "Dasen");
console.log(dasen); // SubType {name: 'Dasen', friends: Array(2), age: 22}
console.log(three); // SubType {name: 'Three', friends: Array(1), age: 22}
dasen.getAge(); // 22
dasen.getFriends(); // Xiaoyu, Three
console.log(dasen instanceof SuperType); // true
console.log(dasen instanceof SubType); // true

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

总结一下组合继承的步骤:

  • 创建一个父类的构造函数,在构造函数中设置父类的属性;
  • 在父类原型上设置父类的方法;
  • 创建一个子类的构造函数,在构造函数中盗用父类的构造函数,并设置子类的属性;
  • 创建一个父类的实例作为子类的原型对象;
  • 在子类原型上设置子类的原型方法;
  • 如果有更下一级的子类可以以同样的方式继续继承;
  • 完成继承。

原型式继承

ECMAScript 5有了Object.create()方法,这个方法的作用是获得一个增强的对象,增强的方式是将原对象作为新对象的原型。

也就是它接收两个参数,第一个是原对象,第二个参数是一个属性描述对象(与Object.defineProperties()的第二个参数一样)。

它做了这样一件事:新建一个对象,将原对象作为这个新建对象的原型对象,如果没有传入第二个参数,就这样把新对象返回,如果传入了第二个参数,就把第二个参数中描述的属性添加到新创建的对象中再返回新对象(同样会遮蔽原型上同名的属性)。

如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let person = {
name: "Dasen",
friends: ["Xiaoyu", "Three"]
};
let anotherPerson = Object.create(person);
anotherPerson.name = "Xiaoyu";
anotherPerson.friends.push("Tom");
let yetAnotherPerson = Object.create(person, {
name: {
value: "Three"
}
});
yetAnotherPerson.friends.push("Jack");
console.log(person.name, anotherPerson.name, yetAnotherPerson.name); // Dasen Xiaoyu Three
console.log(person.friends); // ['Xiaoyu', 'Three', 'Tom', 'Jack']
console.log(anotherPerson.__proto__ === person); // true
console.log(yetAnotherPerson.__proto__ === person); // true

可以看到Object.create()实现的继承就是通过原型进一步加工包装生成的新对象,对于引用值属性使用的时候要谨慎,尤其是不打算共享属性的时候。

原型式继承不需要再创建类了,是直接对象到对象的继承,因此原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。

寄生式继承

寄生式继承实际上就是使用一个工厂函数,接收一个你想把它作为原型的对象,工厂函数给你生产出来一个通过原型式继承得来的对象,返回的新对象以传进来的参数对象为原型,进行特定的增强。

比如这个例子,工厂函数通过增强,让一个不会自我介绍的人变得自信学会了自我介绍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let person = {
name: "Dasen",
age: 22
};
function factoryFun(person) {
let clone = Object.create(person);
clone.sayHi = function () {
console.log("Hi. My name is " + this.name + ".");
console.log("I'm " + this.age + " years old.");
};
return clone;
}
let confidentPerson = factoryFun(person);
confidentPerson.sayHi();

寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。

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

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

寄生式组合继承

之所以要引入寄生式继承,是为了优化组合继承。

组合继承看起来已经很棒了,但是它还是有一个性能上的问题:父类的构造函数始终会被调用两次,一次是在子类构造函数中被盗用,一次是在实例化父类对象作为子类原型的时候。

在这两次调用父类构造函数中能被优化掉的就是实例化父类对象作为子类原型的时候的调用,因为这次调用纯粹是为了得到父类原型上的方法,构造函数里进行的东西没啥用,那么为什么不直接把父类的原型拿来用呢?

寄生式组合继承的实现是这样的:

1
2
3
4
5
function inheritPrototype(subType, supperType) {
let prototype = Object.create(supperType.prototype);
prototype.constructor = subType;
subType.prototype = prototype;
}

即不再使用父类的实例作为子类原型了,而是直接把有用的父类原型偷来,用指向子类的constructor遮蔽掉原来的指向父类的constructor,直接把它当作子类的原型,这样就实现了继承,还避免了多次调用父类构造函数。

然后再使用这个inheritPrototype函数替换掉组合继承中使用父类实例作为子类原型的步骤,就实现了寄生式组合继承。

总而言之,寄生式组合继承可谓继承的最佳模式了。

定义类

类的定义

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

1
2
3
4
// 类声明
class Person {}
// 类表达式
const Animal = class {};

与函数类似的是:

  • 类表达式和函数表达式一样,在它们被求值前不能引用;
  • 类是JavaScript的一等公民,因此可以像其他对象或函数引用一样把类作为参数传递;
  • 与立即调用函数表达式相似,类也可以立即实例化。

与函数不同的是:

  • 函数声明可以提升,类定义不能;
  • 函数受函数作用域限制,而类受块作用域限制。

类的构成

类可以包含:

  • 构造函数方法constructor;
  • 获取函数get;
  • 设置函数set;
  • 静态类方法static。

但这些都不是必需的。

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

构造函数

类构造函数

在类内定义一个名为constructor的函数,这个函数就会成为类的构造函数。这个函数几乎等同于原先ES5中的构造函数,所做的事情也完全一样,只是个语法糖。它用来在使用new操作符定义一个新实例时调用。

在定义一个类的实例时和之前的形式完全一样:

1
let p = new Person();

特别的,如果构造函数不需要参数,那么创建实例时后面的括号也可以不要:

1
let p = new Person;

和之前的构造函数一样,类构造函数也会在结束时返回一个对象,如果显式指定了返回语句返回了一个非空对象,那么这个对象会被返回,否则会自动返回this。

注意如果显式指定返回了一个与新建的对象毫无关系的对象,那么新建的对象会因为没有引用而被回收。

类构造函数与构造函数的主要区别为:类构造函数必须要用new来调用,直接调用会报错,而构造函数都可以,也能当作普通函数调用,只是直接调用的行为可能不太符合预期(this指向的问题)。

类的本质

类既然是构造函数和原型实现的语法糖,那可以预料类的本质应当是函数:

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

那么它也应当有原型对象,并且其原型上的constructor属性指回它本身:

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

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

1
2
let p = new Person();
console.log(p instanceof Person); // true

但是要注意,既然这里的类与之前的构造函数/原型实现的类是等同的,是原先方式的语法糖,那么如今使用new Person()和之前使用new Person()得到的结果应该是等同的,因此使用类构造函数不应该使用new Person.constructor(),这违反了语法糖设计的初衷。在使用时应当把如今的类名Person等同于原先的构造函数Person来使用。

成员

之前的语法中将成员定义到不同的位置会十分麻烦,而如今使用类语法将能够很容易定义以下三种成员:

  • 应存在于实例上的成员;
  • 应存在于原型上的成员;
  • 应存在于类本身的成员。

实例成员

通过类构造函数为this绑定的成员都将成为实例的成员,在其他地方向实例对象添加的成员都将成为实例成员。

注意:在类块内直接写的数据成员也会成为实例的成员,即直接将一个值赋值给一个名称,那么这个名称会自动成为实例的属性。

原型成员

在类块中定义的方法都将位于原型上,但是原型成员只能是方法,也就是说不支持在原型上定义数据成员,但可以定义访问器成员用来包装实例的属性,访问器成员使用get和set关键字来修饰。

类的原型成员函数都默认开启了局部严格模式!因此当类的方法被拿到类外调用时,它们的this都为undefined。

静态类成员

静态类成员使用static修饰,直接定义在类块内。静态类成员非常适合实例工厂来按照一定规则创建实例。

静态成员可以是方法也可以是数据成员,定义的静态成员将会直接挂载在类上,需要通过类来访问。

共享数据成员

原型成员不可以是数据成员,因为数据成员不应该在所有实例中共享,这是一种反模式。由于静态数据成员也是所有数据成员可访问的,因此除非必需,也不应该向类上添加静态成员,或者可以在类上添加一些常量数据成员,但最好想办法保证这些常量数据成员不会被修改。

在类块内直接写的数据成员会成为实例的成员而不会成为静态成员或原型成员,特别地,如果在类块中使用赋值语句将一个函数表达式赋值给一个名称,那么这个方法是实例方法,如果在类块中的是函数声明,那么这个方法是原型方法。

如果一定需要共享的数据成员,可以在类的外部向原型或者类上添加(也可以在类块内定义静态数据成员):

1
2
Person.greeting = "My name is ";
Person.prototype.name = "Dasen";

迭代器和生成器方法

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Person {
*createNameIterator() {
yield "Dasen";
yield "Dasen Sun";
yield "Sadose";
}
static *createHobbyIterator() {
yield "Code";
yield "Girl";
yield "Cube";
}
}
let person = new Person();
let names = person.createNameIterator();
let hobbies = Person.createHobbyIterator();
console.log(names.next().value); // Dasen
console.log(names.next().value); // Dasen Sun
console.log(names.next().value); // Sadose
console.log(hobbies.next().value); // Code
console.log(hobbies.next().value); // Girl
console.log(hobbies.next().value); // Cube

那么也就可以通过添加一个默认迭代器,把类实例变成可迭代对象:

1
2
3
4
5
6
7
8
9
10
11
class Person {
*[Symbol.iterator]() {
yield "Dasen";
yield "Dasen Sun";
yield "Sadose";
}
}
let p = new Person();
for(let name of p) {
console.log(name);
}

继承

ES6中的类语法支持继承,但仍是原型链的语法糖。

继承基础

类语法中的继承使用关键字extends实现,不仅可以继承一个类,还可以继承一个普通构造函数,可以保证向后兼容。extends在类表达式中也可使用。

派生类会通过原型链访问到父类和原型上定义的方法,this的值会反映调用方法的类或实例。

super

派生类可以使用super关键字引用它们的原型,这个原型包括了父类的所有方法,因此super其实主要有两种用法:

  • 使用super()调用父类的构造函数;
  • 使用super.xxx()调用父类的某个原型方法。

在构造函数中调用super()就很像盗用构造函数的继承方式。

在super的使用过程中需要注意:

  • super不能在非派生类中使用;
  • 不能单独使用super关键字,要么使用它调用父类构造函数,要么使用它调用父类方法;
  • 调用super()会调用父类构造函数同时会把返回的实例赋值给this,因此在调用之前使用过this会抛出错误;
  • 使用super()调用父类构造函数时可以传参;
  • 如果没有给子类定义构造函数,那么在实例化子类的时候会自动调用super(),并会把传入的参数全都传给super,也就是子类没有定义自己的构造函数时,就默认继承父类的构造函数;
  • 如果给子类显式定义了构造函数,那么子类构造函数必须要么调用super(),要么就显式返回一个对象。

抽象基类

有时候可能需要定义这样一个类,它可供其他类继承,但本身不会被实例化,这样的类就是抽象基类。

虽然ECMAScript没有专门支持这种类的语法,但通过new.target也很容易实现。

new.target保存通过new关键字调用的类或函数。通过在实例化时检测new.target是不是抽象基类,可以阻止对抽象基类的实例化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Vehicle {
constructor() {
console.log(new.target);
if(new.target === Vehicle) {
throw new Error("Vehicle 是一个抽象基类,不可以实例化!")
}
}
}
class Bus extends Vehicle {}
let v;
try {
v = new Vehicle(); // class Vehicle
} catch (error) {
console.log(error); // Error: Vehicle 是一个抽象基类,不可以实例化!
}
let b = new Bus(); // class Bus extends Vehicle
console.log(v, b); // undefined Bus

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Vehicle {
constructor() {
if(new.target === Vehicle) {
throw new Error("Vehicle 是一个抽象基类,不可以实例化!");
}
if(!this.drive) {
throw new Error("车必须能开!");
}
console.log("成功!");
}
}
class Bus extends Vehicle {
drive() {}
}
class BadVehicle extends Vehicle {}
try {
new Vehicle();
} catch (error) {
console.log(error); // Error: Vehicle 是一个抽象基类,不可以实例化!
}
try {
new BadVehicle();
} catch (error) {
console.log(error); // Error: 车必须能开!
}
new Bus(); // 成功!

这样就通过判断this上是否有drive方法来确保车能开,不能开的车实例化时会抛出错误。

那么为什么这里可以通过this来判断呢?这里的this是什么?

this应当是函数的调用者,这里构造函数的调用者其实就是类本身,因此this指向准备新建实例的类,那么当new BadVehicle()时,this就指向类BadVehicle,当new Bus()时,this就指向类Bus。而这里定义的方法drive实际上是在类原型上,因此通过Bus.drive就可以访问到它,访问不到就说明它没有实现这个方法,这就实现了通过this关键字来检查是否具有相应的方法。

继承内置类型

有时我们希望扩展内置类型的功能,就可以继承内置类型,比如我想给内置数组添加洗牌算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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, 6, 7, 8, 9);
console.log(a); // SuperArray(9) [1, 2, 3, 4, 5, 6, 7, 8, 9]
a.shuffle();
console.log(a); // SuperArray(9) [8, 4, 7, 9, 5, 6, 3, 1, 2]
console.log(a instanceof SuperArray); // true
console.log(a instanceof Array); // true

有些内置类型的方法会返回一个新的对象,比如数组的filter方法。默认情况下返回的新实例的类型和原实例的类型是一致的,也就是说对上面的SuperArray实例使用filter方法,返回的还是SuperArray的实例,要想改变这样的默认行为,可以重写SuperArray的Symbol.species访问器,这个访问器决定在创建返回的实例时使用的类:

1
2
3
4
5
class SuperArray extends Array {
static get [Symbol.species]() {
return Array;
}
}

类混入

把多个类的行为集中到一个类上就是类混入,实际上就是多继承。ES6没有显式支持多继承,但是使用现有特性可以模拟这种行为。

extends关键字后面是一个JavaScript表达式,任何可以解析为一个类或一个构造函数的表达式都是有效的,这个表达式会在求值类定义时被求值,通过这一特性可以实现多继承机制。

假如需要让Person类同时继承A、B、C三个类,那么只需要让B继承A、C继承B,使得ABC组合成一个超类,再让Person类继承它即可。

具体的实现可以这样封装为混入函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Baseclass {}
let AMixin = (Superclass) => class extends Superclass {
funA() {
console.log("A");
}
}
let BMixin = (Superclass) => class extends Superclass {
funB() {
console.log("B");
}
}
let CMixin = (Superclass) => class extends Superclass {
funC() {
console.log("C");
}
}
function mix(baseclass, ...mixins) {
return mixins.reduce((pre, cur) => cur(pre), baseclass);
}
class Subclass extends mix(Baseclass, AMixin, BMixin, CMixin) {}
let o = new Subclass();
o.funA(); // A
o.funB(); // B
o.funC(); // C

很多JavaScript框架(特别是React)已经抛弃混入模式,而是使用组合模式:把方法提取到独立的类和辅助对象中,然后把它们组合起来,但不使用继承。

这反映了那个众所周知的软件设计原则:组合胜过继承(composition over inheritance)。这个设计原则被很多人遵循,在代码设计中能提供极大的灵活性。




* 你好,我是大森。如果文章内容帮到了你,你可通过下方付款二维码支持作者 *