深度解读 JS 构造函数、原型、类与继承

2023年 12月 7日 38.7k 0

01、前言

众所周知,JavaScript 是一门面向对象的语言,而构造函数、原型、类、继承都是与对象密不可分的概念。在我们日常前端业务开发中,系统和第三方库已经为我们提供了大部分需要的类,我们的关注点更多是在对象的使用和数据处理上,而比较少需要去自定义构造函数和类,对原型的直接接触就更少了。

然而,能深度理解并掌握好构造函数、原型、类与继承,对我们的代码设计大有裨益,也是作为一名高级前端工程师必不可少的基本功。

本文旨在用最通俗易懂的解释和简单生动的代码示例,来彻底捋清对象、构造函数、原型、类与继承。我们会以问答对话的形式,层层递进,从构造函数谈起,再引出原型与原型链,分析类为什么是语法糖,最后再推理出 JS 的几种继承方式。

在进入正式篇章之前,我们可以先尝试思考以下几个问题:

1.new Date().__proto__ == Date.prototype?

2.new Date().constructor == Date?

3.Date.__proto__ == Function.prototype?

4.Function.__proto__ == Function.prototype?

5.Function.prototype.__proto__== Object.prototype?

6.Object.prototype.__proto__ == null?

—— 思考分割线 ——

没错,它们都是 true !为啥?听我娓娓道来~

02、构造函数

某IT公司前端研发部,新人小Q和职场混迹多年的老白聊起着构造函数、原型与类的话题。

小Q:构造函数我知道呀,平时 new Date(),new Promise() 经常用, Date,Promise 不就是构造函数,我们通过 new 一个构造函数去创建并返回一个新对象。

老白:没错,这些是系统自带的一些构造函数,那你可以自己写个构造函数吗?

小Q:虽然平时用的不多,但也难不倒我~

// 定义个构造函数
function Person(name) {
    this.name = name;
}
// new构造函数,创建对象
let person = new Person("张三");

小Q:看吧 person 就是对象,Person 就是构造函数,清晰明了!

老白:那我要是单纯写这个方法算不算构造函数?

function add(a, b) {
    return a + b;
}

小Q:这不是吧,这明显就是个普通函数啊?

老白:可是它也可以 new 对象哦!

function add(a, b) {
    return a + b;
}
let a = new add(1, 2);
// add {}
console.log(a);
// true
console.log(a instanceof add);
// object
console.log(typeof a);

小Q:诶?

老白:其实所谓构造函数,就是普通函数,关键看你要不要 new 它,但是 new 是在使用的时候,在定义的时候咋知道它后面会不会被 new 呢,所以构造函数只不过是当被用来new 时的称呼。就像你上面的 Person 函数,不要 new 直接运行也是可以的嘛。

function Person(name) {
    this.name = name;
}
Person("张三");

小Q:哦,我懂了,所有函数都可以被 new,都可以作为构造函数咯,所谓构造函数只是一种使用场景。

老白:嗯嗯,总结得很好,但也不全对,比如箭头函数就不能被 new,因为它没有自己的 this 指向,所以不能作为构造函数。比如下面这样就会报错。

let Person = (name) => {
    this.name = name;
};
// Uncaught TypeError: Person is not a constuctor
let person = new Person("张三");

小Q:原来如此,那你刚刚 Person("张三"); ,既然没有创建新对象,那里面的 this 又指向谁了?

老白:这就涉及到函数内 this 指向问题了,可以简单总结以下 5 种场景。

1. 通过 new 调用,this 指向创建的新对象;

2. 直接当做函数调用,this 指向 window(严格模式下为 undefined);

function Person(name) {
    this.name = name;
}
// this 指向 window
Person("张三");
// 张三
console.log(window.name);

(看吧,不注意的话,不小心把 window 对象改了都不知道)

3.作为对象的方法调用,this 指向该对象;

function Person(name) {
    this.name = name;
}
let obj = {
    Person,
};
// this 指向 obj
obj.Person("张三");
// { "name": "张三", Person: f }
console.log(obj);

4.通过 apply,call,bind 方法,显式指定 this;

function Person(name) {
    this.name = name;
}
// this 指向 call 的第一个参数
Person.call(Math, "张三");
// 张三
console.log(Math.name);

5.箭头函数中没有自己的 this 指向,取决于上下文:

function Person(name) {
    this.name = name;
    
    // 普通函数,this 取决于调用者,即上述的 4 种情况
    setTimeout(function() {
        console.log(this);
    }, 0)
    
    // 箭头函数,this 取决于上下文,我们可以忽略箭头函数的存在
    // 即同上面 this.name = name 中的 this 指向一样
    setTimeout(() => {
        console.log(this);
    }, 0)
}

小Q:原来 this 指向都有这么多种情况,好的,小本本记下了,等下就去试验下。

小Q:等下,我重新看了你的 new add(1, 2),那 a + b = 3 还被 return 了呢,这 3 return 到哪去了?

function add(a, b) {
    return a + b;
}
let a = new add(1, 2);

老白:没错,你注意到了,构造函数是不需要 return 的,函数中的 this 就是创建并返回的新对象了。

但当 new 一个有 return 的构造函数时,如果 return 的是基本类型,则 return 的数据直接被抛弃。

如果 return 一个对象,则最终返回的新对象就是 return 的这个对象,这时原本 this 指向的对象就会被抛弃。

function Person(name) {
    this.name = name;
    // 返回的是对象类型
    return new Date();
}
let person = new Person("张三");
// 返回的是 Date 对象
// Sat Jul 29 2023 16:13:01 GMT+0800 (中国标准时间)
console.log(person);

老白:当然如果要把一个函数的使用用途作为构造函数的话,像我刚刚起名 add() 肯定是不规范的, 一般首字母要大写,并且最好用名词,像你起的 Person 就不错。

小Q:新知识get√

要点归纳

1. 除箭头函数外的所有函数都可以作为构造函数被new

2. 函数内this指向问题

3. 构造函数return问题

4. 构造函数命名规范

03、原型

小Q:都说原型原型,可看了这么久,这代码里也没出现原型呀?

老白:没错,原型是个隐藏的家伙,我们可以通过对象或者构造函数去拿到它。

// 构造函数
function Person(name) {
    this.name = name;
}
// 对象
let person = new Person("张三");

// 通过对象拿到原型(2种方法)
let proto1 = Object.getPrototypeOf(person);
let proto2 = person.__proto__;

// 通过构造函数拿到原型
let proto3 = Person.prototype;

// 验证一下
// true
console.log(proto1 == proto2);
// true
console.log(proto1 == proto3);

小Q:可是这个原型是哪来的呀,我代码里也没创建它呀?

老白:当你声明一个函数时,系统就自动帮你生成了一个关联的原型啦,当然它也是一个普通对象,包含 constructor 字段指向构造函数,并且构造函数的 prototype 属性也会指向这个原型。

当你用构造函数创建对象时,系统又帮你把对象的 __proto__ 属性指向原型。

// 构造函数
function Person(name) {
    this.name = name;
}
// 可以理解为:声明函数时,系统自动执行了下面代码
Person.prototype = {
    // 指向构造函数
    constructor: Person 
}

// 对象
let person = new Person("张三");
// 可以理解为:创建对象时,系统自动执行了下面代码
person.__proto__ == Person.prototype;

小Q:它们的引用关系,稍微有点绕啊~

老白:没事,我画两个图来表示,更加清晰点。

(备注:proto 只是单纯用来表示原型的一个代名而已,代码中并不存在)

图片图片

图片图片

小Q:懂了!

老白:那你说说 {}.__proto__ 和 {}.consrtuctor 分别是什么?

小Q:让我分析下,{} 其实就是 new Object() 的一种字面量写法,本质上就是 Object 对象,那 {}.__proto__ 就是原型 Object.prototype,{}.constructor 就是构造函数 Object,对吧?

老白:没错,只要能熟练掌握上面这个图,构造函数,原型和对象这三者的引用关系基本很清晰了。一开始提的1、2 题基本也迎刃而解了!

  • new Date().__proto__ == Date.prototype ?
  • new Date().constructor == Date ?
  • 小Q:那这个原型有什么用呢?

    老白:一句话总结:当访问对象的属性不存在时,就会去访问原型的属性。

    图片图片

    图3

    老白:我们可以通过代码验证下,person 对象是没有 age 属性的,所以 person.age 返回的其实是原型的 age 属性值,当原型的 age 属性改变时,person.age 也会跟着改变。

    function Person(name) {
        this.name = name;
    }
    // 给原型增加age属性
    Person.prototype.age = 18;
    
    // 对象
    let person = new Person("张三");
    // 18
    console.log(person.age);
    // 修改原型的age属性
    Person.prototype.age++;
    // 19
    console.log(person.age);

    小Q:那如果我直接 person.age++ 呢,改的是 person 还是原型?

    老白:这样的话就相当于 person.age = person.age + 1 啦,等号右边的 person.age 因为  对象目前还没 age 属性,所以拿到的是原型的 age 属性,即18,然后 18 + 1 = 19 将赋值给 person 对象。

    后续当你再访问 person.age 时,因为 person 对象已经存在 age 属性了,就不会再检索到原型上了。

    这种行为我们一般称为重写,在这个例子里也描述为:person 对象重写了原型上的 age 属性。

    图片图片

    图4

    小Q:那这样的话使用起来岂不是很乱,我还得很小心的分析 person.age 到底是 person 对象的还是原型的?

    老白:没错,如果你不想出现这种无意识的重写,将原型上的属性设为对象类型不失为一种办法。

    function Person(name) {
        this.name = name;
    }
    // 原型的info属性是对象
    Person.prototype.info = {
        age: 18,
    };
    let person = new Person("张三");
    
    person.info.age++;

    小Q:我懂了,改变的是 info 对象的 age 属性, person 并没有重写 info 属 性,所以 person 对象本身依然没有 info 属性,person.info 依然指向原型。

    老白:没错!不过这样也有个坏处,每一个 Person 对象都可以共享原型的 info ,当 info 中的属性被某个对象改变了,也会对其他对象造成影响。

    function Person(name) {
        this.name = name;
    }
    Person.prototype.info = {
        age: 18,
    };
    let person1 = new Person("张三");
    let person2 = new Person("李四");
    
    // person1修改info
    person1.info.age = 19;
    // person2也会被影响,打印:19
    console.log(person2.info.age);

    老白:这对我们代码的设计并不好,所以我们一般不在原型上定义数据,而是定义函数,这样对象就可以直接使用挂载在原型上的这些函数了。

    function Person(name) {
        this.name = name;
    }
    Person.prototype.sayHello = function() {
        console.log("hello");
    }
    let person = new Person("张三");
    // hello
    person.sayHello();

    小Q:我理解了,数据确实不应该被共享,每个对象都应该有自己的数据好点,但是函数无所谓,多个对象可以共享同一个原型函数。

    老白:所以你知道为啥 {} 这个对象本身没有任何属性,却可以执行 toString() 方法吗?

    小Q:【恍然大悟】来自它的原型 Object.prototype !

    老白:不仅如此,很多系统自带的构造函数产生的对象,其方法都是挂载在原型上的。比如我们经常用的数组方法,你以为是数组对象自己的方法吗?不,是数组原型 Array.prototype 的方法,我们可以验证下。

    let array = [];
    // array对象的push和原型上的push是同一个
    // 打印:true
    console.log(array.push == Array.prototype.push);
    // array对象本身没有自己的push属性
    // 打印:false
    console.log(array.hasOwnProperty("push"));

    图片图片

    小Q:【若有所思】

    老白:再比如,你随便定义一个函数 function fn() {},为啥它就能 fn.call() 这样执行呢,它的 call 属性是哪来的?

    小Q:来自它的原型?函数其实是 Function 的对象,那它的原型就是 Function.prototype,试验一下。

    function fn() {}
    // true
    console.log(fn.constructor == Function);
    // true
    console.log(fn.call == Function.prototype.call);

    老白:回答正确。在实际开发中,我们也可以通过修改原型上的函数,来改变对象的函数执行。比如说我们修改数组原型的 push 方法,加个监听,这样所有数组对象执行 push 方法时就能被监听到了。

    Array.prototype.push = (function (push) {
        // 闭包,push是原始的那个push方法
        return function (...items) {
            
            // 执行push要指定this
            push.call(this, ...items);
            
            console.log("监听push完成,执行一些操作");
        };
    })(Array.prototype.push);
    
    let array = [];
    // 打印:监听push完成,执行一些操作
    array.push(1, 2);
    // 打印:[1, 2]
    console.log(array);

    老白:不只修改,也可以新增,比如说某些旧版浏览器数组不支持 includes 方法,那我们就可以在原型上新增一个 includes 属性,保证代码中数组对象使用 includes() 不会报错(这也是 Polyfill.js 的目的)。

    // 没有includes
    if(!Array.prototype.includes) {
        Array.prototype.includes = function() {
            // 自己实现includes
        }
    }

    小Q:又又涨知识了~

    老白:原型相关的也说的差不多了,结合刚刚讨论的构造函数,考你一个:手写一个 new 函数。

    小Q:啊啊,提示一下?

    老白:好,我们简单分析一下 new 都做了什么

  • 创建一个对象,绑定原型;
  • 以这个对象为 this 指向执行构造函数。
  • 小Q:我试试~

    function myNew(Fn, ...args) {
        var obj = {
            __proto__: Fn.prototype,
        };
        Fn.apply(obj, args);
        return obj;
    }

    小Q:试验通过!

    // 构造函数
    function Person(name) {
        this.name = name;
    }
    // 原型
    Person.prototype.age = 18;
    // 创建对象
    let person = myNew(Person, "张三");
    
    // Person {name: "张三"}
    console.log(person);
    // 18
    console.log(person.age);

    老白:不错不错,让我帮你再稍微完善一下嘿嘿~

    function myNew(Fn, ...args) {
        // 通过Object.create指定原型,更加符合规范
        var obj = Object.create(Fn.prototype);
        
        // 指定this为obj对象,执行构造函数
        let result = Fn.apply(obj, args);
        
        // 判断构造函数的返回值是否是对象
        return result instanceof Object ? result : obj;
    }

    要点归纳

    1. 对象,构造函数,原型三者的引用关系

    2. 原型的定义,特性及用法

    3. 手写new函数

    04、原型链

    老白:刚刚我们说当访问对象的属性不存在时,就会去访问原型的属性,那假如原型上的属性也不存在呢?

    小Q:返回 undefined?

    老白:不对哦,原型本身也是一个对象,它也有它自己的原型。所以当访问一个对象的属性不存在时,就会检索它的原型,检索不到就继续往上检索原型的原型,一直检索到根原型 Object.prototype,如果还没有,才会返回 undefined,这也称为原型链。

    图片图片

    小Q:原来如此,所以说所有的对象都可以使用根原型 Object.prototype 上定义的方法咯。

    老白:没错,不过有一些原型会重写根原型上的方法,就比如 toString(),在 Date.prototype,Array.prototype 中都会有它们自己的定义。

    // [object Object]
    console.log({}.toString())
    
    // 1,2,3
    console.log([1,2,3].toString())
    
    // Tue Aug 01 2023 17:58:05 GMT+0800 (中国标准时间)
    console.log(new Date().toString())

    小Q:理解了原型链,看回开始的3~6题,好像也不难了。

    Date、Function 的原型是 Function.prototype,第 3、4 题就解了。

    Function.prototype 的原型是 Object.prototype,第 5 题也解了。

    Object.prototype 是根原型,所以它的 __proto__ 属性就为 null,第 6 题也解了。

  • Date.__proto__ == Function.prototype ?
  • Function.__proto__ == Function.prototype ?
  • Function.prototype.__proto__== Object.prototype ?
  • Object.prototype.__proto__ == null ?
  • 老白:完全正确。最后再考你一道和原型链相关的题,手写 instanceOf 函数。提示一下,instanceOf 的原理是判断构造函数的 prototype 属性是否在对象的原型链上。

    // array的原型链:Array.prototype → Object.prototype
    let array = [];
    // true
    console.log(array instanceof Array);
    // true
    console.log(array instanceof Object);
    // false
    console.log(array instanceof Function);

    小Q:好了嘞~

    function myInstanceof(obj, Fn) {
        while (true) {
            obj = obj.__proto__; 
            // 匹配上了
            if (obj == Fn.prototype) {
                return true;
            }
            // 到达原型链的尽头了
            if (obj == null) {
                return false;
            }
        }
    }

    检测一下:

    let array = [];
    // true
    console.log(myInstanceof(array, Array));
    // true
    console.log(myInstanceof(array, Object));
    // false
    console.log(myInstanceof(array, Function));

    老白:Good!

    要点归纳

    1. 原型链

    2. 手写 instanceOf函数

    05、类

    小Q:好不容易把构造函数和原型都弄懂,怎么 ES6 又推出类呀,学不动了 T_T。

    老白:不慌,类其实只是种语法糖,本质上还是”构造函数+原型“。

    我们先看一下类的语法,类中可以包含有以下4种写法不同的元素。

    • 对象属性:key = xx
    • 原型属性:key() {}
    • 静态属性:static key = x 或 static key() {}
    • 构造器:constructor() {}
    class Person {
        // 对象属性
        a = "a";
        b = function () {
            console.log("b");
        };
        // 原型属性
        c() {
            console.log("c");
        }
        // 构造器
        constructor() {
            // 修改对象属性
            this.a = "A";
            // 新增对象属性
            this.d = "d";
        }
        // 静态属性
        static e = "e";
        static f() {
            console.log("f");
        }
    }

    我们再将这种 class 语法糖写法还原成构造函数写法。

    function Person() {
        // 对象属性
        this.a = "a";
        this.b = function () {
            console.log("b");
        };
        // 构造器
        this.a = "A";
        this.d = "d";
    }
    
    // 原型属性
    Person.prototype.c = function () {
        console.log("c");
    };
    
    // 静态属性
    Person.e = "e";
    Person.f = function () {
        console.log("f");
    };

    通过下面一些方法检测,上面的2种写法会得到同样的结果。

    // Person类本质是个构造函数,打印:function
    console.log(typeof Person);
    
    // Person的静态属性,打印:e
    console.log(Person.e);
    
    // 可以看到原型属性c,打印:{constructor: ƒ, c: ƒ}
    console.log(Person.prototype);
    
    let person = new Person();
    
    // 可以看到对象属性a b d,打印:Person {a: 'A', d: 'd', b: ƒ}
    console.log(person);
    // 对象的构造函数就是Person,打印:true
    console.log(person.constructor == Person);

    小Q:所以类只不过是将本来比较繁琐的构造函数的写法给简化了而已,这语法糖果然甜~

    小Q:不过我发现一个问题,在 class 写法中的原型属性只能是函数,不能是数据?

    老白:没错,这也呼应了前面说的,原型上只推荐定义函数,不推荐定义数据,避免不同对象共享同一个数据。

    要点归纳

    1. 类的语法

    2. 类还原成构造函数写法

    06、继承

    小Q:我又又发现了一个问题,ES6 的 class 还可以 extends 另一个类呢,这也是语法糖?

    老白:没错,这就是继承,但是要弄懂 ES6 的这套继承是怎么来的,还得从最开始的继承方式说起。所谓继承,就是我们是希望子类可以拥有父类的属性方法,这和上面谈到的原型的特性有点不谋而合。

    我们用一个例子来思考思考,有这么 2 个类,如何让 Cat 继承 Animal,使得 Cat 的对象也有 type 属性呢?

    // 父类
    function Animal() {
        this.type = "动物";
    }
    // 子类
    function Cat() {
        this.name = "猫";
    }

    小Q:让 Animal 对象充当 Cat 的原型!

    function Animal() {
        this.type = "动物";
    }
    function Cat() {
        this.name = "猫";
    }
    // 指定Cat的原型
    Cat.prototype = new Animal();
    Cat.prototype.constructor = Cat;
    
    let cat = new Cat();
    // Cat对象拥有了Animal的属性
    console.log(cat.type);

    老白:没错,这是我们学完原型之后,最直观的一种继承实现方式,这种继承又叫原型链式继承。但是这种继承方式存在 2 个缺点:

  • 父类对象作为原型,其属性会被所有子类对象共享;
  • 创建子类对象时无法向父类构造函数传参。
  • function Animal(type) {
        this.type = type;
    }
    function Cat(type) {
        this.name = "猫";
    }
    // 在这里就已经创建了Animal对象
    Cat.prototype = new Animal();
    Cat.prototype.constructor = Cat;
    
    // 创建子类对象时无法向父类构造函数传参
    let cat = new Cat("哺乳动物");
    
    // type属性来自原型,被所有Cat对象共享,打印:undefined
    console.log(cat.type);

    小Q:我想到个办法,可以一举解决上面2个缺点。

    在子类构造函数中执行父类构造函数,并且指定执行父类构造函数中的 this 是子类对象,这样属性就都是属于子类对象本身了,不存在共享。同时在创建子类对象时,也可以给父类构造函数传参了,一举两得。

    function Animal(type) {
        this.type = type;
    }
    function Cat(type) {
        // 执行父类,显式指定this就是子类的对象
        Animal.call(this, type);
        this.name = "猫";
    }
    let cat = new Cat("哺乳动物");
    
    // Cat {type: '哺乳动物', name: '猫'}
    console.log(cat);

    老白:这种继承方式叫 构造函数式继承,确实解决了 原型链式继承 带来的问题,不过这种继承方式因为没有用到原型,又有产生了2个新的问题:

  • 没有继承父类原型的属性方法;
  • 子类对象不是父类的实例。
  • function Animal(type) {
        this.type = type;
    }
    // 父类的原型方法
    Animal.prototype.eat = function () {
        console.log("吃");
    };
    function Cat(type) {
        Animal.call(this, type);
        this.name = "猫";
    }
    let cat = new Cat("哺乳动物");
    
    // 没有继承父类原型的属性方法,打印:undefined
    console.log(cat.eat);
    // 子类对象不是父类的实例,打印:false
    console.log(cat instanceof Animal);

    小Q:看来还要再改进,不如我把 原型链式 和 构造函数式 这 2 种继承方式都用上,让它们互补。

    function Animal(type) {
        this.type = type;
    }
    Animal.prototype.eat = function () {
        console.log("吃");
    };
    // 子类构造函数
    function Cat(type) {
        Animal.call(this, type);
        this.name = "猫";
    }
    // 父类对象充当子类原型
    Cat.prototype = new Animal();
    Cat.prototype.constructor = Cat;

    试验一下,果然所有问题都解决了。

    // 可以给父类构造函数传参
    let cat = new Cat("哺乳动物");
    
    // 子类对象拥用自己属性,而非来自原型,避免数据共享
    // 打印:Cat {type: '哺乳动物', name: '猫'}
    console.log(cat);
    
    // 子类对象可以继承到父类原型的方法,打印:吃
    cat.eat();
    
    // 子类对象属于父类的实例,打印:true
    console.log(cat instanceof Animal);

    老白:非常聪明,你又道出了第三种继承方式,组合式继承。即 原型链式 + 构造函数式 = 组合式。问题确实都解决了,但是有没有发现,这种方式执行了 2 遍父类构造函数。

    function Animal(type) {
        this.type = type;
    }
    Animal.prototype.eat = function () {
        console.log("吃");
    };
    function Cat(type) {
        // 第二次执行父类构造函数
        Animal.call(this, type);
        this.name = "猫";
    }
    // 第一次执行父类构造函数
    Cat.prototype = new Animal();
    Cat.prototype.constructor = Cat;

    小Q:多执行了一遍,确实不够完美,这怎么搞?

    老白:其实关键在 Cat.prototype = new Animal(),你只不过想让子类对象也能继承到父类的原型,而这里创建了一个父类对象,为啥?说到底还是利用原型链: 子类对象 → 父类对象 → 父类原型。

    如果我们不要中间那个"父类对象",而是用一个“空对象x”替换,让原型链变成:子类对象 → 空对象x → 父类原型,这样也能达到目的,就不用执行那遍没必要的父类构造函数了。

    // 组合式继承:创建父类对象做子类原型
    let animal = new Animal();
    Cat.prototype = animal;
    
    // 改进:创建一个空对象做子类原型,并且这个空对象的原型是父类原型
    let x = Object.create(Animal.prototype);
    Cat.prototype = x;

    小Q:妙啊,这回完美了。

    function Animal(type) {
        this.type = type;
    }
    Animal.prototype.eat = function () {
        console.log("吃");
    };
    function Cat(type) {
        Animal.call(this, type);
        this.name = "猫";
    }
    // 寄生组合式,改进了组合式,少执行了一遍没必要的父类构造函数
    Cat.prototype = Object.create(Animal.prototype);
    Cat.prototype.constructor = Cat;

    老白:这种继承方式又叫 寄生组合式继承,相当于在组合式继承的基础上进一步优化。回顾上面的几种继承方式的演变过程,原型链式 → 构造函数式 → 组合式 → 寄生组合式, 其实就是不断优化的过程,最终我们才推理出比较完美的继承方式。

    小Q:那 ES6 class 的 extends 继承 又是怎样呢?

    老白:说到底就是 寄生组合式继承 的语法糖。我们先看看它的语法。

    class Animal {
        eat() {
            console.log("吃");
        }
        constructor(type) {
            this.type = type;
        }
    }
    // Cat继承Animal
    class Cat extends Animal {
        constructor(type) {
            // 执行父类构造函数,相当于 Animal.call(this, type);
            super(type);
            
            // 执行完super(),子类对象就有父类属性了,打印:哺乳动物
            console.log(this.type);
            
            this.name = "猫";
        }
    }

    创建对象试验一下:

    let cat = new Cat("哺乳动物");
    
    // 子类原型的原型就是父类原型,打印:true
    console.log(Cat.prototype.__proto__ == Animal.prototype);
    
    // 子类本身拥有父类的属性,打印:Cat {type: '哺乳动物', name: '猫'}
    console.log(cat);

    打印的结果展示的特性和 寄生组合式 是一样的:

  • 子类原型的原型就是父类原型;
  • 子类本身拥有父类的属性。
  • 特性1 可以理解为 extends 背地里执行了:

    Cat.prototype = Object.create(Animal.prototype);
    Cat.prototype.constructor = Cat;

    特性2 在于 super(),它相当于 Animal.call(this),执行 super() 就是执行父类构造函数,将原本父类中的属性都赋值给子类对象。

    在 ES6 的语法中还要求 super() 必须在 this 的使用前调用,也是为了保证父类构造函数先执行,避免在子类构造器中设置的 this 属性被父类构造函数覆盖。

    class Animal {
        constructor() {
            // 假如不报错,this.name = "猫" 就被 this.name= "狗" 覆盖了
            this.name = "狗";
        }
    }
    class Cat extends Animal {
        constructor(type) {
            this.name = "猫";
            // 没有在this使用前调用,报错
            super();
        }
    }

    小Q:看懂 寄生组合式继承, extends 继承 就是小菜一碟呀~

    老白:最后再补充一下 super 的语法,可以子类的静态属性方法中通过 super.xx 访问父类静态属性方法。

    class Animal {
        constructor() {}
        static num = 1;
        static say() {
            console.log("hello");
        }
    }
    class Cat extends Animal {
        constructor() {
            super();
        }
        // super.num 相当于 Animal.num
        static count = super.num + 1;
        
        static talk() {
            // super.say() 相当于 Animal.say()
            super.say();
        }
    }
    // 2
    console.log(Cat.count);
    // hello
    Cat.talk();

     super 是一个语法糖的特殊关键词,特殊用法,并不指向某个对象,不能单独使用,以下情况都是不允许的。

    class Animal {}
    class Cat extends Animal {
        constructor() {
            // 报错
            let _super = super;
            // 报错
            console.log(super);
        }
        static talk() {
            // 报错
            console.log(super);
        }
    }

    要点归纳

  • 原型链式继承
  • 构造函数式继承
  • 组合式继承
  • 寄生组合式继承
  • extends 继承
  • 07、总结

    本文深入浅出地讨论了 JavaScript 构造函数、原型、类、继承的特性和用法,以及它们之间的关系。希望看完本文,能帮助大家对它们有更加清晰通透的认识和掌握!

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论