一次掌握 JavaScript 原型与继承

255 阅读8分钟

一切兼对象

首先要明白一个概念,在 JavaScript 中除了字符串数字布尔值Symbolundefined 这些原始类型的值之外,其他的一切都是对象,也就是说只要不属于这些原始类型的值,在 JavaScript 中遇到的其他数据类型的本质都是这个形式:

const data = {
    name: 'anyDataType',
    toString () {},
    toJSON () {}
}

“除了基本数据类型”,那为什么还说一切兼对象呢?这是因为将基本数据类型当作对象来操作也是可以的,JavaScript 引擎内部会在操作的一瞬间将基本数据类型封装为一个对象,操作结束后再销毁:

const s = 'It\'s a string.'
s.length // -> 14
// 相当于
const s = 'It\'s a string.'
new String(s).length

原型与原型链

我们再来创建一个空对象实例:

const o = {}
typeof o.toString // "function"

为什么对象o明明没有任何属性或方法,在它身上却能调用一个toString函数呢?

这是因为在 JavaScript 中所有的对象都有一个隐匿的__proto__属性,该属性指向另一个对象,这个对象就是原始对象的原型。

当调用一个对象的属性或方法时,如果该对象自身没有这个属性或方法,就会向它的原型也就是它的__proto__属性所指向的那个对象上去寻找,如果原型上也没有,那就到原型的原型上去找,就这样连成一条原型链

原型链的顶端是null

再回到空对象o上:

// “const o = {}” 相当于
const o = new Object()

此时o的原型就指向其构造函数Objectprototype属性,而toString就是Object.prototype对象的方法:

o.toString === Object.prototype.toString // true

对象的__proto__是可以被修改的:

const o2 = { name: 'o2' }
o.__proto__ = o2
o.name // "o2"

此时再调用o.toString:

typeof o.toString // "function"

o的原型已经变成o2了,而o2上没有toString方法,为什么o.toString还是一个function呢?这是因为o2也是一个对象,它也有原型,而它的原型就指向Object.prototype

o访问toStirng方法时,发现它自身和它的原型o2上都没有,所以就继续向o2的原型(Object.prototype)查找,所以整个过程就是一条完整的原型链。

Reflect.getPrototypeOf() Reflect.setPrototypeOf()

其实__proto__属性并不是 ECMAScript 语言的标准,它只是多数浏览器实现的一个非标准属性,如果你是一个十分注重根正功红、血统纯正的处女座,那么就应该使用标准的方法来读取和修改对象实例的原型:

const o = {}
// 读取原型
Object.getPrototypeOf(o) // 返回 o 的原型

// 修改原型
const o2 = { name: 'o2' }
Object.setPrototypeOf(o, o2) // 将 o 的原型修改为 o2

ES6 中将这两个方法挂载到了Reflect对象上,通过Reflect调用和通过Object调用是等价的。

构造函数

我们可以通过人肉手写的方式实现一个又一个实例:

const xiaoMing = {
    name: 'XiaoMing',
    age: 23,
    sayHi () {
        console.log(`Hi! My name is ${this.name}`)
    }
}
const xiaoHua = {
    name: 'XiaoHuang',
    age: 22,
    sayHi () {
        console.log(`Hi! My name is ${this.name}`)
    }
}

很弱智,对不对,代码重复而且容易出错,所以 JavaScript 提供了构造函数,可以使用构造函数来生成一个个相似的对象(实例),构造函数就是实例的模板。

xiaoMing xiaoHua都是人,都有nameagesayHi属性,所以将这些提炼出来,形成Human构造函数:

function Human(name, age) {
    this.name = name
    this.age = age
    this.sayHi = function () {
        console.log(`Hi! My name is ${this.name}`)
    }
}
const xiaoMing = new Human('XiaoMing', 23)
const xiaoHua = new Human('XiaoHua', 22)

new Human大体过程如下:

// “const xiaoMing = new Human('XiaoMing', 23)” 经历了如下过程
const xiaoMing = (function Human(name, age) {
    // 先创建一个新对象
    const _o = {}
    // 这个新对象的原型指向该构造函数的 prototype 属性
    Reflect.setPrototypeOf(_o, Human.prototype)
    // 之后构造函数中的所有 this 均指向这个新对象
    _o.name = name
    _o.age = age
    _o.sayHi = function () {
        console.log(`Hi! My name is ${this.name}`)
    }
    
    return _o
})('XiaoMing', 23)

以上过程要特别留意,所有实例对象的原型均被指向到构造函数的prototype对象上。明白了这一点后,再看Human构造函数,其中的sayHi其实在所有的实例中是相同的,所以没必要在每一个实例上挂载一个新的sayHi方法,可以将这个方法挂载到Humanprototype上,这样所有实例就可以通过原型链访问到这个方法了:

function Human(name, age) {
    this.name = name
    this.age = age
    // 将 this.sayHi 写到 prototype 中
}
Human.prototype.sayHi = function () {
    console.log(`Hi! My name is ${this.name}`)
}

完美。

构造函数的继承

回顾并牢记以下两个知识点:

  1. 原型是一个对象。
  2. 所有实例的原型__proto__就是其构造函数的prototype属性。

所有的函数在初始声明后,都默认有一个prototype属性,这个属性是一个对象,且只有一个成员constructor,就是这个样子:

{
    constructor: 构造函数本身
}

函数的prototype是可以修改的。

所以将一个构造函数的的prototype指向另一个构造函数的prototype,这样通过该构造函数生成的实例对象就可以通过原型链访问到其他构造函数prototype上的方法,实现继承:

// 先写一个父类 Human
function Human(name, age) {
    this.name = name
    this.age = age
}
Human.prototype.sayHi = function () {
    console.log(`Hi! My name is ${this.name}.`)
}

// 再写一个子类
function Gentleman(name, age, occupation) {
    Human.call(this, name, age)
    this.occupation = occupation
}
Gentleman.prototype = Human.prototype

// 此时创建一个实例
const jiBo = new Gentleman('山下智博', 30, 'vlogger')
jiBo.sayHi() // "Hi! My name is 山下智博."
jiBo.occupation // "vlogger"

上面我们通过在子类Gentleman中将this(此时的this就是子类实例化时内部生成的对象)绑定到 Human 并传参执行,使得最后的实例对象上可以挂载到父构造函数内部设置挂载的属性与方法,并且可以通过原型链访问到Human.prototype上的属性与方法。

但是如果要要给子类Gentlemanprototype添加一些只有子类才有的方法,上面的代码就行不能了,因为子类的prototype与父类的prototype指向同一个对象,修改子类就会影响到父类,此时就需要引入一个中间对象:

function Human () {...}

// 创建一个中间对象
const _o = {}
Reflect.setPrototypeOf(_o, Human.prototype)

function Gentleman () {...}
Gentleman.prototype = o

这时即使在子类Gentleman.prototype上添加新的属性和方法,也只是添加到了_o对象上,同时又可以通过原型链访问到Human.prototype

JavaScript 提供Object.create()方法,它接收一个对象作为参数,返回一个以传入对象为原型的新对象,其实整个过程类似于上面代码中创建中间对象_o的那两行代码,所以上面的代码可以改写成:

function Human () {...}

function Gentleman () {...}
Gentleman.prototype = Object.create(Human.prototype)

constructor

还记不记得所有函数默认的prototype对象上有一个constructor属性,该属性就指向该函数本身。

这个属性放到现在其实没什么用处,因为现在我们可以通过__proto__属性或者Reflect.setPrototypeOf() Reflect.getPrototypeOf()来读取和修改一个实例对象的原型,但在老一些的浏览器中这些属性和方法是没有的,只能通过获取实例对象的构造函数,再通过构造函数修改其自身的prototype属性来实现修改实例对象原型的目的:

function F () {}
const f = new F()
f.constructor.prototype.sayHi = function () { console.log('new property') }
f.sayHi() // "new property"

所以,因为这个历史原因,我们要养成一个好习惯,就是在修改了函数的prototype后,要手动地给新prototype添加一个constructor属性,并指向该函数自身:

function F () {}
F.prototype = { a: 1, b: 2 }
F.prototype.constructor = F

this

所有通过实例对象调用的方法,不管这个方法是实例对象自身的,还是从原型链上继承的,方法内部的this都指向这个实例对象:

function F() {}
F.prototype.intro = function () { console.log(this.name) }
const f = new F()
f.name = 'f'
f.intro() // "f"

注意,构造函数prototype属性上的方法千万不要使用箭头函数定义,因为箭头函数会将this与它定义时的环境绑定到一起,再通过实例对象调用时就不能改变this的值了:

function F() {}
F.prototype.intro = () => console.log(this.name)
const f = new F()
f.name = 'f'
f.intro() // ""

ES6 中的 class

ES6中新增了class语法,它只是一个语法糖,因为本质上还是通过构造函数和原型链实现的。

把上面的Humanclass的方式重写一遍:

class Human {
    constructor (name, age) {
        this.name = name
        this.age = age
    }
    sayHi () {
        console.log(`Hi! My name is ${this.name}.`)
    }
}

const xiaoMing = new Human('XiaoMing', 23)

对照着构造函数的老写法,class的语法也一目了然,其中constructor就是Human构造函数的本体,其余的函数就是定义在Human.prototype上的方法,so easy。

static 关键字

ES5中给一个构造函数添加静态方法(直接在构造函数上添加)是这样的:

function Human () { /*...*/ }
Human.isHuman(human) {
    return human instanceof Human
}

ES6的 class 中,通过给函数的前面添加一个static实现:

class Human {
    constructor () { /*...*/ }
    sayHi () { /*...*/ }
    static isHuman (human) {
        return human instanceof Human
    }
}

总结,在class中前面没有static关键字的函数被挂载到构造函数自身上,有static关键字的被挂载到构造函数的prototype上。

ES6 中的继承:extends 与 super 关键字

使用ES6的新语法实现继承了Human的子类Gentleman

class Gentleman extends Human {
    constructor (name, age, occupation) {
        super(name, age)
        this.occupation = occupation
    }
}

ES6规定,子类的constructor里必须实行super,且在执行super前不可使用this关键字,否则会报错。

这里的super代表的就是父类的constructor。因为ES6class实现继承时是先创建父类的实例,然后再通过子类对这个实例进行加工而成的,整个过程用ES5写大概就是这个样子:

// “new Human('山下智博', 30, 'vlogger')” 这里的 human 是通过 class 声明的
const _o = new Human('山下智博', 30)
Gentleman.call(_o, 'vlogger')
Reflect.setPrototypeOf(_o, Gentleman.prototype)

虽然创建的顺序与过去通过构造函数实现的继承不同,但本质是相同的。

通过class继承的子类,不仅prototype继承自父类的prototype,其本身(constructor)的原型也为父类:

Gentleman.prototype.__proto__ === Human.prototype // true
Gentleman.__proto__ === Human // true

所以,子类可以调用父类的静态方法。

super 的不同身份

super在子类中有三种身份:

  • 在子类的construtor中代表父类的constructor
  • 在子类的其他方法中,代表父类的prototype
  • 在子类的静态方法中,代表父类,可以通过它执行父类的静态方法

其实,要想掌握JavaScript中的继承,还是得必须完全掌握本文的前半部分,因为原型和原型链才是JavaScript中实现继承的真正方法,ES6class只是语法糖,它与其他语言如Javaclass还是有本质区别的,所以要抓住根本,把__proto__ prototype这些关键的知识点攻破,掌握class就是水到渠成的事了。

本文对class的介绍并不完整,希望了解更多的同学可以查看阮一峰老师的文章,那里写得非常详细,我就不再重复了。