Bạn lần theo một lỗi qua ba tệp tin chỉ để nhận ra rằng một hàm bổ trợ (helper function) vô hại đã đổi tên một người dùng. Bạn chưa hề chạm vào dữ liệu gốc. Hoặc ít nhất là bạn đã nghĩ thế. Trong JavaScript, việc gán biến này cho biến khác không phải lúc nào cũng diễn ra như bạn mong đợi. Ngôn ngữ này chia dữ liệu thành hai chiến lược lưu trữ, và việc quên mất mình đang sử dụng loại nào chính là cách mà những sự thay đổi ngầm (silent mutations) len lỏi vào mã nguồn thực tế (production code).

Để ngăn chặn điều này, bạn cần hiểu rõ sự khác biệt giữa các giá trị nguyên thủy (primitive values) và đối tượng (objects) chính xác như cách mà engine thực hiện.

Primitives: Bản sao thực sự

Một primitive là một dữ liệu đơn lẻ, không thể chia cắt. Nó không thể bị phân tách thành các giá trị JavaScript nhỏ hơn. Ngôn ngữ này định nghĩa bảy kiểu primitive: String, Number, Boolean, Undefined, Null, Symbol, và BigInt.

Vì các primitive có tính nguyên tử (atomic), chúng thường nằm trực tiếp bên trong liên kết biến (variable binding). Khi bạn sao chép một biến primitive sang một biến khác, engine sẽ nhân bản dữ liệu thực tế. Mỗi biến sẽ nhận được một ô nhớ độc lập riêng.

let a = "Alina";
let b = a;
b = "Ali";

console.log(a); // "Alina"
console.log(b); // "Ali"

Ở đây, a vẫn không thay đổi. Việc gán lại b đã tạo ra một giá trị hoàn toàn mới và trỏ b vào đó, trong khi a vẫn giữ nguyên chuỗi ban đầu. Hành vi này được gọi là sao chép theo giá trị (copy by value). Nó hoạt động tương tự đối với số, boolean, symbol và các kiểu còn lại trong họ primitive. Bạn có thể truyền chúng vào hàm, gán lại hoặc trả về chúng mà không cần lo lắng về các tác dụng phụ (side effects) lên các biến liên quan.

Objects: Địa chỉ dùng chung

Objects thì khác. Một object là một vật chứa phức hợp nhóm nhiều mảnh dữ liệu lại với nhau. Danh mục này bao gồm các plain objects, arrays, functions, dates và mọi kiểu dữ liệu không phải primitive khác. Vì các cấu trúc này có thể trở nên lớn và lồng nhau, JavaScript không lưu trữ toàn bộ object bên trong biến. Thay vào đó, biến giữ một tham chiếu (reference), về cơ bản là một địa chỉ bộ nhớ trỏ đến dữ liệu thực tế được lưu trữ ở nơi khác.

Khi bạn gán một object cho một biến mới, engine sẽ sao chép địa chỉ chứ không phải sao chép object. Giờ đây, hai biến cùng trỏ vào một ngôi nhà duy nhất.

const user = { name: "Alina" };
const copy = user;
copy.name = "Ali";

console.log(user.name); // "Ali"

Việc thay đổi copy.name cũng làm thay đổi user.name vì cả hai tên đều trỏ về cùng một object cơ sở. Đây là sao chép theo tham chiếu (copy by reference). Sự bất ngờ tương tự cũng xảy ra với arrays:

const scores = [82, 91, 74];
const backup = scores;
backup.push(88);

console.log(scores); // [82, 91, 74, 88]

Chỉ có duy nhất một array trong bộ nhớ. scoresbackup chỉ đơn thuần là hai biển báo cùng trỏ vào nó.

Từ khóa const làm tăng thêm một tầng gây nhầm lẫn. Khai báo một object với const sẽ khóa liên kết biến để bạn không thể trỏ nó sang một địa chỉ mới. Nó không khóa chính bản thân object đó.

const settings = { theme: "dark" };
settings.theme = "light";        // Works perfectly.
settings = { theme: "dark" };    // TypeError

Các lập trình viên thường kỳ vọng const sẽ đảm bảo tính bất biến (immutability). Nhưng không phải vậy. Nó chỉ ngăn chặn việc gán lại tham chiếu. Nếu bạn muốn nội dung bên trong được đóng gói cố định, bạn phải thực hiện sao chép một cách có chủ đích.

Nơi các lỗi thực sự ẩn náu

Các lỗi tham chiếu hiếm khi xuất hiện dưới dạng gán lại biến một cách lộ liễu. Chúng ẩn mình bên trong các lời gọi hàm.

function addTimestamp(record) {
  record.timestamp = Date.now();
  return record;
}

const original = { id: 1 };
addTimestamp(original);

console.log(original.timestamp); // A number now exists here. Oops.

Tham số record đã nhận được một bản sao của tham chiếu. Bất kỳ sự thay đổi thuộc tính (property mutation) nào bên trong hàm đều ghi trực tiếp vào object của bên gọi. Hàm này trông có vẻ như một phép biến đổi đơn giản, nhưng nó lại làm rò rỉ trạng thái (state) qua các ranh giới phạm vi (scope boundaries).

Mô hình này đặc biệt gây đau đầu trong các UI framework như React, nơi mà việc cập nhật state phụ thuộc