前端面试 - JS原型知多少

179 阅读7分钟

最近参加了一些面试,经常被面试官问到一些关于JS原型的一些问题,然而,我通常回答的连我自己都不能够被说服。参加面试前,我是抱着这样的心理,这些奇怪的知识工作中也用不着,应该没人会问了吧。但,世界的运转不以个人意志而转移,你担心发生的事情总会发生。

综上,我写下了这篇文章,以便总结一下与原型链的原理及相关知识,一方面是备不时之需,另一方面是提供一个不同的视角。再者,为以后当面试官积累点素材。快速阅读大约需要5分钟。

原型链

JS作为一个脚本语言,最初的设计意图就是力求简单,它需要简单到谁都可以写两行。而继承从语言设计层面来说较难实现,从编码层面来说也难以掌握。因此,JS最终给出了个原型链的继承模型。

通俗来讲,原型链简单、直接、粗暴,且单向。它通过在对象上定义一个特殊的属性[[Prototype]]来确定该对象的原型对象。JS在运行的时候,如果你访问了一个属性或调用了一个方法,它就会查该对象自身有没有这个名字,若没有,查找它的原型,若还是没有,就查找它原型的原型,直到找到或递归到null为止。这个用来追溯这个链条的属性就叫做原型链,可以通过访问[[Object]].__proto__来获取原型链信息。

如果JS止步于此的话,那可以说是一门非常糟糕的语言了。毕竟原型链只是面向对象的基本功能。举个例子,比如我想将一个浮点数保留两位小数,函数式的风格可以写成这样convertNumberToFixed(1.001, 2),面向对象的风格是这样1.001.toFixed(2)。如果这个编程语言希望开发者使用面向对象风格的方式编码,那这里就牵扯到两个问题,1. 最初的原型链存放在哪里?2. 对象的原型链如何被赋值?

下面,我们先回答一下第一个问题。对象的原型链__proto__存放在原型prototype上,下面我们详细看一下原型。

原型

首先,什么是原型,原型就是给原型链赋值的蓝图。这个蓝图可以是语言运行时内置的,也可以是用户自定义的。先举几个内置原型的例子:

  • 对象,Object.prototype
  • 数字,Number.prototype
  • 数组,Array.prototype
  • 时间,Date.prototype
  • ...

这些蓝图,在不借助其它语言特性的情况下,也是可以直接使用的。比如Number.prototype.toFixed.call(1.001, 2)。如果以来原型链的递归查找特性,这个例子可以写成Number.toFixed.call(1.001, 2)。如果依赖JS自动装箱的特性,这个例子可以写成1.001.toFixed(2)。关于装箱,简单提一下,它是用来将基础类型包装为对象的一种特性。

关于第二个问题,原型链是如何被赋值的呢?这里就存在两种情况了:

  1. 对于基础数据类型,会在装箱的时候赋值。
  2. 对于对象类型,会在使用new关键字的时候被赋值。

对于第二点,这里举个例子:

function Apple() {}
const apple = new Apple;
Apple.prototype === apple.__proto__; // true
// Apple的原型prototype在通过new之后,被赋予在了apple的__proto__上

以上,就是朴素的原型与原型链的知识,但对于想成为面试官的面试者的话,止步于此就TOO YOUNG TOO SIMPLE了。下面,我们需要看一下那些经常被问到一些问题。这些问题,基本都围绕一个核心,对原型链的增删改查。

对原型链的增删改查

对本篇作者而言,在日常工作中,很少直接对原型链进行修改。这里只是列举一下这些知识,方便大家分门别类,以备不时之需。

上面提到,原型链被赋值在对象的__proto__属性上,而这个属性默认情况下是可以修改的,所以对原型链的操作存在如下简单粗暴的方法:

// 增
target.__proto__ = source
// 删
target.__proto__ = null
// 改
target.__proto__ = newSource
// 查
target.__proto__

但在ES规范中,并没有明确提出__proto__是用来存放原型链的。因此,如上方法在浏览器可行,但不代表其它环境也可行(作者并不能举出一个反例😅)。那么,如果有规范强迫症,可以这么写:

// 增, 创建对象时,赋值原型链
const target = Object.create(source, {})
// 删
Object.setPrototypeOf(target, null)
// 改, 对已创建完成的对象赋值原型链
const target = Object.create(null) // 创建一个没有原型链的对象
target.toString() // Uncaught TypeError: ocn.toString is not a function,因为toString是定义在Object.prototype上的方法
Object.setPrototypeOf(target, Object.prototype) // 将Object的原型赋值给target的__proto__
target.toString() // OK
// 查
Object.getPrototypeOf(target)

最后,对原型链的增删改查存在一个大前提,那就是被修改的对象是可扩展的(对象的扩展功能都来自于原型链)。下面是一些能够对这一元信息进行修改的一些方法(程度由轻向重):

// 查,检查target是否可扩展
Object.isExtensible(target)

// 阻止扩展,阻止对自有属性增,阻止对原型链的修改
Object.preventExtensions(target)
// 封闭,   阻止对自有属性增、删,阻止对原型链的修改
Object.seal(target)
// 冰冻,   阻止对自有属性的增、删、改,阻止对原型链的修改
Object.freeze(target)

上面,我们了解了对原型链的一些基本操作。那么,对原型链的蓝图——原型而言,是否也具有相似的操作呢?

对原型的创建、修改与使用

原型是原型链的蓝图。首先,在JS中,已经内置了一些原型及其提供的方法,它们一般存在于各种内置类的prototype属性上,包括Function.prototype, Object.prototype等。当然,我们可以创建我们自己的原型,比如:

// 传统做法,将函数作为构造函数,然后添加原型方法
function Cat(name) {
  this.name = name
}
Cat.prototype.say = function() { console.log('Miao miao ~') }

// ES6,使用类来创建原型
class Cat {
  constructor(name) {
    this.name = name
  }
  say() {
    console.log('Miao miao ~')
  }
}

那么既然有这么两种创建原型的方式,那有的面试官就要问了,这两种方式有没有差异呢?答案是,大体没差异,但区别是有的~。当然,这些区别呢,还是像其它面试题一样,日常工作基本用不到,感兴趣的同学可以自行深究,这里不赘述。

关于这块内容,还存在一个经常被问到的面试问题,使用function创建的函数与箭头函数有什么区别?答案是,function创建的函数有prototype而箭头函数没有,在function创建的函数中this取决于调用时的上下文而箭头函数取决于创建时的上下文。当然,第一个区别日常工作中基本用不到,而第二个区别会搞死很多刚入门前端的同学。

上面,我们基本解释清楚了如何创建一个原型,那么如何修改一个原型呢?对于日常工作而言,我们基本不会去直接修改原型,因为它的副作用无法预知。但鉴于文章的完整性,我们还是稍微了解一下:

// 直接修改原型,仅限自定义类型,⚠️直接入下修改会引发很多问题,主要是会覆盖Cat.prototype.constructor的值
Cat.prototype = newPrototype
// 建议操作
Object.assign(Cat.prototype, newPrototype)

// 对于原生原型,直接赋值不会有效果
Object.prototype = newPrototype
// 原因,对于内置类型的原型属性,无法被覆盖
Object.getOwnPropertyDescriptor(Object, 'prototype')
// configurable: false
// enumerable: false
// writable: false 无法被修改

那么,使用原型是比较简单的,那就是通过new操作符。new操作符一般做了下面几件事情:

  1. 创建一个空对象
  2. 将被操作的prototype赋值给这个空对象的__proto__
  3. 将这个空对象作为this调用构造函数
  4. 若构造函数没有返回值,则将this返回

通过以上几点,可以看到,能够被new的函数必定是个构造函数。所以,自定义的class、function、Map、Set、Date等都是构造函数。

综上,今天的分享到此为止,希望能够帮助大家拿到offer,找到工作。