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

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

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

什么是面向对象?

对象这一概念在人类的幼儿期形成,这远远早于我们编程逻辑中常用的值、过程等概念。在幼年期,我们总是先认识到某一个苹果能吃(这里的某一个苹果就是一个对象),继而认识到所有的苹果都可以吃(这里的所有苹果,就是一个类),再到后来我们才能意识到三个苹果和三个梨之间的联系,进而产生数字“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 才是真正应该被称为“面向对象”的语言,因为它是少有的可以不通过类,直接创建对象的语言。

技术上的原型

C++、Java、C# 这些语言都是基于经典的类继承的设计模式,这种模式最大的特点就是提供了非常复杂的规则,并提供了非常多的关键字,诸如 class、friend、protected、private、interface 等,通过组合使用这些关键字,就可以实现继承。而 JavaScript 仅仅在对象中引入了一个原型的属性,就实现了语言的继承机制,基于原型的继承省去了很多基于类继承时的繁文缛节,简洁而优美。

JavaScript 的每个对象都包含了一个隐藏属性 `__proto__` ,我们就把该隐藏属性 `__proto__` 称之为该对象的原型 (prototype),`__proto__` 指向了内存中的另外一个对象,我们就把 `__proto__` 指向的对象称为该对象的原型对象,那么该对象就可以直接访问其原型对象的方法或者属性。下面这张图, 我们看到使用 C.name 和 C.color 时,给人的感觉属性 name 和 color 都是对象 C 本身的属性,但实际上这些属性都是位于原型对象上,我们把这个查找属性的路径称为原型链,它像一个链条一样,将几个原型链接了起来.

原型
原型

在 JavaScript 中的继承非常简洁,就是每个对象都有一个原型属性,该属性指向了原型对象,查找属性的时候,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.getPrototypeOf``Object.setPrototypeOf`。尽管为了浏览器的兼容性,已经将 `__proto__` 属性添加到 ES6 规范中,但它已被不推荐使用。因为修改 `__proto__` 会破坏 v8 通过隐藏类优化好的结构对象,进而引发隐藏类对该数据对象重新优化.

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

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

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

原型链
原型链

稍微说一下 new

function DogFactory(type, color) { this.type = type; this.color = color; } var dog = new DogFactory("Dog", "Black");

对于上面这个代码, new 实际上做了三件事:

var dog = {}; dog.__proto__ = DogFactory.prototype; DogFactory.call(dog, "Dog", "Black");
  • 首先,创建了一个空白对象 dog;
  • 然后,将 DogFactory 的 prototype 属性设置为 dog 的原型对象,这就是给 dog 对象设置原型对象的关键一步;
  • 最后,再使用 dog 来调用 DogFactory,这时候 DogFactory 函数中的 this 就指向了对象 dog,然后在 DogFactory 函数中,利用 this 对对象 dog 执行属性填充操作,最终就创建了对象 dog。

new
new

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

这里简单列举一些关于原型/原型链常用的内置方法,最近在写一个 《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 七大继承全解析

    Search by