「译」JavaScript对象生命周期(Eloquent JavaScript 第六章)
此文翻译《Eloquent Javascript》中第六章,The Secret Life of Objects,侵删。
该书有中文译本出版。此译文仅作交流学习之用。
面向对象的编程语言的问题在于,它们携带了一些隐形的环境。你只是想要一根香蕉,但你得到的是一个拿着香蕉的猴子,和一整个丛林。
—— Joe Armstrong, interviewed in Coders at Work
历史
就像大多数编程故事一样,这个故事也从一个复杂的问题开始。讲道理,把一个复杂的问题分割为很多个小问题,它就变得可控了。这些“小问题”就叫做对象
。
一个对象就像是一个坚硬的外壳,它隐藏了晦涩的内部信息,给我们提供的了一些旋钮和连接器,在这里就是使用对象的接口
(比如不同的方法)。这样的机制能让我们忽略其复杂的内部工作原理,使用相对简单的接口。
举个例子,你可以想象一个对象在电脑屏幕的一个区域提供接口,你就可以在屏幕的这个区域写字或者画画。至于这些形状是如何变为像素点的具体过程,就隐藏在对象内部了。在这里,为了使用这个对象,你需要了解它的一些方法,比如drawCircle
。
在1970和80年代,这个思想最初进入应用。在1990年代,这个概念随着面向对象编程技术革命被炒得很热。一夜之间,很多人跳出来说只有面向对象才是正确的编程方式,没有对象的语言都是垃圾。
狂热终将导致不切实际的愚蠢,那时候就已经出现了一些对象
的反对派。现在在某些圈子里,对象
的名声并不好。
相对于理论派,我更倾向于实践主义。面向对象还有很多有用的概念,最重要的就是封装
(用于区分内部复杂性和外部接口)。很值得学习。
这一章主要讲的就是JavaScript中的对象,和一些经典的面向对象的使用技术。
方法
方法
就是包含函数的简单属性。下面是一个简单的方法:
1 | var rabbit = {}; |
通常情况下,当一个对象的方法被调用时,总是需要做事情的。当一个函数被当作方法object.method()
调用时(被当作一个属性来查找,而且立即执行),函数体内的特殊变量this
就会指向被调用的对象。
1 | function speak(line) { |
这段代码使用this
关键词用于输出正在说话的兔子的类型。回想一下apply
和bind
方法,它们的第一个参数都可以用来模拟方法的调用。第一个参数实际上是给this
传值。
还有一个和apply
类似的方法,叫做call
。它也调用了方法中包含的函数,但是它传递的是更普遍的参数,而不是一个数组。类似于apply
和bind
,call
也给this
传递了特殊的值。
1 | speak.apply(fatRabbit, ["Burp!"]); |
原型
仔细看:
1 | var empty = {}; |
我从空对象里输出了一个值。神奇!
好吧,这并不是真的。我只是保留了JavaScript对象的工作信息。对象
除了很多属性之外,几乎所有的对象
都有原型
。原型
是另外一个作为属性回退的源对象
。当请求一个对象不存在的属性时,就会在它的原型
里寻找,再去原型的原型里寻找,以此类推。
所以控对象的原型是什么呢?就是几乎所有原型的祖先Object.prototyp
。
1 | console.log(Object.getPrototypeOf({}) == |
如你所料,Object.getPrototypeOf
函数返回的是对象的原型。
JavaScript的原型关系是一个树状结构,这个树的根节点就是Object.prototype
。它提供的一些方法,几乎在所有的对象中都可以使用,比如toString
,作用是把对象作为字符串输出。
很多对象的直接原型都不是Object.prototype
,但他们有自己的原型,提供他们自己的默认属性。函数的原型是Function.prototype
,数组的原型是Array.prototype
。
1 | console.log(Object.getPrototypeOf(isNaN) == |
以上的原型对象也有自己的原型,通常是Object.prototype
。所以间接地,还是提供了比如toString
这样的方法。
显然,Object.getPrototypeOf
函数返回的是一个对象的原型。你可以使用Object.create
创建新的原型。
1 | var protoRabbit = { |
兔子原型
就像一个容器,存放了所有兔子共有的属性。一个具体的兔子,比如本例中的killer rabbit,从它的原型中继承共有属性,但这个对象只包含了它自己的属性。
构造器
一个创建对象的更方便的方法,就是使用一个共有的原型constructor
。在JavaScript中,在函数前面加上关键词new
,会被当作一个构造器处理。构造器的this
变量会绑定给一个新的对象,调用后会返回这个新对象,除非是指定返回别的对象例外。
利用new
新建的对象,称做构造器的实例化。
下面是一个兔子简单的构造器。
1 | function Rabbit(type) { |
构造器(或者说全体函数)都会自动获得prototype
这个属性,默认值为空,空对象继承自Object.prototype
。任何一个由这个构造器实例化的对象都有原型。所以给兔子添加、speak
方法,我们可以利用它的构造器简化:
1 | Rabbit.prototype.speak = function(line) { |
了解原型和构造器之间的区别是很重要的,还有对象获得原型的方式。原型
通过构造器给定,获取对象的原型要使用Object.getPrototypeOf
。构造器的原型是Function.prototype
,因为构造器本质是函数。它的原型属性用于指定它实例化的对象,但它自己的原型不是这个。
重写
当你给对象添加一个属性的时候,不论这个属性是不是来自于原型,这个属性都添加给了这个对象本身,由此它就有了自己的属性。如果原型中含有相同名称的属性,那么原型中的属性就不再影响该对象。原型本身的属性没有被改变。
1 | Rabbit.prototype.teeth = "small"; |
下面的图就是代码运行后,它内部处理的情况。Rabbit
和Object
原型就是killerRabbit
的后备资源,当找不到它自己的属性时,就会顺着向上查找。
重写属性也是原型的一个很有用的地方。如同例子中的兔子牙齿,在非特殊的对象中从原型中继承标准值,也允许特殊对象有自己不同的属性。
这一功能也使得数组原型具有不同的toString
方法。
1 | console.log(Array.prototype.toString == |
调用数组的toString
方法,返回值类似于调用.join(",")
,在返回的数组中每个值之间添加逗号。数组直接调用Object.prototype.toString
返回的是一个不同的字符串。函数并没有数组的概念,所以返回值是“object”再加它的数据类型,然后放在中括号中。
1 | console.log(Object.prototype.toString.call([1, 2])); |
原型实例化
基于原型给所有的对象添加属性或者方法随时都可以进行。举个例子,这个功能在给兔子添加跳舞方法就很必要。
1 | Rabbit.prototype.dance = function() { |
这个功能很方便,但也会造成问题。在之前的章节,我们使用对象作为连接名字和给定值的方式。下面是第四章的一个例子:
1 | var map = {}; |
我们可以在for/in
循环中遍历对象中所有的phi值,并且使用in
测试某个名字是否存在。但不幸的是,对象的原型会导致错误。
1 | Object.prototype.nonsense = "hi"; |
这完全是错误的,在我们的数据集里就没有叫做“nonsense”的名字,而且肯定也没有“toString”。
奇怪的是,for/in
循环中也没有出现toString
,但是在in
操作中返回的是true
。这是因为JavaScript区分了可枚举和不可枚举属性。
通过Object.defineProperty
我们可以自定义不可枚举属性,可以使我们控制创建的属性。
1 | Object.defineProperty(Object.prototype, "hiddenNonsense", |
现在这个属性是存在的,但不会出现在循环里了。这点很好。但是in
操作时对象的Object.prototype
还是有问题的。为了解决这个问题,我们可以使用hasOwnProperty
方法。
1 | console.log(map.hasOwnProperty("toString")); |
这个方法能绕过它自身的属性,查看它包含的属性。这个信息比in
操作返回的信息更有用。
当你担心有人混淆了原型时(在自己的程序中加入别人的代码),我推荐你这样写for/in
循环:
1 | for (var name in map) { |
无原型对象
但是兔子问题不止于此。如果有人在我们的map
中添加一个名称为hasOwnProperty
的对象,并且把它的值设为42呢?那么map.hasOwnProperty
调用的是它自身的属性,并且返回一个数值,而不是函数。
在这样的情况下,原型就成了一个阻碍,我们希望不存在原型。我们知道Object.create
函数可以让我们创建自定义原型的对象。你可以指定它的原型为null
,这样就创建了一个没有原型的新对象。对于像map
这样的对象,任何只都可以作为它的属性,那么无原型对象正是我们需要的。
1 | var map = Object.create(null); |
这样就好多了!我们不需要再通过笨办法,用hasOwnProperty
去判断对象自身的属性。现在不管谁对Object.prototype
做了什么,我们都可以安全的使用for/in
循环。
多态
当你对一个对象调用String
函数时(转化一个值为字符串),它会去调用toString
方法返回一个新建的字符串。有一些对象自定义了toString
方法,返回比[object Object]
更有用的信息。
这只是这个强大用途的一个小小例子。当编写一段代码用以处理特定接口的对象时(本例中是toString
方法),任何可用于支持这个接口的代码都可以接入,并且运行良好。
这个机制叫做多态
,当然不涉及任何形态的变化。只要符合接口的要求,多态代码支持传入不同数据类型。
表格处理
我将会给出一个更深入的例子来讲解普遍意义上面向对象的多态机制。例子是这样的:给定一个数组,创建一个格式化的字符串,要求行列对齐。比如:
1 | name height country |
我们建立表格的流程是这样,构建函数先输入每个单元格的宽和高,然后用这个信息确定行列的宽和高。然后构建函数去构建正确大小的单元格,最后把结果保存到一个字符串里。
这个格式化程序使用一个设计优良的接口和单元格对象通信。这样,这个程序支持的单元格并没有预先设定好。我们可以稍后再增加单元格格式,比如如果接口支持的话,不用改变程序,我们也可以给表头添加下划线。
以下为接口:
minHeight()
返回的是一行的最小高度。minWidth()
返回的是一个单元格的最小宽度。draw(width, height)
返回的是一个height
长度的数组,每一个都包含对应字符串的width
长度。这就代表了单元格的内容。
这里我会使用很多高阶数组方法,因为很合适这个例子。
程序的第一行用于计算一个单元格的最小列宽和行高。变量row
保存的是一个嵌套的数组,每一个数组都代表一行单元格。
1 | function rowHeights(rows) { |
以下划线(_)开头的命名的变量,或者命名为单个的下划线,只是为了提高可读性,表示这个不使用这个参数。rowHeights
函数不难理解,使用reduce
去计算一行单元格的最大高度值,然后使用map
去对每一行执行这个计算。colWidths
略有一点变量所以稍微有点难理解,因为外层的数组是代表每一行的数组,并不是每一列。之前没有提到,传递给map
(或者类似forEach
,filter
这样的函数)的第二个参数,是当前元素的索引。通过映射第一行的元素,而且只映射第二个参数,colWidths
给每一列索引建立一个数组。调用reduce
运算的是外层的每一行数组,用于提出改行最宽的单元格和它的索引。
下面是绘制表格的代码:
1 | function drawTable(rows) { |
drawTable
这个函数调用了内部帮助函数drawRow
去绘制每一行,然后在每一行末添加换行符。
首先drawRow
函数把单元格对象转变为blocks
,就是以行为单位,分割为包含单元格内容的字符串数组。一个只包含数字3376的单元格,转换后就是像[“3776”]这样的单个元素,带有下划线的单元格可能被转换为数组[“name”, “——“]。
一个block中的每一行的高度都相等,在最后的输出中应该保持相邻的位置。drawRow
第二次调用map
,从最左边的block开始逐行绘制输出表格。并且在逐行绘制的同时,记录下表格里最宽的那行。然后在每一行末添加换行符,把整行当作是drawRow
的返回值。drawLine
函数用于提取block数组中相邻的行,并且在行之间添加一个空格,这样每列之间就有了一个字符间隔的空格。
现在我们为包含字符的单元格写一个构造函数,实现了单元格的接口。构造函数用split
方法把字符串分割为数组。在字符串中,split
函数在每个传入参数出现的地方,都会将字符串分割,并且返回每个小部分组成的数组。minWidth
函数返回这个数组最宽的行。
1 | function repeat(string, times) { |
这段代码使用了一个叫repeat
的帮助函数,它用于构建一个重复的字符串,重复的内容是第一个参数,重复次数为第二个参数。draw
方法给每一行添加了一个padding,使得每一行长度相同。
我们试着用以上方法创建一个 5 × 5 的棋盘格。
1 | var rows = []; |
这是可行的!但是由于每个单元格大小相等,格式化表格的函数实际上没有做任何事。
我们在这里使用的山脉数据可以在这里下载。
我们需要使用下划线强调一下第一行,也就是列名。这不是大问题,我们只需要写一个带有下划线类型的单元格。
1 | function UnderlinedCell(inner) { |
带有下划线的单元格还包含另一个单元格。它需要获取内部单元格的大小(通过调用minWidth
和minHeight
方法),但是最后单元格高度需要加1,因为下划线也占了一行。
绘制这样一个单元格很简单,我们在原先的内容下增加下划线就可以了。
有了绘制下划线的函数,现在我们可以写一个为我们的数据集绘制单元格的函数。
1 | function dataTable(data) { |
标准的Object.keys
函数返回一个对象,带有名称的数组。表格第一行必须包含有下划线单元格,表示列的名称。然后下面就是所有数据集中的数据内容,我们通过映射所有数据的key
,来确保每一行的单元格顺序不出错。
输出的数据格式如上,除了没有height
这列没有正确对齐。我们等一下会讲到这一点。
获取器和设置器
指定接口的时候,是可以包含不是方法的属性。我们可以定义minHeight
和minWidth
只返回数字。但这就要求在构造函数中做运算,实际上这一部分代码并不是和构造这个对象很相关。会造成一些问题,比如带有下划线单于格的内部单元格发生了改变,那么下划线单元格的大小也会改变。
这就导致有一些人从不在接口中传递非方法的属性。相比于直接获取一个单一的数值属性,他们更倾向于使用getSomething
和setSomething
方法去读写属性。这种办法也有缺点,就是你需要写(或者读)一些额外的代码。
幸运的是,JavaScript提供了一个两全其美的办法。我们可以这样设定,从外部看起来像是传递数值,但实际上传递了一个相关联的方法。
1 | var pile = { |
从字面上理解,get
或者set
可以用于指定读写属性值时候调用的函数。你也可以给已经存在的对象添加这样的属性,比如Object.defineProperty
给原型添加属性(之前使用过这个方法创建了不可枚举属性)。
1 | Object.defineProperty(TextCell.prototype, "heightProp", { |
你还可以用一个类似set
的属性,在对象中传递给defineProperty
,去定义一些方法。当定义了获取器但没有定义设置器,就会忽略写入的方法。
接口
绘制表格的练习已经接近完成了。再加上数字列右侧对齐能提高可读性。我们需要再创建另一个类似TextCell
的单元格,但不是给右边添加空白,而是加在左边,使得他们能够右对齐。
我们现在写一个新的构造函数,在原型中包含三个方法。但是原型自身也可以有原型,所以我们就可以通过一个聪明的方式去构建。
1 | function RTextCell(text) { |
我们复用了TextCell
中的minHeight
和minWidth
方法。RTextCell
和TextCell
基本相同,只是draw
方法博阿含的函数不同。
这种模式叫做继承
。可以使我们使用相对少量的代码对已有的数据类型做小部分的改变。典型的就是新的构造函数会调用旧的构造函数,使用call
方法为了传递给新的对象this
值。一旦这个构造函数被调用,我们就认为旧的对象类型的所有属性都被添加进新的对象。实例化的继承对象也有权限调用其原型的原型的属性。最终,我们可以通过添加新的原型重写某些属性。
现在,我们在dataTable
使用RTextCell
作为单元格函数,它的值就是一个数值,那我们就得到的一开始想要的表格。
1 | function dataTable(data) { |
继承是面向对象的基础的一部分,同样还有封装和多态。但是通常封装和多态都是被赞赏的,而继承存在争议。
原因是继承经常会和多态混淆,宣称是一个很强大的工具,但实际上经常被使用得很差。封装和多态可以分离代码片段,减少程序中代码的混乱程度,而继承基本上就是把代码联合在一起,增加混乱度。
如上,你可以不用继承实现多态。我不是告诉你要完全避免继承,我自己在我程序中也会经常用到继承。但是你要把它当作是一个减少定义代码量的小技巧,而不是组织代码原则。拓展类型
更好的方式是通过组合,比如UnderlinedCell
就是通过存储在一个属性中,在自己的方法中调用存储的属性这种方式,建立在另一个单元格对象之上。
INSTANCEOF运算符
有时候需要要知道一个对象是不是源于一个特定的构造函数,因此,JavaScript提供了二进制运算符instanceof
。
1 | console.log(new RTextCell("A") instanceof RTextCell); |
这个运算符会逐级检查继承。RTextCell
就是TextCell
的一个实例化,因为RTextCell.prototype
源于TextCell.prototype
。这个操作符也可以被用于标准构造函数例如Array
。几乎所有的对象都是Object
的实例化。
总结
所以对象要比我最初描绘的复杂得多。他们有原型,同时原型又是另一个对象。而且只要原型对象中存在的属性,在继承对象中即使定义这个属性,它也会包含这个属性。简单对象的原型是Object.prototype
。
构造函数通常都是以大写字母开头的一类函数,可以被new
操作符来创建新的对象。新对象的原型可以在构造函数的prototype
属性中找到。这一点可以好好利用,你可以把某一类型所有值都共享的属性放进他们的原型中。给定一个对象和构造函数,instanceof
操作符可以判断该对象是否来源于制定构造函数。
有关对象很有用的一件事,就是去给它指定一个接口,然后告诉使用代码的人他们应该通过这个接口去和对象交互。剩余的构成你代码的细节部分都封装
,隐藏在接口后面。
说到接口,谁说一个接口只能给一种对象应用?给不同的对象可以使用同一个接口,编写能够适用于多种对象的接口的代码,就叫做多态。这一点很有用。
当有多个数据类型它们的区别只在很小的细节部分,使用旧数据类型的原型去构建新数据类型的原型就很有用,而且可以在新的构造函数中调用旧的构造函数。这就能使得新的对象类似就的对象,但是你可以根据需求添加或重写属性。