javascript(第三篇)原型、原型链、继承问题,使用 es5、es6实现继承,一网打尽所有面试题

时间:2024-05-02 14:47:26

没错这是一道【去哪儿】的面试题目,手写一个 es5 的继承,我又没有回答上来,很惭愧,我就只知道 es5 中可以使用原型链实现继承,但是代码一行也写不出来。

关于 js 的继承,是在面试中除了【 this 指针、命名提升、事件循环】之外的又一个重要的题目,而且很容易忽视。

  1. this 指针
  2. 命名提升
  3. 事件循环

这一部分内容,还是建议看一遍《你不知道的javascript 》上这本书,看完了你就会发现,你确实是不知道。

 一、继承的概念

先明确继承的概念,继承主要是针对类的,重要的事情说三遍,继承指的是类的继承,子类继承父类的属性和方法,这个时候和对象是没有关系的。

注意,写继承是针对类,有了继承才有子类、父类这一说

在 es6 中可以使用 class + extends 关键字事件继承,但是问题是 es5 中没有 class 关键字,所以我们就使用函数来实现!

在 JavaScript 中,继承是一种机制,它允许一个对象获取另一个对象的属性和方法。这意味着一个对象可以使用另一个对象的特性,而不必重新定义这些特性

上面的定义虽然说【它允许一个对象获取另一个对象的属性和方法】但是我们的代码写的主要还是函数,并且使用这个函数来生成对象!!

es5 中的继承方法主要有四种,分别是【原型链继承、构造函数继承、组合式继承、寄生式继承】

这些名字挺能忽悠人的,尤其是最后一个!

别看一共四种继承方式,但是在文章最后我们只需要记住一套代码就行,一定要认真看完。

关于原型链,其实还有很多知识点,我们往往会被一些概念弄混,比如 prototype、constructor 等,但是我在看完《你不知道的 javascript 上》第五章的内容之后,就豁然开朗了,所以本篇文章还是先总结一下关于原型的知识点,然后再总结各种继承方式吧。

二、原型链的基本知识

2.1 对象的内置属性 [[Prototype]]

js 每一个对象都有一个内置属性 [[Prototype]],js 的对象还有其他的内置属性比如 [[class]],之所以是内置属性,意味着我们不能直接通过属性访问点操作符访问,但是我们可以使用其他的方法访问。

比如,对于内置属性 [[Prototype]] 可以使用 Object.getPrototypeOf(obj) 来获取。也可以使用obj.__proto__ 获取,但是已经弃用,已经被  Object.getPrototypeOf(obj) 取代

obj.__proto__ 已弃用

对于内置属性 [[class]] 可以使用 Object.prototype.toString.call(obj) 来获取

注意,这个内置属性是针对对象的,每个对象都有这个内置属性,也可以简单的说【每个对象都有原型】而原型对象又有原型,所以每个对象都有原型链。

注意,js 中所有的变量都是对象,这意味着函数也是对象,所以函数也有一个内置属性[[Prototype]],也可以使用 Object.getPrototypeOf(fn) 来获取,函数的内置属性指向Function.prototype,箭头函数也有内置属性[[Prototype]],因为箭头函数本质也是一个对象。

Object.getPrototypeOf(Array) === Function.prototype // true

2.2 函数的原型 prototype

函数有一个公开可访问不可枚举属性 prototype,指向一个对象,也称之为函数的原型对象。注意三个关键词【公开】【可访问】【不可枚举】

注意,箭头函数没有 prototype 属性!!!这也是箭头函数不能当作构造函数的原因之一!!参考这篇文章

记住,所有的函数(除了箭头函数)都有一个公开可访问的不可枚举的属性 prototype,这意味着可以直接使用 fn.prototype 来获取,这一点和2.1 中说的对象是不同的,对象是不可直接访问的内置属性,函数是可以访问的公开属性。

所以有一个对象和一个函数,你要判断的只能是【函数的 prototype 属性是否在对象的原型链上】

// 有一个函数
function fn() {

}
// 有一个对象
let a = new fn()

// 判断对象是否在函数的原型链上
Object.getPrototypeOf(a) === fn.prototype // true

2.3 函数的prototype属性的公开可访问不可枚举属性 constructor 

对象有一个公开不可枚举属性 constructor ,翻译过来就是构造函数,注意这个 constructor 是针对对象的,而不是函数的。

函数的 prototype 属性也是一个对象,并且, fn.prototype.constructor = fn

其实,对象本身并没有 .constructor 属性,对象调用 .constructor 的本质是在对象的原型链上找的。再实现继承代码的时候前往别忘了这个 constructor 属性

function fn() {

}

let a = new fn()

fn.prototype.constructor === fn // true 
a.constructor === fn.prototype.constructor // true
a.constructor === fn // true

详细内容还是自己看书吧,书上非常详细!

总结

总之关于原型这块记住三句话

  1. 对象有一个内置属性 [[Prototype]],使用 Object.getPrototypeOf(obj) 获取
  2. 函数有一个公开可访问不可枚举属性 prototype
  3. 函数的 prototype 属性有一个公开可访问的不可枚举属性 constructor,指向函数本身

2.4 原型相关的面试题目

2.4.1 说说你对原型和原型链的理解

回答问题分文两步

(1)原型/原型链是什么?【引用上面的三句话即可】

在 js 中每个对象都有一个内置属性 [[prototype]],可以使用 Object.getPrototypeOf 来获取,指向一个对象;同样的,这个指向的对象也有内置属性[[prototype]] 这样就构成了原型链,原型链最终会指向 Object.prototype,而 Object.prototype 的内置属性 [[prototype]] 指向 null.

同时函数都有一个公开可访问属性 prototype,这个 prototype 属性又有一个 constructor 属性指向函数本身。

(2)原型链有什么用?【属性查找、继承、扩展、属性和方法的共享】

当访问对象的一个属性的时候,如果自身没有找到,就会去原型链上查找,直到找到该属性,或者遍历完完整的原型链,也就是说可以使用原型链实现继承功能。对象可以通过原型链继承父对象的属性或者方法【继承】

也可以使用原型链对对象进行扩展,通过修改原型对象,可以给所有的实例进行属性的增加或修改。如果我们在一个对象的原型上添加属性或者方法,所有基于该原型的实例都会自动继承这些属性和方法,这样可以在不修改每个实例的情况下,实现对对象的扩展【扩展】【注意这一点也是原型链继承的弊端】【也是实例之间属性和方法的共享的方法】

题外话,for ... in 循环就会遍历到对象的原型链上的公开可访问可枚举属性!不能遍历不可枚举属性。

还要注意 for... in 和 for ...of 的区别。

2.4.2 如何获取一个对象的原型对象

(1)从构造函数获取,前提是知道对象的构造函数是谁

(2)使用 Object.getPrototypeOf(obj) 获取

(3)使用 Object.__proto__ 但是官方已经弃用,不建议用了

function fn() {
    //
}

let a = new fn()

console.log('a 的原型对象是', fn.prototype)
console.log('a 的原型对象是', Object.getPrototypeOf(a))
console.log('a 的原型对象是', a.__proto__) // 不建议

2.4.3 打印结果

关于原型的面试题,还有各种打印结果的,而且往往和 this 指针、命名提升掺合在一起,所以基础一定要扎实。

随便看一道题目,可能就答不上来

var F = function() {};
Object.prototype.a = function() {
  console.log('a');
};
Function.prototype.b = function() {
  console.log('b');
}
var f = new F();
f.a();
f.b();
F.a();
F.b()

打印结果是【a、报错 f.b is not a function、 a、 b】 

  1. F 是函数,所有的函数都是 Function 的实例【箭头函数也是】
  2. 函数也是对象,所有对象都是 Object 的实例
  3. f 是对象,所以不会继承 Function,但是作为对象会继承Object

2.4.5 如何使用原型链实现继承,存在什么问题,怎么解决

这个就是本篇文章文章的重点了,不过别担心,最终我们只有一套代码需要记住,前面的都是铺垫!

三、使用原型链继承

3.1 代码实现

原型继承的属性和方法,所以我们在自定义实现的时候,最好是定一个属性,再定义一个方法。而且都会用到 this 指针。

首先要定一个一个子类(函数),一个父类(函数),实现子类继承父类,也就是子类创建的方法可以拥有父类的属性,那么步骤很简单:

  1. 定义一个函数作为父类 Person,并定义一个 name 属性【使用 this 指针】
  2. 给父类原型上加一个方法 getName【使用函数的 prototype 属性 + this 指针】
  3. 定义一个函数作为子类 Student,定一个 gender 属性 【使用 this 指针】
  4. 子类 Student 通过原型继承 Person【使用函数的 prototype 属性 + new 操作符】
  5. 处理子类 Student.prototype 的 constructor,指向 Student
  6. 使用子类创建一个对象 student【使用 new 操作符】
  7. 访问 student.name 和 student.getName
  8. 完成子类 Student 对父类 Person 的属性和方法的继承
function Person() {
    this.name = 'mike';
}
Person.prototype.getName = function() {
    return this.name;
}
function Student(gender) {
    this.gender = gender
}
Student.prototype = new Person();
Student.prototype.constructor = Student;

const student = new Student('man');

console.log(student.gender);  // 子类自己的属性
console.log(student.name); // 继承父类的属性
console.log(student.getName());  // 继承父类的方法

  

3.2 存在的问题

面试官肯定会问你这个问题,使用原型继承存在是什么问题?然后再引出怎么解决问题,再引出 es6 中的 class 的继承。

3.2.1 引用类型属性共享问题

原型链继承存在的问题就是,多个子类的实例,指向同一个父类的实例,所以对于父类的引用类型,修改一个子类的实例会影响到其他的实例!【这个问题是可以解决的,具体看第四章】

3.2.2 原型链上所有的属性和方法都是共享的

在原型链中,子类实例共享父类原型对象上的属性和方法。这意味着,如果一个子类实例修改了原型对象上的属性或方法,那么其他所有子类实例也会受到影响,可能会导致意外的副作用。

3.2.3 子类向父类传参需要手动调用父类构造函数

除非我们手动显式的使用 call/apply 方法调用父类的构造函数,否则无法给父类构造函数传递参数。所以传递参数这个问题也是可以解决的,具体看第四章。

3.2.4 无法实现多重继承

一个子类只能继承一个父类,无法实现多重继承

3.2.4 破坏封装性

原型链继承会导致父类的内部属性和方法暴露给子类,从而破坏了封装性。子类可以直接访问父类原型对象上的属性和方法,无法实现严格的控制访问权限。

3.3 总结

使用原型继承,是 es5 中实现继承的必须要学会的,同时还要记住原型继承存在的问题!这个时候就有一个新的问题了,就是如何使用 es5 中的知识解决这些问题。

答案是将四种继承方式组合起来,取各自的优点。不过在此之前我们还是先看看其他的继称方式。

四、构造函数继承

4.1 代码实现

利用 this 指针的显示绑定方法 call 和 apply ,在子类中调用父类构造函数,把父类的成员属性和方法都挂在到子类的 this上。这个方法解决了 3.2.1 和 3.2.3 中的问题。具体代码如下:

function Person(age) {
    this.name = 'mike';
    this.age = {
        num: age
    }
}

Person.prototype.getName = function() {
    return this.name;
}
function Student(gender, age) {
    // 手动调用父类的构造函数
    Person.call(this, age)
    this.gender = gender
}

const student = new Student('man', 12);
const student1 = new Student('women', 25)

// 修改第一个实例
student.age.num = 3
console.log('第一个学生', student.age)
console.log('第二个学生', student1.age)


  

4.2 存在的问题

因为我们没有写下面原型继承的那两句话,所以就无法继承来自父类原型上的属性和方法。

// 构造函数继承没有这两句话
Student.prototype = new Person()
Student.prototype.constructor = Student;

其实我们要继承原型上的属性和方法,写上就行了呗,但是呢,很多教程中都是这样写的,把构造函数继承和原项链继承分开,然后再引出后面的组合继承,那我也就这么弄吧。

五、组合继承

5.1 代码实现

就是把原型链继承和构造函数继承的优点组合起来,完整代码如下。

function Person(age) {
    this.name = 'mike';
    this.age = {
        num: age
    }
}

Person.prototype.getName = function() {
    return this.name;
}
function Student(gender, age) {
    // 手动调用父类的构造函数
    // 构造函数继承
    Person.call(this, age)
    this.gender = gender
}

// 原型链继承
Student.prototype = new Person()
Student.prototype.constructor = Student;

const student = new Student('man', 12);
const student1 = new Student('women', 25)

// 修改第一个实例
student.age.num = 3
console.log('第一个学生', student.age, student.getName())
console.log('第二个学生', student1.age, student1.getName())


  

5.2 存在的问题

每次创建子类实例都执行了两次构造函数 Person.call 和 new Person() ,虽然不影响功能,但是每次创建子类实例,实例的原型中都有两份相同的属性和方法。

这是可以 Object.create 优化的,这就迎来了 es5 继承的最终极版代码。需要有感情的朗读并背诵全文!!

六、寄生式组合继承【必会】

我不喜欢这个名字,因为他听起来很高端的样子,还不如叫 es5 继承终极版!

很简单,把 new Person() 换成 Object.create(Person.prototype)就行了。

function Person(age) {
    this.name = 'mike';
    this.age = {
        num: age
    }
}

Person.prototype.getName = function() {
    return this.name;
}
function Student(gender, age) {
    // 重点1
    Person.call(this, age)
    this.gender = gender
}

// 重点2
Student.prototype = Object.create(Person.prototype)
// 重点3
Student.prototype.constructor = Student;

const student = new Student('man', 12);
const student1 = new Student('women', 25)
console.log(Object.getPrototypeOf(student))

// 修改第一个实例
student.age.num = 3
console.log('第一个学生', student.age, student.getName())
console.log('第二个学生', student1.age, student1.getName())


  

这里面其实应用到了,Object.create 的原理,这也是一个面试题目,而且也有可能让你手写一个 Object.create 请看这篇文章。

小结

好吧,整半天就一套代码,如果面试官让你写 es5 的继承,你直接上来就终极版代码安排,我想他应该没有什么可问的了吧,所以你别看概念上那么继承方式那么多,但是实际应用就是一个!一定要记住,可别再翻车了。

那么还有最后一个问题就是 es6 中的继承了!

七、es6 继承

7.1 代码实现

使用类 class + extends 实现继承。主要还是学会使用class 类的各种语法,有几个关键点

  1. class 中只能有一个构造函数 constructor
  2. 可以使用 static 定义静态属性和方法,直接使用类名调用
  3. 子类使用 extends 关键字继承父类,且只能继承一个【说明 es6 原生也不支持多重继承】
  4. 子类在构造函数 constructor 中使用 super 来调用父类的构造函数,并且可以传递参数
  5. 子类中的方法和父类的同名,会覆盖父类的方法
  6. 必须使用 new 操作符,创建 class 示例
class Person {
    // 定义属性
    lang = 'zh'
    // 定义静态属性
    static nation = 'china'
    // 构造函数
    constructor(age) {
        this.name = 'mike'
        this.age = {
            num: age
        }
    }
    // 定义方法
    getName() {
        return this.name
    }
    // 定义静态方法
    static getDes () {
        return 'hello word'
    }
}

class Student extends Person {
    constructor(gender, age) {
        super(age)
        this.gender = gender
    }
}
const student = new Student('man', 12)
const student1 = new Student('women', 25)
student.age.num = 234

console.log('静态属性方法',Person.nation, Person.getDes())
console.log('第一个学生', student.lang, student.getName())
console.log('第二个学生', student1, student.getName())

7.2 面试题目

这个时候肯定会问 es5 中的类和 es6 中的类的区别了,用自己的话总结一些这篇文章的内容即可。

7.2.1 es5 中类 es6 中的继承有什么区别

注意 es6 的class 有一个私有属性和方法,以#开头的,这个倒是不常用。

7.2.2 ts 中的类和 es6 中的类有什么区别

  1. ts 中有类型检查
  2. ts 有访问描述符 private 、public 、protected 等,js 中只有 #开头描述的私有属性
  3. ts 中有抽象类和方法的概念
    1. 抽象类可以包含抽象方法,而接口只能定义方法的签名
  4. ts 支持范型