没错这是一道【去哪儿】的面试题目,手写一个 es5 的继承,我又没有回答上来,很惭愧,我就只知道 es5 中可以使用原型链实现继承,但是代码一行也写不出来。
关于 js 的继承,是在面试中除了【 this 指针、命名提升、事件循环】之外的又一个重要的题目,而且很容易忽视。
- this 指针
- 命名提升
- 事件循环
这一部分内容,还是建议看一遍《你不知道的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
详细内容还是自己看书吧,书上非常详细!
总结
总之关于原型这块记住三句话
- 对象有一个内置属性 [[Prototype]],使用 Object.getPrototypeOf(obj) 获取
- 函数有一个公开可访问不可枚举属性 prototype
- 函数的 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】
- F 是函数,所有的函数都是 Function 的实例【箭头函数也是】
- 函数也是对象,所有对象都是 Object 的实例
- f 是对象,所以不会继承 Function,但是作为对象会继承Object
2.4.5 如何使用原型链实现继承,存在什么问题,怎么解决
这个就是本篇文章文章的重点了,不过别担心,最终我们只有一套代码需要记住,前面的都是铺垫!
三、使用原型链继承
3.1 代码实现
原型继承的属性和方法,所以我们在自定义实现的时候,最好是定一个属性,再定义一个方法。而且都会用到 this 指针。
首先要定一个一个子类(函数),一个父类(函数),实现子类继承父类,也就是子类创建的方法可以拥有父类的属性,那么步骤很简单:
- 定义一个函数作为父类 Person,并定义一个 name 属性【使用 this 指针】
- 给父类原型上加一个方法 getName【使用函数的 prototype 属性 + this 指针】
- 定义一个函数作为子类 Student,定一个 gender 属性 【使用 this 指针】
- 子类 Student 通过原型继承 Person【使用函数的 prototype 属性 + new 操作符】
- 处理子类 Student.prototype 的 constructor,指向 Student
- 使用子类创建一个对象 student【使用 new 操作符】
- 访问 student.name 和 student.getName
- 完成子类 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 类的各种语法,有几个关键点
- class 中只能有一个构造函数 constructor
- 可以使用 static 定义静态属性和方法,直接使用类名调用
- 子类使用 extends 关键字继承父类,且只能继承一个【说明 es6 原生也不支持多重继承】
- 子类在构造函数 constructor 中使用 super 来调用父类的构造函数,并且可以传递参数
- 子类中的方法和父类的同名,会覆盖父类的方法
- 必须使用 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 中的类有什么区别
- ts 中有类型检查
- ts 有访问描述符 private 、public 、protected 等,js 中只有 #开头描述的私有属性
- ts 中有抽象类和方法的概念
- 抽象类可以包含抽象方法,而接口只能定义方法的签名
- ts 支持范型