JavaScript 类与继承

一、创建对象的方式

① 工厂模式

1
2
3
4
5
6
7
8
9
function createPerson(name) {
var o = new Object();
o.name = name;
o.getName = function () {
console.log(this.name);
};
return o;
}
var person1 = createPerson('kevin');

工厂模式通过 new Object() 来创建一个对象实例,并为其添加属性和方法。但工厂模式无法确定对象的类型,因为实例直接由 Object() 构造函数创建,原型链上只有 Object.prototype 对象。另外,每创建一个对象实例,都要为每个对象实例创建一遍完全相同的函数方法(理论上每次创建对象的属性均不同,而对象的方法是相同的),这是没有必要的。

② 构造函数模式

1
2
3
4
5
6
7
function Person(name) {
this.name = name;
this.getName = function () {
console.log(this.name);
};
}
var person1 = new Person('kevin');

构造函数模式可以识别实例对象的类型,但缺点仍在于每次创建实例时,每个方法都要重新被创建一次。可以通过将函数移到构造函数外来解决,但同时封装性就很糟糕。

1
2
3
4
5
6
7
8
function Person(name) {
this.name = name;
this.getName = getName;
}
function getName() {
console.log(this.name);
}
var person1 = new Person('kevin');

③ 原型模式

1
2
3
4
5
6
7
function Person(name) { }
Person.prototype.name = 'keivn';
Person.prototype.getName = function () {
console.log(this.name);
};

var person1 = new Person();

原型模式把属性和方法定义在构造函数的原型,而非构造函数内。这样所有对象实例就能共享对象原型上的属性和方法,从而避免重复创建。但缺点在于修改原型上的引用属性会导致所有实例对应的属性都被改变。另外,还不能初始化参数。

1
2
3
4
5
6
7
8
9
10
// 优化
function Person(name) { }
Person.prototype = {
constructor: Person,
name: 'kevin',
getName: function () {
console.log(this.name);
}
};
var person1 = new Person();

优化后提升了封装性,但并没有解决原型模式带来的缺点。

④ 组合模式

在构造函数内定义私有属性和方法,在构造函数原型上定义共有的属性和方法。 目前最广泛使用的方式之一。

1
2
3
4
5
6
7
8
9
10
function Person(name) {
this.name = name;
}
Person.prototype = {
constructor: Person,
getName: function () {
console.log(this.name);
}
};
var person1 = new Person();

动态原型模式

1
2
3
4
5
6
7
8
9
function Person(name) {
this.name = name;
if (typeof this.getName != 'function') {
Person.prototype.getName = function () {
console.log(this.name);
}
}
}
var person1 = new Person();

使用动态原型模式时,不能用对象字面量重写原型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Person(name) {
this.name = name;
if (typeof this.getName != 'function') {
Person.prototype = {
constructor: Person,
getName: function () {
console.log(this.name);
}
}
}
}
var person1 = new Person('kevin');
var person2 = new Person('daisy');
// 报错 并没有该方法
person1.getName();
// 注释掉上面的代码,这句是可以执行的。
person2.getName();

为了解释这个问题,假设开始执行 var person1 = new Person('kevin')。回顾下 new 的实现步骤:

  1. 首先新建一个对象
  2. 然后将对象的原型指向 Person.prototype
  3. 然后 Person.apply(obj)
  4. 返回这个对象

在 apply 时会执行 obj.Person(),这时就会执行 if 语句里的内容,注意构造函数的 prototype 属性指向了实例原型,使用字面量方式直接覆盖 Person.prototype,并不会更改实例的原型的值,person1 依然是指向了以前的原型,而不是 Person.prototype。而之前的原型是没有 getName() 的,所以报错。

如果你就是想用字面量方式写代码,可以尝试下这种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Person(name) {
this.name = name;
if (typeof this.getName != 'function') {
Person.prototype = {
constructor: Person,
getName: function () {
console.log(this.name);
}
}
return new Person(name);
}
}
var person1 = new Person('kevin');
var person2 = new Person('daisy');
person1.getName(); // kevin
person2.getName(); // daisy

⑤ class 定义类

class从 ES6 开始正式被引入到 JS 中,它是组合模式的语法糖,让定义类更加简单。

1
2
3
4
5
6
7
8
class Student {
constructor(name) {
this.name = name;
}
hello() {
alert('Hello, ' + this.name + '!');
}
}

class包含了构造函数constructor和定义在原型对象上的函数hello(),避免了Student.prototype.hello = function () {...}这样分散的代码。

⑥ 寄生构造函数模式

寄生构造函数模式跟工厂模式的唯一区别在于,在创建对象时使用了 new。实际上两者结果是一样的。

1
2
3
4
5
6
7
8
9
10
11
function Person(name) {
var o = new Object();
o.name = name;
o.getName = function () {
console.log(this.name);
};
return o;
}
var person1 = new Person('kevin');
console.log(person1 instanceof Person) // false
console.log(person1 instanceof Object) // true

它只是寄生在构造函数的一种方法,创建的实例使用 instanceof 都无法指向其构造函数。这样方法可以在特殊情况下使用。比如当要创建一个具有额外方法的特殊数组,但又不想直接修改 Array 构造函数时,可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function SpecialArray() {
var values = new Array();
for (var i = 0, len = arguments.length; i < len; i++) {
values.push(arguments[i]);
}
values.toPipedString = function () {
return this.join('|');
};
return values;
}
var colors = new SpecialArray('red', 'blue', 'green');
var colors2 = SpecialArray('red2', 'blue2', 'green2');
console.log(colors);
console.log(colors.toPipedString()); // red|blue|green
console.log(colors2);
console.log(colors2.toPipedString()); // red2|blue2|green2

虽然本意是希望像使用普通 Array 类型一样使用 SpecialArray,但把 SpecialArray 当成函数也一样能用,即使这不优雅。在可以使用其他模式的情况下,不要使用这种模式。

1
2
3
for (var i = 0, len = arguments.length; i < len; i++) {
values.push(arguments[i]);
}

上例中的循环可以替换成:

1
values.push.apply(values, arguments);

⑦ 稳妥构造函数模式

1
2
3
4
5
6
7
8
9
10
11
12
function person(name){
var o = new Object();
o.sayName = function(){
console.log(name);
};
return o;
}
var person1 = person('kevin');
person1.sayName(); // kevin
person1.name = 'daisy';
person1.sayName(); // kevin
console.log(person1.name); // daisy

所谓稳妥对象,指的是没有公共属性,而且其方法也不引用 this 的对象。与寄生构造函数模式有两点不同:

  • 新创建的实例方法不引用 this
  • 不使用 new 操作符调用构造函数

稳妥对象最适合在一些安全的环境中。稳妥构造函数模式也跟工厂模式一样,无法识别对象所属类型。

二、继承

① 原型链继承

1
2
3
4
5
6
7
8
9
10
11
function Parent () {
this.name = 'kevin';
}
Parent.prototype.getName = function () {
console.log(this.name);
}
function Child () { }
Child.prototype = new Parent();

var child1 = new Child();
console.log(child1.getName()) // kevin

原型链继承的缺点在于,引用类型的属性会被所有实例共享。另外在创建 Child 的实例时,不能向 Parent 传参。

1
2
3
4
5
6
7
8
9
10
11
function Parent () {
this.names = ['kevin', 'daisy'];
}
function Child () { }
Child.prototype = new Parent();

var child1 = new Child();
child1.names.push('yayu');
console.log(child1.names); // ['kevin', 'daisy', 'yayu']
var child2 = new Child();
console.log(child2.names); // ['kevin', 'daisy', 'yayu']

② 借用构造函数

1
2
3
4
5
6
7
8
9
10
11
12
function Parent () {
this.names = ['kevin', 'daisy'];
}
function Child () {
Parent.call(this);
}

var child1 = new Child();
child1.names.push('yayu');
console.log(child1.names); // ['kevin', 'daisy', 'yayu']
var child2 = new Child();
console.log(child2.names); // ['kevin', 'daisy']

借用构造函数(经典继承)既避免了引用类型的属性被所有实例共享,也可以在 Child 中向 Parent 传参。

1
2
3
4
5
6
7
8
9
10
11
function Parent (name) {
this.name = name;
}
function Child (name) {
Parent.call(this, name);
}

var child1 = new Child('kevin');
console.log(child1.name); // kevin
var child2 = new Child('daisy');
console.log(child2.name); // daisy

缺点在于方法都在构造函数中定义,每次创建实例都会创建一遍方法。

③ 组合继承

融合原型链继承和构造函数的优点,是 JavaScript 中最常用的继承模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function Parent (name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}
Parent.prototype.getName = function () {
console.log(this.name)
}

function Child (name, age) {
Parent.call(this, name);
this.age = age;
}
Child.prototype = new Parent();

var child1 = new Child('kevin', '18');
child1.colors.push('black');
console.log(child1.name); // kevin
console.log(child1.age); // 18
console.log(child1.colors); // ['red', 'blue', 'green', 'black']
var child2 = new Child('daisy', '20');
console.log(child2.name); // daisy
console.log(child2.age); // 20
console.log(child2.colors); // ['red', 'blue', 'green']。

④ class 继承

组合继承的缺点在于需要编写大量代码。用 class 定义对象让继承的实现更加方便。不必再考虑原型继承的中间对象和原型对象的构造函数,直接通过 extends 来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Student {
constructor(name) {
this.name = name;
}
hello() {
alert('Hello, ' + this.name + '!');
}
}

class PrimaryStudent extends Student {
constructor(name, grade) {
super(name); // 记得用 super 调用父类的构造方法!
this.grade = grade;
}
myGrade() {
alert('I am at grade ' + this.grade);
}
}

extends表示原型链对象来自Student。子类的构造函数可能会与父类不同,如PrimaryStudent需要namegrade两个参数,它必须通过super(name)来调用父类的构造函数,否则父类的name属性无法正常初始化。PrimaryStudent已经自动获得了父类Studenthello(),我们又在子类中定义了新的myGrade()

ES6 引入的class和原有的 JS 原型继承没有任何区别,只是简化了原型链代码。

⑤ 原型式继承

1
2
3
4
5
function createObj(o) {
function F(){}
F.prototype = o;
return new F();
}

ES5 Object.create 的模拟实现,将传入的对象作为创建的对象的原型。缺点在于包含引用类型的属性值始终都会共享相应的值,跟原型链继承一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
var person = {
name: 'kevin',
friends: ['daisy', 'kelly']
}

var person1 = createObj(person);
var person2 = createObj(person);

person1.name = 'person1';
console.log(person2.name); // kevin

person1.firends.push('taylor');
console.log(person2.friends); // ['daisy', 'kelly', 'taylor']

注意:修改 person1.name 的值,person2.name 的值并未发生改变,并不是因为 person1person2 有独立的 name 值,而是因为 person1.name = 'person1',给 person1 添加了 name 值,并非修改了原型上的 name 值。

⑥ 寄生式继承

创建一个仅用于封装继承过程的函数,该函数在内部以某种形式来做增强对象,最后返回对象。

1
2
3
4
5
6
7
function createObj (o) {
var clone = object.create(o);
clone.sayName = function () {
console.log('hi');
}
return clone;
}

缺点:跟借用构造函数模式一样,每次创建对象都会创建一遍方法。

⑦ 寄生组合式继承

在这里重复一下组合继承的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Parent (name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}
Parent.prototype.getName = function () {
console.log(this.name)
}

function Child (name, age) {
Parent.call(this, name);
this.age = age;
}
Child.prototype = new Parent();

var child1 = new Child('kevin', '18');
console.log(child1)

组合继承最大的缺点是会调用两次父构造函数。

一次是设置子类型实例的原型:

1
Child.prototype = new Parent();

一次是创建子类型实例:

1
var child1 = new Child('kevin', '18');

在创建子类型实例时,会执行:

1
Parent.call(this, name);

即又调用了一次 Parent 构造函数。如果打印 child1 对象,会发现 Child.prototype 和 child1 都有一个属性为colors,属性值为['red', 'blue', 'green']

如果不使用 Child.prototype = new Parent() ,而是间接地让 Child.prototype 访问到 Parent.prototype,那就可以避免这一次重复调用。实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Parent (name) {
this.name = name;
this.colors = ['red', 'blue', 'green'];
}
Parent.prototype.getName = function () {
console.log(this.name)
}

function Child (name, age) {
Parent.call(this, name);
this.age = age;
}

// 关键的三步
var F = function () {};
F.prototype = Parent.prototype;
Child.prototype = new F();

var child1 = new Child('kevin', '18');
console.log(child1);

最后我们封装一下这个继承方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function object(o) {
function F() {}
F.prototype = o;
return new F();
}

function prototype(child, parent) {
var prototype = object(parent.prototype);
prototype.constructor = child;
child.prototype = prototype;
}

// 当我们使用的时候:
prototype(Child, Parent);

这种方式的高效率体现它只调用了一次 Parent 构造函数,并且因此避免了在 Parent.prototype 上面创建不必要的属性。与此同时,原型链还能保持不变;因此能够正常使用 instanceof 和 isPrototypeOf。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。