从感性角度学习原型和原型链

从感性角度学习原型和原型链

最近在拜读 winter 大神的《重学前端》系列,果然是大佬的手笔,追本溯源,娓娓道来。感觉不仅是在重学前端,更是在学习一套方法论。这篇文章是对原型/原型链的一个总结,从生活实际入手,攻克 JavaScript 所谓最难理解的一部分。

什么是面向对象?

囿于中文翻译,一直以为“对象”仅仅是为编程而生的概念,大学那会儿老师的口头禅就是“没对象你 new 一个啊”,然而平成就要过去了,我却还是母胎 solo。

爆哭

winter 老师举了如下例子来阐述对象。

对象这一概念在人类的幼儿期形成,这远远早于我们编程逻辑中常用的值、过程等概念。在幼年期,我们总是先认识到某一个苹果能吃(这里的某一个苹果就是一个对象),继而认识到所有的苹果都可以吃(这里的所有苹果,就是一个类),再到后来我们才能意识到三个苹果和三个梨之间的联系,进而产生数字“3”(值)的概念。

所以说,面向对象编程强调的是数据和操作数据的行为本质上是互相关联的,因此好的设计就是把数据以及和它相关的行为封装起来。

举例来说,用来表示一个单词或者短语的一串字符通常被称为字符串。字符就是数据。但是你关心的往往不是数据是什么,而是可以对数据做什么,所以可以应用在这种数据上的行为(计算长度、添加数据、搜索,等等)都被设计成 String 类的方法。

JavaScript 的对象特征

  • 对象具有唯一标识性:即使完全相同的两个对象,也并非同一个对象。

  • 对象有状态:对象具有状态,同一对象可能处于不同状态之下。

  • 对象具有行为:即对象的状态,可能因为它的行为产生变迁。

第一点很好理解,对象存放在堆内存中,具有唯一标识的内存地址,所以具有唯一的标识。而对于“对象有状态和行为”,this 似乎最能阐述这一点,不同方式调用函数让 this 在运行时有不同的指向,从而产生不同的行为。

构造函数

构造函数本身就是一个函数,与普通函数没有任何区别。但为了做些区分,使用 new 生成实例的函数我们把它称为构造函数(形式上我们一般将构造函数的名称首字母大写),而直接调用的就是普通函数。

与传统的面向对象语言不同,JavaScript 没有 的概念,即便是 ES6 增加了 class 关键字,也无非是原型的语法糖。当年 JavaScript 为了模仿 Java,也加入了 new 操作符,但它后面直接跟的是 构造函数 而非 class

function Dog(name, age) {
  this.name = name;
  this.age = age;
  this.bark = function() {
    return 'wangwang~';
  };
}

const husky = new Dog('Lolita', 2);
const alaska = new Dog('Roland', 3);

属性和方法都放在构造函数里

虽然上面的代码有了面向对象的味道,但它却有一个缺陷。我们根据 Dog 创建了两个实例,导致 bark 方法被创建了两次,这无疑造成了浪费。所以有没有一种办法将 bark 方法单独放到一个地方,让所有的实例都能访问到呢?没错,就是接下来要说到的原型。

原型

下面是一张神图,原型/原型链之精髓融汇于此。很多面试管要求你手画原型链,它是个很好的参照。

图解原型链

生活中的原型

何为“原型”? 从感性的角度来讲,原型是顺应人类自然思维的产物。有个成语叫做“照猫画虎”,这里的猫就是虎的原型,另一个俗语“比着葫芦画瓢”亦是如此。可见,“原型”可以是一个具体的、现实存在的事物。

而我们再看“类”。以房屋和图纸为例,这里图纸就是“类”。图纸的意义在于“指导”工人创造出真实的房子(实例)。因此“类”更倾向于是一种具有指导意义的理论和思想。

所以,JavaScript 才是真正应该被称为“面向对象”的语言,因为它是少有的可以不通过类,直接创建对象的语言。

技术上的原型

在 JavaScript 中,每个函数都有一个 prototype 属性(这个说法并不严谨,像 Symbol 和 Math 就没有),该属性指向一个对象,称为 原型对象,当使用构造函数创建实例时,prototype 属性指向的原型对象就成为实例的原型对象。

原型对象默认有一个 constructor 属性,它指向该原型对象对应的构造函数。由于实例对象可以继承原型对象的属性,所以实例对象也可以直接调用constructor 属性,同样指向原型对象对应的构造函数。

构造函数和原型对象的关系

function Foo() {}

const foo = new Foo();

// 原型对象的 constructor 属性指向构造函数
Foo.prototype.constructor === Foo; // true

// 实例的 constructor 属性同样指向构造函数
foo.constructor === Foo; // true

每个实例都有一个隐藏的属性 [[prototype]],指向它的原型对象,我们可以使用下面两种方式的任意一种来获取实例的原型对象。

instance.__proto__;

Object.getPrototypeOf(instance);

注意:在 ES5 之前,为了能访问到 [[Prototype]],浏览器厂商创造了 __proto__ 属性。但在 ES5 之后有了标准方法 Object.getPrototypeOfObject.setPrototypeOf。尽管为了浏览器的兼容性,已经将 __proto__ 属性添加到 ES6 规范中,但它已被不推荐使用。

实例、原型对象和构造函数之间的关系

至此,原型就介绍完了,实际并没那么复杂。通过上面这张图片,我们很容易得到下面这个公式。

Object.getPrototypeOf(实例) === 构造函数.prototype;

所以说,原型对象类似于一座“桥梁”,连通实例和构造函数,因此我们可以把公共的属性或方法放在原型对象里,这样就能解决构造函数实例化产生多个重复方法的问题了。我们修改一下构造函数那个例子,将 bark 方法放到 Dog 构造函数的原型中,这样无论 new 多少个实例都只会创建一份 bark 方法。

function Dog(name, age) {
  this.name = name;
  this.age = age;
}

Dog.prototype.bark = function() {
  return 'wangwang~';
};

const husky = new Dog('Lolita', 2);
const alaska = new Dog('Roland', 3);

husky.bark(); // 'wangwang~'
alaska.bark(); // 'wangwang~'

方法放在原型里

原型链

每个对象都拥有一个原型对象,通过 __proto__ 指针指向上一个原型 ,并从中继承方法和属性,同时原型对象也可能拥有原型,这样一层一层,逐级向上,最终指向 null(null 没有原型)。这种关系被称为原型链 (prototype chain),通过原型链,一个对象会拥有定义在其他对象中的属性和方法。

function Parent(name) {
  this.name = name;
}

const p = new Parent();

p.__proto__ === Parent.prototype; // true
p.__proto__.__proto__ === Object.prototype; // true
p.__proto__.__proto__.__proto__ === null; // true

原型链

一些关于原型/原型链的方法

这里简单列举一些关于原型/原型链常用的内置方法,最近在写一个 《JavaScript API 全解析》 系列,更详细的用法可以直接去里面查看,点击下面各方法的标题也可以直接跳转。

Object.create()

用于创建一个新的对象,它使用现有对象作为新对象的 __proto__。第一个参数为原型对象,第二个参数可选,可以传入属性描述符对象或 null,其他类型直接报错。

没错,这就是“照猫画虎”!

const cat = { type: '猫科' };

const tiger = Object.create(cat);

tiger.tooth = '大牙';

“照猫画虎”

Object.getOwnPropertyNames()

该方法返回一个由指定对象的所有自身属性的属性名组成的数组。

  • 包括不可枚举属性

  • 但不包括 Symbol 值作为名称的属性

  • 不会获取到原型链上的属性

  • 当不存在普通字符串作为名称的属性时返回一个空数组

// 它只会获取自身属性,而不去关心原型链上的属性
Object.getOwnPropertyNames(tiger); // ['tooth']

Object.getPrototypeOf() / Object.setPrototypeOf()

这两个用于获取和设置一个对象的原型,它主要用来代替 __proto__

hasOwnProperty

用来判断一个对象本身是否含有该属性,返回一个 Boolean 值。

  • 原型链上的属性 一律返回 false

  • Symbol 类型的属性也可以被检测

tiger.hasOwnProperty('tooth'); // true
tiger.hasOwnProperty('type'); // false

isPrototypeOf

该方法用于检测一个对象是否存在于另一个对象的原型链上,返回一个 Boolean 值。

cat.isPrototypeOf(tiger); // true

最后

下一篇会着重介绍继承和 ES6 新增的 class,敬请期待。

欢迎关注我的微信公众号:进击的前端

进击的前端

参考

《JavaScript 高级程序设计 (第三版)》 —— Nicholas C. Zakas

《深入理解 ES6》 —— Nicholas C. Zakas

《你不知道的 JavaScript (上卷)》—— Kyle Simpson

三分钟看完 JavaScript 原型与原型链

[进阶 5-1 期] 重新认识构造函数、原型和原型链

[进阶 5-2 期] 图解原型链及其继承

详解 JS 原型链与继承

从 JavaScript 编译原理到作用域(链)及闭包

PREVIOUS POST

从 JavaScript 编译原理到作用域(链)及闭包

JavaScript 七大继承全解析

NEXT POST

JavaScript 七大继承全解析