《JavaScript的那些事》之原型与原型链(下篇)

360 阅读10分钟

前言

通过上篇文章后我们了解到了原型的概念,在下篇中将上篇剩下的 原型链class 补充完整。

什么是原型链?

原型链 顾名思义就是由多个原型对象组成。每个原型对象都有 __proto__ 属性并指向创建该原型对象的构造函数 prototype 原型,多个原型之间通过 __proto__ 连接形成原型链,这就是JavaScript实现继承和共享属性的方式。

__proto__prototype 要分清,prototype 只存在于构造函数中,同时构造函数拥有 __proto__,实例不存在 prototype

下面先来看看如何实现原型链:

function Father () {}
Father.prototype.surname = '李'
Father.prototype.saySurname = function () {
    return this.surname
}

function Son () {}
// 继承Father
Son.prototype = new Father()
Son.prototype.name = '小俊'
Son.prototype.sayName = function () {
    return this.name
}

const baby = new Son()
console.log('surname is ' + baby.saySurname()) // surname is 李
console.log('name is ' + baby.sayName()) // name is 小俊

上面的例子分别是定义了 FatherSon 两个构造函数,Father 原型上具有 surnamesaySurname 属性和方法,定义了 Son 后,通过创建一个 Father 实例并将该实例整体替换了 Son 原有的 prototype,实际上就是重写 Son 原型的操作,此时 Son 拥有了 Father 实例中的属性和方法,这就是原型继承机制。在实现了继承关系后修改从 Son 的新原型里添加了 namesayName,这使得 Son 同时具有 Father 的属性和方法,也有自己的方法。

最后通过 newSon 创建的 baby 实例,打印这个实例之后可以看到它的结构如下:

通过上图可以明显的看出 baby 的结构,__proto__ 内部又有 __proto__,第一层的 __proto__ 实际上是 Sonprototype,在继续下一层是 Fatherprototype,到了最底层就是 Object,这个说明了“Object 是原型链的最顶层原型对象”,同时也说明了 “万物皆对象”这句话。

可能在看上面的结构的时候会不理解为什么会是这样子的结构,不慌,只要理解了 Son 在继承 Father 的时候,JavaScript究竟做了什么就行。

// 继承Father
// Son.prototype = new Father()

// JavaScript干了下面这些事
Son.prototype = {}
Son.prototype.__proto__ = Father.prototype
Father.apply(Son.prototype)

上面就是在继承时,JavaScript所做的事,我们来看看 Son 的原型在继承前和继承后分别是什么样子的。

不难看出,继承前,Son的原型链是 Son — Object的,继承之后就变成了 Son — Father — Object,通过断链重连的方式来实现继承。

Son.prototype.__proto__ === Father.prototype
Father.prototype.__proto__ === Object.prototype
Object.prototype.__proto__ === null

原型链的属性查找机制:当查找对象的属性时,如果在对象中找不到该属性时,会沿着该对象的原型链上的原型继续寻找,即对象的 __proto__ 和 构造函数的 prototype,若在原型链中的某一处原型找到则会返回该属性并停止寻找,若直到原型链的最顶层的原型对象 Object.prototype 都找不到则返回 null

ES6的 class

在ES6之前,如果要定义一个类,都要定义一个构造函数,并设置这个构造函数的 prototype,如果我们不需要在构造方法中做任何操作的话,就必须要定义一个空函数,这样子来定义一个类,感觉会比较多余且复杂,甚至会混淆了函数和类的概念,所以ES6的开发者为了改善类的定义和写法,新增了像Java那样子的 class

如何声明一个类

ES6在声明一个类是非常的简单,同时并不会改变JavaScript的原型机制,并在原有的基础上新增了一些语法糖,如果学过Java的话,那么就应该很熟悉ES6的class。

class Person {
    // 构造方法
    constructor (name) {
        // 私有属性
        this.name = name
    }
    // 静态属性
    static sex = '男'
    // 原型上的方法
    sayName () {
        console.log(this.name)
    }
    // 原型上的属性
    get age () {
        return 18
    }
}

与ES6之前不同的是,定义一个类,将构造方法、原型和静态属性都写在类的结构中,这时候类的定义语法就简单明了得多,上面的代码在ES6之前的写法是这样子的:

// 构造方法
function Person (name) {
    // 私有属性
    this.name = name
}
// 静态属性
Person.sex = '男'
// 原型上的方法
Person.prototype.sayName = function () {
    consle.log(this.name)
}
// 原型上的属性
Person.prototype.age = 18

构造方法:在创建实例中调用的方法。

私有属性:创建实例后实例的私有属性,只在创建实例时调用构造函数才创建,不会出现在类的原型上,与原型上的属性不同,修改私有属性不会影响其他通过该类创建的实例。

静态属性:不会被创建的实例继承,只能通过类名直接调用。

函数声明和类声明的区别

  • 类声明内部的代码运行是强行在严格模式下运行的。
  • 函数和类生命在 typeof 后都是返回 function,函数声明可以直接调用构造方法,但类声明只能通过 new 关键字调用构造方法 constructor,不能直接调用。
  • 函数声明可以提升,但类不能提升,存在暂时性死区。
  • 通过函数声明来创建实例,修改 实例.__proto__的属性会影响其他通过该函数创建的实例的属性;在类声明中无法直接从实例修改类的原型。
  • 如果不需要在构造方法中做任何处理时,函数声明就必须要声明一个空函数,而类声明则可以不用写 constructor 构造方法,因为会默认有一个空的构造方法。
  • 类声明的原型上,方法和属性都是只读和不可枚举的,除非通过 Object.defineProperty 来设置原型的属性类型。

类的成员属性名称可以是变量

在定义类的成员名称时,可以使用 [] 包裹一个表达式来实现定义成员名称,如下代码:

let actionName = 'sayHellow'
let staticName = 'hahaha'
let getName = 'age'
class Person {
    static [staticName] = 666;
    [actionName] () {
        console.log('hellow word')
    }
    get [getName] () {
        return 18
    }
}

let person = new Person()
console.log(person.age) // 18
person.sayHellow() // hellow word
// 注意如果这时候修改变量的话,类和实例的属性名是不会一起修改的
actionName = 'sayHellow1'
person.sayHellow() // hellow word
person.sayHellow1() // [TypeError: person.sayHellow1 is not a function]

类的静态成员

类中定义静态成员只需要在属性名前加 static 关键字即可,static 修饰的成员无法被实例继承,只能通过类名来访问。如下:

class Person {
    static sayHellow () {
        console.log('I am static sayHellow')
    }
}

let p = new Person()
Person.sayHellow() // I am static sayHellow
p.sayHellow() // [TypeError: p.sayHellow is not a function]

// ----- 这是条分割线 ----- //
// 函数声明式定义静态成员
function Person () {}
Person.sayHellow = function () {
    console.log('I am static sayHellow')
}

类中可以定义同名的静态成员和公共成员,没有 static 修饰的成员可以被实例继承。

class Person {
    static sayHellow () {
        console.log('I am static sayHellow')
    }
    sayHellow () {
        console.log('I am prototype sayHellow')
    }
}

let p = new Person()
Person.sayHellow() // I am static sayHellow
p.sayHellow() // I am prototype sayHellow

类的表达式

类可以像函数表达式那样子写类表达式,也可以写成IIFE(立即调用表达式)。

// 类表达式
let Person = class {
    sayName () {
        console.log('啊俊俊')
    }
}
let person = new Person()
person.sayName() // 啊俊俊

// IIFE
let person = new class {
    constructor (name, sex) {
        [this.name, this.sex] = [name, sex]
    }
    say () {
        console.log(`My name is ${this.name},I am a ${this.sex}`)
    }
}('啊俊俊', 'boy')
person.say() // My name is 啊俊俊,I am a boy

类的继承

ES6中类的继承和Java相似,都是通过 extends 实现继承,这样子比通过“断链重连”的语法要更加清晰明了。在子类继承父类之后,会将父类的 prototypestatic 静态属性都一起继承。

class Father {}

class Son extends Father {}

如何在子类中调用父类的构造方法

通过 extends 继承父类,会将父类的原型和静态属性都一起继承,如果要在子类中调用父类的构造方法时就要调用 super() 方法,同时 super() 只能在子类中进行调用,不能从父类进行调用。

class Person {
    constructor (name, sex) {
        [this.name, this.sex] = [name, sex]
    }
}

class Child extends Person {
    constructor (name, sex, girlfriend) {
        super(name, sex)
        this.girlfriend = girlfriend
    }
    say () {
        console.log(`My name is ${this.name}, I am a ${this.sex}, my girlfriend is ${this.girlfriend}`)
    }
}

let me = new Child('啊俊俊', 'boy', '君君')
me.say() // My name is 啊俊俊, I am a boy, my girlfriend is 君君

super 的调用方式

super 关键字既可以当做方法使用,也可以当做对象使用,但使用方式是有区别的。

super 用作函数调用时

  • super() 是调用父类的构造方法 constructor,同时只能在子类的 constructor 中调用。
  • super() 在子类的 constructor 里必须先于 this 调用。
// 错误一,super() 不在子类的 constructor 中调用
class Father {}
class Son extends Father {
    sayName () {
        super() // [SyntaxError: 'super' keyword unexpected here]
    }
}

// 错误二,子类的 constructor 内调用 this 在 super() 之前。
class Father {}
class Son extends Father {
    constructor () {
        this.name = 1
        super() // [ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor]
    }
}
let s = new Son()

super 用作对象调用时

super 用作对象调用的时候,在子类的原型方法内,调用时 super 指向父类原型,在子类的静态方法内,指向父类。

class Father {
    static fatherSay () {
        console.log('我是父类的static')
    }
    fatherSay () {
        console.log('我是父类的prototype')
    }
}

class Son extends Father {
    static sonSay () {
        super.fatherSay()
    }
    sonSay () {
        super.fatherSay()
    }
}

let son = new Son()
Son.sonSay() // 我是父类的static
son.sonSay() // 我是父类的prototype

关于 super 时的this指向

  1. 在子类的原型方法中通过 super 调用父类的方法时,super会将 this 指向子类。
class Father {
    constructor () {
        this.name = '爸爸'
    }
    sayName () {
        console.log(this.name)
    }
}

class Son extends Father {
    constructor () {
        super()
        this.name = '宝贝儿子'
    }
    say () {
        super.sayName()
    }
}
let son = new Son()
son.say() // 宝贝儿子

上面的代码在调用 son 实例的 say 时,say调用super来调用父类,同时会通过call来绑定this指向,super.sayName.call(this)

  1. 通过 super.xxx = xxx 来对某个 super 的某个属性进行赋值,由于super绑定了this的原因,这种方式的赋值都会变成子类实例的属性。
class Father {
    constructor () {
        this.name = '爸爸'
    }
}
class Son extends Father {
    constructor () {
        super()
        this.name = '宝贝儿子'
    }
    changeSuper () {
        super.age = 18
        super.name = '宝贝女儿'
    }
    say () {
        console.log(super.age) // undefined
        console.log(this.name) // 宝贝女儿
        console.log(this.age)  // 18
    }
}
let son = new Son()
son.changeSuper()
son.say()

上面的代码中,在 changeSuper 方法里对 super 的属性进行赋值,但这里的赋值实际上是 this.age = 18this.name = '宝贝女儿',从而让子类拥有了 age 这个属性,如果是获取 super.age 的话就是获取 Father.prototype.age,由于父类并没有这个属性,所以返回 undefined

原型属性重写

在子类继承父类之后,可以直接在子类重写父类的原型属性,直接看下面的代码:

class Father {
    say () {
        console.log('我是父类的方法')
    }
}
class Son extends Father {
    say () {
        console.log('我是子类的方法')
    }
}
let son = new Son()
son.say() // 我是子类的方法

判断实例的类型

在创建实例之后,通过 instanceof 来判断类型的时,若右侧的类型在该实例的原型链上存在,判断的结果都会是 true,所以在使用 instanceof 来判断类型时要注意一下。

class Father {}
class Son extends Father {}

let f = new Father()
let s = new Son()

console.log(f instanceof Father)// true
console.log(f instanceof Son) // false
console.log(f instanceof Object) // true
console.log(s instanceof Father) // true
console.log(s instanceof Son) // true
console.log(s instanceof Object) // true

总结

不得不说,原型和原型链所涉及的范围都比较大,同时也是JavaScript的基础,在面试或笔试的时候面试官都非常喜欢问,在这两篇文章中,先从上篇的原型中的 函数对象构造函数实例newprototype__proto__contructor 入手理解原型,在到下篇的 原型链class 进一步了解整个原型相关的概念,其中可能会有部分的知识点遗漏,但也希望能给你带来收获。