Giới thiệu
Trong lập trình, việc gán một biến với giá trị của một biến khác như let x = y là rất phổ biến. Bạn có thể nghĩ rằng y bây giờ giữ một bản sao của giá trị của x. Và phần lớn thời gian, bạn sẽ đúng. Nhưng rồi, bạn gặp phải một lỗi mà việc thay đổi y lại một cách bí ẩn thay đổi x. Tại sao lại như vậy?
Câu trả lời nằm trong một trong những khái niệm cơ bản và thường bị hiểu nhầm của JavaScript: gán theo giá trị so với gán theo tham chiếu. Mặc dù cú pháp gán luôn giống nhau, nhưng những gì xảy ra sau cảnh phụ thuộc vào loại dữ liệu bạn đang làm việc. Hiểu sự khác biệt này là chìa khóa để ngăn chặn một loạt lỗi khó chịu.
Bài viết này là phần tiếp theo của bài viết trước của tôi về Hiểu biết về Tính biến đổi trong JavaScript.
Trong bài viết này, chúng ta sẽ xem cách gán được xử lý trong các loại dữ liệu khác nhau trong JavaScript.
Gán cho các loại dữ liệu nguyên thủy
Các loại dữ liệu nguyên thủy trong JavaScript như chuỗi, số, boolean, null, và undefined là bất biến. Điều này có nghĩa là giá trị của chúng không thể thay đổi sau khi được tạo ra. Khi bạn gán một kiểu nguyên thủy từ một biến sang biến khác, JavaScript tạo ra một bản sao độc lập mới của giá trị đó.
Hãy nghĩ về điều này như việc làm một bản sao photocopy. Tài liệu gốc không thay đổi, và bất kỳ dấu hiệu nào bạn thực hiện trên bản sao sẽ không chuyển trở lại tài liệu gốc.
let x = 10;
let y = x;
console.log(x, y); // 10, 10
y = 20; // Gán lại cho một giá trị khác
console.log(x, y) // 10, 20 ( x không thay đổi )
Khi thực thi hai dòng đầu tiên, giá trị (10) mà biến x giữ được sao chép đến một vị trí khác trong bộ nhớ và biến y trỏ đến vị trí đó, vì vậy cả hai biến đều giữ cùng một giá trị (10) nhưng trỏ đến các vị trí khác nhau trong bộ nhớ.
Sau y = 20, con trỏ của y được chuyển và trỏ đến một vị trí khác trong bộ nhớ chứa giá trị 20. Điều này xảy ra vì các kiểu nguyên thủy là bất biến, và bây giờ cả x và y đều trỏ đến các vị trí khác nhau chứa các giá trị khác nhau.
Quá trình này cũng xảy ra với tất cả các loại dữ liệu nguyên thủy. Khi bạn gán một biến với giá trị của một biến khác, cả hai biến đều độc lập với nhau và bất kỳ thao tác nào trên một biến sẽ không ảnh hưởng đến biến kia.
Gán cho các loại đối tượng
Đối tượng (bao gồm cả mảng và hàm) là biến đổi, các thuộc tính bên trong của chúng có thể được thay đổi. Khi bạn gán một đối tượng từ một biến sang một biến khác, JavaScript không sao chép toàn bộ đối tượng. Thay vào đó, nó sao chép tham chiếu đến đối tượng (địa chỉ bộ nhớ của nó). Điều này có nghĩa là cả hai biến hiện đang trỏ đến cùng một đối tượng trong bộ nhớ. Nếu một thuộc tính được thay đổi bởi một trong các biến, sự thay đổi đó sẽ được phản ánh ở biến kia.
Hãy tưởng tượng bạn có một tài liệu Google Doc chung. Bạn gửi liên kết đến một người bạn. Bạn và người bạn đang truy cập vào cùng một tài liệu. Nếu người bạn của bạn thực hiện thay đổi, bạn sẽ thấy chúng, và ngược lại, vì bạn đều đang xem cùng một tài liệu duy nhất. Liên kết là tham chiếu, và tài liệu Google Doc là đối tượng trong bộ nhớ.
Xem xét ví dụ dưới đây;
let user = { name: "John", role: "user" };
let admin = user; // Một bản sao của tham chiếu được gán
console.log(user); // { name: "John", role: "user" }
console.log(admin); // { name: "John", role: "user" }
// Thay đổi một thuộc tính của đối tượng qua biến 'admin'
admin.role = "admin";
console.log(admin); // { name: "John", role: "admin" }
console.log(user); // { name: "John", role: "admin" } -> Đối tượng gốc đã thay đổi
Khi chạy đoạn mã này, cả hai biến đều trỏ đến cùng một địa chỉ trong bộ nhớ, không giống như trong trường hợp nguyên thủy, nơi giá trị được sao chép đến một địa chỉ khác trong bộ nhớ. Bởi vì các đối tượng là biến đổi, chúng ta có thể thay đổi trực tiếp các thuộc tính bằng cách sử dụng cú pháp chấm.
Sau dòng admin.role = “admin”;, biến admin đã thay đổi đối tượng tại địa chỉ đó trong bộ nhớ và vì cả hai biến đều trỏ đến cùng một địa chỉ, sự thay đổi cũng được phản ánh ở biến user.
Điều này cũng áp dụng tương tự cho các loại đối tượng khác như mảng và hàm.
Ngắt kết nối
Một câu hỏi hay bây giờ là; “Làm thế nào để chúng ta sao chép nội dung của đối tượng đến một địa chỉ khác trong bộ nhớ và ngắt kết nối?” Có một vài cách để làm điều đó, một trong số đó là đơn giản gán biến admin cho một đối tượng khác tương tự như đối tượng đầu tiên rồi thay đổi thuộc tính mà chúng ta muốn thay đổi.
let user = { name: "John", role: "user" };
let admin = { name: "John", role: "user" }; // Một đối tượng mới được tạo tại một địa chỉ bộ nhớ khác
console.log(user); // { name: "John", role: "user" }
console.log(admin); // { name: "John", role: "user" }
admin.role = "admin";
console.log(admin); // { name: "John", role: "user" }
console.log(user); // { name: "John", role: "admin" } -> Đối tượng gốc vẫn không thay đổi
Mặc dù cách này hoạt động, nhưng có thể thấy rằng phương pháp này khá dài dòng và dễ mắc lỗi. Điều gì sẽ xảy ra nếu đối tượng ban đầu có hơn 20 thuộc tính? Thay vào đó, chúng ta có thể sử dụng spread operator (...) để sao chép nội dung của đối tượng đầu tiên đến một địa chỉ khác trong bộ nhớ, từ đó ngắt kết nối.
let user = { name: "John", role: "user" };
let admin = { ...user }; // Sao chép nội dung đến một địa chỉ bộ nhớ khác
console.log(user); // { name: "John", role: "user" }
console.log(admin); // { name: "John", role: "user" }
admin.role = "admin";
console.log(admin); // { name: "John", role: "user" }
console.log(user); // { name: "John", role: "admin" } -> Đối tượng gốc vẫn không thay đổi
Khi sử dụng spread operator (...), nó sao chép toàn bộ thuộc tính của đối tượng đầu tiên đến một địa chỉ khác trong bộ nhớ. Bạn sẽ nhận thấy rằng hai biến đều trỏ đến các địa chỉ khác nhau trong bộ nhớ, bây giờ thay đổi ở một trong hai sẽ không ảnh hưởng đến biến kia.
Tôi muốn chỉ ra thêm một số thông tin về cách mà spread operator (...) hoạt động:
- Nó hoạt động tương tự cho mảng như nó hoạt động cho đối tượng.
- Nó thực hiện sao chép nông, có nghĩa là chỉ cấp độ đầu tiên của đối tượng được sao chép đến một địa chỉ khác, đối với các cấp độ lồng nhau sâu hơn, chỉ tham chiếu được sao chép.
So sánh giữa các loại dữ liệu
JavaScript coi hai đối tượng là khác nhau khi chúng không trỏ đến cùng một địa chỉ bộ nhớ ngay cả khi các thuộc tính hoặc nội dung của các đối tượng giống hệt nhau.
Bạn có thể xác nhận điều này bằng cách chạy;
let user = { name: "John", role: "user" };
let admin = { ...user };
console.log(user === admin); // false ( Các thuộc tính giống nhau, địa chỉ khác nhau trong bộ nhớ )
Điều này sản xuất false vì JavaScript so sánh các loại đối tượng theo tham chiếu và không phải nội dung của đối tượng. Vì vậy, trừ khi cả hai đối tượng trỏ đến cùng một địa chỉ trong bộ nhớ, việc so sánh như vậy sẽ luôn trả về false.
Điều này trái ngược với các loại nguyên thủy, nơi việc so sánh được thực hiện theo giá trị;
Kết luận
Tóm lại, một hiểu biết vững chắc về cách JavaScript xử lý việc gán biến cho các loại dữ liệu khác nhau là rất quan trọng để viết mã hiệu quả và không có lỗi. Hãy nhớ rằng đối với các kiểu nguyên thủy, việc gán và so sánh luôn được thực hiện theo giá trị, tạo ra một bản sao độc lập mới của dữ liệu. Điều này có nghĩa là một thay đổi trên một biến sẽ không bao giờ ảnh hưởng đến biến gốc.
Tuy nhiên, đối với các loại đối tượng, câu chuyện lại khác. Việc gán và so sánh được thực hiện theo tham chiếu. Khi bạn gán một biến đối tượng cho một biến khác, cả hai đều trỏ đến cùng một vị trí bộ nhớ. Bất kỳ thuộc tính nào bạn thay đổi trên một trong hai sẽ ảnh hưởng đến đối tượng kia, vì bạn đang sửa đổi cùng một đối tượng bên dưới. Để ngắt kết nối tham chiếu chung này và tạo một đối tượng mới, độc lập, bạn có thể sử dụng spread operator (...). Nó tạo ra một bản sao nông, rất tuyệt cho việc thực hiện các sửa đổi an toàn mà không có tác dụng phụ không mong muốn.
Mặc dù spread operator rất mạnh mẽ, nó có những hạn chế, đặc biệt là với các đối tượng lồng nhau. Trong các bài viết tiếp theo, chúng ta sẽ đi sâu hơn vào các phương pháp toàn diện hơn để sao chép đối tượng và khám phá cách các quy tắc này về việc truyền theo giá trị và theo tham chiếu áp dụng khi truyền biến vào các hàm. Biết sự khác biệt giữa một bản sao và một tham chiếu chia sẻ là một kỹ năng cơ bản sẽ giúp bạn tránh được vô số đau đầu trong hành trình lập trình của mình.
Cảm ơn bạn đã đọc!