JS继承的深思问题

    科技2024-06-02  72

    JS继承的六种方法

    es6 中class构造以及继承的底层实现原理

    1、class的实现

    JavaScript的class,其实是基于原型链实现的,换句话说,它只是个语法糖。

    1. 基本原理

    下面是一个最简单的类的写法:

    class Child{ }

    这就是一个类,如果你继续写:

    const c = new Child()

    那么c就是一个Child的实例,只是它没有属于自己的属性和方法,只有继承自上层构造函数(如Object)的属性和方法。

    如果你输出一下Child的类型,你可能会先大吃一惊,反思一下却觉得合情合理:

    > typeof Child < "function"

    没错,我们所定义的“类”,其实就是个函数,准确地讲,是个构造函数(现在再看new Child()语句,是不是豁然开朗?)。

    当然了,class关键字定义的函数不是普通的构造函数。ES6标准规定,它只能被new关键字调用。也就是说,Child()这样的语句会立即报错。

    下面是一个拥有更多属性和方法的类(请注意,class中的各个方法之前既没有分号,也没有逗号,加了都会报错):

    class Parent{ constructor(name, age){ this.name = name; this.age = age; } getName(){ return this.name; } getAge(){ return this.age; } }

    上面我们已经知道,class本质上是在定义构造函数。那么这里定义的三个方法与这个函数又是什么关系呢?下面的输出可以让你明白:

    > Parent.prototype < {constructor: ƒ, getName: ƒ, getAge: ƒ}

    再一次豁然开朗!原来定义在class里的方法,全都被添加到了Parent的原型对象上了。也就是说,上面的Parent类似于以下的实现:

    function Parent(name, age){ this.name = name; this.age = age; } Parent.prototype = { getName: function(){ return this.name; }, getAge: function(){ return this.age; } }

    在基于ES5的实现中,我们虽然没有在prototype上定义constructor方法,但是js引擎仍然会为我们生成默认的方法:Parent.prototype.constructor,它的值正是构造函数本身(即Parent.prototype.constructor === Parent,这与ES6是一致的)。因此这两种写法几乎是完全等价的。

    我们知道,在JavaScript中,函数本质上是个对象。因此我们可以像下面一样为函数直接添加属性和方法:

    Parent.type = "people"; Parent.run = function(){...}

    那么class是如何实现这类的属性和方法的呢?

    这类属性和方法在ES6中被称为类的静态属性和静态方法(它只能通过类直接调用,无法通过实例访问,所以称为静态)。目前对于class来说,静态属性并未提供规范的写法,也就是说你仍然只能通过Parent.type = "people"来为类定义静态属性。

    对于静态方法,ES6给出了规范的写法,即在方法前加static关键字:

    class Parent{ static run(){} }

    这就是在为Parent类定义静态方法run,这个方法只能通过Parent.run()来调用,并且该方法内的this是Parent本身(请区别普通的原型方法,原型方法内的this指向的是构造出的实例对象)。

    目前ES6不允许在class内定义静态属性(date:2020/1/12),不过有提案建议同样通过在属性前加static关键字来定义静态属性。

    如果你对class的实现仍然不是很清楚,没关系,我们来看一张内存图:

    我们可以把原型对象prototype理解为一个“共享池”。它容纳了所有类的实例所共享的属性和方法(在构造实例时,浏览器会默认为每个实例添加一个__proto__属性来指向这个“共享池”,这就是原型链)。而Parent类的静态属性和方法,则直接被添加到了Parent内部。

    不知道你是否注意到,除却通过new Parent()调用生成Parent实例,Parent本身和它所构造出的实例之间并没有直接联系。

    有人可能会说,Parent.prototype是Parent的属性,而Parent实例的__proto__指向这个属性,那不就相当于指向Parent了吗?从概念上确实可以这么理解。但是别忘了,JavaScript中的变量保存的只是内存中的地址。

    也就是说,Parent的prototype属性只是持有了内存中这个“共享池”的内存地址,同样的,Parent实例的__proto__属性也是持有该“共享池”的内存地址。我们总不能因为A和B都有一个属性指向同一块内存地址,就说A指向B吧(哪怕A的这个属性值是从B复制过来的)!

    还有人会说,我们通过实例原型上的constructor就可以访问它的构造函数。这句话确实没错,但这是一种间接关系,我们说的是,两者没有直接关系。

    为什么我们会谈到这一点呢?实际上我们想要阐述的是构造函数、实例和原型对象三者之间的关系。

    当我们定义一个构造函数时(包括定义一个类),js引擎会在内存区中额外开辟一块内存,作为该构造函数所构造的所有实例的“共享池”使用,并将该“共享池”的地址保存在构造函数的prototype属性(实际上是构造函数的静态属性)上。随后,由该构造函数构造出的实例,都会默认得到一个属性__proto__(即使少数浏览器没有暴露出来,该属性也是存在的),它的值正是上述“共享池”的内存地址,是由构造函数的prototype属性赋值而来的。因此,所有的实例都可以访问该“共享池”。

    理解上述关系对理解class的继承至关重要,如果你没有读懂,或者在之后阅读class的继承机制时遇到了困惑,请回头再来理解这段话。

    讲到这里,我想class的实现原理已经很清楚了。以上述的Parent类为例,首先将class Parent{}内声明的所有不带static的方法添加到Parent原型上,将带有static的方法添加为Parent的静态方法。在调用new Parent()时,使用constructor方法来构造实例,构造出的实例默认拥有__proto__属性指向Parent的原型。

    注意:通常来说,实例属性和方法应该在constructor内通过this来添加,不过ES6允许直接写在类的顶部:

    class Parent{ name; age = 24; }

    这种写法适合不需要初始化或者有默认初始值的实例属性,并且更加贴近于传统面向对象语言的类写法。另外,类可以不显式定义constructor方法,js引擎会默认为类添加一个空的constructor方法:constructor(){}。此外比较特别的一点是,在class中定义的方法是不可枚举的,如使用Object.keys(Parent.prototype)不能输出Parent的原型方法,而使用ES5的写法则是可以的。

    2. class语法规范

    (1). 取值函数(getter)和存值函数(setter)

    class允许为实例属性定义取值和存值的拦截函数,当试图读取或修改属性值时,它们就会被调用:

    class Parent{ constructor(name){ this.name = name; } get name(){ console.log("触发name属性的取值函数!"); return this.name; } set name(name){ console.log("触发name属性的存值函数!"); this.name = name; } } const p = new Parent("Carter"); p.name; // => 触发name属性的取值函数! // => Carter p.name = "张三"; // => 触发name属性的存值函数!

    这里的取值和存值函数其实是被定义到了name属性的Descriptor描述符对象上,也就是相当于:

    Object.defineProperty(p, "name", { get(){...}, set(name){...} })

    (2). 属性表达式

    class的属性名允许使用变量,但需要使用中括号括起来,如:

    let f = "getName"; class Parent{ [f](){...} } Parent.prototype.getName; // f

    属性表达式的主要使用场景是使用Symbol类型作为属性名:

    let symbol = new Symbol(); class Parent{ [symbol](){...} } // 或者 class Parent{ *[Symbol.iterator](){ ... // 定义对象的遍历器 } }

    (3). class表达式

    由于类本质上是一个构造函数,所以它也支持表达式,如:

    const Par = class Parent{ static run(){console.log("run");} do(){ return Parent.run(); // 只在class内部可以引用Parent } }

    在使用表达式时需要特别注意的是,此时只有被赋值的变量是可以用new调用的,class后面的原始类名只能在类的内部使用,即:

    const p = new Par(); // 正确 const p2 = new Parent(); // 报错,Parent is not a function p.do(); // run // 因为do是class内部的方法,所以它可以访问Parent

    如果类的内部没有引用Parent,那么它也可以省略:

    const Par = class { ... }

    甚至可以写出立即执行的匿名类:

    const p = class { constructor(name){ this.name = name; } }("张三");

    三、class的继承

    1. 继承的概念

    在最初学习面向对象语言的时候,老师会跟我们说,类的实现有三大要点:封装、继承和多态。

    由于在JavaScript中不存在函数签名,因此JavaScript无法实现传统意义上的多态(当然也没有必要,因为JavaScript的灵活性足以弥补这一点)。上一部分所讲的正是它的封装,而这一部分我们要讲的就是class的继承。

    先简单介绍一下什么是继承吧。这是面向对象的语言将实体抽象为类的一个重要目的。

    一个特定的类应该拥有某些固定的特征,比如 “车” 这个类有 “发送机”、“底盘”、“车身”这些属性,也有“启动”、“停止”、“加速”这些行为。前者称为 “车” 的属性,后者称为它的方法。

    上述定义“车”这个类的过程就是在进行抽象,而抽象的目的是提取某类事物的公共特性。之所以要提取公共特性,是因为面向对象的语言所面临的业务场景通常较为复杂,难以厘清关系。将实体抽象为类,提取公共部分,并对相关的类建立联系,有助于开发者厘清业务逻辑,提升开发效率。

    假如现在我们又定义了另一个类:“货车”。我们知道,“货车”是属于 “车” 的,因此它也应该具备“车”所拥有的那些属性和方法(如“发动机”、“底盘”等属性和“启动”等方法)。我们称“货车”是“车”的一个子类,用面向对象的说法就是,“货车”继承自“车”。

    从语言实现来说,只要我们在定义“货车”时声明它继承自“车”,那么所有的“货车”实例都将自动获得我们为“车”抽象出来的属性和方法,而不用重复定义。如果子类某个属性或方法的值与父类不完全一样,则可以重新定义它,以覆盖父类的实现。这就是继承。比如:

    class Vehicle { // “车” engine; start(){} } class Lorry extends Vehicle{ // “货车”,继承自“车” } const lorry = new Lorry(); // 生成一个“货车”实例 // 即使“货车”没有定义start方法,也可以调用继承自“车”的start方法 lorry.start();

    2. 继承的基本原理

    JavaScript中类的继承包含三个任务:

    通过父类的构造函数构造子类 继承父类的原型属性和方法 继承父类的静态属性和方法。

    **任务一,**通过父类的构造函数构造子类

    。这是为了让子类继承父类的实例属性和方法,主要是通过借用构造函数(关于js的继承方式中有介绍)实现的。它的基本原理是:

    function Parent(name){ this.name = name; } function Child(name){ Parent.call(this, name); //借用构造函数 } const child = new Child("张三"); child.name; // 张三

    Child借用了Parent的构造函数,为自己构造出了name属性,这也是继承的一种体现。

    任务二,继承父类的原型属性和方法

    这可以借助原型链来实现,类似于原型式继承。不过在class中,并不是直接让子类的实例继承父类的原型,而是让子类的原型继承父类的原型。它的实现大致如下:

    function Parent(){} function Child(){} Object.setPrototypeOf(Child.prototype, Parent.prototype);

    setPrototypeOf的实现大致为:

    Object.setPrototypeOf = function (obj, proto) { obj.__proto__ = proto; return obj; } Object.setPrototypeOf = function (obj, proto) { obj.__proto__ = proto; return obj; }

    所以继承父类原型属性和方法的原理可以浓缩成下面的一行代码:

    Child.prototype.__proto__ = Parent.prototype;

    为什么只要这一行代码就可以继承父类的属性和方法了呢?

    让我们来回顾一下原型链查找规则。当我们通过类似child.cry()的形式调用某个方法时,js引擎首先会查找实例中是否存在该方法。如果不存在,会去它的原型上查找,也就是查找child.proto.cry()。如果仍然找不到,就会去child.proto.__proto__上查找,以此类推。

    我们知道,child.proto === Child.prototype,进行一次等价替换,我们就可以得到child.proto.proto === Child.prototype.proto。也就是说,第二次查找实际上是在Child.prototype的__proto__属性上进行的。那么如果我们把它赋值为Parent.prototype,js引擎不就会去父类的原型上去查找该方法了吗?这就实现了原型方法的继承。

    如果你觉得上述理论较为抽象,可以参考下图

    从查找链路(child --> Child.prototype --> Parent.prototype)可以明显看到class是如何继承父类的原型属性和方法的。所以借助这个图,你应该可以明白为什么那一行代码拥有这么大的威力了吧。

    任务三,继承父类的静态属性和方法

    。我们想要实现彻底的继承,就必须把父类的静态属性和方法也继承到子类上,当然了,继承过来之后仍然是静态的,所以仍然不能通过实例来访问。

    举个例子:

    class Parent{ static run(){} } class Child extends Parent{ } Child.run(); // 子类同样应该具有run这个静态方法

    那么这又是怎么实现的呢?其实原理跟任务二类似,它只需要下面的一行代码:

    Child.__proto__ = Parent;

    写成规范的格式是:

    Object.setProrotypeOf(Child, Parent);

    似乎有点不可思议!我们只见过实例具有__proto__属性,没想到构造函数也可以添加__proto__属性。没错,当我们在这样做时,我们是基于一个我已经强调了很多次的理论:JavaScript中的函数也是对象。

    现在让我们暂时忘了Child和Parent是个类,也忘了它们是构造函数,我们只记得它们都是对象。所以下面的图就很好理解了

    Parent和Child不过是两个具有prototype属性,可以用new关键字构造实例的对象而已,那么我们为Child添加__proto__属性又有什么不可以呢?

    注意:严格来说这里并不是添加,而是修改。因为Child本身就有__proto__属性,只是原来指向Function.prototype,毕竟作为函数,它们都是Function的实例。

    有人可能会迷惑,它们既是Function的实例,又是对象(也就是Object的实例),那么Object和Function又是什么关系呢?两者的关系是,Function继承自Object。实际上,Object是JavaScript继承关系树的根节点。

    回到上述原理图,我们所谓的构造函数的静态方法,以一个对象的角度来看,其实就是它的实例方法而已。那么查找规则也很明朗了,当调用Child.run()时,首先从Child上查找run方法,如果查找失败,会去Child.__proto__上去查找。由于我们设置了Child.proto === Parent,因此此时实际上是在Parent上查找该方法,也就是查找Parent的静态方法。至此,我们就实现了静态方法的继承。静态属性也是同样的道理。

    至此,继承的三个任务全部完成,我们用一个整合后的原理图来归纳一下:

    这样就形成了两条继承链,一条是原型对象的继承链,子类实例可以通过这条继承链访问父类原型上的属性和方法;另一条是构造函数的继承链,子类可以通过它访问父类的静态属性和方法。三个任务总结出来就是三行代码:

    // 借用构造函数 Parent.prototype.constructor.call(this, ...args); //构建原型的继承链 Object.setPrototypeOf(Child.prototype, Parent.prototype); //构造静态属性和方法的继承链 Object.setPrototypeOf(Child, Parent);

    3. 继承的相关语法

    (1). super关键字

    ES6规定,super关键字只能在class中出现,用于引用当前类的父类。

    根据super关键字所处的位置不同,super所代表的含义也不同。这与class的继承机制有关,因为根据上述讲解我们知道,子类原型上的方法只能继承自父类的原型,而子类的静态方法只能继承自父类本身。所以,在子类的原型方法中让super指向父类意义不大,还要通过super.prototype找到父类的原型对象,属于多此一举,而在静态方法中也是同样的道理。

    所以,在子类的原型方法(也就是没有添加static的方法)内,super代指父类的原型对象;而在子类的静态方法内,super代指父类。

    特别的是,在调用父类的构造方法时,不需要写super.constructor(),而是直接写super()即可。此外,ES6规定,父类的构造方法只能在子类的构造方法中调用,即super()只能出现在子类的constructor方法中。

    关于super()还有一点必须特别注意,那就是只要子类定义了constructor,都必须在内部调用一次super(),并且在调用该语句之前,不允许使用this。比如下面的例子都会报错:

    class Parent{ constructor(name){ this.name = name; } } // 报错,没有用super()初始化父类 class Child{ constructor(name){ this.name = name; } } // 报错,在调用super之前不允许使用this class Child{ constructor(name){ // 报错,没有用super()初始化父类 this.name = name; super(name) } }

    为什么会出现这样的情况呢?

    因为ES6的class实现继承的机制是,先通过父类的构造函数构造this,然后再为this添加属性和方法。这也就意味着,如果没有显式调用super(),子类构造函数中根本不存在this,直接调用this当然会报错。如果整个构造函数中都没有调用super(),那么构造函数根本没有构造出任何实例,引擎当然也会报错。

    这与ES5的继承机制是不同的。ES5中的继承是先构造一个空的子类实例(即初始化this),再将父类的属性和方法添加到这个空的实例上,最后添加子类自身的属性和方法,所以没有上述要求。

    ES6为class规定的这种特殊机制,使继承原生构造函数成为了可能,下面我们来看。

    (2). 原生构造函数的继承

    上面说到,ES5中的继承是先构造子类实例,再借由父类的构造函数来为这个实例添加属性和方法。但这个机制对原生的构造函数是行不通的,因为原生构造函数不允许绑定this,也就是说,ES5的借用构造函数机制对原生构造函数不生效。如

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

    我们本来是希望通过继承Array,构造一个自己的MyArray类,但是它却与原生的Array行为大相径庭。比如:

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

    如果是原生的Array,arr.length应该输出1,而这里却输出0。

    这是因为我们根本无法直接获取原生构造函数的内部属性和方法(注意,length不是内部属性,内部属性指的是引擎没有暴露给我们的属性)。也就是说,如果你已经有了一个对象,想通过绑定this往这个对象上添加原生构造函数的内部属性和方法是行不通的。而arr[0] = 1这样的语句想要触发length变化,必须要这些内部属性和方法的支持。所以你可以看到,即使为arr[0]赋值了,length属性也并没有变化。即使通过apply将this显式绑定到Array,也无法获取内部属性和方法。

    而ES6的class的继承机制可以解决这个问题。在ES6中,我们并不视图获取原生构造函数的内部属性和方法,而是直接通过原生构造函数构造一个对象出来,再向这个对象添加我们自己的属性和方法。比如下面的例子:

    class MyArray extends Array { constructor(...args) { super(...args); } } var arr = new MyArray(); arr[0] = 12; arr.length // 1

    我们看到,这个MyArray实现了Array的基本能力。因为当我们在调用super(arguments)时,引擎是直接调用原生构造函数Array来构造的this。你甚至可以认为,此时的this就是一个数组实例,那么它具有数组的能力是理所应当的。待this构造完毕后,我们才在这个实例上添加自己的实例方法以及原型方法。这就实现了对原生构造函数的继承。

    阮一峰大神讲解

    Processed: 0.011, SQL: 8