[译] “移除对象属性”能告诉我们哪些 JavaScript 知识点

简要概述:在 JavaScript 中从对象中移除属性可能不是最令人兴奋的工作,但有很多方法可以实现,每种方法都揭示了 JavaScript 原理的一个基本方面。Juan Diego Rodríguez 将在本文中探讨每种技术。

要求一组选手完成以下任务:

使 Object1 和 object2 相似

js 复制代码
let object1 = {
  a: "hello",
  b: "world",
  c: "!!!",
};

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

看起来很简单,对吗?只需删除 object2c 属性即可匹配。令人惊讶的是,每个人都描述了不同的解决方案:

  • 选手 A:"我把 c 设为 undefined。"
  • 选手 B:"我使用了 delete 操作符。"
  • 选手 C:"我通过代理对象 Proxy 删除了属性。"
  • 选手 D:"我使用了对象解构来避免突变。"
  • 选手 E:"我使用了 JSON.stringifyJSON.parse。"
  • 选手 F:"我们公司依赖 Lodash。"

大家给出了很多答案,而且似乎都是有效的选择。那么,谁才是 "正确 "的呢?让我们来剖析一下每一种方法。

选手 A:"我把 c 设为 undefined。"

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

js 复制代码
const movie = {
  name: "Up",
};

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

我们很容易认为,将一个属性设置为 undefined,就可以从对象中删除该属性。但如果我们尝试这样做,就会发现一个很小但很重要的细节:

js 复制代码
const movie = {
  name: "Up",
  premiere: 2009,
};

movie.premiere = undefined;

console.log(movie);

以下是我们得到的输出结果:

js 复制代码
{ name: 'up', premiere: undefined }

正如你所看到的,即使 premiereundefined,它仍然存在于对象内部。这种方法实际上并没有删除属性,而是更改了其值。我们可以使用 hasOwnProperty() 方法确认这一点:

js 复制代码
const propertyExists = movie.hasOwnProperty("premiere");

console.log(propertyExists); // true

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

js 复制代码
console.log(iDontExist);

// Uncaught ReferenceError: iDontExist is not defined

答案就在于什么时候会抛出 ReferenceError 错误,以及什么是引用。

引用是一种已解析的名称绑定,用于指示值的存储位置。它由三个部分组成:基础值、引用名称和严格引用标记。

对于 user.name 引用,基础值是对象 user,而引用名称是字符串 name,如果代码不在严格模式 (use strict) 下,严格引用标记为 false

变量(通过声明语句产生的)与引用有所不同。变量没有父对象,因此其基础值是声明性环境记录 (environment record),即每次执行代码时分配的唯一基础值。

如果我们试图访问没有基础值的一些内容时,JavaScript 会抛出一个 ReferenceError(引用错误)。不过,如果找到了基础值,但引用的名称并没有存在的值,JavaScript 就会简单地赋值为 undefined

"Undefined 类型只有一个值,称为 undefined。任何未赋值的变量的值都是 undefined"。 --- ECMAScript Specification

我们可以用整整一篇文章来讨论 undefined!

选手 B:"我使用了 delete 操作符。"

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

js 复制代码
const dog = {
  breed: "bulldog",
  fur: "white",
};

delete dog.fur;

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

在使用 delete 操作符之前,我们必须考虑到一些注意事项。首先,delete 操作符可用于从数组中删除一个元素。但是,它会在数组中留下一个空槽,这可能会导致意想不到的行为,因为像 length 这样的属性不会被更新,仍然会计算空槽。

js 复制代码
const movies = ["Interstellar", "Top Gun", "The Martian", "Speed"];

delete movies[2];

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

console.log(movies.length); // 4

其次,让我们想象一下下面的嵌套对象:

js 复制代码
const user = {
  name: "John",
  birthday: {day: 14, month: 2},
};

使用 delete 操作符删除 birthday 属性也可以正常运行,但有一种常见的误解是,这样做会释放为对象分配的内存。

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

以字符串为例:

js 复制代码
let movie = "Home Alone";
let bestSeller = movie;

在这种情况下,每个变量在内存中都有一个独立的存储空间。如果我们尝试重新赋值其中一个变量,就能看到这种行为:

js 复制代码
movie = "Terminator";

console.log(movie); // "Terminator"

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

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

js 复制代码
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 操作符对性能的影响还存在一些争论。你可以从链接中找到相关信息,但我还是要提前剧透一下结论:性能上的差异微乎其微,在绝大多数使用情况下都不会造成问题。就我个人而言,我认为操作符的语义化和直接性胜过了对性能的微小影响。

尽管如此,还是有理由反对使用 delete,因为它会更改对象(有的人更喜欢 immutable 编程)。一般来说,避免突变是一种好的做法,因为突变可能会导致意想不到的行为,即变量并不持有我们假设的值。

选手 C:"我通过代理对象 Proxy 删除了属性。"

这位选手绝对是个爱炫耀的人,他的答案使用了代理。代理是一种在对象的常用操作(如获取、设置、定义和删除属性)之间插入逻辑的方法。它通过 Proxy 构造,该构造函数需要两个参数:

  • target:要创建代理的对象。
  • handler:一个对象,包含被代理的操作的中间逻辑。

handler 对象中,我们为不同的操作定义了方法,这些方法被称为拦截器,因为它们会拦截原始操作并执行自定义更改。构造函数将返回一个 Proxy 对象--一个与 target 对象完全相同的对象,但增加了中间逻辑。

js 复制代码
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 会修改 get 操作以返回自定义值。

假设我们希望每次使用 delete 操作符时,都能在控制台中记录我们要删除的属性。我们可以通过代理使用 deleteProperty 拦截器添加这一自定义逻辑。

js 复制代码
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

属性名称会记录在控制台中,但在此过程中会出错:

js 复制代码
Uncaught TypeError: 'deleteProperty' on proxy: trap returned falsish for property 'name'

抛出错误的原因是 handler 没有返回值。这意味着它默认为 undefined。在严格模式下,如果 delete 操作符返回 false,就会产生错误,而 undefined 作为一个 falsy 值,会触发这种行为。

如果我们试图返回 true 来避免错误,就会遇到另一种问题:

js 复制代码
// ...

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 是一个全局对象,集合了对象的所有内部方法。它的方法可以在任何地方作为普通操作使用,但必须在代理内部使用。

js 复制代码
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}

值得注意的是,某些对象(如MathDateJSON)的属性不能使用 delete 操作符或任何其他方法删除。这些属性属于"不可配置 "对象属性,也就是说它们不能被重新赋值或删除。果我们尝试在不可配置的属性上使用 delete 操作符,它将默默地失败,并返回 false 或抛出错误(如果我们在严格模式下运行代码)。

js 复制代码
"use strict";

delete Math.PI;

输出:

js 复制代码
Uncaught TypeError: Cannot delete property 'PI' of #<Object>

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

不过,我假设你更希望知道你在试图删除全局对象,而不是避免错误。

选手 D:"我使用了对象解构来避免突变。"

对象解构是一种赋值语法,可将对象的属性提取到单个变量中。它在赋值的左侧使用花括号符号({})来说明要获取哪些属性。

js 复制代码
const movie = {
  title: "Avatar",
  genre: "science fiction",
};

const {title, genre} = movie;

console.log(title); // Avatar

console.log(genre); // science fiction

使用方括号 ([]),它还可以处理数组:

js 复制代码
const animals = ["dog", "cat", "snake", "elephant"];

const [a, b] = animals;

console.log(a); // dog

console.log(b); // cat

展开语法(...)有点像相反的操作,因为它将多个属性封装到一个对象或者一个数组中,并赋给单个值。

我们可以使用对象解构来解压对象的值,并使用展开语法只保留我们想要的值:

js 复制代码
const car = {
  type: "truck",
  color: "black",
  doors: 4
};

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

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

这样,我们就可以避免对象的突变以及随之而来的潜在副作用!

下面是这种方法的一个边缘案例:仅在属性值为 undefined 时删除它。得益于对象解构的灵活性,我们可以在属性 undefined(准确地说,是 falsy)时删除它们。

想象一下,你经营着一家拥有庞大产品数据库的在线商店。你有一个查找产品的函数。当然,它需要一些参数,也许是产品名称和类别。

js 复制代码
const find = (product, category) => {
  const options = {
    limit: 10,
    product,
    category,
  };

  console.log(options);

  // Find in database...
};

在本例中,用户必须提供产品名称 name 才能进行查询,但类别 category 是可选的。因此,我们可以这样调用函数:

js 复制代码
find("bedsheets");

由于没有指定类别 category,因此返回值为 undefined,输出结果如下:

js 复制代码
{limit: 10, product: 'beds', category: undefined}

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

请注意,数据库可能会错误地认为我们查询的是 undefined 类别的产品!这将导致结果为空,从而产生意想不到的副作用。尽管许多数据库会为我们过滤掉 undefined 属性,但最好还是在查询前对选项进行过滤处理。动态删除 undefined 属性的一个很酷的方法是通过对象解构和 AND 运算符 (&&) 来实现。

原先写法:

js 复制代码
const options = {
  limit: 10,
  product,
  category,
};

......新的写法:

js 复制代码
const options = {
  limit: 10,
  product,
  ...(category && {category}),
};

这看似是一个复杂的表达式,但在理解了每个部分后,它就变成了一个简单明了的单行表达式。我们要做的就是利用 && 运算符。

AND 运算符主要用于条件语句,表示

如果 A 和 B 为真,那么就这样做。

但它的核心是对两个表达式从左到右进行运算,如果左边的表达式是假的,则返回左边的表达式;如果左右边的表达式都是真的,则返回右边的表达式。因此,在我们前面的例子中,AND 运算符有两种情况:

  1. categoryundefined (or falsy);
  2. category 是定义的。

在第一种 categoryfalsy 的情况下,运算符返回左边的表达式,即category。如果我们将 category 插入到对象中,它将以这种方式求值:

js 复制代码
const options = {
  limit: 10,

  product,

  ...category,
};

如果我们试图解构对象中的任何 falsy 值,它们将被解构为空:

js 复制代码
const options = {
  limit: 10,
  product,
};

在第二种情况下,由于运算符是 truthy,因此返回右边的表达式 {category}。插入对象后,它将以这种方式求值:

js 复制代码
const options = {
  limit: 10,
  product,
  ...{category},
};

既然定义了 category,它就会被解构为一个普通属性:

js 复制代码
const options = {
  limit: 10,
  product,
  category,
};

将所有这些组合在一起,我们就得到了下面的 betterFind() 函数:

js 复制代码
const betterFind = (product, category) => {
  const options = {
    limit: 10,
    product,
    ...(category && {category}),
  };

  console.log(options);

  // Find in a database...
};

betterFind("sofas");

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

js 复制代码
{limit: 10, product: 'sofas'}

选手 E:"我使用了 JSON.stringify 和 JSON.parse。"

出乎我意料的是,有一种方法可以通过将属性重新赋值为 undefined 来移除该属性。下面的代码正是这样做的:

js 复制代码
let monitor = {
  size: 24,
  screen: "OLED",
};

monitor.screen = undefined;

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

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

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

尽管 JSON 的灵感直接来自 JavaScript,但它与 JavaScript 的不同之处在于,JSON 采用了强类型语法。它不允许使用函数或 undefined 的值,因此使用 JSON.stringify() 将在转换过程中省略所有无效值,从而得到不含 undefined 属性的 JSON 文本。这样,我们就可以使用 JSON.parse() 方法将 JSON 文本解析回 JavaScript 对象。

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

选手 F:"我们公司依赖 Lodash。"

值得注意的是,Lodash.js、Underscore.js 或 Ramda 等实用库也提供了从对象中删除属性或 pick() 属性的方法。由于每个库的文档都做了很好的说明,我们就不一一举例说明了。

结论

回到我们最初的情景,哪位选手是正确的?

答案是全部!好吧,除了第一位选手。将属性设置为 undefined 并不是我们从对象中移除属性时要考虑的方法,因为我们还有其他方法。

就像开发中的大多数事情一样,最"正确 "的方法取决于具体情况。但有趣的是,每种方法背后都蕴含着有关 JavaScript 本质的一课。了解 JavaScript 中删除属性的所有方法可以让我们了解编程和 JavaScript 的基本方面,例如内存管理、垃圾回收、代理、JSON 和对象突变。对于看似枯燥和琐碎的东西来说,这可是一门很深的学问!

进一步阅读:

相关推荐
好久不见的流星1 小时前
[基于 Vue CLI 5 + Vue 3 + Ant Design Vue 4 搭建项目] 04 安装 Vue CLI 5
javascript·vue.js·ecmascript
曈欣1 小时前
vue 控制组件是否显示
前端·javascript·vue.js
nice666602 小时前
CSS的基本语法
java·前端·css·visual studio code
陈在天box2 小时前
《响应式 Web 设计:纯 HTML 和 CSS 的实现技巧》
前端·css·html
爱吃桃子的ICer3 小时前
[UVM]3.核心基类 uvm_object 域的自动化 copy() compare() print() pack unpack
开发语言·前端·ic设计
阿洵Rain4 小时前
【Linux】环境变量
android·linux·javascript
学地理的小胖砸5 小时前
【GEE的Python API】
大数据·开发语言·前端·python·遥感·地图学·地理信息科学
垦利不6 小时前
css总结
前端·css·html
八月的雨季 最後的冰吻6 小时前
C--字符串函数处理总结
c语言·前端·算法
6230_7 小时前
关于HTTP通讯流程知识点补充—常见状态码及常见请求方式
前端·javascript·网络·网络协议·学习·http·html