Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JavaScript基础: 类与继承 #27

Open
MrErHu opened this issue Mar 25, 2018 · 0 comments
Open

JavaScript基础: 类与继承 #27

MrErHu opened this issue Mar 25, 2018 · 0 comments

Comments

@MrErHu
Copy link
Owner

MrErHu commented Mar 25, 2018

前言

  首先欢迎大家关注我的Github博客,也算是对我的一点鼓励,毕竟写东西没法获得变现,能坚持下去也是靠的是自己的热情和大家的鼓励。
  
  许久已经没有写东西了,因为杂七杂八的原因最近一直没有抽出时间来把写作坚持下来,感觉和跑步一样,一旦松懈下来就很难再次捡起来。最近一直想重新静下心来写点什么,选题又成为一个让我头疼的问题,最近工作中偶尔会对JavaScript继承的问题有时候会感觉恍惚,意识到很多知识即使是很基础,也需要经常的回顾和练习,否则即使再熟悉的东西也会经常让你感到陌生,所以就选择这么一篇非常基础的文章作为今年的开始吧。
  

  JavaScript不像Java语言本身就具有类的概念,JavaScript作为一门基于原型(ProtoType)的语言,(推荐我之前写的我所认识的JavaScript作用域链和原型链),时至今日,仍然有很多人不建议在JavaScript中大量使用面对对象的特性。但就目前而言,很多前端框架,例如React都有基于类的概念。首先明确一点,类存在的目的就是为了生成对象,而在JavaScript生成对象的过程并不不像其他语言那么繁琐,我们可以通过对象字面量语法轻松的创建一个对象:

var person = {
    name: "MrErHu", 
    sayName: function(){
        alert(this.name);
    }
};

  一切看起来是这样的完美,但是当我们希望创建无数个相似的对象时,我们就会发现对象字面量的方法就不能满足了,当然聪明的你肯定会想到采用工厂模式去创建一系列的对象:
  

function createObject(name){
    return {
        "name": name,
        "sayName": function(){
            alert(this.name);
        }
    }
}

  但是这样方式有一个显著的问题,我们通过工厂模式生成的各个对象之间并没有联系,没法识别对象的类型,这时候就出现了构造函数。在JavaScript中构造函数和普通的函数没有任何的区别,仅仅是构造函数是通过new操作符调用的。
  

function Person(name, age, job){
    this.name = name;
    this.sayName = function(){
        alert(this.name);
    };    
}

var obj = new Person();
obj.sayName();

  我们知道new操作符会做以下四个步骤的操作:
  

  1. 创建一个全新的对象
  2. 新对象内部属性[[Prototype]](非正式属性__proto__)连接到构造函数的原型
  3. 构造函数的this会绑定新的对象
  4. 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象

  这样我们通过构造函数的方式生成的对象就可以进行类型判断。但是单纯的构造函数模式会存在一个问题,就是每个对象的方法都是相互独立的,而函数本质上就是一种对象,因此就会造成大量的内存浪费。回顾new操作符的第三个步骤,我们新生成对象的内部属性[[Prototype]]会连接到构造函数的原型上,因此利用这个特性,我们可以混合构造函数模式原型模式,解决上面的问题。

function Person(name, age, job){
    this.name = name;
}

Person.prototype = {
    constructor : Person,
    sayName : function(){
        alert(this.name);
    }
}

var obj = new Person();
obj.sayName();

  我们通过将sayName函数放到构造函数的原型中,这样生成的对象在使用sayName函数通过查找原型链就可以找到对应的方法,所有对象共用一个方法就解决了上述问题,即使你可能认为原型链查找可能会耽误一点时间,实际上对于现在的JavaScript引擎这种问题可以忽略。对于构造函数的原型修改,处理上述的方式,可能还存在:
  

Person.prototype.sayName = function(){
    alert(this.name);
}

  我们知道函数的原型中的constructor属性是执行函数本身,如果你是将原来的原型替换成新的对象并且constructor对你又比较重要记得手动添加,因此第一种并不准确,因为constructor是不可枚举的,因此更准确的写法应该是:

Object.defineProperty(Person, "constructor", {
    configurable: false,
    enumerable: false,
    writable: true,
    value: Person
});

  到现在为止,我们会觉得在JavaScript中创建个类也太麻烦了,其实远远不止如此,比如我们创建的类可能会被直接调用,造成全局环境的污染,比如:
  

Person('MrErHu');
console.log(window.name); //MrErHu

  不过我们迎来了ES6的时代,事情正在其变化,ES6为我们在JavaScript中实现了类的概念,上面的的代码都可以用简介的类(class)实现。
  

class Person {
    constructor(name){
        this.name = name;
    }
    
    sayName(){
        alert(this.name);
    }
}

  通过上面我们就定义了一个类,使用的时候同之前一样:
  

let person = new Person('MrErHu');
person.sayName(); //MrErHu

  我们可以看到,类中的constructor函数负担起了之前的构造函数的功能,类中的实例属性都可以在这里初始化。类的方法sayName相当于之前我们定义在构造函数的原型上。其实在ES6中类仅仅只是函数的语法糖:
  

typeof Person  //"function"

  相比于上面自己创建的类方式,ES6中的类有几个方面是与我们自定义的类不相同的。首先类是不存在变量提升的,因此不能先使用后定义:
  

let person = new Person('MrErHu')
class Person { //...... } 

  上面的使用方式是错误的。因此类更像一个函数表达式。
  
  其次,类声明中的所有代码都是自动运行在严格模式下,并且不能让类脱离严格模式。相当于类声明中的所有代码都运行在"use strict"中。
  
  再者,类中的所有方法都是都是不可枚举的。
  
  最后,类是不能直接调用的,必须通过new操作符调用。其实对于函数有内部属性[[Constructor]][[Call]],当然这两个方法我们在外部是没法访问到的,仅存在于JavaScript引擎。当我们直接调用函数时,其实就是调用了内部属性[[Call]],所做的就是直接执行了函数体。当我们通过new操作符调用时,其实就是调用了内部属性[[Constructor]],所做的就是创建新的实例对象,并在实例对象上执行函数(绑定this),最后返回新的实例对象。因为类中不含有内部属性[[Call]],因此是没法直接调用的。顺便可以提一句ES6中的元属性 new.target
  
  所谓的元属性指的就是非对象的属性,可以提供给我们一些补充信息。new.target就是其中一个元属性,当调用的是[[Constructor]]属性时,new.target就是new操作符的目标,如果调用的是[[Call]]属性,new.target就是undefined。其实这个属性是非常有用的,比如我们可以定义一个仅可以通过new操作符调用的函数:

function Person(){
    if(new.target === undefined){
        throw('该函数必须通过new操作符调用');
    }
}

  或者我们可以用JavaScript创建一个类似于C++中的虚函数的函数:

class Person {
  constructor() {
    if (new.target === Person) {
      throw new Error('本类不能实例化');
    }
  }
}

  

继承

  在没有ES6的时代,想要实现继承是一个不小的工作。一方面我们要在派生类中创建父类的属性,另一方面我们需要继承父类的方法,例如下面的实现方法:
  

function Rectangle(width, height){
  this.width = width;
  this.height = height;
}

Rectangle.prototype.getArea = function(){
  return this.width * this.height;
}

function Square(length){
  Rectangle.call(this, length, length);
}

Square.prototype = Object.create(Rectangle.prototype, {
  constructor: {
    value: Square,
    enumerable: false,
    writable: false,
    configurable: false
  }
});

var square = new Square(3);

console.log(square.getArea());
console.log(square instanceof Square);
console.log(square instanceof Rectangle);

  首先子类Square为了创建父类Rectangle的属性,我们在Square函数中以Rectangle.call(this, length, length)的方式进行了调用,其目的就是在子类中创建父类的属性,为了继承父类的方法,我们给Square赋值了新的原型。除了通过Object.create方式,你应该也见过以下方式:
  

Square.prototype = new Rectangle();
Object.defineProperty(Square.prototype, "constructor", {
    value: Square,
    enumerable: false,
    writable: false,
    configurable: false
});

  Object.create是ES5新增的方法,用于创建一个新对象。被创建的对象会继承另一个对象的原型,在创建新对象时还可以指定一些属性。Object.create指定属性的方式与Object.defineProperty相同,都是采用属性描述符的方式。因此可以看出,通过Object.createnew方式实现的继承其本质上并没有什么区别。
  
  但是ES6可以大大简化继承的步骤:

class Rectangle{
    constructor(width, height){
        this.width = width;
        this.height = height;
    }
    
    getArea(){
        return this.width * this.height;
    }
}

class Square extends Rectangle{
    construct(length){
        super(length, length);
    }
}

  我们可以看到通过ES6的方式实现类的继承是非常容易的。Square的构造函数中调用super其目的就是调用父类的构造函数。当然调用super函数并不是必须的,如果你默认缺省了构造函数,则会自动调用super函数,并传入所有的参数。
  
  不仅如此,ES6的类继承赋予了更多新的特性,首先extends可以继承任何类型的表达式,只要该表达式最终返回的是一个可继承的函数(也就是讲extends可以继承具有[[Constructor]]的内部属性的函数,比如null和生成器函数、箭头函数都不具有该属性,因此不可以被继承)。比如:

class A{}
class B{}

function getParentClass(type){
    if(//...){
        return A;
    }
    if(//...){
        return B;
    }
}

class C extends getParentClass(//...){
}

  可以看到我们通过上面的代码实现了动态继承,可以根据不同的判断条件继承不同的类。
  
  ES6的继承与ES5实现的类继承,还有一点不同。ES5是先创建子类的实例,然后在子类实例的基础上创建父类的属性。而ES6正好是相反的,是先创建父类的实例,然后在父类实例的基础上扩展子类属性。利用这个属性我们可以做到一些ES5无法实现的功能:继承原生对象。

function MyArray() {
  Array.apply(this, arguments);
}

MyArray.prototype = Object.create(Array.prototype, {
  constructor: {
    value: MyArray,
    writable: true,
    configurable: true,
    enumerable: true
  }
});

var colors = new MyArray();
colors[0] = "red";
colors.length  // 0

colors.length = 0;
colors[0]  // "red"

  可以看到,继承自原生对象ArrayMyArray的实例中的length并不能如同原生Array类的实例
一样可以动态反应数组中元素数量或者通过改变length属性从而改变数组中的数据。究其原因就是因为传统方式实现的数组继承是先创建子类,然后在子类基础上扩展父类的属性和方法,所以并没有继承的相关方法,但ES6却可以轻松实现这一点:

class MyArray extends Array {
  constructor(...args) {
    super(...args);
  }
}

var arr = new MyArray();
arr[0] = 12;
arr.length // 1

arr.length = 0;
arr[0] // undefined

  我们可以看见通过extends实现的MyArray类创建的数组就可以同原生数组一样,使用length属性反应数组变化和改变数组元素。不仅如此,在ES6中,我们可以使用Symbol.species属性使得当我们继承原生对象时,改变继承自原生对象的方法的返回实例类型。例如,Array.prototype.slice本来返回的是Array类型的实例,通过设置Symbol.species属性,我们可以让其返回自定义的对象类型:
  

class MyArray extends Array {
  static get [Symbol.species](){
    return MyArray;
  }
    
  constructor(...args) {
    super(...args);
  }
}

let items = new MyArray(1,2,3,4);
subitems = items.slice(1,3);

subitems instanceof MyArray; // true

  最后需要注意的一点,extends实现的继承方式可以继承父类的静态成员函数,例如:
  

class Rectangle{
    // ......
    static create(width, height){
        return new Rectangle(width, height);
    }
}

class Square extends Rectangle{
    //......
}

let rect = Square.create(3,4);
rect instanceof Square; // true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant