yinguangyao / blog

关于 JavaScript 前端开发、工作经验的一点点总结。

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

深入理解 JavaScript 中的类与继承

yinguangyao opened this issue · comments

commented

前言

JavaScript 在设计之初,是一门具有函数式、面向对象( OOP )等风格的多范式语言。
虽然 JavaScript 中到处都是“对象”,但如果想要 JavaScript 中使用类,在 ES5 之前需要使用构造函数和原型( prototype )来模拟类,而涉及到继承的时候,实现方式之多更是让人眼花缭乱。
因此,在 ES6 之后,JavaScript 增加了 class 和 extends 关键字,这让JavaScript更加接近传统的面向对象语言,也方便了开发。

本文比较基础,如果对知识点已经比较了解,可以跳过本文。如果觉得不够了解,那么大家可以来一起复习一下。
本文涉及到对象、构造函数、原型( prototype )等知识,如果有不懂的知识点,建议先去熟悉《JavaScript高级程序设计》第六章相关概念后再来阅读。

1. 类

什么是类呢?这里引用一下维基百科的解释:

类(英语:class)在面向对象编程中是一种面向对象计算机编程语言的构造,是创建对象的蓝图,描述了所创建的对象共同的属性和方法。

类是对现实生活中一类具有共同特征的事物的抽象,像老鼠、猫、人类等都可以作为类。但具体的个体就是对象,比如一只叫汤姆的猫,一只叫杰瑞的老鼠。

在 JavaScript 中每个对象都可以都是基于引用类型创建的,这个引用类型可以是原生类型( Date、String、Boolean 等),也可以是自定义的类型。因此,类也可以被理解为是描述数据和行为的一种复杂类型。

1.1 ES5 中的类

在开始之前,我们先基于上述的描述来实现一个简单的类。
由于类是一种自定义的复杂类型,封装了一类事物的共同特性,而对象只是类返回的一个实例,那么我们完全可以考虑使用函数来创建,最后返回一个对象。
以下面这个人的类为例:

function person(name, age) {
    return {
        name,
        age,
        say() {
            console.log('my name is ', name);
        }
    }
}
person('tom', 23); // { name: 'tom', age: 23, say: f }
person('jerry', 24); // { name: 'jerry', age: 24, say: f }

注意:这段代码充分体现了,在 JavaScript 中到处都是“对象”,只要使用对象字面量就能轻松创建一个对象。

但是这个类的实现方式也有一定问题,比如没有办法设置原型,这样也就无法实现继承。
因此,在 JavaScript 中,一般使用具有 this 的构造函数和 new 操作符来模拟类和对象,这样也是更符合传统面向对象语言开发者直觉的设计。

function Person(name, age) {
    this.name = name;
    this.age = age;
}
Person.prototype.say = function() {
    console.log('my name is ', name);
}
const tom = new Person('tom', 23);
const jerry = new Person('jerry', 24);

当然,你也许会困惑,当使用 new 的时候,它到底做了什么呢?
其实 new 操作符做的事情很简单,大致就是以下几步:

  1. 创建一个空对象 obj
  2. 执行构造函数,将 this 指向 obj
  3. 设置 obj[[Prototype]] 指针指向构造函数的原型 prototype
  4. 返回这个对象

按照上面这四步,可以自己手动实现一个 new 操作符。

function myNew() {
    const Constructor = arguments[0],
        args = [...arguments].slice(1), // arguments是类数组,因此需要转换为数组才能使用slice方法
        obj = {};
        
    Constructor.apply(obj, args);
    // 设置[[Prototype]]指针(不推荐)
    obj.__proto__ = Constructor.prototype;
    // 设置[[prototype]]指针(推荐)
    Object.setPrototypeOf(obj, Constructor.prototype);
    return obj;
}

调用方式也比较简单:

function Person(name, age) {
    this.name = name;
    this.age = age;
}
myNew(Person, 'tom', 23);
new Person('tom', 23);

通过myNew和new最后得到的两个实例,两者表现是一致的,[[Prototype]]也都指向了同一地址。

image_1dmgu1orh19111d1j138gt711pbt55.png-153.9kB

注意:

  1. [[Prototype]] 是存在于实例对象上的一个指针,它指向构造函数的原型( prototype ),通过 [[Prototype]] 连接起一个个对象,最终形成一条原型链,原型链的终点是 Object.prototype.__proto__,也就是null。
  2. 为什么不推荐使用 __proto__ ,而建议用 Object.setPrototypeOf 呢?
    这是因为 __proto__ 并非官方标准,而是浏览器自己实现的,可能会出现兼容性问题,而 Object.setPrototypeOf 则是 ES6 中标准,未来所有浏览器都会支持。

上述代码原型链关系图:

原型链关系图

1.2 ES6 class

在 ES6 中新加入了 class 的语法糖,从此和手动模拟类的时代说拜拜。

class Person {
    static name = 'person';
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
    say() {
        console.log('my name is ', name);
    }
}

注意:如果 say 使用箭头函数来定义,那么 babel 编译后的结果是在构造函数中的,不使用箭头函数则只是绑定到原型( prototype )上。

可以明显地看到,原来的 Person 构造函数,现在变成了constructor。而在 Person 类中直接定义的方法,会被绑定到原型上,我们也不需要再用 Person.prototype.say 这种复杂方式来定义原型方法。
用新增加的 static 关键字定义的属性代表着类上面的静态属性,也取代了原有的 Person.name 的形式(当然,在 ES6 中依然可以用 Person.name 来定义静态属性)。

除以之外,使用 class 定义的类,无法像原来的构造函数一样直接调用,否则会报错。

image_1dmgmin1n1bvr18n41j6f3hgs7o1g.png-18.1kB

2. 继承

继承是指可以在不编写更多代码的情况下,一个类可以使用另一个类上的属性或者方法。甚至父类可以只提供接口,让子类去实现。

一般来说,继承的类叫做「子类」或者「派生类」,而被继承的类叫做「父类」或者「超类」。

2.1 extend

前面我们说过,在 JavaScript 中到处都是“对象”,而对象也是基于引用类型创建的。聪明的孩子也许会想到,能不能将不同的对象进行混合,从而实现类似继承的效果呢?

在 jQuery、underscore/lodash、Backbone 等框架和库中都提供了类似扩展( extend )的功能,直接将对象的属性合并到目标对象中。

image_1dmgnvcqkbv5sij9a48q17rd1t.png-131.2kB

这里使用了 jQuery 中的 extend 方法来将 map 对象分别合并到 baiduMapgoogleMap 上,最终两者都具有了前者的方法。

在 ES6 中也提供了新的方法 Object.assign 来实现对象的合并,用法也和 $.extend 几乎一致。

但不管 extend 还是 Object.assign 都只是浅拷贝,如果将引用类型属性合并到不同的目标对象中,一旦其中一个目标对象修改了这个属性,就会造成其他目标对象也跟着变化。

image_1dmgq0mbl1epr2in1daj17k21mdt4b.png-174.5kB

2.2 mixin

而在 Vue 和 早期的 React 中都支持一种名为 mixin 的方式,mixin的作用在于将不同组件的共同部分抽取出来,实现逻辑的复用。

注意:React 在后期移除了这个特性,官方更提倡使用高阶组件或 hooks 来实现代码复用。

Vue 中的 mixin(将 toggle 提取出来可以供不同组件使用):

const toggle = {
    data() {
        return {
            isShowing: false
        }
    },
    methods: {
        toggleShow() {
            this.isShowing = !this.isShowing;
        }
    }
}

const Modal = {
    template: '#modal',
    mixins: [toggle],
    components: {
        appChild: Child
    }
};

const Tooltip = {
    template: '#tooltip',
    mixins: [toggle],
    components: {
        appChild: Child
    }
};

React 早期的 mixin(将设置默认的props提取出来):

var DefaultNameMixin = {
    getDefaultProps: function () {
        return {name: "Skippy"};
    }
};
var ComponentOne = React.createClass({
    mixins: [DefaultNameMixin],
    render: function() {
        return <h2>Hello {this.props.name}</h2>;
    }
});
var ComponentTwo = React.createClass({
    mixins: [DefaultNameMixin],
    render: function () {
        return (
            <div>
                <h4>{this.props.name}</h4>
                <p>Favorite food: {this.props.food}</p>
            </div>
        );
    }
});

甚至在 sass 和 less 这些 css 预处理器中也支持 mixin 的形式,允许我们灵活复用相同的 css 代码。

scss 中的 mixin(封装了 border-radius ):

@mixin border-radius($radius) {
    -webkit-border-radius: $radius;
    -moz-border-radius: $radius;
    -ms-border-radius: $radius;
    border-radius: $radius;
}

aside { 
    border: 1px solid orange;
    @include border-radius(10px); 
}

2.3 基于原型链的继承

extendmixin 在很多框架和库中被使用,但两者本质上都是做了对象的合并,适用范围有限。
在 ES6 出现之前,如何用 ES5 实现继承也也是前端面试中的常见题型之一,这里将重点介绍组合继承和寄生组合继承。

2.3.1 组合继承

通过原型链的机制,将父类的实例赋值给子类的原型链来实现子类继承父类的属性。
同时,又将父类的构造函数在子类的构造函数中执行,来实现绑定父类构造函数中的属性。

function Child(name, age) {
		Parent.call(this, name, age);
    this.name = name;
    this.age = age;
}
function Parent(name, age) {
    this.name = name;
    this.age = age;
}
Parent.prototype.getName = function() {
    console.log('parent', this.name);
}
Parent.prototype.getAge = function() {
    console.log('parent', this.age);
}
Child.prototype = new Parent();
Child.prototype.getName = function() {
    console.log('child', this.name);
}
var child = new Child('tom', 23);
child.getName();
child.getAge();

注意:父构造函数的执行一定要放到子构造函数属性定义之前,这样可以避免父构造函数上的属性覆盖子构造函数的属性。

原型链关系图:

image_1dmibosjn17g211pbfi9pc1nbg9.png-23.7kB

但是组合继承有个问题,就是在设置 Child 的原型时,需要实例化一次 Parent 构造函数,导致了 Parent 构造函数被调用了两次。

2.3.2 寄生式继承

寄生式继承的思路和工厂模式类似,即创建一个仅用于封装继承过程的函数,该函数内部以某种方式来增强对象,最后返回这个对象。

寄生式继承更像是将对象与对象衔接起来,形成一条原型链。

function createAnother(origin) {
	const clone = Object.create(origin);
	clone.say = function() {
		console.log(this.name);
	};
	return clone;
}
const person = {
	name: 'tom',
	age: 23
}
const anotherPerson = createAnother(person);
anotherPerson.say(); // 'tom'

注意:Object.create 接收一个对象 origin ,以这个对象为原型( prototype ),创建一个新的对象,这个新对象的[[Prototype]]指向 origin 对象。

2.3.2 寄生组合式继承

寄生组合式继承是将寄生式继承与组合继承结合起来的一种继承方式,主要是用 Object.create 来代替原来实例化父构造函数,它解决了组合继承中调用两次父构造函数的弊端,也是最理想的继承范式。

function Child(name, age) {
    Parent.call(this, name, age);
    this.name = name;
    this.age = age;
}
function Parent(name, age) {
    this.name = name;
    this.age = age;
}
Parent.prototype.getName = function() {
    console.log('parent', this.name);
}
Parent.prototype.getAge = function() {
    console.log('parent', this.age);
}
Child.prototype = Object.create(Parent.prototype);
Child.prototype.getName = function() {
    console.log('child', this.name);
}
const child = new Child('tom', 23);
child.getName();
child.getAge();

注意:给 Child.prototype 添加新属性一定要放到赋值之后,不然原来添加的属性会被替换。

原型链关系图:

image_1dmighodq13fb12p716uc9iumq11g.png-39.4kB

《JavaScript高级程序设计》中对寄生组合式继承的评价是这样的:

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

2.4 ES6 继承

在 ES6 中,新增加了 extends 的语法糖,在定义子类的时候可以直接继承父类,这样统一了继承的方式,让大家不再被各种各样的继承方式困扰。

class Parent {
	constructor(name, age) {
		this.name = name;
	}
	say() {
		console.log('my name is', this.name);
	}
}
class Child extends Parent {
	constructor(name, age) {
		super(name);
		this.name = name;
		this.age = age;
	}
}
const child = new Child('tom', 23);
child.say();

注意:super关键字作为函数时,只能在构造函数中调用,代表父类的构造函数。作为对象时,在普通方法中,指向父类的原型对象;在静态方法中,指向父类。

2.5 多重继承

多重继承是指一个子类同时继承多个父类,拥有这多个类的属性和方法。由于 JavaScript 的继承是基于原型链的,原型链一般只有一条链,无法同时指向多个不同的对象,因此 JavaScript 中是无法实现传统的多重继承。

注意:原型链的一般实现是单链表,以 [[prototype]] 指针指向下一个对象,直到最终指向 null

image_1dmik99mf1b15n49q1p1fj1s2q2a.png-22kB但是可以让父类分别互相继承,子类继承最后那个父类来实现多重继承。这种实现方式的缺点就是要在每个父类定义的时候继承另一个父类。

class Parent1 extends Parent2 {}
class Parent2 extends Parent3 {}
class Child extends Parent1 {}

另一种实现方式,就是我们前面提到过的 mixinmixin 不仅在各大框架中被广泛使用,也可以将多个父类进行混合,从而实现多重继承的效果。

function mixin(...mixins) {
    class Mixin {
        constructor(...args) {
            mixins.forEach(
                mixin => copyProperties(this, new mixin(...args)) // 拷贝实例属性
        		) 
        }
    }
    mixins.forEach(
        mixin => {
            copyProperties(Mixin, mixin); // 拷贝静态属性
            copyProperties(Mixin.prototype, mixin.prototype); // 拷贝原型属性
        }
    )

    return Mixin;
}

function copyProperties(target, source) {
    for (let key of Reflect.ownKeys(source)) {
      	if (['constructor', 'prototype', 'name'].indexOf(key) < 0) {
           	let desc = Object.getOwnPropertyDescriptor(source, key);
            Object.defineProperty(target, key, desc);
        }
    }
}

注意:Reflect 是 ES6 中的新 API,Reflect.ownKeys 是获取对象自身的属性,和 Object.keys 不同点在于还会返回不可枚举属性。

使用方式:

class Child extends mixin(Parent1, Parent2, Parent3) {

}

推荐阅读

  1. class 的基本语法
  2. class 的继承
  3. 继承与原型链