一群参赛者被要求完成以下任务:
使 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.stringify 和 JSON.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 操作符主要用于条件语句中说,
如果 A 和 B 为真,则执行此操作。
但它的核心是从左到右评估两个表达式,如果左边的是假值则返回左边的表达式,如果两者都是真值则返回右边的表达式。因此,在我们之前的例子中,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 和对象变异。对于看似如此无聊和琐碎的事情,这是相当多的学习收获!