首页 阅读DAY10 JavaScript高级程序设计 8章上 对象、类与面向对象编程
文章
取消

阅读DAY10 JavaScript高级程序设计 8章上 对象、类与面向对象编程

开始阅读JavaScript高级程序设计(第5版)学习JS,总共有1000+页,非常全面,短期看完不太现实,找到了一篇博客,花些时间跟着这篇博客过一下红宝书。

JavaScript高级程序设计(第5版)微信读书

红宝书《JavaScript高级程序设计(第5版)》学习大纲 - 大前端全栈开发 - SegmentFault 思否

理解对象:

创建自定义对象的通常方式是创建Object的一个新实例,然后再给它添加属性和方法,如下例所示:

1
2
3
4
5
6
7
8
let person = new Object();
person.name = "Alice";
person.age = 29;
person.job = "Software Engineer";

person.sayName = function() {
  console.log(this.name);
};

这个例子创建了一个名为person的对象,而且有三个属性(name、age和job)和一个方法(sayName())。sayName()方法会显示this.name的值,这个属性会解析为person.name。

早期JavaScript开发者频繁使用这种方式创建新对象。几年后,对象字面量变成了更流行的方式。前面的例子如果使用对象字面量则可以这样写:

1
2
3
4
5
6
7
8
let person = {
  name: "Alice",
  age: 29,
  job: "Software Engineer",
  sayName() {
    console.log(this.name);
  }
};

这个例子中的person对象跟前面例子中的person对象是等价的,它们的属性和方法都一样。这些属性都有自己的特征,而这些特征决定了它们在JavaScript中的行为。

属性和方法看似平级,但实际上方法也是把函数作为值赋给了属性键,只是ES6的语法糖简写让键名和函数写法合一了。

ES5 及之前:显式赋值

1
2
3
4
5
6
7
8
9
10
11
12
// 函数作为值赋给属性
const obj = {
  // 方法1:函数表达式赋值
  sayHello: function() {
    return "你好!";
  },
  
  // 方法2:函数声明赋值
  sayBye: function sayByeFunc() {
    return "再见!";
  }
};

ES6 方法简写:语法糖

1
2
3
4
5
6
7
8
9
10
11
// ES6 方法简写
const obj = {
  // 看起来像"定义方法",实际上是语法糖
  sayHello() {
    return "你好!";
  },
  
  sayBye() {
    return "再见!";
  }
};

3箭头函数赋值

1
2
3
4
5
6
7
8
// 箭头函数也是值
const obj = {
  // 箭头函数赋值
  sayHello: () => "你好!",
  
  // 普通函数赋值
  sayBye: () => "再见!"
};

属性的类型:

ECMA-262 使用内部特性来描述属性的特征。这些特性由 JavaScript 引擎实现,开发者不能直接访问,规范用双中括号标记,如 [[Enumerable]]

属性分两种:数据属性访问器属性

  • 数据属性:自己存值。4个特性 → [[Value]][[Writable]][[Enumerable]][[Configurable]]
  • 访问器属性:不存值,通过 getter/setter 读写。4个特性 → [[Get]][[Set]][[Enumerable]][[Configurable]]

两者共享 [[Enumerable]][[Configurable]],区别在于数据属性有 [[Value]] + [[Writable]],访问器属性有 [[Get]] + [[Set]]

1
2
3
4
5
6
7
8
const obj = {
 // 数据属性——直接存值
 name: 'Tom',

 // 访问器属性——通过函数读写
 get fullName() { return this.name + ' Smith'; },
 set fullName(val) { this.name = val.split(' ')[0]; }
};

虽然不能直接读取内部特性,但可以通过 Object.getOwnPropertyDescriptor() 间接查看:

1
2
Object.getOwnPropertyDescriptor(obj, 'name');
// { value: 'Tom', writable: true, enumerable: true, configurable: true }

数据属性:

数据属性包含一个保存数据值的位置,有4个特性描述其行为:

特性含义直接定义时的默认值
[[Configurable]]能否通过 delete 删除、修改特性、改为访问器属性true
[[Enumerable]]能否通过 for-in 循环返回true
[[Writable]]属性值能否被修改true
[[Value]]属性实际的值,读写都发生在这个位置undefined

直接在对象上定义属性时,前三个特性默认为 true[[Value]] 设为指定的值:

1
2
let person = { name: "Alice" };
// [[Value]] = "Alice",其余三个特性均为 true

Object.defineProperty() 修改特性:

要修改属性的默认特性,必须使用 Object.defineProperty(),接收3个参数:对象、属性名、描述符对象。描述符的属性名与特性名一一对应(去掉双中括号,全小写)。

设为只读writable: false):

1
2
3
4
5
6
7
let person = {};
Object.defineProperty(person, "name", {
 writable: false,
 value: "Alice"
});
person.name = "Greg"; // 非严格模式:静默忽略;严格模式:抛出错误
console.log(person.name); // "Alice"

设为不可配置configurable: false):

1
2
3
4
5
6
7
let person = {};
Object.defineProperty(person, "name", {
 configurable: false,
 value: "Alice"
});
delete person.name; // 非严格模式:无效果;严格模式:抛出错误
console.log(person.name); // "Alice"

不可配置是单向操作,一旦设为 false,就不能再变回 true,再次调用 Object.defineProperty() 修改任何非 writable 属性会抛出错误:

1
2
3
4
// 抛出错误——不可配置的属性不能再变回可配置
Object.defineProperty(person, "name", {
 configurable: true
});

defineProperty 的默认值不同:

直接在对象上定义属性,configurable/enumerable/writable 默认为 true。但通过 Object.defineProperty() 定义时,不指定的特性默认为 false。这是最常见的坑:

1
2
Object.defineProperty(person, "name", { value: "Alice" });
// 等同于 { value: "Alice", writable: false, enumerable: false, configurable: false }

多数情况下,可能都不需要Object.defineProperty()提供的这些强大的设置,但要理解JavaScript对象,就要理解这些概念。

访问器属性:

访问器属性不包含数据值,而是包含 getter 和 setter 函数(非必需)。读取时调用 getter 返回值,写入时调用 setter 并传入新值,由 setter 决定如何修改数据。4个特性:

特性含义默认值
[[Configurable]]能否删除、修改特性、改为数据属性true
[[Enumerable]]能否通过 for-in 返回true
[[Get]]获取函数,读取属性时调用undefined
[[Set]]设置函数,写入属性时调用undefined

[[Configurable]][[Enumerable]] 与数据属性含义一致,区别在于访问器属性没有 [[Value]][[Writable]],取而代之的是 [[Get]][[Set]]

访问器属性不能直接定义,必须使用 Object.defineProperty()。以下是一个典型场景:设置 year 时自动更新 edition

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let book = {
    year_: 2023, // 下划线前缀表示"伪私有",不希望外部直接访问
    edition: 1
};

Object.defineProperty(book, "year", {
    get() { 
        return this.year_; 
    },
    set(newValue) {
        if (newValue > 2023) {
            this.year_ = newValue;
            this.edition += newValue - 2023;
        }
    }
});

book.year = 2024;
console.log(book.edition); // 2

year 本身不存值,getter 从 year_ 读取,setter 在修改 year_ 的同时联动更新了 edition。这就是访问器属性的核心用途,可以在读写属性时触发额外逻辑。

getter/setter 的单独使用:

getter 和 setter 不必同时定义。只定义 getter 意味着属性只读,尝试写入会被忽略(严格模式抛出错误);只定义 setter 则属性不可读,读取返回 undefined(严格模式抛出错误)。

访问器属性的本质:不存值,通过函数控制读写。和数据属性相比,数据属性直接存值,访问器属性通过函数间接读写,适合需要在读写时执行额外逻辑的场景。

在不支持Object.defineProperty()的浏览器中没有办法修改 [[Configurable]] 或[[Enumerable]]。

访问对象属性:

要读取对象属性,可以使用点号或方括号语法。点号是最常见也是最直观的方式,需要先写出对象然后加上点号(.)再写出属性名:

1
2
3
4
5
6
7
const person = {
  name: "Alice",
  age: 30
};

console.log(person.name); // Alice
console.log(person.age); // 30

此外,也可以使用方括号,传入字符串形式的属性名:

1
2
console.log(person["name"]); // Alice
console.log(person["age"]); // 30

这两种方式的结果相同,但方括号适合属性名需要动态确定或者包含特殊字符或空格的情形。不过,静态代码分析工具不一定总认为person.name和person[“name”] 是一样的,因此推荐使用点号语法

连缀属性:

在一个对象中嵌套另一个对象在JavaScript编程中是司空见惯的。从父对象访问子对象的属性非常简单,只要连续写出属性名即可,这称为属性链。比如下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
const person = {
  name: "Alice",
  address: {
    city: "Chicago",
    street: "1060 W Addison St"
  }
};

console.log(person.address.city);       // Chicago
console.log(person.address.postalCode); // undefined

console.log(person.address.postalCode.length);
// TypeError: Cannot read property 'length' of undefined

在这个例子中,通过连缀属性可以轻松访问子对象address的属性。这就相当于以下逻辑:

1
2
3
const person = { ... };
const address = person.address;
console.log(address.city); // Chicago

连缀属性非常适合嵌套对象结构完整的情形,但如果某个中间对象不存在就会出问题,就像上面示例最后一行所展示的:抛出了TypeError。为避免这个问题,需要检查属性链中涉及的每个对象是否存在,结果代码可能会非常冗长

1
2
3
4
5
6
const person = { ... };
if (person.address) {
  if (person.address.postalCode) {
    console.log(person.address.postalCode.length);
  }
}

为简化这种逻辑,可以使用可选连缀操作符,即在想要访问的属性名后面加上问号(?.)。这样如果属性对应的对象不存在,即访问链中相应部分是undefined或null,则求值就会短路并返回undefined:

1
2
3
4
5
console.log(person.address?.postalCode?.length);      // undefined

// 只要可选连缀的属性发生短路
// 就不再对操作符右侧表达式求值
console.log(person.address?.postalCode?.foo.bar.baz); // undefined

要注意,可选连缀操作符只会短路属性链中特定的部分:

1
2
3
4
5
console.log(person.address.postalCode?.length);
// undefined

console.log(person.address?.postalCode.length);
// TypeError: Cannot read property 'length' of undefined

对象静态方法:

Object类提供了非常多的静态方法,用于检视和操作对象。因为JavaScript中所有的非原始值都继承Object,所以这些方法可以用于任何对象。下表总结了这些方法并简单描述了每个方法的行为。

image-20260429203719536

控制对象是否可修改:

Object提供了控制和操作对象可修改能力的静态方法。开发者可以冻结对象,让对象完全不可修改,决定对象是否可以被添加新属性,也可以封存对象以防止添加和删除属性,但允许修改属性。

冻结对象:

Object.freeze()方法主要用于冻结对象,把对象变成完全不可修改的状态。被冻结的对象变得不能扩展,其全部已有属性变得不能配置。对象被冻结后,不能再添加新属性,已有属性也不能被修改或删除,对象的原型也不能改变。对冻结对象的任何修改操作都会导致错误或失败。可以使用Object.isFrozen()检测对象是否被冻结。

1
2
3
4
5
6
7
8
9
10
11
12
const person = {
  name: "Alice",
  age: 30
};

console.log(Object.isFrozen(person)); // false 
Object.freeze(person);
console.log(Object.isFrozen(person)); // true

person.name = "Bob";
// 非格式模式下会被忽略
// 严格模式下会抛出错误

冻结不能撤销,是一个永久性不可逆操作。

冻结仅限于被冻结对象的直接属性。如果其中某个属性的值是对象,则该对象的属性仍然是可以修改的。要想“深度冻结”嵌套的对象,必须递归冻结其所有非原始值属性。

封存对象:

Object.seal()方法提供了封存对象的途径,让对象变得不可扩展,并将其全部已有属性标记为不可配置。封存对象可以阻止对属性的添加或删除,同时仍然允许修改已有属性的值。对象被封存后,不能再添加新属性,但仍然可以修改已有属性的值。可以使用Object.isSealed()检测对象是否被封存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const person = {
  name: "Alice",
  age: 30
};

console.log(Object.isSealed(person)); // false 
Object.seal(person);
console.log(Object.isSealed(person)); // true

person.name = "Bob"; // 封存后仍然允许修改已有属性
person.height = "6 feet";
// 非格式模式下会被忽略
// 严格模式下会抛出错误

delete person.age;
// 非格式模式下会被忽略
// 严格模式下会抛出错误

封存对象不会限制对已有属性值的修改,但会阻止添加新的属性和删除已有的属性。相对于使用Object.freeze()冻结对象,封存相对宽松一些。

控制可扩展能力:

Object.preventExtensions()方法用于将对象设置为不可扩展,也就是不能添加新属性。默认情况下,JavaScript对象都是可以扩展的,也就是可以添加新属性。可以使用Object.isExtensible()方法检测对象是否可扩展。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const person = {
  name: "Alice",
  age: 30
};

console.log(Object.isExtensible(person)); // true 
Object.preventExtensions(person);
console.log(Object.isExtensible(person)); // false

person.name = "Bob"; // 仍然允许修改已有的属性
person.gender = "Female";
// 非格式模式下会被忽略
// 严格模式下会抛出错误

delete person.age;
// 非格式模式下会被忽略
// 严格模式下会抛出错误

相较于Object.seal(),它不会将已有属性标记为不可配置,因此还允许删除已有属性。

定义多个属性:

要在一个对象上同时定义多个属性,可以使用ECMAScript提供的Object.defineProperties()方法。这个方法可以通过多个描述符一次性定义多个属性。

它接收两个参数:要为之添加或修改属性的对象和另一个描述符对象,其属性与要添加或修改的属性一一对应。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let book = {};
Object.defineProperties(book, {
  year_: {
    value: 2023
  },

  edition: {
    value: 1
  },

  year: {
    get() {
      return this.year_;
    },

    set(newValue) {
      if (newValue > 2023) {
        this.year_ = newValue;
        this.edition += newValue - 2023;
      }
    }
  }
});

这段代码在book对象上定义了两个数据属性year_ 和edition,还定义了一个访问器属性year。最终的对象跟上一节示例中的一样,并且数据属性的configurable、enumerable和writable特性值都是false。

读取属性的特性:

使用Object.getOwnPropertyDescriptor()方法可以取得指定属性的属性描述符。

这个方法接收两个参数:属性所在的对象和要取得其描述符的属性名。返回值是一个对象,对于访问器属性包含configurable、enumerable、get和set属性,对于数据属性包含configurable、enumerable、writable和value属性:

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
let book = {};
Object.defineProperties(book, {
  year_: {
    value: 2023
  },

  edition: {
    value: 1
  },

  year: {
    get: function() {
      return this.year_;
    },

    set: function(newValue){
      if (newValue > 2023) {
        this.year_ = newValue;
        this.edition += newValue - 2023;
      }
    }
  }
});

let descriptor = Object.getOwnPropertyDescriptor(book, "year_");
console.log(descriptor.value);          // 2023
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"

对于数据属性year_,value等于原来的值,configurable是false,get是undefined。对于访问器属性year,value是undefined,enumerable是false,get是一个指向获取函数的指针。

Object.getOwnPropertyDescriptors()静态方法实际上会在每个自有属性上调用Object.getOwnPropertyDescriptor()并在一个新对象中返回它们。自有属性指的是直接在对象上定义的属性,不是从原型链上继承来的属性。

对于前面的例子,使用这个静态方法会返回如下对象:

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
40
41
42
43
44
45
let book = {};
Object.defineProperties(book, {
  year_: {
    value: 2023
  },

  edition: {
    value: 1
  },

  year: {
    get: function() {
      return this.year_;
    },

    set: function(newValue){
      if (newValue > 2023) {
        this.year_ = newValue;
        this.edition += newValue - 2023;
      }
    }
  }
});

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
//   }
// }

合并对象:

Object.assign() 将一个或多个源对象的可枚举(propertyIsEnumerable() 返回 true)且自有(hasOwnProperty() 返回 true)属性复制到目标对象。字符串和符号为键的属性会被复制。复制过程通过源对象的 [[Get]] 取值,再通过目标对象的 [[Set]] 赋值。

基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
let dest, src, result;

// 简单复制——修改目标对象,同时返回它
dest = {};
src = { id: 'src' };
result = Object.assign(dest, src);
console.log(dest === result); // true
console.log(result); // { id: 'src' }

// 多个源对象
dest = {};
result = Object.assign(dest, { a: 'foo' }, { b: 'bar' });
console.log(result); // { a: 'foo', b: 'bar' }

Object.assign()的行为大概有下面四个特性:

1. 浅复制,只复制引用,不复制嵌套对象:

1
2
3
4
dest = {};
src = { a: {} };
Object.assign(dest, src);
console.log(dest.a === src.a); // true,指向同一个对象

2. 同名属性,后者覆盖前者

1
2
3
dest = { id: 'dest' };
result = Object.assign(dest, { id: 'src1', a: 'foo' }, { id: 'src2', b: 'bar' });
console.log(result); // { id: 'src2', a: 'foo', b: 'bar' }

3. getter/setter 不会转移:源对象访问器属性的值通过 getter 取出后,作为静态值赋给目标对象,访问器本身不会被复制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 的 getter → 调用 dest 的 setter 并传入 "foo"
// 但 setter 没有执行赋值,所以值并未转移
console.log(dest); // { set a(val) {...} }

4. 出错即中止,不回滚Object.assign() 是尽力而为的方法,赋值过程中出错会中止,但已完成的修改不会回滚:

1
2
3
4
5
6
7
8
9
10
11
12
dest = {};
src = {
  a: 'foo',
  get b() { throw new Error(); }, // 复制到这个属性时抛出错误
  c: 'bar'
};

try {
  Object.assign(dest, src);
} catch(e) {}

console.log(dest); // { a: 'foo' }——b 之前的修改保留,c 未复制

Object.assign() 是浅复制、后者覆盖、getter/setter 不转移、出错不回滚。深合并需要自己实现或用其他库。

对象标识及相等判定:

在某些边界情况下,=== 操作符会表现出不符合预期的行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 这些是===符合预期的情况
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

为解决这类情况,ECMAScript定义了Object.is(),这个方法与 === 很像,但同时也考虑到了上述边界情形。这个方法必须接收两个参数:

1
2
3
4
5
6
7
8
9
10
11
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
function recursivelyCheckEqual(x, ...rest) {
  return Object.is(x, rest[0]) &&
         (rest.length < 2 || recursivelyCheckEqual(...rest));
}

增强的对象语法:

ECMAScript为定义和操作对象提供了很多极其有用的语法糖特性。这些特性都没有改变现有引擎的行为,但极大地提升了处理对象的方便程度(对象语法糖大合集)。

本节介绍的所有对象语法同样适用于ECMAScript的类。

相比以往的替代方案,本节介绍的增强对象语法更加简洁,表达力更强。因此本章及本书会默认使用这些新语法特性。

属性值简写:

在给对象添加变量的时候,开发者经常会发现属性名和变量名是一样的。

1
2
3
4
5
6
7
let name = 'Matt';

let person = {
  name: name
};

console.log(person); // { name: 'Matt' }

为此,简写属性值语法出现了。简写属性值只要使用变量名(不用再写冒号)就会自动被解释为同名的属性键。如果没有找到同名变量,则会抛出ReferenceError。

1
2
3
4
5
6
7
let name = 'Matt';

let person = {
  name
};

console.log(person); // { name: 'Matt' }

代码压缩程序足够聪明,能在不同作用域间保留属性名,以防止找不到引用。以下面的代码为例:

1
2
3
4
5
6
7
8
9
function makePerson(name) {
  return { // 函数返回一个对象
    name // ES6 简写语法,等价于 { name: name }
  };
}

let person = makePerson('Matt');

console.log(person.name);  // Matt

在这里,即使参数标识符只限定于函数作用域,编译器也会保留初始的name标识符。

比如,如果使用Google Closure编译器压缩,那么函数参数会被缩短,而属性名不变,压缩的例子会如下:

1
2
3
4
5
6
7
8
9
function makePerson(a) { // 参数被重命名为: a
  return {
    name: a // 显式赋值,属性名不变
  };
}

var person = makePerson("Matt");

console.log(person.name); // Matt

可计算属性:

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

1
2
3
4
5
6
7
8
9
10
const nameKey = 'name';
const ageKey = 'age';
const jobKey = 'job';

let person = {};
person[nameKey] = 'Matt';
person[ageKey] = 27;
person[jobKey] = 'Software engineer';

console.log(person); // { name: 'Matt', age: 27, job: 'Software engineer' }

中括号语法:计算括号内的表达式,将结果作为属性名来访问对象。

有了可计算属性,就可以在对象字面量中完成动态属性赋值中括号包围的对象属性键告诉运行时将其作为JavaScript表达式而不是字符串来求值

1
2
3
4
5
6
7
8
9
10
11
const nameKey = 'name';
const ageKey = 'age';
const jobKey = 'job';

let person = {
  [nameKey]: 'Matt',
  [ageKey]: 27,
  [jobKey]: 'Software engineer'
};

console.log(person); // { name: 'Matt', age: 27, job: 'Software engineer' }

因为被当作JavaScript表达式求值,所以可计算属性本身可以是复杂的表达式,在实例化时再求值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const nameKey = 'name';
const ageKey = 'age';
const jobKey = 'job';
let uniqueToken = 0;

function getUniqueKey(key) {
  return `${key}_${uniqueToken++}`;
}

let person = {
  [getUniqueKey(nameKey)]: 'Matt',
  [getUniqueKey(ageKey)]: 27,
  [getUniqueKey(jobKey)]: 'Software engineer'
};

console.log(person);  // { name_0: 'Matt', age_1: 27, job_2: 'Software engineer' }

可计算属性表达式中抛出任何错误都会中断对象创建。如果计算属性的表达式有副作用,那就要小心了,因为如果表达式抛出错误,那么之前完成的计算是不能回滚的。

简写方法名:

本文开头就有出现过。

在给对象定义方法时,通常都要写一个方法名、冒号,然后再引用一个匿名函数表达式,如下所示:

1
2
3
4
5
6
7
let person = {
  sayName: function(name) {
    console.log(`My name is ${name}`);
  }
};

person.sayName('Matt'); // My name is Matt

新的简写方法的语法遵循同样的模式,但开发者要放弃给函数表达式命名(不过给作为方法的函数命名通常没什么用)。相应地,这样也可以明显缩短方法声明。

以下代码使用了简写语法:

1
2
3
4
5
6
7
let person = {
  sayName(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
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

简写方法名与可计算属性键可以一起使用:

1
2
3
4
5
6
7
8
9
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
let person = { name: 'Matt', age: 27 };

// 不使用解构
let personName = person.name, personAge = person.age;

// 使用解构——等价写法
let { name: personName, age: personAge } = person;

简写语法:

想让变量直接使用属性名时,可以省略冒号和别名:

1
2
3
let { name, job } = person;
console.log(name); // Matt
console.log(job); // undefined——属性不存在则为 undefined

默认值:

引用的属性不存在时,可以提供默认值:

1
2
let { name, job = 'Software engineer' } = person;
console.log(job); // Software engineer

原始值的解构:

解构内部使用 ToObject() 将源数据转换为对象,因此原始值也会被当成对象处理。但 nullundefined 不能被解构。ToObject()nullundefined 没有定义,没法把它们包装成对象,所以直接抛 TypeError:

解构不只能用于对象,原始值也能解构,因为引擎会自动把原始值包装成对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 字符串 → String 包装对象
let { length } = 'foobar';
// 引擎内部:ToObject('foobar') → new String('foobar')
// String 对象有 length 属性 → 6
console.log(length); // 6

// 数字 → Number 包装对象
let { constructor: c } = 4;
// 引擎内部:ToObject(4) → new Number(4)
// Number 对象继承自 Number.prototype,有 constructor 属性
// Number.prototype.constructor === Number → true
console.log(c === Number); // true

let { _ } = null; // TypeError
let { _ } = undefined; // TypeError

给事先声明的变量赋值:

解构不要求变量必须在表达式中声明,但给已声明的变量赋值时,整个赋值表达式必须用括号包裹,否则花括号会被解析为代码块:

1
2
3
4
5
let personName, personAge;
let person = { name: 'Matt', age: 27 };

({ name: personName, age: personAge } = person);
console.log(personName, personAge); // Matt, 27

嵌套解构:

解构可以复制对象属性,也可以匹配嵌套结构。

复制属性,但注意是浅复制,嵌套对象只复制引用:

1
2
3
4
5
6
7
8
9
10
11
let person = {
 name: 'Matt', age: 27,
 job: { title: 'Software engineer' }
};
let personCopy = {};
 // 因为一个对象的引用被赋值给personCopy,所以修改
 // person.job对象的属性也会影响personCopy 
({ name: personCopy.name, age: personCopy.age, job: personCopy.job } = person);

person.job.title = 'Hacker';
console.log(personCopy.job.title); // Hacker——共享同一个 job 对象

匹配嵌套属性直接解构到内层:

1
2
3
4
5
6
7
8
9
10
11
12
let person = {
  name: 'Matt',
  age: 27,
  job: {
    title: 'Software engineer'
  }
};

// 声明title变量并将person.job.title的值赋给它
let { job: { title } } = person;

console.log(title); // Software engineer

外层属性不存在时不能嵌套解构,无论源对象还是目标对象,中间某一层为 undefined 就会报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let person = {
  job: {
    title: 'Software engineer'
  }
};
let personCopy = {};

// foo在源对象上是undefined
({
  foo: {
    bar: personCopy.bar
  }
} = person);
// TypeError: Cannot destructure property 'bar' of 'undefined' or 'null'.

// job在目标对象上是undefined
({
  job: {
    title: personCopy.job.title
  }
} = person);
// TypeError: Cannot set property 'title' of undefined

部分解构:

需要注意的是,涉及多个属性的解构赋值是一个输出无关的顺序化操作。如果一个解构表达式涉及多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let person = {
  name: 'Matt',
  age: 27
};

let personName, personBar, personAge;

try {
  // person.foo是undefined,因此会抛出错误
  ({name: personName, foo: { bar: personBar }, age: personAge} = person);
} catch(e) {}

console.log(personName, personBar, personAge);
// Matt, undefined, undefined

参数上下文匹配:

在函数参数列表中也可以进行解构赋值。对参数的解构赋值不会影响arguments对象,但可以在函数签名中声明在函数体内使用局部变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let person = {
  name: 'Matt',
  age: 27
};

function printPerson(foo, {name, age}, bar) {
  console.log(arguments);
  console.log(name, age);
}

function printPerson2(foo, {name: personName, age: personAge}, bar) {
  console.log(arguments);
  console.log(personName, personAge);
}

printPerson('1st', person, '2nd');
// ['1st', { name: 'Matt', age: 27 }, '2nd']
// 'Matt', 27

printPerson2('1st', person, '2nd');
// ['1st', { name: 'Matt', age: 27 }, '2nd']
// 'Matt', 27

剩余操作符:

在重新构造对象时,可以使用剩余操作符把所有未明确列出的可枚举属性都收集到一个对象中。来看下面的例子:

1
2
3
4
5
6
7
8
9
const person = {
  name: 'Matt',
  age: 27,
  job: 'Engineer'
};
const { name, ...remainingData } = person;

console.log(name); // Matt
console.log(remainingData); // { age: 27, job: 'Engineer' }

在每个对象字面量中,最多只能使用一次剩余操作符,而且必须放在最后面。因为每个对象字面量只能用一个剩余操作符,所以就有了嵌套剩余操作符的可能。在嵌套的时候,因为不存在把某个属性子树的元素分配到任意指定剩余操作符的可能,所以得到的对象永远不会出现内容重叠的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const person = {
  name: 'Matt',
  age: 27,
  job: {
    title: 'Engineer',
    level: 10
  }
};

const { name, job: { title, ...remainingJobData }, ...remainingPersonData } = person;

console.log(name);                 // Matt
console.log(title);                // Engineer
console.log(remainingPersonData);  // { age: 27 }
console.log(remainingJobData);     // { level: 10 }

const { ...a, job } = person;
// SyntaxError: Rest element must be last element

剩余操作符在对象间执行浅拷贝,因此对象的引用会被拷贝,而非克隆整个对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
const person = {
  name: 'Matt',
  age: 27,
  job: {
    title: 'Engineer',
    level: 10
  }
};

const { ...remainingData } = person;

console.log(person === remainingData); // false
console.log(person.job === remainingData.job); // true

剩余操作符会拷贝所有可枚举的自有属性,包括符号:

1
2
3
4
5
6
7
const s = Symbol();
const foo = { a: 1, [s]: 2, b: 3 };

const {a, ...remainingData} = foo;

console.log(remainingData);
// { b: 3, Symbol(): 2 }

扩展操作符:

扩展操作符可以让我们把两个对象以类似数组拼接的方式组合到一起。应用到内部对象的扩展操作符会将所有可枚举的自有属性(包括符号)浅拷贝到外部对象:

1
2
3
4
5
6
7
8
const s = Symbol();
const foo = { a: 1 };
const bar = { [s]: 2 };

const foobar = {...foo, c: 3, ...bar};

console.log(foobar);
// { a: 1, c: 3, [Symbol()]: 2 }

扩展对象列出的顺序很重要,主要有两个原因。

对象会记录插入顺序。从扩展对象拷贝出来的属性将按照它们在对象字面量中被列出的顺序执行赋值。

对象会在遇到重名时覆盖属性。后出现的属性将覆盖先出现的属性。

下面的代码示例展示了顺序的重要性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const foo = { a: 1 };
const bar = { b: 2 };

const foobar = { c: 3, ...bar, ...foo };

console.log(foobar);
// { c: 3, b: 2, a: 1 }

const baz = { c: 4 };

const foobarbaz = { ...foo, ...bar, c: 3, ...baz };

console.log(foobarbaz);
// { a: 1, b: 2, c: 4 }

与剩余操作符一样,所有拷贝都是浅拷贝:

1
2
3
4
5
6
const foo = { a: 1 };
const bar = { b: 2, c: { d: 3 } };

const foobar = {...foo, ...bar};

console.log(foobar.c === bar.c); // true
本文由作者按照 CC BY 4.0 进行授权

阅读DAY9 JavaScript高级程序设计 7章下 迭代器与生成器

阅读DAY11 JavaScript高级程序设计 8章中 对象、类与面向对象编程