手写面试题六:原型与原型链

1,426 阅读9分钟

转载请注明原文链接。原文链接

手写面试题系列是我为了准备当下和以后的面试而编写的文章系列,当然对于前端小伙伴也有帮助。我建议读完之后,自己动手敲代码或者手写一遍才能更好地掌握。

相信很多从事前端工作一两年的读者,或多或少都了解或者听说过原型原型链。作者我在大学期间没有专门学过JavaScript,是从jquery开始写的前端代码,所以在很长一段时间内对于JavaScript的很多概念并不了解,其中就包括原型原型链。我对于原型与原型链的理解也不是一蹴而就的,经过反复地学习和码代码验证才算是弄懂了它们。

在介绍原型原型链的概念之前,我首先要告诉读者原型原型链是用来做什么的。理解了JavaScript的原型原型链,就能很好地理解JavaScript中的面向对象和继承的概念。所以,学会了原型原型链,就学会了JavaScript的面向对象和继承。

一、什么是原型?

在第一次接触原型这个概念时,我是很懵逼。因为,我把大学时学的Java的面向对象的概念代入到了JavaScript上,对自己学习和理解JavaScript的原型造成了一定的困扰。 为了避免读者也遇到我这样的情况,所以在介绍原型的概念浅我需要先说明一下。

一谈起面向对象的编程语言,最容易让人想起的的就是Java。在Java中,所有的逻辑都在class(类)中,关于class的概念随处可见。但是在JavaScript的ES6出现之前,都没有class(类)的概念,难道JavaScript不是一个面向对象的语言?

其实并不是。应该说,Java是一种基于class(类)的面向对象的编程语言,而JavaScript却并不是基于class(类)的面向对象的编程语言。也就是说,并不是只有基于class才能实现面向对象,只不过一些主流的编程语言是基于class实现了面向对象而已。

JavaScript是基于原型实现了面向对象,或者说JavaScript是一种基于原型的语言。既然JavaScript并不是基于class实现的面向对象,那么在学习JavaScript原型的时候就不要带着class的想法去对照,因为两者是完全不一样的实现思路。

1. 原型的概念

在JavaScript中,函数可以被实例化并创建这个函数的实例对象,就相当于Java中可以实例化class(类)创建实例对象一样。那么为什么函数可以被实例化呢?因为每个函数都拥有一个特殊的属性:原型(prototype,JavaScript就是基于**原型(prototype)**实现了面向对象。

首先,通过一个例子来看下每个函数都有的属性prototype

function add(a, b) { return a + b; }
console.log(add.prototype);
var add = function(a, b) { return a + b; };
console.log(add.prototype);
// 在谷歌浏览器控制台中运行会报错因为谷歌浏览器的策略导致,但对于JavaScript而言是合法的语法
// add = new Function('a', 'b', 'return a + b;' );
// console.log(add.prototype);

如果把上面的代码拷贝到浏览器控制台中运行,都会输出一个对象。不论是函数声明还是函数表达式定义的函数都具有属性prototype。如果把打印在控制台的对象展开,可以得到下面的输出结果:

{constructor: ƒ}
constructor: ƒ add(a, b)
[[Prototype]]: Object
	constructor: ƒ Object()
	hasOwnProperty: ƒ hasOwnProperty()
	isPrototypeOf: ƒ isPrototypeOf()
	propertyIsEnumerable: ƒ propertyIsEnumerable()
	toLocaleString: ƒ toLocaleString()
	toString: ƒ toString()
	valueOf: ƒ valueOf()
	__defineGetter__: ƒ __defineGetter__()
	__defineSetter__: ƒ __defineSetter__()
	__lookupGetter__: ƒ __lookupGetter__()
	__lookupSetter__: ƒ __lookupSetter__()
		__proto__: (…)
	get __proto__: ƒ __proto__()
	set __proto__: ƒ __proto__()

可以看到,原型对象(prototype)中包含一个特殊属性constructor;在JavaScript中通过new关键字实例化对象时,其实就是对函数的constructor属性进行实例化的。但仅仅可以被实例化还不够,JavaScript还通过原型对象(prototype)实现了属性和方法的继承。举个例子来说明一下:

function Dog(name) {
	this.name = name;
}
Dog.prototype.bark = () => console.log('汪汪汪...');

const wangcai = new Dog('旺财');
console.log(wangcai.name); // 输出:'旺财'
wangcai.bark(); // 输出:'汪汪汪...'

我来解释下上面的例子。上面的代码中,通过new关键字实例化Dog得到了对象wangcaiwangcai在实例化时获得了属性name并设置值为'旺财'(new关键字实例化时把this指向了对象wangcai,所以wangcai获得了this上的所有属性和方法),同时还从父级Dog继承了方法bark()

那么对象wangcai是如何从它的**"父亲"**继承方法bark()的呢?

如果是Java,那么实际上在实例化Dog得到wangcai的时候把Dog中的所有属性和方法都赋给了对象wangcai,但是JavaScript不是通过这个方式实现的继承。我把上面的例子改造一下,用以说明:

function Dog(name) {
	this.name = name;
}
Dog.prototype.bark = () => console.log('汪汪汪...');

const wangcai = new Dog('旺财');
Dog.prototype.run = () => console.log('狗狗跑快快');
wangcai.run(); // 输出:'狗狗跑快快'

上面的例子中,在实例化wangcai后又给Dog的原型对象prototype添加了方法run(),然后通过对象wangcai调用了方法run()。如果按照Java的思路,wangcai是在实例化的时候把Dog原型对象上的属性和方法都赋值给了对象wangcai,那么在实例化之后添加的方法run()不应该被wangcai继承到。

其实wangcai并不是在实例化的时候获取了**"父亲"Dog的原型对象上的属性和方法,而是直接调用了Dog的原型对象上的方法。也就是说,JavaScript中子对象并不是在实例化的时候拷贝了"父类"上的属性和方法,而是建立了途径可以直接调用"父类"**原型对象上的属性和方法。

这里就不得不提及JavaScript所有对象特有的一个属性__proto__,又被称为隐式原型。一个对象的隐式原型(__proto__)指向了创建它的对象的原型对象(prototype),用上面的例子表达就是: wangcai.__proto__ === Dog.prototype。可以把__proto__理解为一个指针,它指向了创建这个对象的原型对象(prototype),所以通过__proto__就可以访问到原型对象(prototype)上的属性和方法。

访问对象上的属性和方法时,如果对象上拥有该属性或方法就直接方法对象已有的属性或方法,如果对象上没有改属性或方法就会访问对象的隐式原型上有没有该属性或方法。如此饶舌拗口的概念用代码示例来说明:

function Dog(name) {
	this.name = name;
}
Dog.prototype.bark = () => console.log('汪汪汪...');

const wangcai = new Dog('旺财');
Dog.prototype.run = () => console.log('狗狗跑快快');
wangcai.run(); // 输出:'狗狗跑快快'
wangcai.run = () => console.log('旺财跑快快');
wangcai.run(); // 输出:'旺财跑快快'

可以看到,对象本身就拥有的属性或方法会被优先调用,在对象本身没拥有时才回去对象的隐式原型上去寻找(由于隐式原型指向的是创建该对象的原型对象,所以实际访问的创建该对象的原型对象上的属性或方法)。

2. 结论

总结一下,可以得到一下结论:

  1. JavaScript通过原型(prototype)实现了面向对象;
  2. 函数都拥有属性prototypeprototype是一个对象并拥有属性constructor,所以函数都可以被实例化;
  3. 对象实例访问自身的属性或方法时,会优先访问自身拥有的属性或者方法,如果找不到就会去对象的隐式愿想(__proto__)上寻找;

以上面例子中对象wangcai为例,如果访问的属性或方法在Dog也没有呢?JavaScript会怎么寻找?例如,通过wangcai.age想要访问Dog上也不具有的属性age,会怎样呢?这就不得不提到下一个概念:原型链

二、原型链

接上面的问题,如果想要通过wangcai.age想要访问Dog上也不具有的属性age,会怎样? 先看下面的代码:

function Dog(name) {
	this.name = name;
}
Dog.prototype.bark = () => console.log('汪汪汪...');
const wangcai = new Dog('旺财');
console.log(wangcai.age); // 输出: undefined
Object.prototype.age = 10;
console.log(wangcai.age); // 输出:10
console.log(Dog.age); // 输出:10

Javascript中,每个对象拥有一个原型对象,对象以其原型为模板、从原型继承方法和属性。原型对象也可能拥有原型,并从中继承方法和属性,一层一层、以此类推,这种关系常被称为原型链 (prototype chain)

读完解释,读者有没有些许疑问:原型链到底是个什么东西?原型链起了什么作用?(建议思考30秒)

原型链是一种链式关系。实际上是原型对象和该原型对象的原型构成的一种链式关系,这种关系被称为原型链;其中原型对象可以继承该原型对象的原型的属性和方法

简而言之,原型链表示了原型对象和该原型对象的原型之间的一种链式关系,但是本质上还是因为原型对象和原型对象的原型之间有继承关系。所以,原型链实际上是表示JavaScript语言中通过原型实现的继承关系,这种继承关系是链式的,故而被称为原型链。

用上面的例子来说明就是:wangcai.__proto__ === Dog.prototypeDog.__proto__ === Object.prototypewangcai.__proto__.__proto__ === Dog.prototype.__proto__ === Object.prototype

原型和原型链不好理解也不好讲解,读者可以记忆我列出的几条结论,就可以很好地解决原型和原型链的相关问题。结论如下:

  1. 对象的__proto__属性指向了构造它的对象的原型(prototype);
  2. 对象都拥有属性__proto__,函数都拥有属性prototype,函数拥有__proto__prototype,所以函数既是对象也是函数;
  3. 对象最终都是Function对象构造,函数最终都是由Object对象构造,所以对象的__proto__最终都指向Object.prototype,函数的__proto__都指向Function.prototype
  4. Object对象本身也是函数,所以Object.__proto__ === Function.prototype,Function对象本身也是函数,所以Function.__proto__ === Function.prototype,Function是对象所以Function.prototype.__proto__ === Object.prototype
  5. 原型链的最终指向null,所以Object.prototype.__proto__ === null

上面的结论中,第二、三、四点都是对第一点的补充解释,第5点指出了原型链的重点为null。所以,如果读者理解上面的结论,要记忆原型和原型链的知识点只需要记住以下几点:

  1. 对象的__proto__属性指向了构造它的对象的原型(prototype);
  2. 拥有__proto__属性的是对象,拥有prototype属性的是函数,函数也是对象;
  3. 对象都是由Object对象创建的,函数都是由Function对象创建的;
  4. 原型链的终点指向null;

理解了上面的文章,记住以上几点就不会把FunctionObject以及原型中的各种指向弄混淆了。但最终,光是阅读这篇文章还不够,读者需要自己去敲代码去试验和试错并检验这些结论,最终得到自己对于原型和原型链理解的版本。

参考资料、建议阅读的文章:

  1. 对象原型(mdn)
  2. Javascript中的继承(mdn)