图解 JavaScript 之原型继承
目录
原型继承
你有没有想过为什么我们可以在字符串、数组或对象上使用诸如 .length
、 .split()
、 .join()
这些内置方法呢?我们从来没有明确指定过它们,它们到底是从哪里来的呢?现在别说 “哈哈,没人知道,这就是神奇的 JavaScript🧚🏻♂️”。这实际上是因为一种叫做原型继承(prototypal inheritance)的玩意儿。它很棒,而且我们用到它的次数意想不到的多!
我们经常要创建很多相同类型的对象。假设我们有一个网站,在这个网站上,人们可以浏览狗!对每一只狗,我们都需要对象来表示它!🐕 我们用不着每次都写一个新对象,而是用一个构造器函数(constructor function)(我知道你在想什么,稍后我将介绍 ES6 类!),用 new
关键字创建 Dog 实例(不过,本文并非是要解释构造器函数,所以我不想谈太多)。每只狗都有名字(name)、品种(breed)、颜色(color)以及一个 bark () 函数!
当我们创建 Dog
构造器函数时,它并不是我们创建的唯一对象。我们还自动创建了另一个对象,称为 prototype(原型)!默认情况下,这个对象包含一个 constructor 属性,它只是对原始构造器函数的引用,在本例中是 Dog
。
当我们创建一个构造器函数时,同时也创建了一个 prototype 对象。其构造器有一个对原始构造器函数的引用。
Dog 构造器函数上的 prototype
属性是不可枚举(non-enumerable)的,也就是说,当我们试图访问该对象的属性时,它是不会出现。但它依然存在!
好吧,那么为什么我们会有这个属性对象呢?首先,我们来创建一些我们想展示的狗。为简单起见,我叫它们 dog1
和 dog2
。 dog1
是 Daisy,一只可爱的黑色拉布拉多犬! dog2
是 Jack,一只无畏的白色杰克罗素犬!😎
下面我们把 dog1
输出到控制台,并展开其属性!
我们可以看到添加的属性,如 name
、 breed
、 color
和 bark
。但是 __proto__
属性是什么玩意!它是不可枚举的,也就是说当我们试图获取对象的该属性时,它通常不会出现。下面我们把它展开!😃
它看起来就像 Dog.prototype
对象!其实 __proto__
就是对 Dog.prototype
对象的一个引用。这就是原型继承的目的:构造器的每个实例都可以访问构造器的原型!🤯
实例也包含一个 __proto__
属性,这是对实例的构造器的原型的引用,在本例中是 Dog.prototype。
为什么这很酷呢?有时我们有一些所有实例都共享的属性。比如本例中的 bark
函数:它对每个实例都是完全相同的,那么与其每次创建一个新的 dog 时都创建一个新函数,每次都消耗内存,还不如将其添加到 Dog.prototype
对象!🥳
我们将属性添加到所有实例都可以共享的原型上,而不是每次创建该属性的新副本。
每当我们试图访问实例上的属性时,引擎首先在本地(locally)搜索,看看该属性是否在对象本身上定义。不过,如果找不到我们要访问的属性,那么引擎就会通过 __proto__
属性沿着原型链(prototype chain)遍历!
当试图访问一个对象上某个属性时,引擎首先在本地搜索。然后,通过 __proto__
属性沿着原型链遍历。
现在这只是一个步骤,但它可以包含几个步骤!如果继续往下看,我们就可能会注意到,当展开 __proto__
对象时,并没有包含一个显示 Dog.prototype
的属性。 Dog.prototype
本身是一个对象,也就是说它实际上是 Object
构造器的一个实例!这意味着 Dog.prototype
也包含一个 __proto__
属性,这个属性是对 Object.prototype
的一个引用!
最后,我们就有了所有内置方法来自何方的答案:它们在原型链上!😃
比如 .toString()
方法。它是在 dog1
对象上本地定义的吗?不是的,它是定义在 dog1.__proto__
的引用,即 Dog.prototype
对象上的吗?也不是!它是定义在 Dog.prototype.__proto__
的引用,即 Object.prototype
对象上的吗?是的!🙌🏼
原型链可以有几个步骤。比如,Dog.prototype 本身是个对象,因而继承来自内置的 Object.prototype 的属性。
现在,我们刚刚用过了构造器函数( function Dog() { ... }
),它仍然是有效的 JavaScript。不过,ES6 实际上为构造器函数以及处理原型引入了一种更简单的语法:类(classes)!
注意
类只是构造器函数的语法糖(syntactical sugar)。其工作机制还是一样的!
我们用 class
关键字编写类。类有一个 constructor
函数,它基本上就是我们用 ES5 语法编写的构造器函数!我们要添加到原型中的属性是在类主体上定义的。
ES6 引入了类,类是构造器函数的语法糖。
类的另一个好处是,我们可以很容易地继承(extend)其他类。
假设我们要展示几只相同品种的狗,即吉娃娃狗(chihuahua)!不管咋样,吉娃娃依然是狗。为简单起见,我们现在只保留一个 name
属性给 Dog 类。不过这些吉娃娃也可以做些特别的事情,它们的叫声很小(smallBark),它们的叫声不是 Woof!
,而是 Small woof!
。🐕
在继承的类中,我们可以使用 super
关键字访问父类的构造器。父类的构造器期望的参数,我们必须传递给 super
,在本例中是 name
。
myPet
既可以访问 Chihuahua.prototype
,又可以访问 Dog.prototype
(并且由于 Dog.prototype
是个对象,又可以自动访问 Object.prototype
)。
原型继承在类与 ES5 构造器上的工作机制是一样的。
由于 Chihuahua.prototype
有 smallBark
函数,而 Dog.Prototype
有 bark
函数,因此在 myPet
上,我们既可以访问 smallBark
,也可以访问 bark
!
我们可以料想得到,原型链不会永远持续下去。最终有一个原型等于 null
的对象,在本例中就是 Object.prototype
对象!如果我们尝试访问在本地或原型链上找不到的属性,就会返回 undefined
。
我们可以调用从被继承的类所继承的方法。原型链在 proto 的值为 null 的时候结束。
TIP
实际上,class 的 __proto__
应该先指向 Function.prototype
, Function.prototype.__proto__
再指向 Object.prototype
。
尽管我在这里用构造器函数和类解释了将原型添加到对象的所有内容,但是将原型添加到对象还有一种方法,就是用 Object.create()
方法。用这个方法,我们可以创建一个新对象,并可以准确指定该对象的原型!💪🏼
为此,我们将已有对象作为参数传递给 Object.create
方法。该对象就是我们创建的对象的原型!(扩展:参见 Object.create ()、new Object () 和 {} 的区别)
下面输出我们刚刚创建的 me
对象。
我们没有向对象 me
添加任何属性,它仅包含不可枚举的 __proto__
属性! __proto__
属性引用了我们定义为原型的对象: person
对象,它有一个 name
和一个 age
属性。由于 person
对象是一个对象,因此 person
对象上的 __proto__
属性值就是 Object.prototype
。
希望你现在了解了为什么原型继承在 JavaScript 的世界中如此重要!
经典继承与原型继承
与大多数其他语言不同,JavaScript 的对象系统是基于原型的,而不是基于类。不幸的是,大多数 JavaScript 开发者并不了解 JavaScript 的对象系统,也不知道如何将其发挥到最佳效果。还有一些人理解它,但希望它的行为更像基于类的系统(class based systems)。结果是,JavaScript 的对象系统有一个令人困惑的人格分裂,这意味着 JavaScript 开发者需要对原型和类都有一定的了解。
类和原型继承之间的区别
类的继承(Class Inheritance):一个类就像一个蓝图 -- 对将要创建的对象的一些描述。类继承于其他类,并创建子类关系(subclass relationships):层次化的类分类法(hierarchical class taxonomies)。
Class Inheritance
A class is like a blueprint — a description of the object to be created.
实例通常是通过带有 "new" 关键字的构造函数来实例化的。类的继承可以使用也可以不使用 ES6 的 class
关键字。你可能从 Java 等语言中了解到的类在技术上并不存在于 JavaScript,而是使用构造函数(constructor functions)。ES6 的 class
关键字可以解构为一个构造函数(desugars)。
js
class Foo {}
typeof Foo // 'function'
1
2
2
在 JavaScript 中,类的继承是在原型继承(prototypal inheritance)的基础上实现的,但这并不意味着它做了同样的事情。
JavaScript 的类继承使用原型链(prototype chain)将子类 Constructor.prototype
与父类 Constructor.prototype
连接起来进行委托(delegation)。通常情况下,也会调用 super()
构造函数。这些步骤形成了单祖先的父 / 子层次结构(single-ancestor parent/child hierarchies),并在 OO 设计中创造了最紧密的耦合。
"类继承于类,并建立子类关系:分层的类分类法。"
“Classes inherit from classes and create subclass relationships: hierarchical class taxonomies.”
总结
一个类就像一个蓝图。创建一个对象的经典方法是使用 CLASS 声明(CLASS declaration)来定义对象的结构,并将该类实例化(instantiate)来创建一个新的对象。以这种方式创建的对象有它们自己的所有实例属性的副本(copies),加上每个实例方法的单一副本的链接。继承(Inheritance)是 OO 设计中可用的最紧密的耦合(tightest coupling)。而且,子代类(descendant classes)对它们的祖代类(ancestor classes)有很深的了解。
原型的继承(Prototypal Inheritance):原型是一个工作对象(working object)的实例。对象直接继承于其他对象。
实例可以由许多不同的源对象组合,允许轻松的选择性继承(selective inheritance)和一个扁平的原型委托层次结构(flat [[Prototype]] delegation hierarchy)。换句话说,类的分类法(class taxonomies)不是原型 OO 的自动副作用(automatic side-effect):一个关键的区别。
实例通常是通过工厂函数(factory functions)、对象字面量(object literals)或 Object.create()
来实例化的。
"原型是一个工作的对象实例。对象直接继承于其他对象。"
“A prototype is a working object instance. Objects inherit directly from other objects.”
总结
与其他语言不同,JavaScript 没有类。它使用原型和原型链的概念进行继承。原型的继承都是关于对象的。对象从其他对象继承属性。在原型继承中,你不是通过一个类来定义结构,而是简单地创建一个对象。这个对象然后被新的对象重用。实例通常通过工厂函数或 Object.create()
方法进行实例化。实例可以由许多不同的对象组成,允许轻松地进行选择性继承。它比经典继承更灵活。任何现有的对象都可以成为一个类,其他对象将从这个类中产生。当你的对象提供了几套服务或在你的程序到达需要继承的点之前,它们经历了大量的状态转换时,这就很方便。
为什么这很重要?
继承从根本上说是一种代码重用机制(code reuse mechanism),不同种类的对象共享代码的方式。分享代码的方式很重要,因为如果你弄错了,会产生很多问题,特别是:类的继承会产生父 / 子对象分类法的副作用(Class inheritance creates parent/child object taxonomies as a side-effect.)。
这些分类法几乎不可能对所有的新用例都正确,而且广泛使用一个基类会导致脆弱的基类问题(the fragile base class problem),这使得它们在出错时很难修复。事实上,类的继承会导致 OO 设计中许多众所周知的问题。
- 紧耦合问题(The tight coupling problem)(类继承是 OO 设计中最紧的耦合),这导致了下一个问题...
- 脆弱的基类问题(The fragile base class problem)
- 不灵活的层次结构问题(Inflexible hierarchy problem)(最终,所有不断发展的层次结构对于新的用例都是错误的)
- 必要的重复问题(The duplication by necessity problem)(由于不灵活的层次结构,新的用例往往是通过重复而不是调整(adapting)现有的代码来塞进去的)。
- 猩猩 / 香蕉问题(The Gorilla/banana problem)(你想要的是一根香蕉,但你得到的是一只拿着香蕉的猩猩,以及整个丛林)。
所有这些问题的解决方案是支持对象组合(object composition)而不是类继承(class inheritance)。
这里总结地很好:
所有的继承都是坏事吗?
当人们说 "倾向于组合而不是继承" 时,这是 "倾向于组合而不是类继承" 的简称(原话来自四人帮的《设计模式》)。这是 OOD 中的常识,因为类的继承有很多缺陷,会导致很多问题。通常人们在谈论类继承的时候会把类这个词省掉,这让人觉得所有的继承都是坏的 -- 但其实不然。
实际上,有几种不同的继承,而且大多数都很适合从多个组件对象中组成复合对象。
三种不同类型的原型继承
连接式继承(Concatenative inheritance)。通过复制源对象的属性,将特征直接从一个对象继承到另一个对象的过程。在 JavaScript 中,源原型通常被称为 mixins。从 ES6 开始,这个功能在 JavaScript 中有一个方便的工具,叫做
Object.assign()
。在 ES6 之前,这通常是通过 Underscore/Lodash 的.extend()
或者 jQuery 的$.extend()
等来实现的......原型委托(Prototype delegation)。在 JavaScript 中,一个对象可以有一个链接到原型的委托。如果在对象上找不到一个属性,查找就会被委托给委托原型(delegate prototype),而委托原型可能有一个链接到它自己的委托原型,以此类推,直到你到达
Object.prototype
,它就是根委托(root delegate)。当你连接到Constructor.prototype
并使用new
进行实例化时,这个原型就会被挂起(hooked up)。你也可以使用Object.create()
来达到这个目的,甚至可以把这个技术和连接式继承技术混合起来,以便把多个原型平铺到一个委托上,或者在创建后扩展对象实例。功能性继承(Functional inheritance)。在 JavaScript 中,任何函数都可以创建一个对象。当该函数不是一个构造函数(或 ` 类 ')时,它被称为工厂函数(factory function)。功能性继承的工作原理是,从工厂中产生一个对象,并通过直接为其分配属性来扩展所产生的对象(使用连接性继承)。Douglas Crockford 创造了这个术语,但功能继承在 JavaScript 中已经被普遍使用了很长时间。
你可能已经开始意识到,连接式继承是在 JavaScript 中实现对象组合的秘诀,这使得原型委托(prototype delegation)和功能性继承都变得更加有趣。
当大多数人想到 JavaScript 中的原型 OO 时,他们想到的是原型委托。现在你应该明白,他们错过了很多东西。委托原型并不是类继承的最佳替代品 -- 对象组合(object composition)才是。
为什么组合不受脆弱的基类问题的影响?
要理解脆弱的基类问题(fragile base class problem)以及为什么它不适用于组合,首先你必须了解它是如何发生的。它源于你不能对你所继承的东西有所选择。你继承了一切。
之所以不能用类的继承来实现,是因为当你使用类的继承时,你就引进了整个现有的类的分类法(class taxonomy)。
如果你想为一个新的用例做一些调整,你要么最终重复了现有分类法的一部分(必要的重复问题),要么由于脆弱的基类问题,你重构了所有依赖于现有分类法的分类法,使其适应新的用例。
组成对这两种情况都是免疫的。
你真的知道原型吗
如果有人教你建立类或构造函数,并从它们那里继承,那么你所学的不是原型继承。你学到的是如何使用原型来模仿类的继承(mimic class inheritance using prototypes)。参见 "关于 JavaScript 中继承的常见误解"。
在 JavaScript 中,类的继承是建立在很早以前的丰富、灵活的原型继承功能之上的,但当你使用类的继承时 -- 甚至是建立在原型之上的 ES6+ class
继承,你并没有使用原型 OO 的全部功能和灵活性。事实上,你是在把自己困在角落里,选择了所有的类继承问题。