文章目录
- 原型专题
- 前言
- 什么是原型?
- 实例和原型的关系
- 什么是原型链?
- Object 和 Function 的继承关系
- 原型运用例子:
- 手写instanceof
- 手写call,apply
- 手写new
- 六大继承方式
- 原型链继承
- 构造函数继承
- 原型链 + 构造函数
- 优化(原型链 + 构造函数)
- 再次优化(原型链 + 构造函数)
- ES6中的class继承
- 总结
原型专题
前言
这是一篇详细介绍Javascript中原型和继承的内容,从里到外透析继承在Javascript中的秘密,文中会参杂一些常见有关继承的面试题目,耐心看完相信会有收获。
什么是原型?
这是一个比较抽象的概念。
在Javascript中的各种官方定义好的对象如,Object和Function等其实都是由一个源对象创建出来的,这是所有数据对象的起源,我们自己定义的构造函数等其实本质上都来源于这个源对象。
实例和原型的关系
首先我们要理顺三个概念,构造函数,实例,原型这三者直接的关系。所有Javascript有关原型的内容其实都是在这三者间反复横跳。
function Person() {
}
let person = new Person()
在上面的代码中,Person就是我们的构造函数,person 就是构造函数new出来的实例。那么原型是?
从上面的原型概念已经说了,一个对象的原型是来源于创建时关联的对象。那我们应该如何找到这个对象呢?
这里需要借助两个东西,prototype 和 proto
我们构造函数可以借助prototype找到原型,而实例可以借助proto找到原型。
console.log(Person.prototype)
console.log(Person.prototype === person.__proto__) // true
同样的原型也可以找到他生成的构造函数的,利用 constructor
console.log(Person.prototype === Person.prototype.constructor) // true
介绍到这里我们应该大致了解的原型,构造函数,实例之间的关系了。
用一张图来表示就是这样。
到这里,我们来正式的介绍一下prototype,proto和constructor三者。
- prototype:构造函数用来指向原型对象,也是我们new出一个实例的关键属性。
- proto:这是每一个对象都会有的属性(null除外),实例用来指向原型对象的属性。
- constructor:原型对象用来指向构造函数的属性。
什么是原型链?
首先我们要明白为什么要有原型链。
我们的原型之所以重要,很重要一点是每个实例都会继承原型的属性,重点在于继承这个功能。
当我们读取一个实例的属性,如果找不到该属性,我们就可以顺着原型链去寻找这个实例的原型有没有该属性,如果还没有则一直找下去,直到找到最顶层。
举个例子:
function Person() {
}
Person.prototype.name = 'lyy';
person.name = 'LYY';
console.log(person.name) // LYY
delete person.name;
console.log(person.name) // lyy
在这个例子中,我们最开始会找实例有没有name这个属性,如果找得到就直接输出,如果找不到就去找原型的name。
通过上面这个例子,应该知道原型链大概是个什么东西了,相互关联的原型组成的链状结构就是原型链,也就是蓝色的这条线。
在源对象后面就是null。
Object 和 Function 的继承关系
既然我们定义一个函数function,我们依托这个函数生成的实例必然会继承一个对象,那么function继承的对象是否就是Object?
我们构造函数的Function和Object到底是什么关系?
举一个例子:
function Person() {
}
var obj = {
name: 'lyy',
age: 23,
}
如果我们对Object深挖下去的话, 可以发现对于Object来说,他确实是离源对象最近的一个对象。
而且直接用var obj出来的对象其实就是Javascript内置Object构造函数的一个实例。
因此我们不难发现,obj确实是继承链条上的顶点了。
console.log(Object.prototype) // 源对象
console.log(Object.__proto__) // f()
console.log(Object.__proto__.__proto__) // 源对象
console.log(Object.__proto__.__proto__.__proto__) // null
console.log(Object.prototype === obj.__proto__) // true
那么对于function呢?
Funtion的prototype指向的是和Object.proto一样的东西,我们通过打印出来会发现他其实是一个函数fn
这是啥?我相信很多人看了都会疑问
console.log(Function.prototype === Object.__proto__) // true
console.log(Function.__proto__ == Function.prototype) // true
console.log(Person.__proto__.__proto__ === Object.prototype) // true
为了形象表述,我将整个流程大概画出来,如下图。
实际上我们发现Funtion和Object其实也没什么太大联系,也不是我们之间谁继承谁,只是他们的源对象都是一致,并且为Funtion的prototype指向的是和Object.proto一样。
通过查阅资料发现:
Function.prototype 对象是一个函数对象(它的 [[Class]] 是 Function),Function.prototype 可以被调用,它接受任何参数,并且返回 undefined。
但是 Function.prototype 的原型是 Object.prototype。
翻译过来也就是说:
- Function.prototype 是一个函数
- 但是 Function.prototype.proto === Object.prototype
太奇葩了,而官方解释是:
The Function prototype object is specified to be a function object to ensure compatibility with ECMAScript code that was created prior to the ECMAScript 2015 specification.
翻译:这么做是为了兼容之前的 ECMAScript 代码。
所以function和Object并不是相互继承的关系,他们之间的fn函数是来源于历史遗留问题。
原型运用例子:
下面写一些依据原型继承的常见面试八股吧
手写instanceof
instanceof是判断构造函数和实例对象在原型链上是否有关系,有就返回true,否则就返回false
这个就是典型的利用原理链去比对即可,我们知道构造函数可以用prototype找到原型,而实例对象也可以靠proto找到原型,还能在此基础上不断向上搜索。
function myInstanceof(target,origin) {
while(target) {
if (target.__proto__ === origin.prototype) return true;
target = target.__proto__;
}
return false;
}
手写call,apply
call 和 apply都是用来继承的,写一个方法,让一个新对象来继承他的方法。
call作用:提供新的 this 值给当前调用的函数/方法。更简单点的理解,call把this换成了obj的this,并且给调用的函数用了。
手写call:
call 需要完成两个点:
- 改变this指向,将之变为传入的对象this
- 可以传入一个不定长的参数。
Function.prototype.call2 = function (obj) {
obj = obj || window; // 如果传进来为null,则为函数应该挂到window上
obj.fn = this;
var args = [];
for (let i = 1; i < arguments.length; i ++ ) {
args.push('arguments[' + i +']');
}
var res = eval('obj.fn(' + args + ')');
delete obj.fn;
return res;
}
手写apply:
apply和call实现一样,唯一区别他会给出一个数组参数。
Function.prototype.apply2 = function(obj,arr) {
var obj = obj || window;
obj.fn = this;
var res,args = [];
for (let i = 0; i < arr.length; i ++ ) {
args.push('arr[ ' + i + ']');
}
res = eval('obj.fn(' + args + ')');
delete obj.fn
return res;
}
手写new
new的作用是利用构造函数来创建一个实例。
实际上,我们创建一个对象非常容易,直接var obj就可以立即创建,这是第一步。
第二步,我们创建的实例是依据于对应的构造函数的,所以从上面的原型链学习就已经知道了,实例的proto指向必须指向原型,也就是和构造函数的prototype一致。
第三步,我们要继承构造函数的属性和方法,所以继承的手段我们直接用call或者apply即可。
function mynew(fn, ...args) {
// 1. 创建一个新对象
const obj = {};
// 2. 为新对象添加属性__proto__,将该属性链接至构造函数的原型对象
obj.__proto__ = fn.prototype;
// 3. 执行构造函数,this被绑定在新对象上
const res = fn.call(obj, ...args);
// 4. 确保返回一个对象
return res instanceof Object ? res : obj;
}
在这里还是说明一下最后一步为什么要来判断吧。
call的返回值和构造函数的返回值有关,如果构造函数有返回值就会返回构造函数的返回值,如果没有的话就会返回undefined。
所以call让obj继承完后,需要判断一下构造函数是否有返回值。
例如:
/// 1.如果在mynew中打印res,结果是undefined
function Person(name, age) {
this.name = name;
this.age = age;
}
///2.如果在mynew中打印res,结果就是实例对象了。
function Person(name, age) {
this.name = name;
this.age = age;
return this
}
六大继承方式
继承是我们面向对象最重要的性质之一。另A对象通过继承B对象,获取B对象的属性和方法来提高代码复用性。
下面来介绍Javascript的六大继承。为了方便,我们先在这里举个例子,下面的各种继承都以这个例子进行操作,下文代码不会重复写。
//父类型
function Person(name, age) {
this.name = name,
this.age = age,
this.play = [1, 2, 3]
this.setName = function () { }
}
Person.prototype.setAge = function () { }
//子类型
function Student(price) {
this.price = price
this.setScore = function () { }
}
原型链继承
这是最简单粗暴的继承方式,直接让父类型的实例对象作为子类型的一个原型。
Student.prototype = new Person()
那这样,子类就可以借助于proto访问到父类的实例甚至是原型,实现了属性和方法的继承。
优点:
- 子类可以访问到父类的全部属性和方法。
缺点:
- 无法实现多继承(子类只能继承一个父类的属性和方法)
- 由于是直接调用实例,因此实际上父类的属性和方法是给全部的子类实例共享。
构造函数继承
利用 call 继承,子类在构造函数中用 call 调用父类的构造函数,让子类的 this 继承到父类的构造函数的属性和方法。
function Student(name, age, price) {
Person.call(this, name, age) // 相当于: this.Person(name, age)
this.age = age
this.price = price
}
优点:
- 可以实现多继承(call多个父类)
- 解决了子类实例共享父类属性/方法的问题。
- 可以传参
缺点:
- 无法获取父类原型的属性和方法。
原型链 + 构造函数
研究上面两个例子,我们不难发现这两个继承方式是相辅相成的,因此我们对两个种继承方式各持所需。
通过调用父类的构造函数,继承父类的属性方法和传参的特点,在利用原型链继承父类原型。
function Student(name, age, price) {
Person.call(this,name,age)
this.price = price
this.setScore = function () { }
}
Student.prototype = new Person()
Student.prototype.constructor = Student // 构建完整的构造函数指向原型
这里解释一下最后一步的操作:这句话的作用是保存好构造函数和原型间的链条,因此在前面重置了Student的原型,所以constructor也需要指向新的构造函数。
优点:
- 可以多继承
- 可以传参
- 不会出现子类实例共享父类方法属性
缺点:
- 调用了两次构造函数,一次在new Person,一次在call上。
优化(原型链 + 构造函数)
对于上面的缺点,我们可以进行一个优化,改变子类原型转变成父类实例,变成直接指向父类原型。
function Student(name, age, price) {
Person.call(this, name, age)
this.price = price
this.setScore = function () { }
}
Student.prototype = Person.prototype
Student.prototype.sayHello = function () { }
优点:
- 解决了两次初始化实例
缺点:
- 无法区分创建的实例时来自子类还是父类,因为他们原型都一致。
再次优化(原型链 + 构造函数)
借助Object.create() 来创建对象,var B = Object.create(A) 以 A 为原型,生成了B对象。
function Student(name, age, price) {
Person.call(this, name, age)
this.price = price
this.setScore = function () {}
}
Student.prototype = Object.create(Person.prototype) //核心代码
Student.prototype.constructor = Student //核心代码
无暇的方案,子类继承了父类全部的属性和方法,最完美的形态诞生了!
ES6中的class继承
ES6中引入了class,class 可以借助 extends 关键字来继承。
虽然引入了class,但是本质上class是语法糖,实际上还是基于原型实现的。
class Person {
//调用类的构造方法
constructor(name, age) {
this.name = name
this.age = age
}
//定义一般的方法
showName() {
console.log("调用父类的方法")
console.log(this.name, this.age);
}
}
let p1 = new Person('kobe', 39)
console.log(p1)
//定义一个子类
class Student extends Person {
constructor(name, age, salary) {
super(name, age)//通过super调用父类的构造方法
this.salary = salary
}
showName() {//在子类自身定义方法
console.log("调用子类的方法")
console.log(this.name, this.age, this.salary);
}
}
let s1 = new Student('wade', 38, 1000000000)
console.log(s1)
s1.showName()
ES5继承和ES6继承方式的区别:
ES5 是先创建出子类的this,再把父类的属性方法挂载到子类的this上。
而ES6正好反过来,先把父类的属性和方法挂载到子类this上(如在class必须向调用super) , 再用子类的构造函数去修改this。
总结
最后来个总结吧,在做项目的时候发现是时候该总结一下目前学的内容了,所以在此记录下这篇博客。这篇文章详细介绍了原型/继承,这个知识点算是Javascript的基础内功了,在实际开发过程中也会经常遇到,也是面试中非常常考的知识点,如果觉得不错来个点个👍吧。
参考链接:
https://github.com/mqyqingfeng/Blog/issues/11
https://github.com/mqyqingfeng/Blog/issues/2