六.JavaScript对象

122 阅读17分钟

对象可看做是属性的无序集合,每个属性都是一个名/值对。属性名是字符串,因此我们可以把对象看成是从字符串到值的映射。然而对象不仅仅是字符串到值的映射,除了可以保持自有的属性,JavaScript对象还可以从一个称为原型的对象继承属性

除了字符串、数字、true、false、null和undefined之外,JavaScript中的值都是对象。

1. 对象属性和属性的四个特性

对象的属性包括名字和值。属性名可以是包含空字符串在内的任意字符串,值可以是任意JavaScript值,或者是一个getter或setter函数。除了名字和值之外,每个属性还有一些与之相关的值,称为“属性特性”(property attribute):

  • 可写(writable attribute),表明是否可以设置该属性的值。
  • 可枚举(enumerable attribute),表明是否可以通过for/in循环返回该属性。
  • 可配置(configurable attribute),表明是否可以删除或修改该属性。

可以认为一个属性包含一个名字和4个特性。数据属性的4个特性分别是它的值(value)、可写性(writable)、可枚举性(enumerable)和可配置性(configurable)。存取器属性不具有值(value)特性和可写性,它们的可写性是由setter方法存在与否决定的。因此存取器属性的4个特性是读取(get)、写入(set)、可枚举性和可配置性。

“属性描述符”(property descriptor)对象,这个对象代表那4个特性。描述符对象的属性和它们所描述的属性特性是同名的。因此,数据属性的描述符对象的属性有valuewritableenumerableconfigurable。存取器属性的描述符对象则用get属性和set属性代替value和writable。其中writable、enumerable和configurable都是布尔值,当然,get属性和set属性是函数值。

1.1 Object.getOwnPropertyDescriptor()

通过调用Object.getOwnPropertyDescriptor()可以获得某个对象特定属性的属性描述符:

image.png 从函数名字就可以看出,Object.getOwnPropertyDescriptor()只能得到自有属性的描述符。要想获得继承属性的特性,需要遍历原型链

1.2 Object.definePeoperty()

要想设置属性的特性,或者想让新建属性具有某种特性,则需要调用Object.definePeoperty(),传入要修改的对象、要创建或修改的属性的名称以及属性描述符对象:

image.png

  • 传入Object.defineProperty()的属性描述符对象不必包含所有4个特性。
  • 注意,这个方法要么修改已有属性要么新建自有属性,但不能修改继承属性。

1.3 Object.defineProperties()

如果要同时修改或创建多个属性,则需要使用Object.defineProperties()。第一个参数是要修改的对象,第二个参数是一个映射表,它包含要新建或修改的属性的名称,以及它们的属性描述符,例如:

image.png 这段代码从一个空对象开始,然后给它添加两个数据属性和一个只读存取器属性。最终Object.defineProperties()返回修改后的对象(和Object.defineProperty()一样)。

1.4 复制属性的特性

例子给出了改进的extend(),它使用Object.getOwnPropertyDescriptor()和Object.defineProperty()对属性的所有特性进行复制。新的extend()作为不可枚举属性添加到Object.prototype中,因此它是Object上定义的新方法,而不是一个独立的函数。

/*
*给Object.prototype添加一个不可枚举的extend()方法
*这个方法继承自调用它的对象,将作为参数传入的对象的属性一一复制
*除了值之外,也复制属性的所有特性,除非在目标对象中存在同名的属性,
*参数对象的所有自有对象(包括不可枚举的属性)也会一一复制。
*
*/

Object.defineProperty(
  Object.prototype,
  "extend", //定义Object.prototype.extend
  {
    writable: true,
    enumerable: false, //将其定义为不可枚举的
    configurable: true,
    value: function(o) {
      //值就是这个函数
      //得到所有的自有属性,包括不可枚举属性
      var names = Object.getOwnPropertyNames(o); //遍历它们
      for (var i = 0; i < names.length; i++) {
        //如果属性已经存在,则跳过
        if (names[i] in this) continue; //获得o中的属性的描述符
        var desc = Object.getOwnPropertyDescriptor(o, names[i]); //用它给this创建一个属性
        Object.defineProperty(this, names[i], desc);
      }
    }
  }
);

2. 对象三个特性

除了包含属性之外,每个对象还拥有三个相关的对象特性(object attribute):

  • 对象的原型(prototype)指向另外一个对象,本对象的属性继承自它的原型对象。
  • 对象的类(class)是一个标识对象类型的字符串。
  • 对象的扩展标记(extensible flag)指明了是否可以向该对象添加新属性。

对三类JavaScript对象和两类属性作区分:

  • 内置对象(native object)是由ECMAScript规范定义的对象或类。例如,数组、函数、日期和正则表达式都是内置对象。
  • 宿主对象(host object)是由JavaScript解释器所嵌入的宿主环境(比如Web浏览器)定义的。客户端JavaScript中表示网页结构的HTMLElement对象均是宿主对象。既然宿主环境定义的方法可以当成普通的JavaScript函数对象,那么宿主对象也可以当成内置对象。
  • 自定义对象(user-defined object)是由运行中的JavaScript代码创建的对象。
  • 自有属性(own property)是直接在对象中定义的属性。
  • 继承属性(inherited property)是在对象的原型对象中定义的属性。

每一个对象都有与之相关的原型(prototype)、类(class)和可扩展性(extensible attribute)。

2.1 对象的原型(原型属性)

对象的原型属性是用来继承属性的,这个属性非常重要,以至于我们经常把“o的原型属性”直接叫做“o的原型”。

原型属性是在实例对象创建之初就设置好的,通过对象直接量创建的对象使用Object.prototype作为它们的原型。通过new创建的对象使用构造函数的prototype属性作为它们的原型。通过Object.create()创建的对象使用第一个参数(也可以是null)作为它们的原型。

  • 将对象作为参数传入Object.getPrototypeOf()可以查询它的原型。
  • 也经常使用表达式o.constructor.prototype来检测一个对象的原型。 注意,通过对象直接量或Object.create()创建的对象包含一个名为constructor的属性,这个属性指代Object()构造函数。

要想检测一个对象是否是另一个对象的原型(或处于原型链中),请使用isPrototypeOf()方法。例如,可以通过p.isPrototypeOf(o)来检测p是否是o的原型:

image.png

需要注意的是,isPrototypeOf()函数实现的功能和instanceof运算符非常类似

Mozilla实现的JavaScript(包括早些年的Netscape)对外暴露了一个专门命名为__proto__的属性,用以直接查询/设置对象的原型。但并不推荐使用__proto__,因为尽管Safari和Chrome的当前版本都支持它,但IE和Opera还未实现它

2.2 类属性

对象的类属性(class attribute)是一个字符串,用以表示对象的类型信息。只有一种间接的方法可以查询它。默认的toString()方法(继承自Object.prototype)返回了如下这种格式的字符串:

[object class]

因此,要想获得对象的类,可以调用对象的toString()方法,然后提取已返回字符串的第8个到倒数第二个位置之间的字符。不过让人感觉棘手的是,很多对象继承的toString()方法重写了,为了能调用正确的toString()版本,必须间接地调用Function.call()方法

function classof(o) {
  // if (o === null) return "Null";
  // if (o === undefined) return "Undefined";
  return Object.prototype.toString.call(o).slice(8, -1);
}

classof()函数可以传入任何类型的参数。数字、字符串和布尔值可以直接调用toString()方法,就和对象调用toString()方法一样。

通过内置构造函数(比如Array和Date)创建的对象包含“类属性”(class attribute),它与构造函数名称相匹配。宿主对象也包含有意义的“类属性”,但这和具体的JavaScript实现有关。通过对象直接量和Object.create创建的对象的类属性是"Object",那些自定义构造函数创建的对象也是一样,类属性也是"Object"

image.png

2.3 可拓展性

对象的可扩展性用以表示是否可以给对象添加新属性。所有内置对象和自定义对象都是显式可扩展的,宿主对象的可扩展性是由JavaScript引擎定义的。

可扩展属性的目的是将对象“锁定”,以避免外界的干扰。

  • 通过将对象传入Object.isExtensible(),来判断该对象是否是可扩展的
  • 如果想将对象转换为不可扩展的,需要调用Object.preventExtensions(),将待转换的对象作为参数传进去。注意,一旦将对象转换为不可扩展的,就无法再将其转换回可扩展的了。
  • Object.seal()和Object.preventExtensions()类似,除了能够将对象设置为不可扩展的,还可以将对象的所有自有属性都设置为不可配置的。也就是说,不能给这个对象添加新属性,而且它已有的属性也不能删除或配置
  • 已经封闭(sealed)起来的对象是不能解封的。可以使用Object.isSealed()来检测对象是否封闭。
  • Object.freeze()将更严格地锁定对象——“冻结”(frozen)。除了将对象设置为不可扩展的和将其属性设置为不可配置的之外,还可以将它自有的所有数据属性设置为只读(如果对象的存取器属性具有setter方法,存取器属性将不受影响,仍可以通过给属性赋值调用它们)。
  • 使用Object.isFrozen()来检测对象是否冻结。 Object.preventExtensions()、Object.seal()和Object.freeze()都返回传入的对象,也就是说,可以通过函数嵌套的方式调用它们:

image.png

3. 创建对象

可以通过对象直接量、关键字new和Object.create()函数来创建对象。

3.1 对象直接量

创建对象最简单的方式就是在JavaScript代码中使用对象直接量。

var empty = {}; //没有任何属性的对象
var point = { x: 0, y: 0 }; //两个属性
var point2 = { x: point.x, y: point.y + 1 }; //更复杂的值
var book = {
  "main title": "JavaScript", //属性名字里有空格,必须用字符串表示
  "sub-title": "The Definitive Guide", //属性名字里有连字符,必须用字符串表示
  for: "all audiences",
  author: {
    //这个属性的值是一个对象
    firstname: "David", //注意,这里的属性名都没有引号
    surname: "Flanagan"
  }
};
console.log(book.for); //=>all audiences

对象直接量是一个表达式,这个表达式的每次运算都创建并初始化一个新的对象。每次计算对象直接量的时候,也都会计算它的每个属性的值。

3.2 通过new创建对象

new运算符创建并初始化一个新对象。关键字new后跟随一个函数调用。这里的函数称做构造函数(constructor),构造函数用以初始化一个新创建的对象。JavaScript语言核心中的原始类型都包含内置构造函数。例如:

var o = new Object(); //创建一个空对象,和{}一样
var a = new Array(); //创建一个空数组,和[]一样
var d = new Date(); //创建一个表示当前时间的Date对象
var r = new RegExp("js"); //创建一个可以进行模式匹配的EegExp对象

3.3 原型

每一个JavaScript对象(null除外)都和另一个对象相关联。“另一个”对象就是我们熟知的原型,每一个对象都从原型继承属性。

  • 所有通过对象直接量创建的对象都具有同一个原型对象,并可以通过JavaScript代码Object.prototype获得对原型对象的引用。
  • 通过关键字new和构造函数调用创建的对象的原型就是构造函数的prototype属性的值。 因此,同使用{}创建对象一样,通过new Object()创建的对象也继承自Object.prototype。同样,通过new Array()创建的对象的原型就是Array.prototype,通过new Date()创建的对象的原型就是Date.prototype。

所有的内置构造函数(以及大部分自定义的构造函数)都具有一个继承自Object.prototype的原型。 例如,Date.prototype的属性继承自Object.prototype,因此由new Date()创建的Date对象的属性同时继承自Date.prototype和Object.prototype。这一系列链接的原型对象就是所谓的“原型链”(prototype chain)。

3.4 Object.create()

Object.create方法,它创建一个新对象,其中第一个参数是这个对象的原型,第二个可选参数,用以对对象的属性进行进一步描述。使用它的方法很简单,只须传入所需的原型对象即可:

image.png

可以通过传入参数null来创建一个没有原型的新对象,但通过这种方式创建的对象不会继承任何东西,甚至不包括基础方法,比如toString()

image.png

如果想创建一个普通的空对象(比如通过{}或new Object()创建的对象),需要传入Object.prototype:

image.png

可以通过任意原型创建新对象(换句话说,可以使任意对象可继承),这是一个强大的特性。 可以用类似例中的代码来模拟原型继承:

image.png

inherit()函数的其中一个用途就是防止库函数无意间(非恶意地)修改那些不受你控制的对象。

4属性查询和设置

4.1 继承--原型链查询

JavaScript对象具有“自有属性”(own property),也有一些属性是从原型对象继承而来的。

假设要查询对象o的属性x,如果o中不存在x,那么将会继续在o的原型对象中查询属性x。如果原型对象中也没有x,但这个原型对象也有原型,那么继续在这个原型对象的原型上执行查询,直到找到x或者查找到一个原型是null的对象为止。可以看到,对象的原型属性构成了一个“链”,通过这个“链”可以实现属性的继承。

image.png

属性赋值操作首先检查原型链,如果允许属性赋值操作,它也总是在原始对象上创建属性或对已有的属性赋值,而不会去修改原型链。

在JavaScript中,只有在查询属性时才会体会到继承的存在,而设置属性则和继承无关,这是JavaScript的一个重要特性,该特性让程序员可以有选择地覆盖(override)继承的属性。

image.png

4.2 属性访问错误

查询一个不存在的属性并不会报错,如果在对象o自身的属性或继承的属性中均未找到属性x,属性访问表达式o.x返回undefined。

但是,如果对象不存在,那么试图查询这个不存在的对象的属性就会报错。null和undefined值都没有属性,因此查询这些值的属性会报错,接上例:

image.png

除非确定book和book.subtitle都是(或在行为上)对象,否则不能这样写表达式book.subtitle.length,因为这样会报错,下面提供了两种避免出错的方法:

//一种冗余但很易懂的方法
var len = undefined;
if (book) {
  if (book.subtitle) len = book.subtitle.length;
}
//一种更简练的常用方法,获取subtitle的length属性或undefined
var len = book && book.subtitle && book.subtitle.length;

当然,给null和undefined设置属性也会报类型错误。给其他值设置属性也不总是成功,有一些属性是只读的,不能重新赋值,有一些对象不允许新增属性:

image.png

5. 删除属性

delete运算符只能删除自有属性,不能删除继承属性(要删除继承属性必须从定义这个属性的原型对象上删除它,而且这会影响到所有继承自这个原型的对象)。

当delete表达式删除成功或没有任何副作用(比如删除不存在的属性)时,它返回true。如果delete后不是一个属性访问表达式,delete同样返回true:

image.png

在这些情况下的delete操作会返回false:

image.png

6 检测对象的属性

我们经常会检测集合中成员的所属关系——判断某个属性是否存在于某个对象中。可以通过in运算符、hasOwnPreperty()和propertyIsEnumerable()方法来完成这个工作,甚至仅通过属性查询也可以做到这一点。

6.1 in运算符

in运算符的左侧是属性名(字符串),右侧是对象。如果对象的自有属性或继承属性中包含这个属性则返回true:

var o = { x: 1 };
"x" in o; //true:"x"是o的属性
"y" in o; //false:"y"不是o的属性
"toString" in o; //true:o继承toString属性

6.2 hasOwnProperty()方法

对象的hasOwnProperty()方法用来检测给定的名字是否是对象的自有属性。对于继承属性它将返回false:

image.png

6.3 propertyIsEnumerable()

propertyIsEnumerable()是hasOwnProperty()的增强版,只有检测到是自有属性且这个属性的可枚举性(enumerable attribute)为true时它才返回true。

image.png

使用“!==”判断一个属性是否是undefined:

var o = { x: 1 };
o.x !== undefined; //true:o中有属性x
o.y !== undefined; //false:o中没有属性y
o.toString !== undefined; //true:o继承了toString属性

然而有一种场景只能使用in运算符而不能使用上述属性访问的方式。in可以区分不存在的属性和存在但值为undefined的属性。例如下面的代码:

var o = { x: undefined }; //属性被显式赋值为undefined
o.x !== undefined; //false:属性存在,但值为undefined
o.y !== undefined; //false:属性不存在
"x" in o; //true:属性存在
"y" in o; //false:属性不存在
delete o.x; //删除了属性x
"x" in o; //false:属性不再存在

7. 遍历对象的属性(枚举属性)

for/i n循环可以在循环体中遍历对象中所有可枚举的属性(包括自有属性和继承的属性),把属性名称赋值给循环变量。对象继承的内置方法不可枚举的,但在代码中给对象添加的属性都是可枚举的

var o = { x: 1, y: 2, z: 3 }; //三个可枚举的自有属性
o.propertyIsEnumerable("toString"); //=>false,不可枚举
for (p in o) //遍历属性
  console.log(p); //输出x、y和z,不会输出toString

过滤for/in循环返回的属性和方法

for (p in o) {
  if (!o.hasOwnProperty(p)) continue; //跳过继承的属性
}

for (p in o) {
  if (typeof o[p] === "function") continue; //跳过方法
}

用来枚举属性的对象工具函数extend()等:

/*
*把p中的可枚举属性复制到o中,并返回o
*如果o和p中含有同名属性,则覆盖o中的属性
*这个函数并不处理getter和setter以及复制属性
*/
function extend(o, p) {
  for (prop in p) {
    //遍历p中的所有属性
    o[prop] = p[prop]; //将属性添加至o中
  }
  return o;
}
 /*
  *将p中的可枚举属性复制至o中,并返回o 
  *如果o和p中有同名的属性,o中的属性将不受影响 
  *这个函数并不处理getter和setter以及复制属性
  */
function merge(o, p) {
  for (prop in p) {
    //遍历p中的所有属性
    if (o.hasOwnProperty[prop]) continue; //过滤掉已经在o中存在的属性
    o[prop] = p[prop]; //将属性添加至o中
  }
  return o;
} 
/* 
  *如果o中的属性在p中没有同名属性,则从o中删除这个属性 
  *返回o
  */
function restrict(o, p) {
  for (prop in o) {
    //遍历o中的所有属性
    if (!(prop in p)) delete o[prop]; //如果在p中不存在,则删除之
  }
  return o;
} 
/*
  *如果o中的属性在p中存在同名属性,则从o中删除这个属性
  *返回o
  */
function subtract(o, p) {
  for (prop in p) {
    //遍历p中的所有属性
    delete o[prop]; //从o中删除(删除一个不存在的属性不会报错)
  }
  return o;
} 
/*
*返回一个新对象,这个对象同时拥有o的属性和p的属性
*如果o和p中有重名属性,使用p中的属性值
*/
function union(o, p) {
  return extend(extend({}, o), p);
} 
/*
*返回一个新对象,这个对象拥有同时在o和p中出现的属性
*很像求o和p的交集,但p中属性的值被忽略
*/
function intersection(o, p) {
  return restrict(extend({}, o), p);
} 
/*
*返回一个数组,这个数组包含的是o中可枚举的自有属性的名字
*/
function keys(o) {
  if (typeof o !== "object") throw TypeError(); //参数必须是对象
  var result = []; //将要返回的数组
  for (var prop in o) {
    //遍历所有可枚举的属性
    if (o.hasOwnProperty(prop))
      //判断是否是自有属性
      result.push(prop); //将属性名添加至数组中
  
  return result; //返回这个数组
}

  • 用以枚举属性名称的函数。第一个是Object.keys(),它返回一个数组,这个数组由对象中可枚举的自有属性的名称组成
  • 第二个枚举属性的函数是Object.getOwnPropertyNames(),它和Ojbect.keys()类似,只是它返回对象的所有自有属性的名称,而不仅仅是可枚举的属性。

8 属性的getter和setter特性

属性值可以用一个或两个方法替代,这两个方法就是getter和setter。由getter和setter定义的属性称做“存取器属性”(accessor property),它不同于“数据属性”(data property),数据属性只有一个简单的值。

  • 当程序查询存取器属性的值时,JavaScript调用getter方法(无参数)。这个方法的返回值就是属性存取表达式的值。
  • 当程序设置一个存取器属性的值时,JavaScript调用setter方法,将赋值表达式右侧的值当做参数传入setter。从某种意义上讲,这个方法负责“设置”属性值。

定义存取器属性最简单的方法是使用对象直接量语法的一种扩展写法:

image.png

存取器属性定义为一个或两个和属性同名的函数,这个函数定义没有使用function关键字,而是使用get和(或)set。注意,这里没有使用冒号将属性名和函数体分隔开,但在函数体的结束和下一个方法或数据属性之间有逗号分隔。

例如,下面这个表示2D笛卡尔点坐标的对象。它有两个普通的属性x和y分别表示对应点的X坐标和Y坐标,它还有两个等价的存取器属性用来表示点的极坐标(r,theta):

image.png

注意在这段代码中getter和setter里this关键字的用法。JavaScript把这些函数当做对象的方法来调用,也就是说,在函数体内的this指向表示这个点的对象,因此,r属性的getter方法可以通过this.x和this.y引用x和y属性。

和数据属性一样,存取器属性是可以继承的,因此可以将上述代码中的对象p当做另一个“点”的原型

image.png

还有很多场景可以用到存取器属性,比如智能检测属性的写入值以及在每次属性读取时返回不同值:

//这个对象产生严格自增的序列号
var serialnum = {
  //这个数据属性包含下一个序列号
  //$符号暗示这个属性是一个私有属性
  $n: 0, //返回当前值,然后自增
  get next() {
    return this.$n++;
  }, //给n设置新的值,但只有当它比当前值大时才设置成功
  set next(n) {
    if (n >= this.$n) this.$n = n;
    else throw "序列号的值不能比当前值小";
  }
};

使用getter方法的例子:

//这个对象有一个可以返回随机数的存取器属性
//例如,表达式"random.octet"产生一个随机数
//每次产生的随机数都在0~255之间
var random = {
  get octet() {
    return Math.floor(Math.random() * 256);
  },
  get uint16() {
    return Math.floor(Math.random() * 65536);
  },
  get int16() {
    return Math.floor(Math.random() * 65536) - 32768;
  }
};

9.序列化对象

对象序列化(serialization)是指将对象的状态转换为字符串,也可将字符串还原为对象。

JSON.stringify()和JSON.parse()用来序列化和还原JavaScript对象。这些方法都使用JSON作为数据交换格式,JSON的全称是"JavaScript Object Notation"——JavaScript对象表示法,它的语法和JavaScript对象与数组直接量的语法非常相近:

o = { x: 1, y: { z: [false, null, ""] } }; //定义一个测试对象
s = JSON.stringify(o); //s是'{"x":1,"y":{"z":[false,null,""]}}'
p = JSON.parse(s); //p是o的深拷贝

JSON的语法是JavaScript语法的子集,它并不能表示JavaScript里的所有值。

  • 支持对象、数组、字符串、无穷大数字、true、false和null,并且它们可以序列化和还原。
  • NaN、Infinity和-Infinity序列化的结果是null
  • 日期对象序列化的结果是ISO格式的日期字符串(参照Date.toJSON()函数),但JSON.parse()依然保留它们的字符串形态,而不会将它们还原为原始日期对象。
  • 函数、RegExp、Error对象和undefined值不能序列化和还原。 JSON.stringify()只能序列化对象可枚举的自有属性。

JSON.stringify()和JSON.parse()都可以接收第二个可选参数,通过传入需要序列化或还原的属性列表来定制自定义的序列化或还原操作。

10.定义在Object.prototype里的对象方法

这些方法非常好用而且使用广泛,但一些特定的类会重写这些方法。

image.png

10.1 toString()

toString()方法没有参数,它将返回一个表示调用这个方法的对象值的字符串。在需要将对象转换为字符串的时候,JavaScript都会调用这个方法。比如,当使用“+”运算符连接一个字符串和一个对象时或者在希望使用字符串的方法中使用了对象时都会调用toString()。

默认的toString()方法的返回值带有的信息量很少(尽管它在检测对象的类型时非常有用),例如,下面这行代码的计算结果为字符串"[object Object]":

var s={x:1,y:1}.toString();

默认的toString()方法并不会输出很多有用的信息,因此很多类都带有自定义的toString()。例如,当数组转换为字符串的时候,结果是一个数组元素列表,只是每个元素都转换成了字符串,再比如,当函数转换为字符串的时候,得到函数的源代码。

10.2 toLocaleString()方法

对象都包含toLocaleString()方法,这个方法返回一个表示这个对象的本地化字符串。 Date和Number类对toLocaleString()方法做了定制,可以用它对数字、日期和时间做本地化的转换。

image.png

10.3 valueOf()方法

valueOf()方法和toString()方法非常类似,但往往当JavaScript需要将对象转换为某种原始值而非字符串的时候才会调用它,尤其是转换为数字的时候。 如果在需要使用原始值的上下文中使用了对象,JavaScript就会自动调用这个方法。

默认的valueOf()方法不足为奇,但有些内置类自定义了valueOf()方法(比如Date.valueOf())

image.png