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

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

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 题基本也迎刃而解了!

  1. new Date().__proto__ == Date.prototype ?
  2. 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 都做了什么

  1. 创建一个对象,绑定原型;
  2. 以这个对象为 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 题也解了。

  1. Date.__proto__ == Function.prototype ?
  2. Function.__proto__ == Function.prototype ?
  3. Function.prototype.__proto__== Object.prototype ?
  4. 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 个缺点:

  1. 父类对象作为原型,其属性会被所有子类对象共享;
  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个新的问题:

  1. 没有继承父类原型的属性方法;
  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. 子类原型的原型就是父类原型;
  2. 子类本身拥有父类的属性。

特性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);
    }
}

要点归纳

  1. 原型链式继承
  2. 构造函数式继承
  3. 组合式继承
  4. 寄生组合式继承
  5. extends 继承

07、总结

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

责任编辑:武晓燕 来源: 搜狐技术产品
相关推荐

2011-08-24 13:56:27

JavaScript

2022-06-20 09:22:55

js原型链前端

2009-08-13 18:26:35

C#继承构造函数

2011-08-31 14:48:33

JavaScript

2009-09-18 13:40:40

继承关系

2009-08-13 18:15:06

C#继承构造函数

2022-03-29 09:15:55

Javascript函数属性

2010-02-02 17:39:31

C++构造函数

2010-01-27 16:10:32

C++静态构造函数

2013-09-18 14:01:46

JavaScript

2009-08-13 18:36:36

C#继承构造函数

2022-04-14 20:43:24

JavaScript原型链

2009-12-11 10:42:00

Scala讲座类定义构造函数

2009-12-10 13:37:16

PHP parent

2010-01-27 10:13:22

C++类对象

2010-01-25 14:00:27

C++类

2011-08-24 13:51:56

JavaScript

2020-04-29 14:40:19

JavaScript继承编程语言

2021-07-16 04:56:03

NodejsAddon

2020-09-10 07:04:30

JSJavaScript 原型链
点赞
收藏

51CTO技术栈公众号