0

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).

  1. Tại sao vậy? Tại sao this lại mất kết nối đến myDog?
  2. 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 hay global ở môi trường node)?
  3. 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()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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí