从感性角度学习原型和原型链
最近在拜读 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。
一些关于原型/原型链的方法
这里简单列举一些关于原型/原型链常用的内置方法,最近在写一个 《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
PREVIOUS POST
从 JavaScript 编译原理到作用域(链)及闭包
NEXT POST
JavaScript 七大继承全解析