关于Javascript原型与继承的思考

从一个对象开始

不知你有没有想过,什么是对象?在Javascript中,对象是这样的

const animal = {
    name: 'a',
    getName() {
        return this.name
    }
}
复制代码

如果你还不清楚this的指向问题,看这里 this

因此,我们可以这么简单的描述,对象,是属性的集合,是一种数据结构,是一种代码的组织形式,是一种对现实世界物体的表示方法。
关于面向对象的不再多说,总之,我们就从这个对象为起点,开始讨论。

对象的模板

现在,假设我们需要3个对象,表示3只动物,如果直接把上面的代码复制3遍,那可是一个不好的习惯,要改掉哦,谨记DRY原则,Don’t Repeat Yourself。
最好呢,有一个模板,比如用函数:

function newAnimal(name) {
    return {
        name,
        getName() {
            return this.name
        }
    }
}

const animal1 = newAnimal('a')
const animal2 = newAnimal('b')
const animal3 = newAnimal('c')
复制代码

按下F12,讲这些代码复制进去运行看看,这样生成的对象没问题。

这样的写法,至少看着就不优雅了,而且继承也不好搞,关于继承先别急,后面会说。
这里介绍下Javascript的关键字:new,上面的代码可以这么写

function Animal(name) {
    this.name = name,
    this.getName = function() {
        return this.name
    }
}

const animal1 = new Animal('a')
const animal2 = new Animal('b')
const animal3 = new Animal('c')
复制代码

看着是不是就优雅了一些呢。
那么,这个new关键字干了啥呢?我们自己实现一个简单的函数来模拟new:

// new关键字当然不止干了这点事,其他的先不管
function newFunc(func, name) {
    var obj = {} // 创建一个新的空对象
    func.call(obj, name) // 调用函数,并将函数里面的this指向到obj
    return obj // 返回这个对象
}
const animal4 = newFunc(Animal, 'd')
复制代码

运行起来,对比下animal3跟animal4。
图片[1]-关于Javascript原型与继承的思考-一一网
animal3左边有个Animal,是函数Animal的名字,也既Animal.name,说明这个对象是Animal创建的。

关于new的更多资料,参考new运算符的介绍
如果call函数看不懂,参考call函数介绍

对象的原型(prototype)

使用new来执行一个函数,是解决了模板的问题,但是还存在一个不可忽视的问题:每次创建一个对象的时候,对象里面的所有属性都要创建一次。

对于有些属性,比如函数getName,只需要一个,所有对象都使用这个函数就行了,多个相同的函数会浪费内存,显然可以优化。

而且,记住,Javascript是动态语言,运行过程中的属性是可以随意修改的,为了避免在运行中,修改函数的实现的需要找到所有对象一个个的改,也不能让每个对象都自带一个函数。

Javascript对此的解决方案,叫做原型(prototype),这么写:

function Animal(name) {
    this.name = name
}
// 把getName放到了原型上
Animal.prototype.getName = function() {
    return this.name
}
var animal = new Animal('a')
console.log(animal.getName())
复制代码

运行起来,getName能调用到。

prototype哪里来的

Javascript对每一个函数,自动给它加上prototype属性,它就是个对象。

实际上不是所有函数都有prototype的,下面会说到。

// 默认的原型对象
Animal.prototype = {
    constructor: Animal // constructor是啥?先无视它
}

// 这只是个默认操作,如果我们用这样的写法
// 默认的对象就会被覆盖掉,constructor就没了
Animal.prototype = {
    getName: function() {
        return this.name
    }
}
复制代码

运行代码,将两种写法运行下进行验证。
这样写有constructor

这样写没有

对象为什么可以直接调用prototype上的属性

new的时候,Javascript会给新建的对象设置一个__proto__属性,指向prototype。

animal.__proto__ = Animal.prototype
复制代码

然后,当在animal本身找不到属性时,会往__proto__里面找。
也既,animal.__proto__.getName 可以直接写成animal.getName

__proto__属性已被删除,你不可以在代码中使用它。MDN这里说了

基于原型的继承

想必继承的概念不必解释吧。假设我们现在需要写一个对象来表示狗,代码是这样的:

function Dog(name) {
    this.name = name
}
Dog.prototype.getName = function() {
    return this.name
}
Dog.prototype.bark = function() {
    return 'wang'
}
复制代码

我们来分析下这段代码,先跟Animal对比下,问题就很明显,大部分代码跟Animal重复了。
所以,最好让Dog继承Animal。
首先,分析Animal,可以看到,Animal实例化后的对象属性分两种:

  • 一种是对象本身的,写在类函数里面
  • 一种是原型上的,写在prototype上面

对于第一种,可以像模拟new的实现一样,使用call函数把属性复制过来。
对于第二种,因为对象可以访问原型上的属性,让Dog的原型指向Animal的原型可以吗?
答案是,不可以

如果Dog原型直接指向Animal原型,Dog跟Animal就共用一个原型对象了,在Dog里面加个属性,Animal里面就有了,假如我们再写个对象Cat继承Animal,那Cat里面岂不是也有了个bark属性?

第一次继承尝试

既然不能直接指向,我们先尝试复制一个Animal的原型对象,指向这个复制后的,代码如下

function Dog(name) {
    // 运行Animal函数,将函数里面的this指向Dog的this,相当于给Dog添加属性
    Animal.call(this, name)
}
// 复制Animal的原型对象,老规矩,当constructor不存在
Dog.prototype = Object.assign({}, Animal.prototype)
Dog.prototype.bark = function() {
    return 'wang'
}
复制代码

Object.assign的介绍 看这里

运行代码,跟预期的结果相符

这样的方式,虽说可用,但也有限制。记住,Javascript是动态语言,如果在复制完Dog的属性之后,Animal的原型上增加了一个属性,Dog是没有的。

// 比如给Animal加个walk属性
Animal.prototype.walk = function() {
    return 'walking'
}
复制代码

再运行代码,可以看到,walk属性dog里面并没有。

第二次继承尝试

我们再回顾下原型,JS在查找属性的时候,如果本身没找到,会去__proto__里面找。如果__proto__里面也没找到呢?
这里就要说下原型链了,简单的说,__proto__里面也可以继续嵌套__proto__的,属性查找就可以这样继续下去。
一个Dog的创建出来的对象如果能长这样子,不就能解决Animal原型上增加了属性Dog里面没有的问题了吗?

var dog = new Dog('dog')
// 这里只是示例,实际上并不相等
dog == {
    name: 'dog',
    __proto__: {
        bark: function() {
            return 'wang'
        },
        __proto__: {
            getName: function() {
                return this.name
            }
        }
    }
}
复制代码

我们再回想之前讲到的:

  • 第一,使用new创建对象时干了这么一件事
dog.__proto__ === Dog.prototype
复制代码
  • 第二,原型的默认值是这样的,并且可以更改
Dog.prototype = {
    constructor: Dog
}
复制代码
  • 第三,这个上面没说过。
// 我们平时创建对象都习惯使用声明式语法
const prototype = {
    constructor: Dog
}
// 其实还有个构造式语法
const prototype = new Object()
prototype.constructor = Dog
// 两者创建的对象时一样的。
复制代码

在构造式语法中,我们就可以轻易看出来,原型对象,它也是有__proto__的。

Dog.prototype.__proto__ === Object.prototype
复制代码

因此,只要Dog的原型不用默认的,用Animal的实例,像这样子

Dog.prototype = new Animal('ani')
复制代码

但是呢,这么写的话,Dog.prototype上就存在name属性了。

当然,这样问题也不大,根据属性查找规则,实例本身的name属性优先级是要高于原型上的,所以正常访问也不会访问出错。但是,如果把实例上的name属性删除了,dog.name还是会存在,也就是说这个属性怎么删也删不掉。
聪明的你,可能会想,那我把原型上的name删除不就完了吗?这。。。也行吧。

我们有更好的解决办法,既然我们想要的只是Animal的原型,那就可以这样

function T() {}
T.prototype = Animal.prototype
Dog.prototype = new T()
复制代码

到这里,我们就实现一个理想的继承了。
来看下JS的继承完整代码

function Animal(name) {
    this.name = name
}

Animal.prototype.getName = function() {
    return this.name
}

function Dog(name) {
    Animal.call(this, name)
}

function T() {}
T.prototype = Animal.prototype
Dog.prototype = new T()

Dog.prototype.bark = function() {
    return 'wang'
}
复制代码

看下运行效果
图片[2]-关于Javascript原型与继承的思考-一一网
再看下这代码,别说优雅了,看都很难看懂好吧。
连封装成一个函数都不好封装,因为Dog里面为了得到Animal的属性,是必须要调用Animal的。

ES6的新写法

所幸,现在是2021年了,javascript的划时代版本,ES6,已经推出6年了。来看下ES6版本的代码

class Animal {
    constructor(name) {
        this.name = name
    }
    
    getName() {
        return this.name
    }
}

class Dog extends Animal {
    constructor(name) {
        super(name)
    }
    
    bark() {
        return 'wang'
    }
}
复制代码

是不是看着都舒服,但是这些class extends super都是啥啊?

类理论

说到这些关键字,就不得不提下类。如果你学过Java,就很熟悉类是什么,简单说一句,类就是一种设计模式,对继承思想的一种实现。
你可能从来没把类当成一种设计模式来看,但看看我们用Javascript实现继承,从头到尾都没提到类,不也照样实现了,只是代码好丑,所以Javascript在ES6版本就借用了类的语法。
要记住,继承,是一种编程思想;类,是一种实现方案。
Javascript另辟跷径的用原型也做到了,思路都是相通的。

关于类与继承的更多资料,推荐一本书,你不知道的Javascript,上卷第二部分第四章,深度好书,每一章都值得看。

再顺便看下Java的继承代码实现

public class Animal {
    private String name = "";

    public Animal(String name) {
        this.name = name;
    }

    public String getName() {
        return this.name;
    }
}
public class Dog extends Animal {
    public Dog(String name) {
        super(name);
    }

    public String bark() {
        return "wang";
    }
}
复制代码

是不是看起来就挺像的。但是,Javascript中的类,就是语法糖,那些类语法的关键字class extends super啊,都是虚的,记住能这么写就行,实际运行时还是原型那一套。

对比Java的类以及Javascript的原型对继承的实现

Javascript的原型实现继承我们已经基本清楚了,那它跟基于类的继承有什么区别呢?

这里只是为了跟Javascript做个对比,会说的尽量简单,大概思路清楚就行。
如果想知道Java继承的详细实现,可以去这里看看

概念对应

首先理清两个概念跟Javascript的对应关系。

  • 变量:指的是除了函数之外的数据类型
  • 方法:就是函数

先来看下Java的class是干嘛用的。不去管public private这些权限修饰符。class里面有

  • 静态方法
  • 静态变量
  • 实例方法
  • 实例变量
  • 构造函数

以Animal为例,静态属性跟静态方法的调用是这样的

Animal.a
Animal.b()
复制代码

在Javascript中,Animal首先是个对象,然后才是个函数,这也意味着,我们可以给Animal本身添加属性,这就模拟了Java中的静态变量、方法了。

函数跟对象的区别,看这里

再看下实例方法跟实例变量
Javascript写在函数里面的this.xxx就是实例属性,而且,比Java更强大的是,Javascript中函数是一等公民。

// Javascript可以把函数也当成变量
class A {
    constructor() {
        this.go = function() {
            return 'gogogo'
        }
    }
}
复制代码
// Java中只能把函数当成方法
public class A {
    public String go() {
        return 'gogogo';
    }
}
复制代码

不过在Java8之后引入了lambda表达式,这里就不管它了。
至于构造函数,Javascript中就是Animal本身了。

具体实现

先加载类

你可以这么理解,Java在加载类的时候,先把类变成一个对象,里面放着静态属性、静态方法、实例方法、构造函数,以及对父类的引用。
而在Javascript中,静态属性静态方法都是函数的属性,实例方法在原型上,构造函数就是函数本身,对父类的引用也在原型上。

再实例化对象

当Java执行new Animal的时候,就会调用类的构造函数,并逐级往上,调用所有父类的构造函数,将所有实例变量放到new出来的对象,并存放Animal类的引用。
以animal跟dog为例,大概长这样子

dog = {
    '类Dog的引用': {
        '父类Animal的引用': {
            '实例方法getName': function() { return this.name }
        }
        '实例方法bark': function() { return 'wang' }
    }
    '父类Animal的实例变量': {
        name: 'parent'
    },
    '类Dog的实例变量': {
        name: 'parent'
    }
}
复制代码

大概就是这样,所以,可以把类看成Javascript的原型,都是引用,不会把这些函数放在自己身上。
这里可以看出一个区别,Javascript继承时如果遇到子类的属性跟父类一样,子类会直接覆盖,而Java会两者都保留,而且通过多态可以切换访问父类与子类的实例变量。
对于方法的查找也跟Javascript差不多,都是逐级向上。
总的来说,基于原型跟基于类的实现,其实大同小异。

尴尬的constructor

constructor,就是构造函数。干嘛用的呢?先别急,看下Java,没办法,构造函数就是类理论的东西,Javascript老想着模拟类咱也没办法。

Java为什么需要构造函数

因为Java实例化一个对象的模板是类,而Javascript的模板是函数,函数可以直接执行,类怎么直接执行呢?

如果你非要问函数为什么可以直接执行,那就没完没了,不是一篇文章讲得完的。

因此,同样是 new Animal() 这么一句代码。

  • Java的Animal是一个类,需要借助构造函数执行初始化操作
  • Javascript的Animal是一个函数,直接就执行初始化操作了

constructor有啥用

我们再想想,Javascript,它需要再来个构造函数吗?明显不需要嘛。
那Javascript搞了这个constructor有啥用呢?
也不能说没用吧,至少面试的时候面试官会问下。开玩笑的。
不过,在ES6之前,constructor还真的没啥用,就规范里说了原型上要有个构造函数,指向函数本身,然后就没了,Javascript中没有任何地方使用到原型上的构造函数。
到了ES6,constructor终于被用上了,除此之外,有些第三方库的代码也会使用这个属性。

关于构造函数干嘛用,请看这里

只要记住原型上有个构造函数,指向函数本身,不能弄没了,有人会用。

prototype constructor __proto__的三角关系

大部分都理不清这些关系,我感觉是因为对原型不够了解造成的,特别是原型跟类混在一起就更乱了。
我们以ES6版本的Animal跟Dog为例,一步步来。

// new一条狗
const dog = new Dog('dog')
复制代码

把这条狗展开

再把Dog这个函数展开
图片[3]-关于Javascript原型与继承的思考-一一网
有点尴尬,这个展不开,不管了。

dog.__proto__ === Dog.prototype

对比dog的展开,是不是明摆着一样的。再用全等号验证下

// 那么
dog.__proto__.__proto__  === Dog.prototype.__proto__
// 并且
Dog.prototype === new Animal()// 可以当作是Animal的实例。假设叫它animal
// 又因为
animal.__proto__ === Animal.prototype
// 综上所述
dog.__proto__.__proto__ === Animal.prototype
// 同理可得
dog.__proto__.__proto__.__proto__ === Object.prototype
// 不管继承了多少次,这么推导下去一定能一级一级的推导出来
复制代码

dog.constructor === Dog

记住,constructor只在原型中存在。

// 因为
Object.hasOwnProperty.call(dog, 'constructor') === false
// 所以
dog.constructor === dog.__proto__.constructor
// 又因为
dog.__proto__ === Dog.prototype
// 所以
dog.__proto__.constructor === Dog.prototype.constructor
// 所以
dog.constructor === Dog
// 再来个乱七八糟的
Dog.prototype.__proto__.constructor == Animal
// 自己推导试试
复制代码

Dog.constructor === Function

同对象一样,函数也有构造式写法

const Dog = new Function(name, 'Animal.call(this, name)')
复制代码

这样就可以看出来,Dog,是Function的一个实例。

来一些探索

请注意,下面涉及到Javascript本质的讨论,比较有难度,我也还没吃透,难免有些错误

Object哪来的

可能你会说,Object是Javascript内置的,是这么说没错,但我们来更进一步试试看。
先打印下Object
图片[4]-关于Javascript原型与继承的思考-一一网
看不到Object的源代码代码。

[native code]是什么意思呢,可以看下别人怎么说 别人的回答

那么我们再试试这个。
图片[5]-关于Javascript原型与继承的思考-一一网
哦,Object居然是一个函数。倒也是,new Object() 也很好的证明的这一点,因为new后面只能是函数。

如果new一个对象的时候,函数没有参数,不带括号也可以的哦,new Object也是正确语法

那么,我们就可以说,下面的等式也是成立的

// 自己推导,不解释
Object.__proto__ === Function.prototype
Object.constructor === Function
Object.prototype === ? // prototype是创建函数默认加上的,下面继续讨论
Object.prototype.__proto__ === null // 没了,原型链到此结束
复制代码

这里有个地方打破了我的认知,默认对象都是Object的实例。
那么Object.prototype是对象吗?当然是。
那么它是Object的实例吗?居然不是。
如果是,那么,Object.prototype.__proto__就应该等于Object.prototype。但是它等于null啊。
不过这也好理解,如果这样设计,原型链就没有终点了,这会导致属性的查询陷入死循环。

再看看Function

跟Object一样,打印出来也是[native code],也是一个函数。

那么问题来了,Function的constructor __proto__ prototype会是什么呢?
我看来看下浏览器的打印
图片[6]-关于Javascript原型与继承的思考-一一网
说实话,看到这些玩意我人都傻了。

我一直以为prototype默认就是个new Object,可这是啥?函数?不过函数也是一个可执行的对象,这总没错吧,来试试看。
图片[7]-关于Javascript原型与继承的思考-一一网
还好,这个是对的。

那么,所有函数都有prototype这个结论靠谱吗?经过一番查证,还真找到了答案,不靠谱
图片[8]-关于Javascript原型与继承的思考-一一网
意不意外,函数不一定有prototype哦。

从上面的运行结果我们可以得出以下结论

Function.constructor === Function
Function.__proto__ === Function.prototype
复制代码

这说明了什么?Function自己创建了自己?对于这个答案,我是不满意的。
继续找答案,这种问题应该要找RFC规范吧,然而,我死活没找到,RFC就说了要这么干,没说为什么。
所幸,不止我一个人有疑问,答案在这,这就是个先有鸡还是先有蛋的问题。不想写了,有兴趣的自己去看吧。

github上的关于这些头疼问题的讨论

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享