移除对象属性对我们理解 JavaScript 的启示

109 阅读12分钟

一群参赛者被要求完成以下任务:

使 object1 与 object2 类似。

let object1 = {
  a: "hello",
  b: "world",
  c: "!!!",
};

let object2 = {
  a: "hello",
  b: "world",
};

看起来很简单,对吧?只需简单地删除属性 c 以匹配 object2。令人惊讶的是,每个人描述了不同的解决方案:

参赛者 A:“我将 c 设置为 undefined。”
参赛者 B:“我使用了 delete 操作符。”
参赛者 C:“我通过代理对象删除了属性。”
参赛者 D:“我通过使用对象解构避免了变异。”
参赛者 E:“我使用了 JSON.stringifyJSON.parse。”
参赛者 F:“我们公司依赖于 Lodash。”

给出的答案非常多,而且它们看起来都是有效的选项。那么,谁是“正确”的?让我们剖析每种方法。

参赛者 A:“我将 c 设置为 undefined。”

在 JavaScript 中,访问一个不存在的属性会返回 undefined。

const movie = {
  name: "Up",
};

console.log(movie.premiere); // undefined

人们很容易认为将属性设置为 undefined 会将其从对象中移除。但如果我们尝试这样做,我们会注意到一个小但重要的细节:

const movie = {
  name: "Up",
  premiere: 2009,
};

movie.premiere = undefined;

console.log(movie);

得到的输出是:

{name: 'up', premiere: undefined}

如你所见,即使被设置为 undefined,premiere 属性仍然存在于对象中。这种方法实际上并没有删除属性,而是改变了它的值。我们可以使用 hasOwnProperty() 方法来确认:

const propertyExists = movie.hasOwnProperty("premiere");

console.log(propertyExists); // true

但在我们的第一个示例中,如果对象中不存在属性,为什么访问 object.premiere 会返回 undefined?它不应该像访问一个不存在的变量时那样抛出错误吗?

console.log(iDontExist);

// Uncaught ReferenceError: iDontExist is not defined

答案在于 ReferenceError 的行为方式以及引用首先是什么。

引用是一个解析后的名称绑定,它指示一个值存储在哪里。它由三个组成部分构成:一个基值、一个被引用的名称和一个严格的引用标志。

对于 user.name 引用,基值是对象 user,被引用的名称是字符串 name,如果代码不在严格模式下,严格的引用标志为 false。

变量的行为不同。它们没有父对象,因此它们的基值是一个环境记录,即每次执行代码时分配给一个独特的基值。

如果我们尝试访问没有基值的东西,JavaScript 将抛出 ReferenceError。然而,如果找到了基值,但被引用的名称没有指向一个存在的值,JavaScript 就只会简单地分配值 undefined。

Undefined 类型只有一个值,称为 undefined。任何未被赋值的变量都有值 undefined。”

——ECMAScript 规范

参赛者 B:“我使用了 delete 操作符。”

delete 操作符的唯一目的就是从对象中移除属性,如果元素被成功移除则返回 true。

const dog = {
  breed: "bulldog",
  fur: "white",
};

delete dog.fur;

console.log(dog); // {breed: 'bulldog'}

使用 delete 操作符之前,我们必须考虑一些警告。首先,delete 操作符可以用来从数组中移除元素。然而,它会在数组中留下一个空位,这可能会导致意外的行为,因为像 length 这样的属性不会被更新,仍然计算空位。

const movies = ["Interstellar", "Top Gun", "The Martian", "Speed"];

delete movies[2];

console.log(movies); // ['Interstellar', 'Top Gun', empty, 'Speed']
console.log(movies.length); // 4

第二,让我们想象以下嵌套对象:

const user = {
  name: "John",
  birthday: {day: 14, month: 2},
};

尝试使用 delete 操作符移除 birthday 属性完全没有问题,但有一个常见的误解,认为这样做会释放为对象分配的内存。

在上面的例子中,birthday 是一个包含嵌套对象的属性。JavaScript 中的对象与原始值(例如数字、字符串和布尔值)在内存中的存储方式不同。它们按引用存储和复制,而原始值则作为整体值独立复制。

以一个原始值为例,比如字符串:

let movie = "Home Alone";
let bestSeller = movie;

在这种情况下,每个变量在内存中有独立的空间。如果我们尝试重新分配它们中的一个,就会看到这种行为:

movie = "Terminator";

console.log(movie); // "Terminator"
console.log(bestSeller); // "Home Alone"

在这种情况下,重新分配 movie 不会影响 bestSeller,因为它们在内存中处于两个不同的位置。持有对象的属性或变量(例如常规对象、数组和函数)是指向单一内存空间的引用。如果我们尝试复制一个对象,我们只是复制了它的引用。

let movie = {title: "Home Alone"};
let bestSeller = movie;

bestSeller.title = "Terminator";

console.log(movie); // {title: "Terminator"}
console.log(bestSeller); // {title: "Terminator"}

如你所见,它们现在是对象,重新分配 bestSeller 属性也改变了 movie 的结果。在幕后,JavaScript 查看内存中的实际对象并执行更改,两个引用都指向已更改的对象。

了解对象按引用的行为,我们现在可以理解使用 delete 操作符为什么不释放内存空间。

编程语言释放内存的过程称为垃圾回收。在 JavaScript 中,当没有更多引用并且对象变得不可访问时,对象的内存会被释放。因此,使用 delete 操作符可能会使属性的空间有资格被回收,但可能有更多引用阻止它从内存中被删除。

也就是说,可以提出反对使用 delete 的论点,因为它会变异对象。通常,避免变异是一个好习惯,因为它们可能导致意外的行为,其中变量不包含我们假定的值。

参赛者 C:“我通过代理对象删除了属性。”

这个参赛者绝对是在炫耀,他们使用代理来回答。代理是一种在对象的常见操作之间插入一些中间逻辑的方式,比如获取、设置、定义,还有删除属性。它是通过接受两个参数的 Proxy 构造函数来工作的:

- target:我们想要创建代理的对象来源。
- handler:包含操作中间逻辑的对象。

在 handler 内部,我们为不同的操作定义方法,称为 traps,因为它们拦截原始操作并执行自定义更改。构造函数将返回一个 Proxy 对象 —— 与目标对象完全相同的对象 —— 但加上了额外的中间逻辑。

const cat = {
  breed: "siamese",
  age: 3,
};

const handler = {
  get(target, property) {
    return `cat's ${property} is ${target[property]}`;
  },
};

const catProxy = new Proxy(cat, handler);

console.log(catProxy.breed); // cat's breed is siamese
console.log(catProxy.age); // cat's age is 3

这里,handler 修改了获取操作以返回自定义值。

假设我们想在每次使用 delete 操作符时将我们正在删除的属性记录到控制台。我们可以通过使用 deleteProperty 陷阱通过代理添加此自定义逻辑。

const product = {
  name: "vase",
  price: 10,
};

const handler = {
  deleteProperty(target, property) {
    console.log(`Deleting property: ${property}`);
  },
};

const productProxy = new Proxy(product, handler);

delete productProxy.name; // Deleting property: name

属性名称记录在控制台中,但在此过程中抛出了一个错误:

Uncaught TypeError: 'deleteProperty' on proxy: trap returned falsish for property 'name'

错误被抛出是因为 handler 没有返回值。这意味着它默认为 undefined。在严格模式下,如果 delete 操作符返回 false,它将抛出错误,而 undefined 作为一个假值触发了这种行为。

如果我们尝试返回 true 来避免错误,我们将遇到一个不同的问题:

const handler = {
  deleteProperty(target, property) {
    console.log(`Deleting property: ${property}`);
    return true;
  },
};

const productProxy = new Proxy(product, handler);

delete productProxy.name; // Deleting property: name
console.log(productProxy); // {name: 'vase', price: 10}

属性没有被删除!

我们用这段代码替换了 delete 操作符的默认行为,

所以它不记得它必须“删除”属性。

这就是 Reflect 发挥作用的地方。

Reflect 是一个全局对象,包含一个对象的所有内部方法的集合。它的方法是可以在任何地方作为普通操作使用,但它的目的是在代理内部使用。

例如,我们可以通过在 handler 内返回 Reflect.deleteProperty()(即,delete 操作符的 Reflect 版本)来解决我们代码中的问题。

const product = {
  name: "vase",
  price: 10,
};

const handler = {
  deleteProperty(target, property) {
    console.log(`Deleting property: ${property}`);
    return Reflect.deleteProperty(target, property);
  },
};

const productProxy = new Proxy(product, handler);

delete productProxy.name; // Deleting property: name
console.log(product); // {price: 10}

值得指出的是,某些对象,如 Math、Date 和 JSON,具有不能使用 delete 操作符或任何其他方法删除的属性。这些是“不可配置的”对象属性,意味着它们不能被重新分配或删除。如果我们尝试在不可配置的属性上使用 delete 操作符,它将默默失败并返回 false,或者如果我们在严格模式下运行代码,则会抛出错误。

"use strict";

delete Math.PI;

Output:
Uncaught TypeError: Cannot delete property 'PI' of #<Object>

如果我们想避免使用 delete 操作符和不可配置的属性时出现错误,我们可以使用 Reflect.deleteProperty() 方法,因为在尝试删除不可配置的属性时它不会抛出错误 —— 即使在严格模式下 —— 因为它默默失败。

然而,我假设你宁愿知道你正在尝试删除一个全局对象,而不是避免错误。

利用 KendoReact Server Data Grid 的强大数据获取和优化的捆绑包大小
立即尝试

参赛者 D:“我通过使用对象解构避免了变异。”

对象解构是一种将对象属性提取到个别变量中的赋值语法。它在赋值的左侧使用花括号 {} 来指示要获取哪些属性。

const movie = {
  title: "Avatar",
  genre: "science fiction",
};

const {title, genre} = movie;

console.log(title); // Avatar
console.log(genre); // science fiction

它还可以使用方括号 [] 与数组一起使用:

const animals = ["dog", "cat", "snake", "elephant"];

const [a, b] = animals;

console.log(a); // dog
console.log(b); // cat

扩展语法 (...) 有点相反,因为它将多个属性封装到一个对象中,或者如果它们是单个值则封装到一个数组中。

我们可以使用对象解构来解包我们对象的值,并使用扩展语法仅保留我们想要的属性:

const car = {
  type: "truck",
  color: "black",
  doors: 4
};

const {color, ...newCar} = car;

console.log(newCar); // {type: 'truck', doors: 4}

这样,我们避免了必须变异我们的对象以及随之而来的潜在副作用!

这种方法的一个边缘情况是:仅当它是 undefined 时才删除属性。由于对象解构的灵活性,我们可以在它们是 undefined(或者更确切地说是假值)时删除属性。

想象你经营一个拥有庞大产品数据库的在线商店。你有一个查找它们的功能。当然,它将需要一些参数,可能是产品名称和类别。

const find = (product, category) => {
  const options = {
    limit: 10,
    product,
    category,
  };

  console.log(options);

  // 在数据库中查找...
};

在这个例子中,产品名称必须由用户提供以进行查询,但类别是可选的。所以,我们可以这样调用函数:

find("bedsheets");

由于没有指定类别,它返回为 undefined,导致以下输出:

{limit: 10, product: 'beds', category: undefined}

在这种情况下,我们不应该使用默认参数,因为我们不是在寻找一个特定的类别。

注意,数据库可能会错误地假设我们正在查询名为 undefined 的类别中的产品!这将导致空结果,这是一个意外的后果。尽管许多数据库会为我们过滤掉 undefined 属性,但在执行查询之前最好先清理选项。通过对象解构以及 AND 操作符 (&&) 动态删除一个 undefined 属性的一个很好的方式。

我们不是这样写选项:

const options = {
  limit: 10,
  product,
  category,
};

…我们可以这样写:

const options = {
  limit: 10,
  product,
  ...(category && {category}),
};

这看起来像是一个复杂的表达式,但在理解了每个部分之后,它变成了一个简单的一行代码。我们正在利用 && 操作符的优势。

AND 操作符主要用于条件语句中说,

如果 AB 为真,则执行此操作。

但它的核心是从左到右评估两个表达式,如果左边的是假值则返回左边的表达式,如果两者都是真值则返回右边的表达式。因此,在我们之前的例子中,AND 操作符有两种情况:

category 是 undefined(或假值);
category 是已定义的。

在第一个假值的情况下,操作符返回左边的表达式,category。如果我们将 category 插入对象的其余部分,它的评估方式如下:

const options = {
  limit: 10,

  product,

  ...category,
};

如果我们尝试解构对象中的任何假值,它们将被解构为无:

const options = {
  limit: 10,
  product,
};

在第二种情况下,由于操作符为真值,它返回右边的表达式,{category}。当插入对象时,它的评估方式如下:

const options = {
  limit: 10,
  product,
  ...{category},
};

由于 category 已定义,它被解构为一个普通属性:

const options = {
  limit: 10,
  product,
  category,
};

将它们全部放在一起,我们得到了以下更好的 find() 函数:

const betterFind = (product, category) => {
  const options = {
    limit: 10,
    product,
    ...(category && {category}),
  };

  console.log(options);

  // 在数据库中查找...
};

betterFind("sofas");

如果我们不指定任何类别,它就不会出现在最终的选项对象中。

{limit: 10, product: 'sofas'}

参赛者 E:“我使用了 JSON.stringify 和 JSON.parse。”

令人惊讶的是,有一种通过将属性重新分配为 undefined 来删除属性的方法。下面的代码正是这样做的:

let monitor = {
  size: 24,
  screen: "OLED",
};

monitor.screen = undefined;

monitor = JSON.parse(JSON.stringify(monitor));

console.log(monitor); // {size: 24}

我对你撒了个小谎,因为我们正在使用一些 JSON 技巧来实现这个技巧,但我们可以从中学到一些有用且有趣的东西。

尽管 JSON 直接从 JavaScript 中汲取灵感,但它在语法上有强烈的类型特性。它不允许函数或 undefined 值,所以使用 JSON.stringify() 将在转换期间省略所有无效的值,从而产生不包含 undefined 属性的 JSON 文本。从那里,我们可以使用 JSON.parse() 方法将 JSON 文本解析回 JavaScript 对象。

了解这种方法的局限性很重要。例如,JSON.stringify() 会跳过函数,如果发现循环引用(即,属性引用其父对象)或 BigInt 值,则会抛出错误。

参赛者 F:“我们公司依赖于 Lodash。”

值得一提的是,像 Lodash.js、Underscore.js 或 Ramda 这样的实用程序库也提供了从对象中删除 —— 或挑选() —— 属性的方法。我们不会详细介绍每个库的不同示例,因为它们的文档已经在这方面做得很好了。

结论

回到我们最初的场景,哪位参赛者是对的?

答案是:他们所有人都是对的!嗯,除了第一位参赛者。将属性设置为 undefined 并不是我们想要考虑的从对象中删除属性的方法,鉴于我们有所有其他的方法。

像开发中的大多数事情一样,最“正确”的方法取决于情况。但有趣的是,每种方法背后都有关于 JavaScript 本质的教训。了解在 JavaScript 中删除属性的所有方法可以教会我们编程和 JavaScript 的基本概念,例如内存管理、垃圾回收、代理、JSON 和对象变异。对于看似如此无聊和琐碎的事情,这是相当多的学习收获!