懂王系列(三)之彻底搞懂JavaScript对象

365 阅读12分钟

作为一名前端小白,不知道大家是否遇到和我一样的问题。看了一道面试题的解析,当时觉得会了,可是过两天以后再看又不会了;盲目追求各种新技术,感觉什么都会点,但是一上手就不行了... 痛定思痛后,我终于认识到了问题所在,开始专注于基本功的修炼。近半年来通读了(其实是囫囵吞枣)《JavaScript高级程序设计》、《你不知道的JavaScript上、中、下》等书籍,本系列文章是我读书过程中对知识点的一些总结。喜欢的同学记得帮我点个赞😁。

懂王系列(一)之彻底搞懂JavaScript函数执行机制
懂王系列(二)之彻底搞懂JavaScript作用域
懂王系列(三)之彻底搞懂JavaScript对象
懂王系列(四)之彻底搞懂JavaScript类
懂王系列(五)之彻底搞懂JavaScript原型
懂王系列(六)之彻底搞懂JavaScript中的this
懂王系列(七)之彻底搞懂JavaScript数据类型
懂王系列(八)之彻底搞懂JavaScript语句
懂王系列(九)之彻底搞定JavaScript类型转换

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

1. 内置对象

• String
• Number
• Boolean
• Object
• Function
• Array

var strPrimitive = "I am a string"; 
typeof strPrimitive; // "string" 
strPrimitive instanceof String; // false 
var strObject = new String( "I am a string" ); 
typeof strObject; // "object" 
strObject instanceof String; // true 
// 检查 sub-type 对象
Object.prototype.toString.call( strObject ); // [object String]

原始值 "I am a string" 并不是一个对象,它只是一个字面量,并且是一个不可变的值。如果要在这个字面量上执行一些操作,比如获取长度、访问其中某个字符等,那需要将其转换为 String 对象。 幸好,在必要时语言会自动把字符串字面量转换成一个 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)。对于布尔字面量来说也是如此

2. 内容

存储在对象容器内部的是这些属性的名称,它们就像指针(从技术角度来说就是引用)一样,指向这些值真正的存储位置

var myObject = { 
    a: 2 
}; 
myObject.a; // 2 
myObject["a"]; // 2

.a 语法通常被称为“属性访问”,["a"] 语法通常被称为“键访问”

这两种语法的主要区别在于 . 操作符要求属性名满足标识符的命名规范,而 [".."] 语法可以接受任意 UTF-8/Unicode 字符串作为属性名。举例来说,如果要引用名称为 "SuperFun!" 的属性,那就必须使用 ["Super-Fun!"] 语法访问

在对象中,属性名永远都是字符串。如果你使用string(字面量)以外的其他值作为属性名,那它首先会被转换为一个字符串。即使是数字也不例外

2.1 可计算属性名

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

2.2 属性与方法

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

function foo() { 
    console.log( "foo" ); 
} 
var someFoo = foo; // 对 foo 的变量引用
var myObject = { 
    someFoo: foo 
}; 
foo; // function foo(){..}
someFoo; // function foo(){..}
myObject.someFoo; // function foo(){..}

someFoo 和 myObject.someFoo 只是对于同一个函数的不同引用,并不能说明这个函数是特别的或者“属于”某个对象。如果 foo() 定义时在内部有一个 this 引用,那这两个函数引用的唯一区别就是 myObject.someFoo 中的 this 会被隐式绑定到一个对象。

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

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

2.3 数组

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

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

2.4 复制对象

function anotherFunction() { /*..*/ } 
var anotherObject = { 
    c: true
}; 
var anotherArray = []; 
var myObject = { 
    a: 2, 
    b: anotherObject, // 引用,不是复本!
    c: anotherArray, // 另一个引用!
    d: anotherFunction 
};
anotherArray.push( anotherObject, myObject );

对于浅拷贝来说,复制出的新对象中 a 的值会复制旧对象中 a 的值,也就是 2,但是新对象中 b、c、d 三个属性其实只是三个引用,它们和旧对象中 b、c、d 引用的对象是一样的。对于深复制来说,除了复制 myObject 以外还会复制 anotherObject 和 anotherArray。这时问题就来了,anotherArray 引用了 anotherObject 和 myObject,所以又需要复制 myObject,这样就会由于循环引用导致死循环。

  1. 通过JSON字符串
var newObj = JSON.parse( JSON.stringify( someObj ) );
  1. Object.assign(..)

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

3.5 属性描述符

writable(可写)、enumerable(可枚举)和 configurable(可配置)

  1. 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

简单来说,writable:false 可以看作是属性不可改变,相当于你定义了一个空操作 setter。严格来说,如果要 和 writable:false 一致的话,你的 setter 被调用时应当抛出一个 TypeError错误

  1. Configurable
    只要属性是可配置的,就可以使用 defineProperty(..) 方法来修改属性描述符。相反,属性不可配置时,使用defineProperty(..) 方法来修改属性会报错

要注意有一个小小的例外:即便属性是 configurable:false,我们还是可以把 writable 的状态由 true 改为 false,但是无法由 false 改为 true。

除了无法修改,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; 
myObject.a; // 2
  1. Enumerable
    这个描述符控制的是属性是否会出现在对象的属性枚举中,比如说for..in 循环。

3.6 不变性

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

myImmutableObject.foo; // [1,2,3] 
myImmutableObject.foo.push( 4 ); 
myImmutableObject.foo; // [1,2,3,4]
  1. 对象常量
    结合 writable:false 和 configurable:false 就可以创建一个真正的常量属性(不可修改、重定义或者删除)
var myObject = {}; 
Object.defineProperty( myObject, "FAVORITE_NUMBER", { 
    value: 42, 
    writable: false, 
    configurable: false
} );
  1. 禁止扩展
    如 果 你 想 禁 止 一 个 对 象 添 加 新 属 性 并 且 保 留 已 有 属 性, 可 以 使 用 Object.prevent Extensions(..)
var myObject = { 
    a:2 
}; 
Object.preventExtensions( myObject ); 
myObject.b = 3; 
myObject.b; // undefined

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

  1. 密封
    Object.seal(..) 会创建一个“密封”的对象,这个方法实际上会在一个现有对象上调用Object.preventExtensions(..) 并把所有现有属性标记为 configurable:false。所以,密封之后不仅不能添加新属性,也不能重新配置或者删除任何现有属性(虽然可以修改属性的值)。

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

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

3.7 [[get]]

var myObject = { 
    a: 2 
}; 
myObject.a; // 2

myObject.a 在 myObject 上实际上是实现了 [[Get]] 操作(有点像函数调用:[Get])。对象默认的内置 [[Get]] 操作首先在对象中查找是否有名称相同的属性,如果找到就会返回这个属性的值。如果无论如何都没有找到名称相同的属性,那 [[Get]] 操作会返回值 undefined:

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

3.8 [[Put]]

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

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

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

3.9 Getter和Setter

当给一个属性定义 getter、setter 或者两者都有时,这个属性会被定义为“访问描述符”(和“数据描述符”相对)。对于访问描述符来说,JavaScript 会忽略它们的 value 和writable 特性,取而代之的是关心 set 和 get(还有 configurable 和 enumerable)特性。

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

由于我们只定义了 a 的 getter,所以对 a 的值进行设置时 set 操作会忽略赋值操作,不会抛出错误。而且即便有合法的 setter,由于我们自定义的 getter 只会返回 2,所以 set 操作是没有意义的

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

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

3.10 存在性

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))。在这种情况下,myObejct.hasOwnProperty(..)就会失败

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

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

  1. 枚举
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 循环来遍历数值索引。

还可以通过propertyIsEnumerable(..) 式来区分属性是否可枚举,propertyIsEnumerable(..) 会检查给定的属性名是否直接存在于对象中(而不是在原型链上)并且满足 enumerable:true。

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

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

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

4. 遍历

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

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

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

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

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

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