Việc xóa thuộc tính đối tượng cho chúng ta biết điều gì về JavaScript

theanh

Administrator
Nhân viên
Một nhóm thí sinh được yêu cầu hoàn thành nhiệm vụ sau:
Làm cho object1 giống với object2.
Mã:
let object1 = { a: "hello", b: "world", c: "!!!",};let object2 = { a: "hello", b: "world",};
Có vẻ dễ phải không? Chỉ cần xóa thuộc tính c để khớp với object2. Đáng ngạc nhiên là mỗi người lại mô tả một giải pháp khác nhau:
  • Thí sinh A: “Tôi đặt c thành undefined.”
  • Thí sinh B: “Tôi đã sử dụng toán tử delete.”
  • Thí sinh C: “Tôi đã xóa thuộc tính thông qua đối tượng Proxy.”
  • Thí sinh D: “Tôi đã tránh đột biến bằng cách sử dụng phân rã đối tượng.”
  • Thí sinh E: “Tôi đã sử dụng JSON.stringifyJSON.parse.”
  • Thí sinh F: “Chúng tôi dựa vào Lodash tại công ty của tôi.”
Rất nhiều câu trả lời được đưa ra và tất cả đều có vẻ là những lựa chọn hợp lệ. Vậy, ai “đúng”? Hãy cùng phân tích từng cách tiếp cận.

Thí sinh A: "Tôi đặt c thành undefined."​

Trong JavaScript, việc truy cập một thuộc tính không tồn tại sẽ trả về undefined.
Mã:
const movie = { name: "Up",};console.log(movie.premiere); // undefined
Thật dễ dàng để nghĩ rằng việc đặt một thuộc tính thành undefined sẽ xóa thuộc tính đó khỏi đối tượng. Nhưng nếu chúng ta thử làm như vậy, chúng ta sẽ quan sát thấy một chi tiết nhỏ nhưng quan trọng:
Mã:
const movie = { name: "Up", premiere: 2009,};movie.premiere = undefined;console.log(movie);
Đây là kết quả đầu ra mà chúng ta nhận được:
Mã:
{name: 'up', premiere: undefined}
Như bạn thấy, premiere vẫn tồn tại bên trong đối tượng ngay cả khi nó undefined. Cách tiếp cận này không thực sự xóa thuộc tính mà thay vào đó là thay đổi giá trị của nó. Chúng ta có thể xác nhận điều đó bằng cách sử dụng phương thức hasOwnProperty():
Mã:
const propertyExists = movie.hasOwnProperty("premiere");console.log(propertyExists); // true
Nhưng tại sao, trong ví dụ đầu tiên của chúng ta, việc truy cập object.premiere lại trả về undefined nếu thuộc tính không tồn tại trong đối tượng? Nó không nên đưa ra lỗi như khi truy cập một biến không tồn tại sao?
Mã:
console.log(iDontExist);// Uncaught ReferenceError: iDontExist is not defined
Câu trả lời nằm ở cách ReferenceError hoạt động và tham chiếu là gì ngay từ đầu.
Tham chiếu là ràng buộc tên đã giải quyết cho biết giá trị được lưu trữ ở đâu. Nó bao gồm ba thành phần: giá trị cơ sở, tên được tham chiếucờ tham chiếu nghiêm ngặt.
Đối với tham chiếu user.name, giá trị cơ sở là đối tượng, user, trong khi tên được tham chiếu là chuỗi, name và cờ tham chiếu nghiêm ngặt là false nếu mã không ở chế độ nghiêm ngặt.

Các biến hoạt động khác nhau. Chúng không có đối tượng cha, vì vậy giá trị cơ sở của chúng là bản ghi môi trường, tức là một giá trị cơ sở duy nhất được gán mỗi lần mã được thực thi.

Nếu chúng ta cố gắng truy cập vào thứ gì đó không có giá trị cơ sở, JavaScript sẽ đưa ra ReferenceError. Tuy nhiên, nếu tìm thấy giá trị cơ sở nhưng tên được tham chiếu không trỏ đến giá trị hiện có, JavaScript sẽ chỉ gán giá trị undefined.
“Kiểu Undefined có đúng một giá trị, được gọi là undefined. Bất kỳ biến nào chưa được gán giá trị đều có giá trị undefined.”

Đặc tả ECMAScript
Chúng ta có thể dành toàn bộ một bài viết chỉ để giải quyết những trò hề undefined!

Thí sinh B: “Tôi đã sử dụng toán tử delete.”​

Mục đích duy nhất của toán tử delete là xóa một thuộc tính khỏi một đối tượng, trả về true nếu phần tử được xóa thành công.
Mã:
const dog = { breed: "bulldog", fur: "white",};delete dog.fur;console.log(dog); // {breed: 'bulldog'}
Một số cảnh báo đi kèm với toán tử delete mà chúng ta phải cân nhắc trước khi sử dụng. Đầu tiên, toán tử delete có thể được sử dụng để xóa một phần tử khỏi mảng. Tuy nhiên, nó để lại một ô trống bên trong mảng, điều này có thể gây ra hành vi không mong muốn vì các thuộc tính như length không được cập nhật và vẫn tính ô trống.
Mã:
const movies = ["Interstellar", "Top Gun", "The Martian", "Speed"];delete movies[2];console.log(movies); // ['Interstellar', 'Top Gun', empty, 'Speed']console.log(movies.length); // 4
Thứ hai, hãy tưởng tượng đối tượng lồng nhau sau:
Mã:
const user = { name: "John", birthday: {day: 14, month: 2},};
Cố gắng xóa thuộc tính birthday bằng toán tử delete sẽ hoạt động tốt, nhưng có một quan niệm sai lầm phổ biến rằng việc thực hiện điều này sẽ giải phóng bộ nhớ được phân bổ cho đối tượng.

Trong ví dụ trên, birthday là thuộc tính chứa đối tượng lồng nhau. Đối tượng trong JavaScript hoạt động khác với các giá trị nguyên thủy (ví dụ: số, chuỗi và boolean) về cách chúng được lưu trữ trong bộ nhớ. Chúng được lưu trữ và sao chép "bằng tham chiếu", trong khi các giá trị nguyên thủy được sao chép độc lập dưới dạng một giá trị toàn bộ.

Lấy ví dụ, một giá trị nguyên thủy như chuỗi:
Mã:
let movie = "Home Alone";let bestSeller = movie;
Trong trường hợp này, mỗi biến có một không gian độc lập trong bộ nhớ. Chúng ta có thể thấy hành vi này nếu chúng ta cố gắng gán lại một trong số chúng:
Mã:
movie = "Terminator";console.log(movie); // "Terminator"console.log(bestSeller); // "Home Alone"
Trong trường hợp này, việc gán lại movie không ảnh hưởng đến bestSeller vì chúng nằm trong hai không gian khác nhau trong bộ nhớ. Thuộc tính hoặc biến chứa đối tượng (ví dụ: đối tượng thông thường, mảng và hàm) là các tham chiếu trỏ đến một không gian duy nhất trong bộ nhớ. Nếu chúng ta cố gắng sao chép một đối tượng, chúng ta chỉ đang sao chép tham chiếu của đối tượng đó.
Mã:
let movie = {title: "Home Alone"};let bestSeller = movie;bestSeller.title = "Terminator";console.log(movie); // {title: "Terminator"}console.log(bestSeller); // {title: "Terminator"}
Như bạn thấy, giờ đây chúng là các đối tượng và việc gán lại thuộc tính bestSeller cũng thay đổi kết quả movie. Ở bên trong, JavaScript sẽ xem xét đối tượng thực tế trong bộ nhớ và thực hiện thay đổi, và cả hai tham chiếu đều trỏ đến đối tượng đã thay đổi.

Biết được cách các đối tượng hoạt động "theo tham chiếu", giờ đây chúng ta có thể hiểu được cách sử dụng toán tử delete không giải phóng không gian trong bộ nhớ.

Quá trình mà ngôn ngữ lập trình giải phóng bộ nhớ được gọi là thu gom rác. Trong JavaScript, bộ nhớ được giải phóng cho một đối tượng khi không còn tham chiếu nào nữa và đối tượng đó trở nên không thể truy cập được. Do đó, sử dụng toán tử delete có thể khiến không gian của thuộc tính đủ điều kiện để thu thập, nhưng có thể có nhiều tham chiếu hơn ngăn không cho xóa khỏi bộ nhớ.

Trong khi chúng ta đang nói về chủ đề này, cần lưu ý rằng có một chút tranh luận xung quanh tác động của toán tử delete lên hiệu suất. Bạn có thể theo dõi đường dẫn từ liên kết, nhưng tôi sẽ tiết lộ phần kết cho bạn: sự khác biệt về hiệu suất không đáng kể đến mức không gây ra vấn đề gì trong phần lớn các trường hợp sử dụng. Cá nhân tôi coi cách tiếp cận đơn giản và mang tính thành ngữ của toán tử là một chiến thắng so với một tác động nhỏ đến hiệu suất.

Tuy nhiên, có thể đưa ra lập luận phản đối việc sử dụng delete vì nó làm biến đổi một đối tượng. Nhìn chung, nên tránh các đột biến vì chúng có thể dẫn đến hành vi không mong muốn khi một biến không giữ giá trị mà chúng ta cho là nó có.

Thí sinh C: "Tôi đã xóa thuộc tính thông qua một đối tượng Proxy."​

Thí sinh này chắc chắn là một người thích khoe khoang và đã sử dụng proxy cho câu trả lời của mình. Proxy là một cách để chèn một số logic trung gian giữa các hoạt động chung của đối tượng, như lấy, thiết lập, định nghĩa và, vâng, xóa các thuộc tính. Nó hoạt động thông qua hàm tạo Proxy có hai tham số:
  • target: Đối tượng mà chúng ta muốn tạo proxy.
  • handler: Một đối tượng chứa logic ở giữa cho các hoạt động.
Bên trong handler, chúng ta định nghĩa các phương thức cho các hoạt động khác nhau, được gọi là traps, vì chúng chặn hoạt động ban đầu và thực hiện một thay đổi tùy chỉnh. Hàm tạo sẽ trả về một đối tượng Proxy — một đối tượng giống hệt với target — nhưng có thêm logic trung gian.
Mã:
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 siameseconsole.log(catProxy.age); // tuổi của mèo là 3
Ở đây, trình xử lý sửa đổi thao tác nhận để trả về một giá trị tùy chỉnh.

Giả sử chúng ta muốn ghi nhật ký thuộc tính mà chúng ta đang xóa vào bảng điều khiển mỗi khi chúng ta sử dụng toán tử delete. Chúng ta có thể thêm logic tùy chỉnh này thông qua proxy bằng cách sử dụng bẫy deleteProperty.
Mã:
const product = { name: "vase", price: 10,};const handler = { deleteProperty(target, property) { console.log(`Đang xóa thuộc tính: ${property}`); },};const productProxy = new Proxy(product, handler);delete productProxy.name; // Xóa thuộc tính: name
Tên của thuộc tính được ghi vào bảng điều khiển nhưng lại báo lỗi trong quá trình xử lý:
Mã:
Uncaught TypeError: 'deleteProperty' trên proxy: trap trả về giá trị sai cho thuộc tính 'name'
Lỗi được báo vì trình xử lý không có giá trị return. Điều đó có nghĩa là mặc định là undefined. Ở chế độ nghiêm ngặt, nếu toán tử delete trả về false, nó sẽ gây ra lỗi và undefined, là giá trị falsy, sẽ kích hoạt hành vi này.

Nếu chúng ta cố gắng trả về true để tránh lỗi, chúng ta sẽ gặp phải một loại vấn đề khác:
Mã:
// ...const handler = { deleteProperty(target, property) { console.log(`Đang xóa thuộc tính: ${property}`); return true; },};const productProxy = new Proxy(product, handler);delete productProxy.name; // Đang xóa thuộc tính: nameconsole.log(productProxy); // {name: 'vase', price: 10}
Thuộc tính không bị xóa!

Chúng tôi đã thay thế hành vi mặc định của toán tử delete bằng mã này, vì vậy nó không nhớ rằng nó phải "xóa" thuộc tính.

Đây là lúc Reflect phát huy tác dụng.
Reflect là một đối tượng toàn cục với tập hợp tất cả các phương thức nội bộ của một đối tượng. Các phương thức của nó có thể được sử dụng như các hoạt động bình thường ở bất kỳ đâu, nhưng nó được thiết kế để sử dụng bên trong một proxy.
Ví dụ, chúng ta có thể giải quyết vấn đề trong mã của mình bằng cách trả về Reflect.deleteProperty() (tức là phiên bản Reflect của toán tử delete) bên trong trình xử lý.
Mã:
const product = { name: "vase", price: 10,};const handler = { deleteProperty(target, property) { console.log(`Đang xóa thuộc tính: ${property}`); return Reflect.deleteProperty(target, property); },};const productProxy = new Proxy(product, handler);delete productProxy.name; // Đang xóa thuộc tính: nameconsole.log(product); // {price: 10}
Cần lưu ý rằng một số đối tượng nhất định, như Math, DateJSON, có các thuộc tính không thể xóa bằng toán tử delete hoặc bất kỳ phương thức nào khác. Đây là các thuộc tính đối tượng "không thể định cấu hình", nghĩa là chúng không thể được gán lại hoặc xóa. Nếu chúng ta thử sử dụng toán tử delete trên một thuộc tính không thể cấu hình, nó sẽ không hoạt động và trả về false hoặc báo lỗi nếu chúng ta đang chạy mã ở chế độ nghiêm ngặt.
Mã:
"use strict";xóa Math.PI;
Đầu ra:
Mã:
Uncaught TypeError: Không thể xóa thuộc tính 'PI' của #
Nếu chúng ta muốn tránh lỗi với toán tử delete và các thuộc tính không thể định cấu hình, chúng ta có thể sử dụng phương thức Reflect.deleteProperty() vì nó không đưa ra lỗi khi cố gắng xóa một thuộc tính không thể định cấu hình — ngay cả ở chế độ nghiêm ngặt — vì nó không báo lỗi.

Tuy nhiên, tôi cho rằng bạn thích biết khi nào bạn đang cố gắng xóa một đối tượng toàn cục hơn là tránh lỗi.

Thí sinh D: “Tôi đã tránh được đột biến bằng cách sử dụng cấu trúc hóa đối tượng.”​

Cấu trúc hóa đối tượng là cú pháp gán trích xuất các thuộc tính của đối tượng thành các biến riêng lẻ. Nó sử dụng ký hiệu dấu ngoặc nhọn ({}) ở bên trái của một phép gán để cho biết thuộc tính nào sẽ nhận được.
Mã:
const movie = { title: "Avatar", category: "science fiction",};const {title, category} = movie;console.log(title); // Avatarconsole.log(genre); // science fiction
Nó cũng hoạt động với các mảng sử dụng dấu ngoặc vuông ([]):
Mã:
const animals = ["dog", "cat", "snake", "elephant"];const [a, b] = animals;console.log(a); // dogconsole.log(b); // cat
Cú pháp spread (...) giống như phép toán ngược lại vì nó đóng gói một số thuộc tính vào một đối tượng hoặc một mảng nếu chúng là các giá trị đơn.

Chúng ta có thể sử dụng cấu trúc phân rã đối tượng để giải nén các giá trị của đối tượng và cú pháp spread để chỉ giữ lại những giá trị chúng ta muốn:
Mã:
const car = { type: "truck", color: "black", doors: 4};const {color, ...newCar} = car;console.log(newCar); // {type: 'truck', doors: 4}
Theo cách này, chúng ta tránh phải đột biến các đối tượng của mình và các tác dụng phụ tiềm ẩn đi kèm!

Đây là một trường hợp ngoại lệ với cách tiếp cận này: chỉ xóa một thuộc tính khi thuộc tính đó chưa xác định. Nhờ tính linh hoạt của việc hủy cấu trúc đối tượng, chúng ta có thể xóa các thuộc tính khi chúng không xác định (hoặc falsy, chính xác là như vậy).

Hãy tưởng tượng bạn điều hành một cửa hàng trực tuyến với cơ sở dữ liệu sản phẩm khổng lồ. Bạn có một hàm để tìm chúng. Tất nhiên, nó sẽ cần một số tham số, có thể là tên sản phẩm và danh mục.
Mã:
const find = (product, category) => { const options = { limit: 10, product, category, }; console.log(options); // Tìm trong cơ sở dữ liệu...};
Trong ví dụ này, name sản phẩm phải được người dùng cung cấp để thực hiện truy vấn, nhưng category là tùy chọn. Vì vậy, chúng ta có thể gọi hàm như thế này:
Mã:
find("bedsheets");
Và vì category không được chỉ định, nên hàm trả về là undefined, dẫn đến kết quả đầu ra sau:
Mã:
{limit: 10, product: 'beds', category: undefined}
Trong trường hợp này, chúng ta không nên sử dụng tham số mặc định vì chúng ta không tìm kiếm một danh mục cụ thể nào.

Lưu ý cách cơ sở dữ liệu có thể cho rằng chúng ta đang truy vấn các sản phẩm trong danh mục có tên là undefined! Điều đó sẽ dẫn đến kết quả trống, đây là một tác dụng phụ không mong muốn. Mặc dù nhiều cơ sở dữ liệu sẽ lọc thuộc tính undefined cho chúng ta, nhưng tốt hơn hết là nên khử trùng các tùy chọn trước khi thực hiện truy vấn. Một cách hay để xóa động thuộc tính undefined là thông qua việc hủy đối tượng cùng với toán tử AND (&&).

Thay vì viết options như thế này:
Mã:
const options = { limit: 10, product, category,};
…chúng ta có thể làm như sau:
Mã:
const options = { limit: 10, product, ...(category && {category}),};
Có vẻ như đây là một biểu thức phức tạp, nhưng sau khi hiểu từng phần, nó trở thành một dòng lệnh đơn giản. Những gì chúng ta đang làm là tận dụng toán tử &&.

Toán tử AND chủ yếu được sử dụng trong các câu lệnh điều kiện để nói rằng,
Nếu ABđúng, thì hãy thực hiện điều này.
Nhưng về bản chất, nó đánh giá hai biểu thức từ trái sang phải, trả về biểu thức bên trái nếu nó sai và trả về biểu thức bên phải nếu cả hai đều đúng. Vì vậy, trong ví dụ trước của chúng ta, toán tử AND có hai trường hợp:
  1. categoryundefined (hoặc falsy);
  2. category đã được định nghĩa.
Trong trường hợp đầu tiên là falsy, toán tử trả về biểu thức bên trái, category. Nếu chúng ta chèn category vào phần còn lại của đối tượng, nó sẽ đánh giá theo cách này:
Mã:
const options = { limit: 10, product, ...category,};
Và nếu chúng ta cố gắng giải cấu trúc bất kỳ giá trị sai nào bên trong một đối tượng, chúng sẽ bị giải cấu trúc thành không có gì:
Mã:
const options = { limit: 10, product,};
Trong trường hợp thứ hai, vì toán tử là truthy, nó trả về biểu thức bên phải, {category}. Khi được cắm vào đối tượng, nó sẽ đánh giá theo cách này:
Mã:
const options = { limit: 10, product, ...{category},};
Và vì category đã được định nghĩa, nên nó được giải cấu trúc thành một thuộc tính bình thường:
Mã:
const options = { limit: 10, product, category,};
Gộp tất cả lại với nhau, chúng ta sẽ có hàm betterFind() sau:
Mã:
const betterFind = (product, category) => { const options = { limit: 10, product, ...(category && {category}), }; console.log(options); // Tìm trong cơ sở dữ liệu...};betterFind("sofas");
Và nếu chúng ta không chỉ định bất kỳ category nào, thì nó sẽ không xuất hiện trong đối tượng options cuối cùng.
Mã:
{limit: 10, product: 'sofas'}

Thí sinh E: “Tôi đã sử dụng JSON.stringifyJSON.parse.”​

Thật ngạc nhiên với tôi, có một cách để xóa một thuộc tính bằng cách gán lại thuộc tính đó cho undefined. Mã sau đây thực hiện chính xác điều đó:
Mã:
let monitor = { size: 24, screen: "OLED",};monitor.screen = undefined;monitor = JSON.parse(JSON.stringify(monitor));console.log(monitor); // {size: 24}
Tôi đã nói dối bạn một chút vì chúng ta đang sử dụng một số mánh khóe JSON để thực hiện thủ thuật này, nhưng chúng ta có thể học được điều gì đó hữu ích và thú vị từ chúng.

Mặc dù JSON lấy cảm hứng trực tiếp từ JavaScript, nhưng nó khác ở chỗ nó có cú pháp được gõ mạnh. Nó không cho phép các hàm hoặc giá trị undefined, vì vậy sử dụng JSON.stringify() sẽ bỏ qua tất cả các giá trị không hợp lệ trong quá trình chuyển đổi, dẫn đến văn bản JSON không có thuộc tính undefined. Từ đó, chúng ta có thể phân tích cú pháp văn bản JSON trở lại thành đối tượng JavaScript bằng phương thức JSON.parse().

Điều quan trọng là phải biết những hạn chế của phương pháp này. Ví dụ, JSON.stringify() bỏ qua các hàm và trả về lỗi nếu có tham chiếu vòng tròn (tức là thuộc tính đang tham chiếu đến đối tượng cha của nó) hoặc tìm thấy giá trị BigInt.

Thí sinh F: “Chúng tôi tin tưởng Lodash tại công ty của tôi.”​

Cần lưu ý rằng các thư viện tiện ích như Lodash.js, Underscore.js hoặc Ramda cũng cung cấp các phương thức để xóa — hoặc pick() — thuộc tính khỏi một đối tượng. Chúng tôi sẽ không đi qua các ví dụ khác nhau cho từng thư viện vì tài liệu hướng dẫn của họ đã thực hiện rất tốt việc đó.

Kết luận​

Quay lại tình huống ban đầu của chúng ta, thí sinh nào đúng?

Câu trả lời: Tất cả! Vâng, ngoại trừ thí sinh đầu tiên. Đặt thuộc tính thành undefined không phải là cách tiếp cận mà chúng ta muốn cân nhắc để xóa thuộc tính khỏi đối tượng, vì có rất nhiều cách khác mà chúng ta phải thực hiện.

Giống như hầu hết mọi thứ trong quá trình phát triển, cách tiếp cận "đúng đắn" nhất phụ thuộc vào tình huống. Nhưng điều thú vị là đằng sau mỗi cách tiếp cận là một bài học về bản chất của JavaScript. Hiểu được tất cả các cách xóa thuộc tính trong JavaScript có thể dạy chúng ta các khía cạnh cơ bản của lập trình và JavaScript, chẳng hạn như quản lý bộ nhớ, thu gom rác, proxy, JSON và đột biến đối tượng. Thật là một bài học khá lớn cho một thứ có vẻ nhàm chán và tầm thường!
Đọc thêm trên SmashingMag​
  • “Khám phá các đối tượng nguyên thủy trong JavaScript (Phần 1),” Kirill Myshkin
  • “Các đối tượng nguyên thủy trong JavaScript: Khi nào nên sử dụng chúng (Phần 2),” Kirill Myshkin
  • “Giới thiệu lại về phép gán cấu trúc,” Laurie Barth
  • “Hình học mô hình đối tượng tài liệu (DOM): Giới thiệu và hướng dẫn cho người mới bắt đầu,” Pearl Akpan
 
Back
Bên trên