JavaScript中的对象

2022-1-6 JavaScriptES6

对象的属性类型、语法及解构

# 理解对象

对象是一组没有特定顺序的值,对象的每个属性或方法都由一个名称来表示,这个名称映射到一个值。

# 属性类型

ECMA-262规范用[[ ]]将一些内部特性的名称括起来,开发者不能在JavaScript中直接访问这些特性。属性分两种:

# 数据属性

该属性包含一个保存数据值的位置,用于值的读取和写入。数据属性有4个特性用来描述它们的行为:

  • [[Configurable]]:表示是否可以通过delete删除并重新定义、是否可以修改它的特性、以及是否可以把它改为访问器属性,默认直接定义在对象上的属性时该特性为true
  • [[Enumerable]]:表示属性是否可以通过for-in循环返回,默认直接定义在对象上的属性时该特性为true
  • [[Writable]]:表示属性的值是否可以被修改,默认直接定义在对象上的属性时该特性为true
  • [[Value]]:包含属性实际的值,默认为undefined

要修改属性的默认特性,就必须使用Object.defineProperty()方法。这个方法接收3个参数:对象名属性名一个描述对象,描述对象上的属性可以包含:configurable、enumerable、writable 和 value,跟相关特性的名称一一对应。根据要修改的特性,可以设置其中一个或多个值。

let person = {}; 
Object.defineProperty(person, "name", { 
  writable: false, 
  value: "Nicholas" 
}); 
console.log(person.name); // "Nicholas" 
person.name = "Greg"; 
console.log(person.name); // "Nicholas" 
// 因为设置了writable为false,所以无法修改该属性的值
1
2
3
4
5
6
7
8
9

注:

  • 一个属性被定义为不可配置之后,就 不能再变回可配置的了,且再次调用Object.defineProperty()并修改任何非value属性会导致错误(如果此前writable为true则修改有效,否则无效)
  • 在调用Object.defineProperty()时,configurable、enumerable 和 writable 的值如果不 指定,则都默认为 false

# 访问器属性

访问器属性不包含数据值,它包含一个getter函数和一个setter函数。在读取访问器属性时,会调用 getter 函数,这个函数用于返回一个有效的值;在写入访问器属性时,会调用 setter 函数并传入新值,这个函数决定对数据做出什么修改。(其中 getter 和 setter 是概念,get 和 set 是具体实现方法)访问器属性的4个特性:

  • [[Configurable]]:表示是否可以通过delete删除并重新定义、是否可以修改它的特性、以及是否可以把它改为数据属性,默认直接定义在对象上的属性时该特性为true
  • [[Enumerable]]:表示属性是否可以通过for-in循环返回,默认直接定义在对象上的属性时该特性为true
  • [[Get]]:获取函数,在读取属性时调用,默认值为undefined
  • [[Set]]:设置函数,在写入属性时调用,默认值为undefined

访问器属性时不能直接定义的,必须使用Object.defineProperty()

// 定义一个对象,包含伪私有成员 year_和公共成员 edition 
let book = { 
  year_: 2017, // 下划线常用来表示该属性并不希望在对象方法的外部被访问
  edition: 1
}; 
Object.defineProperty(book, "year", { 
  get() { 
    return this.year_; 
  }, 
  set(newValue) { 
    if (newValue > 2017) { 
      this.year_ = newValue; 
      this.edition += newValue - 2017; 
    } 
  } 
}); 
book.year = 2018; 
console.log(book.edition); // 2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

这是访问器属性的经典使用场景,即设置一个属性值会导致一些其他变化发生。其中 get 和 set 函数不一定都要定义,如只定义 get 意味着属性是只读的。 在不支持 Object.defineProperty()的浏览器中没有办法修改[[Configurable]]或[[Enumerable]]。

数据属性是实际存储属性值的属性,而访问器属性不实际存储数据,专门提供对其他数据/变量的保护。如果不使用Object.defineProperty()或者Object.defineProperties()以及指定get和set等特殊方法定义的对象属性,默认都是数据属性。

# 定义多个属性

ES提供了一个可以同时定义多个属性的方法Object.defineProperties('要操作的对象','一个描述符对象'),不过通过字面量直接定义也可以,但不能字面量形式不能直接定义访问器属性。其中通过 Object.defineProperties()定义的数据属性的前三个特性都是false

let book = {}; 
Object.defineProperties(book, { 
  year_: { 
    value: 2017 
  }, 
  edition: { 
    value: 1 
  }, 
  year: { 
    get() { 
      return this.year_; 
    }, 
    set(newValue) { 
      if (newValue > 2017) { 
        this.year_ = newValue; 
        this.edition += newValue - 2017; 
      } 
    } 
  } 
}); 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 读取属性的特性

Object.getOwnPropertyDescriptor('操作对象', '属性名'),返回值是一个对象,包含对应属性的4个特性。

//采用上一段代码的例子
let descriptor = Object.getOwnPropertyDescriptor(book, "year_"); 
console.log(descriptor.value); // 2017 
console.log(descriptor.configurable); // false 
console.log(typeof descriptor.get); // "undefined" 
let descriptor = Object.getOwnPropertyDescriptor(book, "year"); 
console.log(descriptor.value); // undefined 
console.log(descriptor.enumerable); // false 
console.log(typeof descriptor.get); // "function" 
1
2
3
4
5
6
7
8
9

ES2017新增了Object.getOwnPropertyDescriptors()静态方法。这个方法实际上就是在每个自有属性上调用 Object.getOwnPropertyDescriptor() 并在一个新对象中返回它们。

console.log(Object.getOwnPropertyDescriptors(book)); 
/* { 
     edition: { 
       configurable: false, 
       enumerable: false, 
       value: 1, 
       writable: false 
     }, 
     year: { 
       configurable: false, 
       enumerable: false, 
       get: f(), 
       set: f(newValue), 
     }, 
    year_: { 
      configurable: false, 
      enumerable: false, 
      value: 2017, 
      writable: false 
    } 
	}*/ 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 合并对象

合并对象就是把原对象所有本地属性一起复制到目标对象上。ES6提供了合并对象的方法Object.assign(),这个方法接收一个目标对象和一个或多个源对象作为参数,然后将每个源对象中可枚举(Object.propertyIsEnumerable() 返回 true)和自有(Object.hasOwnProperty() 返回 true)属性复制到目标对象。以字符串和符号为键的属性会被复制。对每个符合条件的属性,这个方法会使用源对象上的[[Get]]取得属性的值,然后使用目标对象上的[[Set]]设置属性的值。

let dest, src, result; 
/** 
 * 简单复制
 */ 
dest = {}; 
src = { id: 'src' }; 
result = Object.assign(dest, src); 
// Object.assign 修改目标对象
// 也会返回修改后的目标对象
console.log(dest === result); // true 
console.log(dest !== src); // true 
console.log(result); // { id: src } 
console.log(dest); // { id: src } 
/** 
 * 多个源对象
 */ 
dest = {}; 
result = Object.assign(dest, { a: 'foo' }, { b: 'bar' }); 
console.log(result); // { a: foo, b: bar } 
/** 
 * 获取函数与设置函数
 */ 
dest = { 
  set a(val) { 
    console.log(`Invoked dest setter with param ${val}`); 
  } 
}; 
src = { 
  get a() { 
    console.log('Invoked src getter'); 
    return 'foo'; 
  } 
}; 
Object.assign(dest, src); 
// 调用 src 的获取方法
// 调用 dest 的设置方法并传入参数"foo" 
// 因为这里的设置函数不执行赋值操作
// 所以实际上并没有把值转移过来
console.log(dest); // { set a(val) {...} } 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

Object.assign() 实际上对每个源对象执行的是浅复制。如果多个源对象都有相同的属性,则使用最后一个复制的值。不能在两个对象间转移获取函数和设置函数。如果赋值期间出错,则操作会中止并退出,完成部分复制,同时抛出错误。

let dest, src, result; 
/** 
 * 错误处理
 */ 
dest = {}; 
src = { 
  a: 'foo', 
  get b() { 
    // Object.assign()在调用这个获取函数时会抛出错误
    throw new Error(); 
  }, 
  c: 'bar' 
}; 
try { 
  Object.assign(dest, src); 
} catch(e) {} 
// Object.assign()没办法回滚已经完成的修改
// 因此在抛出错误之前,目标对象上已经完成的修改会继续存在:
console.log(dest); // { a: foo } 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 对象标识及相等判定

在ES6之前,有些特殊情况即使是===操作符也无能为力。

// 这些是===符合预期的情况
console.log(true === 1); // false 
console.log({} === {}); // false 
console.log("2" === 2); // false 
// 这些情况在不同 JavaScript 引擎中表现不同,但仍被认为相等
console.log(+0 === -0); // true 
console.log(+0 === 0); // true 
console.log(-0 === 0); // true 
// 要确定 NaN 的相等性,必须使用极为讨厌的 isNaN() 
console.log(NaN === NaN); // false 
console.log(isNaN(NaN)); // true 
1
2
3
4
5
6
7
8
9
10
11

为了改善这一情况,ES6新增了Object.is(),这个方法和===很像,但也考虑了上面这些情况。这个方法必须接收两个参数:

console.log(Object.is(true, 1)); // false 
console.log(Object.is({}, {})); // false 
console.log(Object.is("2", 2)); // false 
// 正确的 0、-0、+0 相等/不等判定
console.log(Object.is(+0, -0)); // false 
console.log(Object.is(+0, 0)); // true 
console.log(Object.is(-0, 0)); // false 
// 正确的 NaN 相等判定
console.log(Object.is(NaN, NaN)); // true
1
2
3
4
5
6
7
8
9

# 增强的对象语法

ES6新增了很多有用的语法糖,这些特性没有改变现有引擎的行为,但提升了处理对象的方便程度,这些对象语法同样适用与ES6的类。

  1. 属性值简写

    let name = 'Matt'; 
    let person = { 
     name // 与 name: name等价
    }; 
    console.log(person); // { name: 'Matt' }
    
    1
    2
    3
    4
    5

    注:简写属性名只要使用变量名就会被自动解释为同名的属性键,如果没有找到同名变量,则会抛出ReferenceError(经测试,如果没有找到同名变量,会被解释成空串,可能是后面规范修改的)

  2. 可计算属性

    在引入可计算属性之前,如果想使用变量的值作为属性,那么必须先声明对象,然后使用中括号语法来添加属性。换句话说,不能在对象字面量中直接动态命名属性。有了可计算属性,就可以直接在对象字面量中完成动态属性赋值

    const nameKey = 'name'; 
    const ageKey = 'age'; 
    const jobKey = 'job'; 
    // 在没引入可计算属性之前
    let person = {}; 
    person[nameKey] = 'Matt'; 
    person[ageKey] = 27; 
    person[jobKey] = 'Software engineer'; 
    // 引入可计算属性之后
    let person = { 
      [nameKey]: 'Matt', 
      [ageKey]: 27, 
      [jobKey]: 'Software engineer' 
    }; 
    console.log(person); // { name: 'Matt', age: 27, job: 'Software engineer' } 
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15

    可计算属性本身可以是复杂的表达式,在实例化时再求值。可计算属性表达式中抛出任何错误都会中断对象创建。如果计算属性的表达式有副 作用,那就要小心了,因为如果表达式抛出错误,那么之前完成的计算是不能回滚的。

  3. 简写方法名

    // 在给对象定义方法时,通常都要写一个方法名、冒号,然后再引用一个匿名函数表达式
    let person = { 
      sayName: function(name) { 
        console.log(`My name is ${name}`); 
      } 
    }; 
    // 简写方法
    let person = { 
      sayName(name) { 
        console.log(`My name is ${name}`); 
      } 
    }; 
    person.sayName('Matt'); // My name is Matt
    
    // 简写方法名对获取和设置函数也适用
    let person = { 
      name_: '', 
      get name() { 
        return this.name_; 
      }, 
      set name(name) { 
        this.name_ = name; 
      }, 
      sayName() { 
        console.log(`My name is ${this.name_}`); 
      } 
    }; 
    person.name = 'Matt'; 
    person.sayName(); // My name is Matt
    
    // 简写方法名与可计算属性键相互兼容
    const methodKey = 'sayName'; 
    let person = { 
      [methodKey](name) { 
        console.log(`My name is ${name}`); 
      } 
    } 
    person.sayName('Matt'); // My name is Matt 
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38

    简写方法名对ES6的类更有用。

# 对象解构

对象解构就是使用与对象匹配的结构来实现对象属性赋值。

let person = { 
  name: 'Matt', 
  age: 27 
}; 
// 不使用对象解构
let personName = person.name, personAge = person.age; 
// 使用对象解构
let { name: personName, age: personAge } = person; 
// 或者
let personName, personAge;
({name: personName, age: personAge} = person);// 如果是给实现声明的变量赋值,则赋值表达式必须包含在一对括号中
console.log(personName); // Matt 
console.log(personAge); // 27 

// 如果想让变量直接使用属性的名称,可以使用简写语法
let { name, age } = person; 
console.log(name); // Matt 
console.log(age); // 27
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  • 解构赋值不一定与对象的属性匹配。赋值的时候可以忽略某些属性,而如果引用的属性不存在,则该变量的值就是 undefined。也可以在解构赋值的同时定义默认值。
  • 解构在内部会使用函数 ToObject()(不能在运行时环境中直接访问)把源数据结构转换为对象。这意味着在对象解构的上下文中,原始值会被当成对象。这也意味着(根据 ToObject()的定义),null 和 undefined 不能被解构,否则会抛出错误。
  • 在外层属性没有定义的情况下不能使用嵌套解构。
  • 如果解构赋值出错,则整个解构赋值只会完成一部分。
  • 在函数参数列表中也可以进行解构赋值。
let { length } = 'foobar'; 
console.log(length); // 6 
let { constructor: c } = 4; 
console.log(c === Number); // true 
let { _ } = null; // TypeError 
let { _ } = undefined; // TypeError 
1
2
3
4
5
6