JavaScript(十一)理解对象

802 阅读6分钟

合并对象

合并(merge)对象,就是把源对象所有的本地属性复制到目标对象上。这种操作也被称为“混入”(mixin)。

Object.assign()

接收两个(或以上)参数:目标对象,一个或多个源对象。

每个源对象的可枚举(Object.propertyIsEnumerable()返回 true)和自有(Object.hasOwnProperty()返回 true)属性复制到目标对象。

以字符串和符号为键的属性会被复制。对于符合条件的属性,使用源对象上的[[Get]]取得属性的值,使用目标对象上的[[Set]]设置属性的值。

let dest = {};
let src = {
  id: "src",
};

let result = Object.assign(dest, src);

console.log(dest); //{ id: 'src' }

// 虽然合并后值相同,但两个对象不等价
console.log(dest !== src); // true

// 但是result被认为和dest等价
console.log(result === dest); //true

面对多个源对象:

let dest = {};
let result = Object.assign(dest, { a: "apple" }, { b: "pear" });
console.log(result); // { a: 'apple', b: 'pear' }

对于获取函数与设置函数进行合并时就会发生问题:

let dest = {
  year_: 0,
  set setYear(val) {
    this.year_ = val;
  },
  get gettingYear() {
    return this.year_;
  },
};

let src = {
  year_: 0,
  get getYear() {
    return this.year_;
  },
};

Object.assign(dest, src);
console.log(dest); // { year_: 0, setYear: [Setter], gettingYear: [Getter], getYear: 0 }
console.log(dest.getYear); // 0

dest.setYear = 2000;
console.log(dest); // { year_: 2000, setYear: [Setter], gettingYear: [Getter], getYear: 0 }
console.log(dest.getYear); // 0
console.log(dest.gettingYear); // 2000

合并进去的访问器属性 getYear,只能获取 src 内部的 year_。即使合并到 dest 后成为 dest 的属性,对于 dest 中的 year_的数值变化,并不敏感。

覆盖属性

相同名称的属性在合并时,会被后来居上的覆盖。

dest = { id: "dest" };
result = Object.assign(
  dest,
  { id: "src1", a: "foo" },
  { id: "src2", b: "bar" }
);
// Object.assign 会覆盖重复的属性
console.log(result); // { id: src2, a: foo, b: bar }

// 可以通过目标对象上的设置函数观察到覆盖的过程:
dest = {
  set id(x) {
    console.log(x);
  },
};
Object.assign(dest, { id: "first" }, { id: "second" }, { id: "third" });
// first
// second
// third

这里其实也出现了设置函数这个特性的另一个使用办法,用来监听属性的变化。

对象合并其实是一个浅复制

dest = {};
src = { a: {} };
Object.assign(dest, src);
// 浅复制意味着只会复制对象的引用
console.log(dest); // { a :{} }
console.log(dest.a === src.a); // true

这或许可以解答之前我遇到的问题。

合并时出错怎么办

如果赋值期间出错,则操作会中止并退出,同时抛出错误。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 }

对象的相等判定

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

// 这些是===符合预期的情况
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 6 新增了 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

要检查超过两个值,递归地利用相等性传递即可:

function recursivelyCheckEqual(x, ...rest) {
  return (
    Object.is(x, rest[0]) && (rest.length < 2 || recursivelyCheckEqual(...rest))
  );
}

增强的对象语法

属性值简写

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

let name = "Matt";
let person = {
  name: name,
};
console.log(person); // { name: 'Matt' }

简写属性名只要使用变量名就会被解释为同名的属性键。如果没有找到同名变量,则会抛出 ReferenceError。下面的代码与上面的代码等价:

let name = "Matt";
let person = {
  name,
};
console.log(person); // { name: 'Matt' }

可计算属性

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

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 表达式而不是字符串来求值:

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 表达式求值。在实例化时再求值:

const nameKey = 'name';
const ageKey = 'age';
const jobKey = 'job';
let uniqueToken = 0;
function getUniqueKey(key) {
  return `${key}_${uniqueToken++}`;
}
let person = {
  [getUniqueKey(nameKey)]: 'Matt', 9 [getUniqueKey(ageKey)]: 27,
  [getUniqueKey(jobKey)]: 'Software engineer'
};
console.log(person); // { name_0: 'Matt', age_1: 27, job_2: 'Software engineer' }

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

简写方法名

一般情况下,给对象添加一个方法需要这样做:

let person = {
  sayName: function (name) {
    consolo.log(name);
  },
};

而其实现在使用的都是简写的写法,可以变成:

let person = {
  sayName(name) {
    console.log(name);
  },
};

简写方法名对获取函数和设置函数也是适用的。

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

对象解构

对象解构语法,可以在一条语句中使用嵌套数据实现一个或多个赋值操作。也就是使用与对象匹配的解构来实现对象属性赋值。

首先不适用对象解构:

let person = {
  name: "Matt",
  age: 27,
};

let personName = person.name,
  personAge = person.age;

从而将 person 中的 name 和 age 分别赋值给 personName 和 personAge。

使用对象解构的话,我们可以:

let person = {
  name: "Matt",
  age: 27,
};

let { name: personName, age: personAge } = person;

如果想直接命名和对象内属性一样名字的变量名,甚至可以省略成:

let person = {
  name: "Matt",
  age: 27,
};

let { name, age } = person;
console.log(name); // Matt
console.log(job); // undefined

也可以在解构的过程中添加自定义值:

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

下面这块内容有点奇怪 解构在内部使用函数 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

前两个例子其实我没有太看懂,书里也没说清楚。总之记住 null 和 undefined 不能被解构就行了。

解构并不要求变量必须在解构表达式中声明。不过,如果是给事先声明的变量赋值,则赋值表达式必须包含在一对括号中:

let personName, personAge;
let person = {
  name: "Matt",
  age: 27,
};
({ name: personName, age: personAge } = person);

嵌套解构

解构对于引用嵌套的属性或赋值目标没有限制。为此,可以通过解构来复制对象属性:

let person = {
  name: "Matt",
  age: 27,
  job: {
    title: "Software engineer",
  },
};
let personCopy = {};
({ name: personCopy.name, age: personCopy.age, job: personCopy.job } = person);
// 因为一个对象的引用被赋值给personCopy,所以修改
// person.job 对象的属性也会影响personCopy

下面的赋值感觉过于花哨,也许有些前端面试官比较有个人特色会问一下。但我感觉有点舍近求远没必要。

解构赋值可以使用嵌套结构,以匹配嵌套的属性:

let person = {
  name: "Matt",
  age: 27,
  job: {
    title: "Software engineer",
  },
};
// 声明title 变量并将person.job.title 的值赋给它
let {
  job: { title },
} = person;
console.log(title); // Software engineer

在外层属性没有定义的情况下不能使用嵌套解构。无论源对象还是目标对象都一样:

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

部分解构

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

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 对象,但可以在函数签名中声明在函数体内使用局部变量:

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