前端面试扫盲:Prototype原型链相关问题

43 阅读8分钟

近年来无论是在传统网站类的 B/S 系统中,还是移动端 App 与服务端联动而成的 C/S 系统里,前端承载了越来越多的业务逻辑,随之而来诞生了 大量优秀的第三方代码库也对前端同学的 JavaScript 功底提出了更高的要求。因此,在国内外各大互联网公司的前端面试中,对 JavaScript 基本功的考察往往占据了很大的比重。

在 JavaScript 面试题中,对 JS 语法方面的考察你可以抓住两条主线:

  1. 原型链 Prototype
  2. 作用域链 Scope

毫不客气地说,如果你没有掌握这两样东西,绝大部分开源的三方库源码对你来说都会无从下手,想要做些定制化改造那更是举步维艰。这也是这两样东西会作为核心面试考点高频出现的原因,毕竟在日常开发过程中,一位高级前端工程师(Senior Front End)必备的一项能力就是处理第三方库使用中出现的各种非预期的行为或报错,如果没有能力读 JS Library 的源代码,你就只能靠搜索引擎寻求答案进行“撞大运编程”,亦或求助于身边的旁人了。

那么,今天我们就首先来聊聊原型链的话题。我们不会深入过多引擎实现细节或者 JS 语言冗长的历史包袱问题,主要针对当下 ES5+ 环境为主的浏览器和 Node.js 等运行过程中的实战经验,这也是面试中比较有经验的面试官的主要关注点之一。

每个 JS Object 的__proto__属性

当我们谈到原型链时,通常指的是 JS 中的每一个 Object 下具有的__proto__属性,所实现的一种机制,也可以称为__proto__指针,因为它指向的永远是一个引用类型。来看这张图:

对于以如下方式用__proto__属性连接起来的对象,如果你在代码中持有 obj1 这个变量的实例,并尝试获取它的 a 属性,你显然会得到这样的结果:

console.log(obj1.a); // 1 (Number)

而如果你尝试访问 obj1 的 b 属性呢?我们知道 obj1 自身是没有 b 属性的,按照大多数编程语言使用的常识来说,应该是访问不到的,但实际上:

console.log(obj1.b); // true (Boolean)

因为根据 JS 语言特有的原型链机制,当我们访问到一个 Object 上不存在的属性时,JS 引擎会自动去这个 Object 的__proto__属性指向的 Object 上继续寻找,如果依然没找到的话还会继续顺着那个 Object 的__proto__寻找下去,直到“尽头”为止。

需要说明一下:

  1. __proto__属性只能指向 Object 引用类型,不能指向 nunber string 这些基本类型;

  2. 任何一个 Object 默认情况下,__proto__指向 Object.prototype,而 Object.prototype 自己也作为一个 Object,它自身的__proto__指向的是 null,这也就是刚才说的“尽头”;

  3. 现在你回过头想想,自己平时调用很多 Object 或 array 上的原生方法时,其实你调用的方法都在 Object 的原型对象 或 Array 的原型对象上面。

接下来,当我们访问 obj1 的 c 属性时,依然会通过上述机制获取到实际上由 obj3 持有的 c 属性的值:

console.log(obj1.c); // foo (String)

如果访问的属性在 obj2 和 obj3 上都没有,还会继续顺着 obj3 的__proto__属性一直查找下去,我们通常对这种机制称之为 JavaScript 语言的原型链。

顺便说一句,__proto__指针是可以在运行时动态修改的,默认情况下每一个以字面量方式声明的 JavaScript 对象,它的__proto__指针都会默认指向所有 JS 对象的构造函数的原型对象:Object.prototype,而 Object.prototype 本身作为一个对象,它的__proto__指针是指向 null 的。

总而言之,__proto__指针把一个一个孤立的 JS 对象连接了起来,在运行时访问对象属性时可以通过__proto__指针访问到当前对象上没有的数据或函数,这种 JS 特有的语法机制被称为原型链。

那么,原型链有什么用呢?我们可以利用原型链的机制,结合构造函数,实现面向对象(OOP)的能力。

运用 JS 中的构造函数配合 Prototype 实现面向对象

前面我们讲到的__proto__指针实现了原型链机制,你可能会问,这种机制有什么用呢?这种机制大有用处,最常见的用法是: 利用原型链的机制,结合构造函数,实现面向对象(OOP)的能力

比如我们声明一个最简单的类 Person,包含一个 name 属性 和一个 hello 方法,那么可以这么做:

function Person(n) {
  this.name= n;
}
Person.prototype.hello = function () {
  console.log('myname is', this.name);
};

然后我们来实例化一个 Person 类的对象,并调用一下 hello 方法:

varp1 = new Person('Tom');
p1.hello();

接下来,我们再创建一个 Engineer 类,让它继承 Person 类,并在子类里增加一个 type 方法,用于标明工程师的工种:

function Person(n) {
  this.name = n;
}
Person.prototype.hello = function () {
  console.log('mynameis', this.name);
};
function Engineer(n, t) {
  Person.call(this, n);
  this.type =t;
}
Engineer.prototype = new Person();
Engineer.prototype.hello = function () {
  console.log('mynameis', this.name, 'and Iama ', this.type);
};
var p1= new Person('Tom');
varp2 = new Engineer('Jerry', 'front-end');
p1.hello();
p2.hello();

这段代码里有两个值得解释的地方:

第一,子类的构造函数借用了父类的构造函数对 this 去进行加工,有些书籍或文章中会把这种技术称为“借用构造函数”,这样做的好处是为了实现子类继承父类的属性,在日常代码维护中父类如果发生改变子类也会跟着改变,而子类特化的属性不会影响到父类以及父类派生出的其他子类。

第二,对于子类的 Prototype 对象的赋值语句,有些新手可能会产生疑惑,为什么要 new Person() 而不是直接把 Person.prototype 赋给 Engineer.prototype 呢?如果面试官这样问你,但你只是看别人的代码照猫画虎这样做的,那可能就是一个 0 分回答了。

事实上,这么做的主要原因是 JS 里面对象的赋值是 引用传递 而不是值传递,这也就意味着:假设直接把 Person 类的原型对象赋给 Engineer 类,在面向对象编程实践中,我们常常会在子类里面去覆盖(override)父类的某个同名方法。

在我们这个例子中,当我们需要在 Engineer 子类中覆盖 hello 方法时,由于子类持有的 Prototype 对象是父类 Prototype 对象的引用,我们的覆盖的 hello 方法会直接覆盖到父类 Prototype 对象上去,于是父类 Prototype 上的 hello 方法就彻底被我们改变了。

你可能会问,那又怎样呢?如果具备一定的其他面向对象编程语言开发经验的话,你会知道父类 Person 很可能还派生出了许多其他子类,如果其他子类也在使用这个 hello 方法的话,他们原本期待的 hello 方法的表现行为就被你这个操作给改变了,原本程序运行的正确性无法保证。因此我们需要 new Person() 赋值给 Engineer.prototype,然后在 Engineer.prototype 上安全地实现子类方法。这样,既实现了对父类 Prototype 的隔离保护,又满足了面向对象编程范式的可扩展性和代码重用等。

好了,上面的例子,就是 JavaScript 工程师在实现面向对象时最实用的一套方法,你可以带着这个思路去看看很多开源库的源码,大部分涉及到 Prototype 或者__proto__的用法都是基于这个思路的变种。

总结

最后拓展一下。这节课我们谈的都是 ES5 版本的语法,其实对于 ES6+ 之后的 class 相关语法,本质上只是一层语法糖。把这里的构造函数变成了 class 关键字,把 call 父类构造函数变成了 super 关键字。所谓万变不离其宗,掌握了__proto__属性和构造函数的 Prototype 对象之后才是最重要的。

这节课我为你分享是一个最简单的例子,主要是为了告诉你在一些开源项目中,对原型链的运用其实可以更加灵活。比如说,当你想实现一个非常庞大的类的时候,可以把类的方法拆到不同的文件或者说模块中去实现,而将构造函数或者 ES6 的 class 作为几个文件之间的桥梁。并且还可以利用 JS 的动态语言的特性,让 Prototype 在运行时动态地去挂载一些方法。这样用起来,你就把 JS 作为一门动态语言的优势发挥出来了。

当然,这既是静态语言中不具备的灵活性,同时也是动态语言在构建大型项目时被很多人诟病的一个“陋习”。没有完美的编程语言,优秀的工程师应该明白自己所使用的编程语言的长处与短处,扬长避短是最重要的。

最后我想说,在掌握了原型链这个核心知识点之后,我建议你再结合着阅读一些开源项目,比如从你每天在使用的 React、Vue 或者其他三方库的源码入手,长此以往便能做到“知其然亦知其所以然”。相比学习了很多框架的 API 用法,这才是面试官更加关注的一些核心能力。