Hiểu đúng về tham chiếu JavaScript
Có nhiều người hay phàn nàn rằng JavaScript là thứ ngôn ngữ kỳ quặc, tiềm ẩn nhiều thứ làm cho lập trình viên gây nhiều lỗi, tôi cho rằng không hẳn do JS, chẳng qua là chúng ta chưa đủ hiểu nó. Tham chiếu trong JS cũng vậy, mặc dù cơ bản nhưng có những điểm mà ngay cả một số lập trình viên lâu năm bỏ sót và đôi lúc gặp sai lầm khó hiểu.
Recap
Tôi đã đề cập về phép so sánh tham chiếu ở bài Tìm hiểu về useCallback về sự ảnh hưởng của nó đối với useMemo hay memo trong React. Để bắt đầu tìm hiểu về phép tham chiếu của JavaScript chúng ta hay xem lại những phép so sánh này.
true === true // true
false === false // true
1 === 1 // true
'a' === 'a' // true
{} === {} // false
[] === [] // false
function foo() {return 0 }
function bar() { return 0 }
foo === bar; // false
foo === foo; // true
Khi bắt đầu học lập trình, bạn sẽ được học là “hãy tưởng tượng biến (variable) như một cái hộp, việc bạn đặt một đồ vật vào cái hộp thì vật đó cũng tương tự như giá trị của biến”. Thực chất, cách hoạt động của biến không phải như vậy, nhưng mà mới học mà giải thích nhiều quá thì chắc bỏ học luôn 🤣
let box = "flowers"
Vậy tham chiếu trong JS là gì?
Trong JavaScript, tham chiếu có mặt ở mọi nơi, nhìn có vẻ giống là biến nhưng thực ra nó vô hình. Ở trong một số ngôn ngữ, nó gọi thẳng là pointer (như C hay Go gì đó), nhưng mà JS thì không có pointer, cả cú pháp dành cho nó cũng không có.
Lấy ví dụ, chúng ta đặt một biến box với có giá trị books
let box = "books"
Thì lúc này, xét về bản chất, thì box được “trỏ” vào books , nghĩa là thực chất books mới chính là cái hộp chứ không phải box như mô tả lúc ban đầu.
Tiếp theo, ta gán một giá trị mới cho box
box = "cats"
Lúc này, books không bị thay bằng cats mà books vẫn tồn tại đâu đó trong ứng dụng. Tức là một cái hộp mới được tạo ra với tên là cats và biến books được gán lại với cái hộp mới tên là cats.
Lưu ý: Các giá trị trước đó tuy nó vẫn tồn tại đâu đó nhưng hệ thống sẽ có một cơ chế riêng gọi là garbage collector sẽ làm nhiệm vụ thu gom.
Nếu thử gán giá trị cho một biến của hàm, bạn sẽ thấy là trong hàm có thể thay đổi giá trị nhưng ngoài hàm sẽ vẫn không thay đổi.
function defineWord(word) {
word = "world"
console.log(word)
}
let globalWord = "hello"
defineWord(globalWord) // cái này sẽ in ra console là "world"
console.log(globalWord) // cái này sẽ in ra console là "hello"
Nguyên nhân là vì việc gán lại giá trị phía bên trong hàm chỉ áp dụng ở bên trong hàm đó, không thay đổi gì ở bên ngoài hàm. Bạn nào nắm về khái niệm closure, scope trong JavaScript thì cũng biết rằng bên trong hàm có thể access được giá trị biến global nhưng bên ngoài thì không access được bên trong hàm.
Xét về mặt bản chất, chỉ có mỗi biến globalWord ở bên ngoài được gán vào “hello”, nhưng đối với phía bên trong hàm thì cả globalWord và word đều được gán vào “hello”.
Sau khi gán lại word=”world” thì world đã có một giá trị mới là “world” nhưng với globalWord thì vẫn không thay đổi, nên globalWorld vẫn giữ nguyên giá trị cũ.
Đây là cách hoạt động của việc gán giá trị vào biến trong JavaScript, việc gán lại giá trị cho một biến thì chỉ thay đổi giá trị của biến đó, nó không thay đổi giá trị của các biến khác nếu các biến đó trỏ về cùng một giá trị. Điều này luôn đúng với bất kỳ các loại giá trị nào như là string, boolean, number, array, object, function....
Các kiểu giá trị
JavaScript có 2 danh mục lớn cho các kiểu giá trị, và 2 danh mục đó cũng khác nhau về nguyên tắc gán giá trị, hay phép tham chiếu bình đẳng (referential equality). Đó là:
Kiểu giá trị nguyên thủy (primitive)
Các kiểu giá trị nguyên thủy bao gồm: string, number, bigint, boolean, symbol, undefined, null. Các loại giá trị này là bất biến (immutable/read-only) không thể thay đổi.
Khi một biến được gán giá trị thuộc kiểu primitive, ta sẽ không thay đổi giá trị của nó được, cách duy nhất là gán nó vào một giá trị khác mà thôi, đây là vấn đề quan trọng.
Nói theo cách ví von ở trên là khi một giá trị bên trong hộp là string, number, boolean, bigint, symbol, undefined, null, ta sẽ không thay đổi được cái hộp, chỉ có cách là tạo ra một cái hộp mới.
Ví dụ cho lý do tại sao tất cả các phương thức cho string sẽ trả về một string mới thay vì thay đổi string đó, trong trường hợp mà bạn muốn thay đổi giá trị thì phải lưu nó ở đâu đó.
let word = "Hello";
word.toLowerCase();
console.log(word); // vẫn là "Hello" viết hoa
word = word.toLowerCase();
console.log(word) // giờ mới là "hello"
Tính bất biến
Ví dụ trên đã cho chúng ta khái niệm về tính bất biến, Nếu ta truyền một giá trị primitive vào một hàm, biến gốc mà ta đã truyền vào được đảm bảo được giữ nguyên, hàm sẽ không thể thay đổi được gì biến đó. Và ta cũng sẽ yên tâm rằng biến sẽ luôn giống nhau khi gọi bất kì một hàm.
function pureFunc(a, b) {
return a + b
}
// Trong trường hợp chúng ta làm thay đổi giá trị, hàm trả về giá trị mới có sự thay đổi biến nhưng nó không ảnh hưởng đối với phía bên ngoài hàm
function mutableFunc(a,b) {
a = a + b
return a
}
var x = 2
var y = 2
mutable(x, y) // => 4
Nhiều lập trình viên JS thích viết theo hướng bất biến vì nó dễ nhận biết code hoạt động như thế nào, và biến cũng không bị thay đổi ngoài mong muốn. Nếu mà hàm được viết theo kiểu bất biến thì chắc chắn chúng ta cũng không cần lo cái gì sẽ xảy ra.
Hàm thuần (pure function) cũng là một trong những cốt lõi của functional programming, một hàm được gọi à “pure” khi nó không thay đổi đối số của nó (giá trị truyền vào tham số), hoặc không thay đổi bất kỳ thông tin nào nằm ngoài nó. Nếu muốn thay đổi giá trị của đối số, nó sẽ trả về một giá trị mới để đảm bảo giá trị ban đầu của đối số không thay đổi.
Lợi ích của việc sử dụng hàm thuần là sự linh hoạt khi nó có thể quyết định việc sử dụng giá trị mới như thế nào.
Các kiểu khác như: object, array, function...
Các loại như object, array, function, và cả Map, Set, thực ra trong JavaScript gọi chung kiểu Object. Một điểm khác biệt lớn giữa object type và primitive type là object type có thể thay đổi được (mutable) giá trị, tức là ta có thể đổi cái hộp.
Ok, phần này khá thú vị. Chúng ta bắt đầu bằng một ví dụ sau:
let picture = {
name: "Mona Lisa",
artist: "Da Vinci",
isAbstract: false,
}
Mỗi property của object mySong được gán vào những giá trị khác nhau
Bây giờ ta chạy đoạn code
picture.name = “Starry night”
picture.artist = “Van Gogh”
Biến picture không hề thay đổi, nó vẫn trỏ về đúng một object, và chỉ có các thuộc tính bên trong nó thay đổi. Và tất nhiên, thuộc tính nằm trong nó cũng thay đổi đúng như cách hoạt động của biến được gán giá trị primitive, chỉ khác ở chỗ là ta phải truy cập biến bằng cách gọi picture.name , và gán cho nó một giá trị khác thông qua cách truy cập đó.
Đối với kiểu object (object, array, function, Map, Set), chúng ta sẽ không có sự đảm bảo tính bất biến, giả sử như bạn truyền một object vào một hàm thì hàm đó có khả năng thay đổi object đó, hoặc nếu bạn truyền một array vào hàm thì hàm đó có thể thêm, bớt, thậm chí xóa toàn bộ dữ liệu bên trong array.
Đến đây, điều quan trọng là ta cần nắm rõ là object không thay đổi. Giả sử như ta copy picture qua một biến khác và gán lại giá trị cho nó:
let picture = {
name: "Mona Lisa",
artist: "Da Vinci",
isAbstract: false,
}
let copyOfPicture = picture
copyOfPicture.isAbstract = true
console.log(copyOfPicture === picture) // Trả về true
Tức là dòng let copyOfPicture = picture trỏ copyOfPicture vào object picture hiện tại, nghĩa là nó không hẳn là một bản copy.
Dòng console.log cuối cùng chứng minh rằng picture vẫn tương đương với copyOfPicture, vì nó vẫn trỏ vào chung một object và không thay đổi gì khối object đó mà chỉ thay đổi nội tại bên trong nó.
Khi ta thực hiện phép gán copyOfPicture bằng picture , JS lập tức tìm cái mà picture đang được gán vào và trỏ vào đó luôn chứ không phải trỏ vào picture. Điều này khá thuận tiện vì ta không cần phải nhớ là biến nào trỏ vào biến nào, rất rối rắm.
Thay đổi Object bên trong hàm
Như đoạn trước chúng ta đã nhắc đến việc gán lại một biến bên trong hàm thì nó sẽ không bị lộ ra ngoài hàm.
function defineWord(word) {
word = "world"
console.log(word)
}
let globalWord = "hello"
defineWord(globalWord) // cái này sẽ in ra console là "world"
console.log(globalWord) // cái này sẽ in ra console là "hello"
Nhưng đó là đối với giá trị primitive.
Vậy nếu mà hàm đó nhận một object và chúng ta thay đổi giá trị bên trong object đó.
function checkoutBook(book) {
book.isCheckedOut = true
return book
}
let book = { title: "Dế mèn phiêu lưu ký", author: "Tô Hoài", isCheckedOut: false } checkoutBook(book);
Và tình huống xảy ra là object book vẫn giữ nguyên, nó chỉ bị thay đổi bằng cách gán lại thuộc tính bên trong object vào một biến khác, không quan trọng bên trong hàm hay ngoài hàm, sự thay đổi chỉ diễn ra bên trong phép gán.
Để tránh tình huống không mong muốn người ta sẽ viết hàm này bằng cách không gây đột biến dữ liệu cho biến bên ngoài bằng cách tạo một bản copy là biến nằm trong hàm.
function checkoutBook(book) {
let copy = { ...book }
copy.isCheckedOut = true
return copy
}
let book = { title: "Dế mèn phiêu lưu ký", author: "Tô Hoài", isCheckedOut: false }
book = checkoutBook(book);
👨💻 functional programmer 👍
Vài ví dụ thực tế
Sau đây tôi sẽ thêm vài ví dụ mà có thể trong quá trình làm việc, các bạn cũng có thể sẽ gặp:
Ví dụ 1:
document.addEventListener('click', () => console.log('clicked'));
document.removeEventListener('click', () => console.log('clicked'));
Các bạn có đoán được là hàm () => console.log('clicked') có được loại bỏ khỏi DOM hay không?
Chắc các bạn cũng hình dung được là rõ ràng 2 cái hàm mũi tên này là khác nhau, vì vậy là cái hàm đầu tiên sẽ không bao giờ bị loại bỏ khỏ DOM.
Ví dụ tiếp theo, hàm này sắp xếp và trả về số nhỏ nhất trong array
function minimum(array) {
array.sort();
return array[0]
}
const items = [5, 1, 8, 4];
const min = minimum(items);
console.log(min);
console.log(items);
Các bạn đoán thử xem chuyện gì sẽ xảy ra? 1 và [5, 1, 8, 4] chăng?
Kết quả có thể không như chúng ta nghĩ, bởi vì hàm sort() đã làm thay đổi array gốc.
Trên thực tế bạn sẽ gặp rất nhiều trường hợp như vậy nhưng hãy nhớ nguyên tắc là biến gán primitive value sẽ không đổi và không bị leak ra ngoài hàm, còn các loại khác thì có.
Hy vọng thông qua bài viết này, các bạn cũng đã có một cái nhìn sâu hơn về phép gán tham chiếu trong Javascript và tránh được những sai sót không đáng có trong quá trình áp dụng.