深入理解JavaScript对象

Rinsann 2021年11月17日 329次浏览

对象到底是什么呢?

语法

对象可以同两种形式定义:声明(文字)形式和构造形式。

对象的文字语法形式:

var myObj = {
	key:value
	// ....
};

构造形式:

var myObj = new Object();
myObj.key = value;

构造形式和文字形式生成的对象是一样的。唯一的区别在于,文字声明中可以添加多个键值对,但是在构造形式中必须逐个添加属性。

用构造形式来创建对象在实际场景中比较少见,一般使用文字语法,JavaScript 内置对象也是这样做的。

类型

对象时 JavaScript 的基础,在 JavaScript 中一共有六种主要类型。

  • string
  • number
  • boolean
  • null
  • undefined
  • object

简单基本类型(stringbooleannumberundefined)本身并不是对象。

null 有时会被当做一种对象类型,但是这其实只是语言本身的一个 bug ,对 null 执行 typeof null 时会返回字符串 "object"。

原理是这样的,不同的对象在底层都表示为二进制,在 JavaScript 中二进制前三位都为 0 的话会被判断为 object 类型,null 的二进制表示是全 0 ,自然前三位也是 0,所以执行 typeof 时会返回 "object"。

实际上,null 本身是基本类型。

有一种错误的说法是 " JavaScript 中万物皆是对象 "。

实际上,JavaScript 中有许多特殊的对象子类型,我们可以称为复杂基本类型。

函数就是对象的一个子类型(从技术角度来说就是 "可调用的对象")。

JavaScript 中的函数时 "一等公民",因为它们本质上和普通对象一样(只是可以调用),所以可以想操作其它对象一样操作函数(比如当作另一个函数的参数)。

数组也是对象的一种类型,举报一些额外的行为。

数组中内容的组织方式比一般对象要复杂。

内置对象

JavaScript 中还有一些对象子类型,通常被称为内置对象。

有些内置对象的名字看起来和简单基础类型一样,不过实际上它们的关系更复杂。

  • String
  • Number
  • Boolean
  • Object
  • Function
  • Array
  • Date
  • RegExp
  • Error

这些内置对象看上去很想其它语言中的类型(type)或者类(class),比如 Java 中的 String 类。

但是在 JavaScript 中,它们实际上只是一些内置函数。

这些内置函数可以当做构造函数(由 new 产生的函数调用)来使用,从而可以构造一个对应子类型的新对象。

var strPrimitive = "I am a string";
typeof strPrimitive; // "string"
strPrimitive instanceof String ; // false

var strObject = new String("I am a string");
typeof strObject; // "objecty"
strObject instanceof String; // true

//检查 sub-type 对象
Object.prototype.toString.call( strObject ); // [object String]

简单来说,可以认为子类型在内部借用了 Object 中的 toString() 方法,从代码中可以看出,strObject 是由 String 构造函数创建的一个对象。

初始值 "I am a string" 并不是一个对象,只是一个字面量,并且是一个不可变的值。

如果要在这个字面量上执行一些操作,比如获取长度、访问其中某个字符等,那就需要将其转换成 String 对象。

好在必要时 JavaScript 会自动把字符串字面量转换成一个 String 对象,也就是说我们并不需要显示创建一个对象。

var strPrimitive = "I am a string";

console.log( strPrimitive.length ); //13

console.log( strPrimitive.charAt(3) ); // "m"

使用以上两种方法,都可以直接在字符串字面量上访问属性或方法,之所以可以这样做,是因为引擎自动把字面量转换成 String 对象,所有可以访问属性和方法。

同样的也会发生在数值字面量上,如果使用类似 42.359.toFixed(2) 的方法,引擎会把 42 转换成 new Number(42),布尔字面量也是如此。

nullundefined 没有对于的构造形式,它们只有文字形式,相反的,Date只有构造,没有文字形式。

对于ObjectArrayFunctionRegExp (正则表达式)来说,无论使用文字形式还是构造形式,它们都是对象,不是字面量。

Error 对象很少在代码中显示创建,一般是在抛出异常时被自动创建(也可以使用 new Error(...) 这种构造形式来创建)。

内容

之前提到过,对象的内容是由一些存储在特定命名位置的(任意类型)值组成的,我们称之为属性。

需要注意的是,"内容" 这些值并不是被储存在对象内部,这只是它的表现形式,在引擎中,这些值的存储方式是有很多种的。

存储在对象容器中的是这些属性的名称,它们就像指针(引用)一样,指向这些值真正存储的位置。

var myObject = {
	a:2
};

myObject.a; //2

myObject["a"];// 2

要访问 myObjecta 位置上的值,需要使用 . 操作符或者 [] 操作符,.a 语法通常被称为 "属性访问",["a"] 语法通常称为 "键访问"。

实际上它们访问的是同一个位置,并且都会返回相同的值 2,所以这两个术语是可以互换的。

这两种语法的区别在于 . 操作符要求属性名满足标识符的命名规范,而 ["..."] 语法可以接受任意 URF-8/Unicode 字符串作为属性名。

在对象找那个,属性名永远都是字符串,如果使用 string 之外的其它值作为属性名,那么它会被转换成一个字符串,即使是数字也不例外。

虽然在数组下标中使用的是数字,但是在对象属性中数字会被转换成字符串。

var myObject = {};

myObject[true] = "foo";
myObject[3] = "bar";
myObject[myObject] = "baz";

myObject["true"]; // "foo"
myObject["3"]; //bar
myObject["[object Object]"]; // "baz"

可计算属性名

如果需要通过表达式来计算属性名,那么 myObject[...] 这种属性访问语法就可以派上用场了,如可以使用 myObject[prefix + name],但是使用文字形式来声明对象时这样是不行的。

ES6 增加了可计算属性名,可以在文字形式中使用 [] 包裹一个表达式来当作属性名:

var prefix = "foo";

var myObject = {
	[prefix + "bar"]: "hello",
	[prefix + "baz"]: "world"
};

myObject["foobar"]; // hello
myObject["foobaz"]; // world

可计算属性名最常用的场景可能是 ES6 的符号(Symbol)。

简单来说,它是一个新的基础数据类型,包含一个不透明且无法预测的值(从技术角度来说就是一个字符串)。

一般不会用到符合的实际值(因为在不同 JavaScript 引擎中值是不同的),所有通常接触到的都是符号的名称,比如 Symbol.Xxxx

var myObject = {
	[Symbol.Xxxx]: "Hello World"
};

属性和方法

如果访问的对象属性是一个函数,有些开发者喜欢使用不同的叫法区分。

由于函数很容易被认为是属于某个对象,在其他语言中属于对象(类)的函数通常被称为"方法",因此有把 "属性访问" 说成是 "方法访问"就不奇怪了。

有意思的是,JavaScript 的语法规范也做了同样的区分。

从技术角度来说,函数永远不会 "属于" 一个对象,所以把对象内部引用的函数称为 "方法" 有些不妥。

有些函数确实具有 this 引用,有时候这些 this 会指向调用位置的对象引用,但是这种用法本质上来说并没有把一个函数变成一个 "方法",因为 this 是运行时根据调用位置动态绑定的,所以函数和对象的关系最多也只能说是间接关系。

无论返回值是什么类型,每次访问对象的属性就是属性访问,如果属性访问返回的是一个函数,那它也并不是一个 "方法"。

属性访问返回的函数和其它函数没有任何区别(除了可能发生隐式绑定 this

function foo(){
	console.log("foo");
}

var someFoo = foo; //对 foo 的变量引用

var myObject = {
	someFoo: foo
};

foo; //function foo(){...}
someFoo; //function foo(){...}
myObject.someFoo; // function foo(){...}

someFoomyObject.someFoo 只是对于同一个函数的不同引用,并不能说这个函数是特别的或者属于某个对象。

如果 foo() 定义时在内部有一个 this 引用,那这两个函数引用唯一区别就是 myObject.someFoo 中 的 this 会被隐式绑定到一个对象。

无论哪种形式都不能称之为 "方法"。

最保险的说法可能是,"函数" 和 "方法" 在 JavaScript 中是可以互换的。

即使在对象的文字形式中声明一个函数表达式,这个函数也不会属于这个对象 —— 它们只是对相同函数对象的多个引用。

var myObject = {
	foo: function(){
		console.log("foo");
	}
};

var someFoo = myObject.foo;

someFoo; //function foo(){...}

myObject.foo; //function foo(){...}

数组

数组也支持 [] 访问形式,不过数组有一套更加结构化的值存储机制(但是仍然不限制值的类型)。

数组期望的是数值下标,也就是值储存的位置(通常也被称为索引)是非负数的。

var myArray = ["foo", 42, "bar"];

myArray.length; //3

myArray[0]; //"foo"

myArray[2]; //"bar"

数组也是对象,所以虽然每个下标都是整数,但是仍然可以给数组添加属性:

var myArray = ["foo", 42, "bar"];

myArray.baz = "baz";

myArray.length; // 3

myArray.baz; //"baz"

虽然添加了命名属性(无论是通过 . 还是 []语法 ),数组的length值并未发生变化。

完全可以把数组当成一个普通的键值对象来使用,并且不添加任何数值索引,但是这并不是一个好主意。

数组和普通对象都根据其对应的行为和用途进行了优化,所以最好只用对象来存储键值对,只用数组来存储数值下标/值对。

注意:如果向数组添加一个属性,但是属性名 "看起来" 像一个数字,那么它就会变成一个数值下标(因此会修改数组的内容而不是添加一个属性)。

var myArray = ["foo",42,"bar"];
myArray["3"] = "baz";
myArray.length; //4
myArray[3]; //"baz";

复制对象

新手最常见的问题之一就是如何复制一个对象,开起来应该有一个内置的copy()

但是实际上比想象更复杂,因为我们无法选择一个默认的复制算法。

function anotherFunction(){/*...*/}

var anotherObject = {
    c:true
};

var anotherArray = [];

var myObject = {
  	a:2,
  	b:anotherObject, //引用,不是复制
    c:antherArray, // 引用
    d:anotherFunction
};
anotherArray.push(anotherObject, myObject);

如何准确的描述myObject的复制呢?

首先,应该判断它是浅复制还是深复制。

对于浅拷贝来说,复制出的新对象中 a 的值会复制旧对象中 a 的值,也就是 2 ,但是新对象中 bcd三个属性其实只是三个引用,它们和旧对象中的bcd引用的对象是一样的。

对于深复制来说,除了复制 myObject 以外还会复制 anotherObjectanotherArray

问题来了,anotherArray 引用了 anotherObjectmyObject,所以有需要复制 myObject,这样就会由于循环引用导致死循环。

是应该检测循环引用并终止循环(不复制深层元素)?

还是应该直接报错或者是选择其它方法?

除此之外,还不确定 "复制" 一个函数意味着什么,有人会通过 toString() 来序列化一个函数的源代码(但是结果取决于 JavaScript 的具体实现,而且不同的引擎对于不同类型的函数处理方式并不完全相同)。

那么如何解决这些问题?

许多 JavaScript 框架都提出来自己的解决方法,但是 JavaScript 采用那种方法作为标准呢?

对于 JSON 安全(也就是说可以被序列化为一个 JSON 字符串并且可以根据这个字符串解析出一个结构和值完全一样的对象)的对象来说,有一种巧妙的复制方法:

var newObj = JSON.parse(JSON.stringify( someObj ))

当然,这种方法需要保证对象时安全的,所以只适用于部分情况。

相比深复制,浅复制非常易懂并且问题要少得多,所以 ES6 定义了 Object.assign(...) 方法来实现浅复制。

Object.assign(...) 方法第一个参数是目标对象,之后还可以跟一个或多个源对象。它会遍历一个或过个源对象的所有可枚举(enumerable)的自有键(owned key)并把它们复制(使用 = 操作符赋值)到目标对象,最后返回目标对象:

var newObj = Object.assign( {}, myObject );

newObj.a; //2
newObj.b === anotherObject; // true
newObj.c === anotherArray; // true
newObj.d === anotherFunction; // true

由于 Object.assign(...) 就是使用 = 操作符来赋值,所以源对象属性的一些特性(比如 Writable) 不会被复制到目标对象。

属性描述符

ES5 之前,JavaScript 语言本身并没有提供可以直接检测属性特性的方法,比如判断属性是否只读。

但是 ES5 开始,所有的属性都具备了属性描述符。

var myObject = {
	a:2
};

Object.getOwnPropertyDescriptor( myObject, "a" );
//{
//    configurable: true
//    enumerable: true
//    value: 2
//    writable: true
//}

这个普通的对象属性对于的属性描述符(也被叫做 数据描述符,因为它只保存一个数据值)可不仅仅只是一个2

它还包含另外三个特性:writable(可写)、enumerable(可枚举)和 configurable(可配置)。

在穿件普通属性时属性描述符会使用默认值,也可以使用 Object.defineProperty(...) 来添加一个新属性或者修改一个已有属性(如果它是 configurable)并对特性进行设置。

var myObject = {};

Object.defineProperty( myObject,"a",{
	value:2,
	writable: true,
	configurable: true,
	enumerable:true
} );
myObject.a; //2

我们使用 defineProperty(...)myObject 添加了一个普通的属性并显示指定了一些特性,然而,一般来说不会使用这种方式,除非想修改属性描述符。

1.Writable

writable 决定是否可以修改属性的值。

var myObject = {};
Object.defineProperty( myObject,"a",{
	value:2,
	writable: false,//不可写
	configurable: true,
	enumerable: true
});

myObject.a = 3;
myObject.a; //2

此时对属性值的修改静默失败了。如果在严格模式下,这种方式会出错:

"use strict"

var myObject = {};

Object.defineProperty( myObject,"a",{
	value:2,
	writable: false,//不可写
	configurable: true,
	enumerable: true
});

myObject.a = 3; //TypeError: Cannot assign to read only property 'a' of object '#<Object>'

2.Configurable

只要属性时可配置的,就可以使用 defineProperty(...) 方法来修改属性描述符:

var myObject = {
	a:2
};

myObject.a = 3;
myObject.a; //3

Object.defineProperty( myObject, "a",{
	value:4,
	writable:true,
	configurable: false,//不可配置
	enumerable: true
});

myObject.a; // 4
myObject.a = 5;
myObject.a; //5

Object.defineProperty( myObject, "a",{
	value:6,
	writable:true,
	configurable: true,//不可配置
	enumerable: true
}); // TypeError: Cannot redefine property: a

最后一个 defineProperty(...) 会产生一个 TypeError 错误,不管是否处于严格模式,常是修改一个不可配置的属性描述符都会出错。

注意:把 configurable 修改成 false 是单项操作,无法撤销。

除了无法修改,configurable:false 还会禁止删除这个属性:

var myObject = {
	a:2
};

myObject.a; //2

delete myObject.a;
myObject.a; //undefined

Object.defineProperty( myObject, "a",{
	value:2,
	writable:true,
	configurable: false,
	enumerable: true
});

myObject.a;//2
delete myObject.a; //此处delete语句静默失败了,因为属性时不可配置的
myObject.a; //2

delete 只用来直接删除对象的(可删除)属性。

如果对象的某个属性时某个对象/函数的最后一个引用这,对这个属性执行 delete 操作之后,这个未引用的对象/函数就可以被垃圾回收。

但是,不要把 delete 看作是一个释放内存的工具(C/C++中那样),它就是一个删除对象属性的操作,仅此而已。

3.Enumerable

这里还要在介绍一个属性描述符是 enumerable

从名字就可以看出,这个描述符控制的是属性是否会出现在对象的属性枚举中,比如说 for..in 循环。

如果把 enumerable 设置成 false,这个属性就不会出现在枚举中,虽然仍然可以正常访问它。

用户定义的所有的普通属性默认都是 enumerable:true,这通常就是我们想要的。

但是如果不希望某些特殊属性出现在枚举中。那么就把它设置成 enumerable:false

不变性

有时候我们会希望属性或者对象时不可改变的(无论有意或无意),在 ES5 中可以通过很多方法来实现。

很重要的一点是,所有的方法创建的都是浅不变性的,也就是说,它们只会影响目标对象和它的直接属性。如果目标对象引用了其它对象(数组、对象、函数等),其它对象的内容不受影响,仍然是可变的:

myImmutableObject.foo; // [1,2,3]
myImmutableObject.foo.push(4);
myImmutableObject.foo; // [1,2,3,4]

假设代码中的 myImmutableObject 已经被创建而且是不可变的,但是为了保护它的内容 myImmutableObject.foo,还需要让 foo 也不可变。

JavaScript程序中很少用到深不可变性。有些特殊情况可能需要这样做,但是根据通用的设计模式,如果发现需要密封或者冻结所有对象,那么或许应该退一步,重新思考一下程序的设计,让它们更好地应对对象值的改变。

1.对象常亮

结合 writable:falseconfigurable:false 就可以创建一个真正的常亮属性(不可修改,重定义或者删除):

var myObject = {};

Object.defineProperty( myObject, "FAVORITE_NUMBER",{
	value:42,
	writable:false,
	configurable: false,
});

2.禁止扩展

如果你想禁止一个对象添加新属性并且保留已有属性,可以使用 Object.preventExtensions(...)

var myObject = {
	a:2
};

Object.preventExtensions(myObject);

myObject.b = 3;
myObject.b;// undefined

在非严格模式下,创建属性 b 会静默失败,在严格模式下,将会抛出 TypeError 错误。

3.密封

Object.seal(...) 会创建一个 "密封" 的对象,这个方法实际上会在一个现有对象上调用 Object.preventExtensions(...) 并把所有现有属性标记为 configurable:false

所以,密封之后不仅不能添加新属性,也不能重新配置或者删除任何现有属性(虽然可以修改属性的值)。

4.冻结

Object.freeze(...) 会创建一个冻结对象,这个方法实际上会在一个现有对象上调用 Object.seal(...) 并把所有 "数据访问" 属性标记为 writable:false,这样就无法修改它们的值。

这个方法实可以应用到对象之上的级别最高的不可变性,它会禁止对于对象本身及其任何直接属性的修改(不过就像之前说的,这个对象引用的其它对象时不受影响的)。

可以 "深度冻结" 一个对象,具体方法为,首先在这个对象上调用 Object.freeze(...),任何遍历它引用的所有对象并在这些对象上调用 Object.freeze(...)

但是一定要小心,因为这样做可能会在无意中冻结其它(共享)对象。

Get

属性访问在实现时有一个微妙却非常重要的细节:

var myObject = {
	a:2
};

myObject.a; //2

myObject.a 是一次属性访问,但是这条语句并不仅仅是在 myObject 中查找名字为 a 的属性。

在语言规范中,myObject.amyObject 上实际上是实现了 [[Get]] 操作(有点像函数调用:[[Get]]())。

对象默认的内置 [[Get]] 操作首先在对象中查找是否有名称相同的属性,如果找到就会返回这个属性的值。

然而,如果没有找到名称相同的属性,按照 [[Get]] 算法的定义会执行另外一个非常重要的行为(遍历可能存在的 [[Prototype]] 链,也就是原型链)。

如果如论如何都没有找到名称相同的属性,那 [[Get]] 操作会返回 undefined

var myObject = {
	a:undefined
};

myObject.a;//undefiend
myObject.b;//undefiend

注意:这种方法和访问变量时是不一样的,如果引用了一个当前词法作用域中不存在的变量,并不会像对象属性一样返回 undefined,而是会抛出一个 ReferenceError 异常。

var myObject = {
	a:undefined
};

myObject.a;//undefiend
myObject.b;//undefiend

从返回值的角度来说,这两个引用没有区别——它们都返回了 undefined,然而,尽管乍看没有什么区别,实际上底层 [[Get]] 操作对 myObject.b 进行了更复杂的处理。

仅通过返回值,无法判断一个属性是存在并且持有一个 undefined 值,还是变量不存在,所以 [[Get]] 无法返回某个特定值而返回默认的 undefined

Put

既然有可以获取属性值的 [[Get]] 操作,就一定有对应的 [[Put]] 操作。

你可能会认为给对象的属性赋值会触发 [[Put]] 来设置或者创建这个属性,但实际情况并不完全是这样的。

[[Put]] 被触发时,实际的行为取决于许多因素,包括对象中是否已经存在了这个属性(这是最重要的因素)。

如果已经存在了这个属性, [[Put]] 算法大致会检查以下内容:

  1. 属性是否是访问描述符?如果是并且存在 setter 就 调用 setter
  2. 属性的数据描述符中 writable 是否是 false ?如果是,在非严格模式下静默失败,在严格模式下抛出 TypeError 异常。
  3. 如果都不是,将该值设置为属性的值。

如果对象中不存在这个属性, [[Put]] 操作会更加复杂。

Getter 和 Setter

对象默认的 [[Put]][[Get]] 操作分别可以控制属性值的设置和获取。

ES5 中可以使用 gettersetter 部分改写默认操作,但是只能应用在单个属性上,无法应用在整个对象上。

getter 是一个隐藏函数,会在获取属性值时调用。

setter 也是一个隐藏函数,会在设置属性值时调用。

当给一个属性定义 gettersetter 或者两者时定义,这个属性会被定义为 "访问描述符"(和"数据描述符"相对)。

对于访问描述符来说,JavaScript 会忽略它们的 valuewritable 特性,取而代之的是关心 setget (还有 configurableenumerable)特性。

var myObject = {
	// 给a 定义一个getter
	get a(){
		return 2;
	}
};

Object.defineProperty(
	myObject,//目标对象
	"b", //属性名
    { // 描述符
        //给 b 设置一个 getter
        get:function(){return this.a * 2},
        //确保 b会出现在对象的属性列表中
        enumerable: true
    }
);

myObject.a;//2
myObject.b;//4

不管对象文字语法中的 get a(){...},还是 defineProperty(...) 中的显示定义,二者都会在对象中创建一个不包含值的属性,对于这个属性的访问会自动调用一个隐藏函数,它的返回值会被当做属性访问的返回值:

var myObject = {
	//给 a 定义一个 getter
	get a(){
		return 2;
	}
};

myObject.a = 3;
myObject.a; //2

由于只定义了 agetter,所以对 a 的值进行设置时 set 操作就会忽略复制操作,不会抛出错误。

而且即便有合法的 setter,由于我们自定义的 getter 只会返回 2,所以 set 操作是没有意义的。

为了让属性更合理,还应当定义 setter,和我们期望的引用,setter 会覆盖单个属性默认的 [[Put]](也可以叫做赋值)操作。

通常来说 gettersetter 是成对出现的(只定义一个的话会产生意料之外的行为):

var myObject = {
	//给 a 定义一个 getter
	get a(){
		return this._a_;
	},
	
	// 给 a 定义一个 setter
	set a(val){
		this._a_ = val * 2
	}
};

myObject.a = 2;
myObject.a; // 4

在这个例子中,实际上我们把赋值 [[Put]] 操作中的值 2 存储到了另一个变量 _a_中。名称 _a_ 只是一种惯例,没有任何特殊的行为——和其它普通属性一样。

存在性

签名我们介绍过,如 myObject.a 的属性访问返回值可能是 undefined,但是这个值有可能是属性中存储的 undefined,也可能是因为属性不存在所以返回 undefined

那么如何区分这两种情况呢?

我们可以在不访问属性值的情况下判断对象中是否存在这个属性:

var myObject = {
	a:2
};

("a" in myObject); // true
("b" in myObject); //false

myObject.hasOwnProperty("a"); // true
myObject.hasOwnProperty("b"); // false

in 操作符会检查属性是否在对象及其 [[Prototype]] 原型链中。

相比之下,hasOwnProperty(...) 只会检查属性是否存在 myObject 对象中,不会检查 [[Prototype]] 链。

所有的普通对象都可以通过对于 Object.prototype 的委托来访问 hasOwnProperty(...),但是有的对象可能没有连接到 Object.prototype(通过 Object.create(null)来创建的)。

在这种情况下,形如 myObject.hasOwnProperty(...) 就会失败。

这是就可以使用一种更加强硬的方法来进行判断:Object.prototype.hasOwnProperty.call(myObject,"a"),它借用基础的 hasOwnProperty(...)方法并把它显示绑定到myObject上。

看上去 in 操作符可以检查容器内是否有某个值,但是它实际上检查的是某个属性名是否存在。对于数组来说这个区别非常重要, 4 in [2,4,6] 的结果并不是我们期待的 true,因为[2,4,6] 这个数组中包含的属性名是 012 ,没有 4

1.枚举

之前介绍 enumerable 属性描述符特性时简单的解释过什么是 "可枚举性",现在详细介绍一下:

var myObject = {};
Object.defineProperty(
	myObject,
	"a",
	// 让a像普通属性一样可以枚举
	{enumerable: true,value:2}
);

Object.defineProperty(
	myObject,
	"b",
	// 让 b 不可枚举
	{enumerable: false,value:3}
);

myObject.b; //3
("b" in myObject); //true
myObject.hasOwnProperty("b");// true

for( var k in myObject){
	console.log(k,myObject[k])
}
// "a" 2

可以看到,myObject.b 确实存在并且有访问值,但是却不会出现在 for...in 循环中(尽管可以通过in操作符来判断是否存在)。

原因是 "可枚举" 就相当于 "可以出现在对象属性的遍历中"、

在数组上应用 for...in 有时会产生出人意料的结果,因为这种枚举不仅会包含所有数值索引,还会包含所有可枚举属性。

最好只在对象上应用 for...in 循环,如果要遍历数组就使用传统的 for 循环来遍历数组索引。

也可以通过另一种方式来区分属性是否可枚举:

var myObject = {};
Object.defineProperty(
	myObject,
	"a",
	// 让a像普通属性一样可以枚举
	{enumerable: true,value:2}
);
Object.defineProperty(
	myObject,
	"b",
	// 让 b 不可枚举
	{enumerable: false,value:3}
);
myObject.propertyIsEnumerable("a") //true
myObject.propertyIsEnumerable("b") //false

Object.keys(myObject);// ["a"]
Object.getOwnPropertyNames(myObject); //["a","b"]

propertyIsEnumerable(...) 会检查给定的属性名是否直接存在于对象中(而不是在原型链上)并且满足 enumerable:true

Object.keys(...) 会返回一个数组,包含所有可枚举属性,Object.getOwnPropertyNames(...) 会返回一个数组,包含所有属性,无论它们是否可以枚举。

inhasOwnProperty(...) 的区别在于是否查找 [[Prototype]] 链,然而,Object.keys(...)Object.getOwnPropertyNames(...) 都只会查找对象直接包含的属性。

目前并没有内置的方法可以获取 in 操作符使用的属性列表(对象本身的属性以及 [[Prototype]] 链中的所有属性)。

不过可以递归遍历某个对象的整条 [[Prototype]] 链并保存每一层使用 Object.keys(...) 得到的属性列表——只包含可枚举属性。

遍历

for...in 循环可以用来遍历对象的可枚举属性列表(包括 [[Prototype]] 链),但是如何遍历属性的值呢?

对于数值索引的数组来说,可以使用标准的 for 循环来遍历值:

var myArray = [1,2,3];

for(var i = 0; i < myArray.length;i++){
	console.log(myArray[i]);
} //1 2 3

这实际并不是在遍历值,而是遍历下标来指向值,如 myArray[i]

ES5 中增加了一些数组的辅助迭代器,包括 forEach(...)every(...) 和 some(...)。

每种辅助迭代器都可以接受一个回调函数并把它应用到数组的每个元素上,唯一的区别就是它们对于回调函数返回值的处理方式不同。

forEach(...)会遍历数组中的所有值并忽略回调函数的返回值,every(...) 会一直运直到回调函数返回 false(或者 "假" 值),some(...) 会一直运行直到回调函数返回true(或者 "真" 值)。

every(...)some(...) 中特殊的返回值和普通 for 循环中的 break 语句类似,它们会提前终止遍历。

使用 for...in 遍历对象时无法直接获取属性值的,因为它实际上遍历的是对象中的所有可枚举属性,我们需要手动获取值。

遍历数组下标时采用的数字顺序(for 循环或者其它迭代器),但是遍历对象属性时的顺序是不确定的,在不同的JavaScript引擎中可能不一样。因此,在不同的环境中需要保证一致性时,一点不要相信任何观察的顺序,它们是不可靠的。

那么如何直接遍历值而不是数组下标(或者对象属性)呢?

ES6 增加了一种用来遍历数组的 for..of 循环语法(如果对象本身定义了迭代器的话也可以遍历对象):

var myArray = [1,2,3];

for(var v of myArray){
    console.log(v);
}

for...of 循环首先会被访问对象请求一个迭代器对象,然后通过调用迭代器对象的 next() 方法来遍历所有的返回值。

数组中有内置的 @@iterator,因此 for...of 能直接应用在数组上。

使用内置 @@iterator 来手动遍历数组,看看它是怎么工作的:

var myArray = [1,2,3];
var it = myArray[Symbol.iterator]();

it.next(); //{value:1,done:false}
it.next(); //{value:2,done:false}
it.next(); //{value:3,done:false}
it.next(); //{done:true}

我们使用 ES6 中的符号 Symbol.iterator 来获取对象的 @@iterator 内部属性。

引用类似 iterator 的特殊属性时要使用符号名,而不是符号包含的值,此外,虽然看上去像一个对象,但是 @@iterator 本身并是不一个迭代器对象,而是一个返回迭代器对象的函数——这点非常精巧并且重要。

调用迭代器的 next() 方法会返回形式为 {value: ...,done: ...} 的值,value 是当前的遍历值,done 是一个布尔值,表示是否还有可以遍历的值。

注意,和值 "3" 一起返回的 done:false,乍看好像很奇怪,必须在调用一次 next() 才能得到 done:true,从而完成遍历。

这个机制和 ES6 中发生器函数的语义相关。

和数组不同,普通的对象没有内置的 @@iterator,所以无法自动完成 for..of 遍历,之所以要这样做,有许多原因。

不过简单的来说,这样做是为了避免影响未来的对象类型。

当然,我们可以给任何想遍历的对象定义 @@iterator

var myObject = {
	a:2,
	b:3
};

Object.defineProperty( myObject,Symbol.iterator,{
   enumerable: false,
    writable: false,
    configurable: true,
    value:function(){
        var o = this;
        var idx = 0;
        var ks = Object.keys(o);
        return {
            next:function(){
                return {
                    value:o[ks[idx++]],
                    done:(idx > ks.length)
                };
            }
        };
    }
});

// 手动遍历 myObject
var it = myObject[Symbol.iterator]();
it.next();//{value:2,done:false}
it.next();//{value:3,done:false}
it.next();//{value:undefined,done:true}

// 用 for..of循环遍历 myObject
for(var v of myObject){
    console.log(v);
}

我们使用 Object.defineProperty(...) 定义了我们自己的 @@iterator(主要是为了让它不可枚举),不过注意,我们把符号当做可计算属性名。

此外,也可以直接在定义对象时进行声明,比如 var myObject = {a:2, b:3, [Symbol.iterator]:function(){/*....*/}}

for...of 循环每次调用 myObject 迭代器对象的 next() 方法时,内部的指针都会向前移动并返回对象属性列表的下一个值(提醒:需要注意遍历对象属性/值时的顺序)。

代码中的遍历非常简单,只是传递了属性本身的值,不过只要愿意,也可以在自定义的数据结构上 实现各种复杂的遍历。

对于用户定义的对象来说,结合 for..of 循环和自定义迭代器可以组成非常强大的对象操作工具。

比如说,一个 Pixel 对象(有 x 和 y 坐标值)列表可以按照距离原点的直线距离来决定遍历顺序,也可以过滤掉 "太远" 的点,等等。

只要迭代器的 next() 调用会返回{value:...}{done:true}ES6 中的 for...of 就可以遍历它。

实际上,甚至可以定义一个 "无限" 迭代器,它永远不会 "结束" 并且总是返回一个新值(比如随机数、递增值、唯一标识符等等。

我们可能永远不会在 for...of 循环中使用这样的迭代器,因为它永远不会结束,程序会被挂起:

var randoms = {
	[Symbol.iterator]:function(){
		return {
			next:function(){
				return {value: Math.random()};
			}
		};
	}
};

var randoms_pool = [];
for( var n of randoms ){
	randoms_pool.push( n );
	
	//防止无限运行!
	if(randoms_pool.length === 100) break;
}

这个迭代器会生成 "无限个" 随机数,因此添加一条 break语句,防止挂起。

总结

JavaScript 中的对象有字面形式(比如 var a = {...} ) 和 构造形式(比如 var a = new Array(...) )。

字面形式更常用,不过有时候构造形式可以提供更多选项。

许多人都以为 "JavaScript中万物都是对象",这是错误的。对象是 6 个(或者 7 个取决于个人观点)基础类型之一。

对象有包括 function 在内的子类型,不同子类型具有不同的行为,比如内部标签 [object Array] 表示这是对象的子类型数组。

对象就是 键值对的集合,可以通过 .propName 或者 ["propName"] 语法来获取属性值。

访问属性是,引擎实际上会调用内部的默认 [[Get]] 操作(在设置属性时时 [[Put]]),[[Get]] 操作会检查对象本身是否包含这个属性,如果没有找到的话还会查找 [[Prototype]] 链。

属性的特性可以通过属性描述符来控制,比如 writable 和 configurable。

此外,可以使用 Object.preventExtension(...)Object.seal(...)Object.freeze(...) 来设置对象(及其属性)的不可变性级别。

属性不一定包含值——它们可能是具备 getter/setter 的 "访问描述符",属性可以是可枚举的或者不可枚举,这决定了它们是否会出现在 for...in 循环中。

可以使用 ES6 中的 for...of 语法来遍历数据结构(数组、对象等等)中的值,for...of 会寻找内置或自定义的 @@iterator 对象并调用它的 next() 方法来遍历数据值。