Mất kết nối với `this` khi gọi hàm JavaScript
1. Mở bài
Chào các bạn, this
là một trong những kiến thức cơ bản nhưng lại khó thẩm thấu của ngôn ngữ JavaScript, đặc biệt là các bạn mới học về JS hoặc chưa có nhiều kinh nghiệm làm việc với this
. Để hiểu rõ về cơ chế hoạt động chuyên sâu của nó, mình khuyến khích các bạn nên đọc chương "this & Object Prototypes" của bác Kyle Simpson trong series sách nổi tiếng "You Don't Know JS Yet".
Trong bài viết này, mình không có ý định giảng giải toàn bộ cách hoạt động của this
, mà chỉ muốn đưa ra một vấn đề thường xuyên gặp trong các buổi phỏng vấn về JavaScript, và đề xuất một số phương án mà bạn có thể trả lời người phỏng vấn. Hi vọng bài viết sẽ cung cấp cho bạn một số kiến thức hữu ích về JavaScript.
Trong bài viết này, mình sẽ dùng TypeScript để viết code, nhưng nó sẽ không làm thay đổi bản chất của vấn đề này trong JavaScript nhé.
2. Vấn đề về mất kết nối với this
khi gọi hàm
Hãy cùng đánh giá đoạn code đơn giản bên dưới:
class Dog {
name: string = '';
constructor(name: string) {
this.name = name;
}
speak(): void {
console.log(this.name);
}
}
const myDog = new Dog('Max');
myDog.speak(); // Max
const speakFn = myDog.speak; // không phải myDog.speak() nhé các bạn
speakFn(); // TypeError: Cannot read properties of undefined (reading 'name')
Ở dòng code cuối cùng, chúng ta tạo một biến mới speakFn
và tham chiếu đến hàm myDog.speak
với mong muốn là giữ kết nối đến hàm speak
của object myDog
. Tuy nhiên khi chúng ta gọi hàm speakFn()
, nó không hề xuất ra Max
như cách chúng ta gọi myDog.speak()
mà lại văng ra lỗi TypeError
(vì this
khi ấy là undefined
).
- Tại sao vậy? Tại sao
this
lại mất kết nối đếnmyDog
? - Tại sao
this
lại làundefined
mà không phải object nào khác (nhưwindow
ở môi trường browser hayglobal
ở môi trường node)? - Làm thế nào để giữ kết nối đến
myDog
?
Trên đây là một số câu hỏi mà bạn có thể nhận được từ đề bài trên.
3. Tại sao this
lại mất kết nối đến myDog
?
Để trả lời câu hỏi này một cách thấu đáo, một lần nữa mình khuyến khích các bạn đọc chương "this & Object Prototypes" của bác Kyle Simpson trong series sách nổi tiếng "You Don't Know JS Yet". Nói ngắn gọn, để xác định được this
, bạn cần hiểu về "call-site", tức là nơi hàm được gọi (invocation) chứ không phải nơi hàm được khai báo (declaration). Trong trường hợp này, hàm được gọi theo cách thông thường mà không có một đối tượng đi kèm phía trước speakFn()
, nên nó sẽ tuân theo quy tắc "Default Binding" và this
sẽ là global object: window
nếu bạn chạy ở môi trường browser, hoặc global
nếu ở môi trường node.
STOP, khi mình chạy đoạn code trên, this
hoàn toàn không phải là object window
, tại sao vậy??? Mình sẽ giải thích ở phía cuối bài viết nhé, tạm thời bạn hãy hiểu về quy tắc "Default Binding" này trước.
4. Giải pháp 1 - dùng hàm bind để "ép" giữ lại kết nối
Để khi gọi hàm speakFn()
mà this
trong đó được giữ tham chiếu đến myDog
và xuất ra giá trị mong muốn (Max
), bạn có thể dùng một hàm khá nổi tiếng: bind
...
constructor(name: string) {
this.name = name;
this.name = this.name.bind(this); // Dòng này nè các bạn
}
...
const speakFn = myDog.speak;
speakFn(); // Max
Bằng cách dùng ép context của hàm name bằng cách dùng .bind(this)
, chúng ta đã "hard-code" context của hàm speak với object sở hữu (trong trường hợp này là myDog
). Nên dù bạn có tạo biến mới và tham chiếu đến nó, thì nó vẫn giữ context đến myDog
.
Vì sao mình gọi là "ép"? Vì nếu bạn cố tình thay đổi kết nối đến object khác thì chỉ thất bại mà thôi. Ví dụ:
const anotherDog = new Dog('John');
const speakFn = myDog.speak.bind(anotherDog);
speakFn(); // still "Max"
5. Giải pháp 2 - dùng hàm bind để "linh hoạt" giữ lại kết nối
Vẫn dùng hàm bind
, nhưng theo cách linh hoạt hơn:
...
constructor(name: string) {
this.name = name;
// chúng ta không bind ở đây nữa
}
...
const speakFn = myDog.speak.bind(myDog); // mà bind ở đây nè
speakFn(); // Max
Tại sao mình lại gọi là "linh hoạt"? Vì bạn có thể thay đổi context theo ý muốn. Ví dụ:
const anotherDog = new Dog('John');
const speakFn = myDog.speak.bind(anotherDog);
speakFn(); // John
Bạn lưu ý rằng không phải lúc nào "linh hoạt " cũng tốt và "ép" là xấu. Tùy vào tình huống cụ thể, việc ép context có thể sẽ tốt theo ý muốn của chúng ta để đảm bảo context không bị thay đổi ngoài ý muốn (và cũng giữ cho việc gọi hàm được đơn giản).
6. Giải pháp 3 - dùng arrow function
Một giải pháp khác mà đơn giản là dùng arrow function khi khai báo hàm speak
:
class Dog {
name: string = '';
constructor(name: string) {
this.name = name;
}
speak = (): void => {
console.log(this.name);
}
}
const myDog = new Dog('Max');
const speakFn = myDog.speak;
speakFn(); // Max
7. Giải pháp 4 - dùng TypeScript decorator
Cách này về bản chất vẫn chỉ là dùng hàm bind
thôi, nhưng mình có thể tạo một decorator để giúp code của chúng ta nhìn chuyên nghiệp, dễ chia sẻ và tái sử dụng hơn. Hãy thử xem qua đoạn code hoàn chỉnh sau khi "trang trí" sẽ trông như thế nào nha:
class Dog {
...
@bound
speak() {
console.log(this.name);
}
}
Bạn có để ý thấy gì khác biệt không? Đó chính là @bound
, một decorator giúp hàm speak tự bind với object sở hữu, tương tự như giải pháp #1. Hãy cùng xem chúng ta xây dựng decorator @bound
như thế nào nhé:
function bound(originalMethod: any, context: ClassMethodDecoratorContext) {
const methodName = context.name;
context.addInitializer(function () {
this[methodName] = this[methodName].bind(this);
});
}
Chúng ta chỉ đơn giản là khai báo một hàm như trên thôi là đã tạo thành công một decorator rồi đó (bạn lưu ý là chạy trên TypeScript 5.x nhé, không là sẽ bị lỗi đấy). Có thể bạn thấy việc dùng Decorators cho bài toán này có phần rườm rà không cần thiết. Thực ra mình dùng nó để demo thôi, chứ Decorators còn có rất nhiều lợi ích khác nữa, bạn có thể tự tìm hiểu thêm (chẳng hạn như tự động cache kết quả gọi hàm, hay chèn thêm biến số, hay ghi log tự động...)
8. Tại sao this
lại là undefined
mà không phải global object?
Nếu bạn đọc chương sách mà mình giới thiệu ở trên, bạn sẽ nhận ra rằng với quy tắc Default Binding, this
đúng ra phải giữ tham chiếu đến window
object (môi trường browser) hoặc global
object (môi trường node). Tuy nhiên nếu bạn đọc kĩ hơn, bạn sẽ thấy rằng ở chế độ strict mode, this
sẽ không tham chiếu đến global object nữa mà sẽ là undefined
. Nói đơn giản là việc tham chiếu đến một global object tiềm ẩn rủi ro lỗi không mong muốn, nên khi bật chế độ "strict mode", nó sẽ ngăn việc đó (nó còn ngăn nhiều thứ khác nữa, bạn tự tìm hiểu thêm nhé). Để bật strict mode, bạn chỉ cần thêm một dòng "use strict";
là xong.
Nhưng mà, bạn có thể sẽ hỏi: "mình có thấy dòng "use strict";
nào được sử dụng đâu??? Câu trả lời cũng rất giản, vì nó đã bật sẵn cho bạn rồi . Nói cụ thể hơn, body của Classes (ES6) sẽ được chạy ở chế độ strict mode dù bạn không chủ đích khai báo nó. Bạn tham khảo thêm ở đây.
9. Kết bài
Theo kinh nghiệm cá nhân của mình, khi phỏng vấn bạn không nên chỉ đưa ra giải pháp một cách máy móc (giống học thuộc lòng), mà hãy trả lời kèm theo những lời giải thích hợp lý và chia sẻ kinh nghiệm cá nhân của bạn (chẳng hạn như cách ứng dụng vào những trường hợp cụ thể mà bạn đã từng áp dụng). Như vậy mình tin rằng người phỏng vấn sẽ ấn tượng và đánh giá cao bạn hơn. Chúc bạn thành công.
All rights reserved