Bàn về JS - Làm thế nào để xác định this?

Ở bài trước mình đã trình bày về this và call-site, ở bài này mình sẽ nói về cách xác định this:

  • Đầu tiên bạn xác định call-site, tức là xem hàm được gọi ở đâu chứ không phải được khai báo ở đâu.
  • Sau đó xem xét xem 4 luật dưới đây luật nào được áp dụng cho trường hợp của mình.

1. Default Binding: this tham chiếu tới global object

Nhớ rằng nếu trường hợp của bạn không ứng với luật nào khác, thì luật này được xài.

Xem xét đoạn code:

function foo() {
    console.log(this.a)
}
var a = 2
foo() // 2

Đầu tiên (nếu bạn chưa biết) là các biến khai báo ở global scope, như kiểu var a = 2, chính là thuộc tính của global object (điều này không đúng với NodeJS hoặc strict mode, với node thì thay vì var a = 2 ta sử dụng a = 2). Thứ hai, khi chạy code bạn sẽ thấy this.a chính là thuộc tính a của global object.

Default Binding đã được sử dụng ở đây, this trỏ tới global object.

Ờ, thế tại sao chúng ta lại biết trường hợp này là Default Binding ?

Trong đoạn code, foo() được gọi một cách thuần túy, không thông qua object, constructor, hay sử dụng một phương thức bind nào, thì Default Binding được áp dụng, có nghĩa là this trong hàm sẽ trỏ tới global object. Với strict mode, sẽ không bao giờ có Default Bindingglobal object undefined, nếu không có luật nào áp dụng, this của chúng ta sẽ là undefined 😟

2. Implicit Binding: this tham chiếu tới object ngữ cảnh

Xem xét đoạn code:

function foo() {
    console.log(this.a)
}
var obj = {
    a: 2,
    foo: foo
}
obj.foo() // 2

Đầu tiên, chú ý cái cách hàm foo() được khai báo và sau đó được thêm vào obj như kiểu một thuộc tính tham chiếu. Ta cần nắm được rằng, dù foo có khai báo bên trong obj hay được tham chiếu từ bên ngoài vào, thì cũng đều không có ý nghĩa rằng hàm này "thuộc về" obj.

Tuy nhiên, call-site (nơi hàm được gọi) sử dụng obj làm ngữ cảnh để tham chiếu tới hàm, nên ta "có thể" nói rằng obj "sở hữu" hàm foo() tại thời điểm hàm được gọi.

Khi một object được chỉ định làm ngữ cảnh cho hàm, luật Implicit Binding sẽ được áp dụng, khi đó this sẽ trỏ tới object ngữ cảnh. Do đó, this.a đồng nghĩa với obj.a và giá trị in ra là 2.

Implicitly lost:

Đây là một lỗi có thể sẽ gây đau đầu với những người chưa biết, khi mà Implicit Binding bị trôi về thành Default Binding.

Xem xét:

function foo() {
    console.log(this.a)
}
var obj = {
    a: 2,
    foo: foo
}
var bar = obj.foo // function reference/alias!
var a = "oops, global" // 'a' also property on global object
bar() // "oops, global"

Mặc dù bar trông có vẻ nhưng là được tham chiếu từ obj.foo, nhưng thực tế, nó chỉ đơn thuần là một tham chiếu khác của foo. Call-site ở đây chính là vấn đề, call-site là bar(), một cách gọi hàm thuần túy, không thêm thắt linh tinh, và Default Binding được sử dụng.

Trong thực tế, lỗi này thường xảy ra khi chúng ta truyền vào hàm một callback function:

function foo() {
    console.log(this.a)
}
function doFoo(fn) {
    // 'fn' is just another reference to 'foo'
    fn() // <-- call-site!
}
var obj = {
    a: 2,
    foo: foo
}
var a = "oops, global" // 'a' also property on global object
doFoo(obj.foo) // "oops, global"

3. Explicit Binding: Cha mẹ đặt đâu, con ngồi đấy.

Luật này được áp dụng khi ta gọi các hàm call(..), apply(...). Các hàm này nằm trong prototype của function.

Xem xét đoạn code sau:

function foo() {
    console.log(this.a)
}
var obj = {
    a: 2
}
foo.call(obj) // 2

Gọi foo thông qua hàm call() cho phép chúng ta gán this của foo cho obj. Do đó kết quả in ra 2.

Hard Binding: Chung thủy sắt son

Xem xét:

function foo() {
    console.log(this.a);
}
var obj = {
    a: 2
}
var bar = function() {
    foo.call(obj)
}
bar() // 2
setTimeout(bar, 100)  // 2
// hard-bound 'bar' can no longer have its 'this' overriden
bar.call(window) // 2

Ở đây, chúng ta tạo một hàm bar() và bên trong nó gọi foo.call(obj), do đó bắt buộc this của foo phải được bind với obj, dù this trong bar có bind với cái gì đi chăng nữa thì foo cũng sẽ bind với obj.

Binding kiểu này vừa rõ ràng vừa bền vững, nên người ta gọi là Hard Binding.

Vì đây là một pattern phổ biến, nên với ES5 nó được đưa vào thành built-in ultility, nằm trong Function.prototype, và ví dụ được sử dụng như sau:

function foo(something) {
    console.log(this.a, something)
    return this.a + something
}
var obj = {
    a: 2
}
var bar = foo.bind(obj)
var b = bar(3) // 2 3
console.log(b) // 5

bind(..) trả về một hàm mới mà đã được chỉ định sẵn this.

API call "context"

Còn một kiểu Explicit Binding nho nhỏ nữa giới thiệu nốt, đấy là một số hàm built-in cung cấp một optional paramteter, thường được gọi là "context", để mình truyền vào obj thay vì gọi bind(..), kiểu để đảm bảo callback function của mình gọi đúng theo cái "this" mà mình muốn ấy.

Ví dụ:

function foo(el) {
    console.log(el, this.id)
}
var obj = {
    id: "awesome"
}
// use 'obj' as 'this' for 'foo(..)' calls
var arr = [1, 2, 3]
arr.forEach(foo, obj)
// 1 awesome   2 awesome   3 awesome

Thực chất bên trong các hàm này cũng sử dụng Explicit Binding thông qua call(..) hoặc apply(..) ấy mà.

4. new Binding

Luật binding cuối cùng này có thể khiến ta nhớ lại một quan niệm sai lầm về function và object trong JS.

Trong các ngôn ngữ hướng đối tượng truyền thống như JAVA, C#, "constructor" là một hàm đặc biệt được gắn với class, và khi class được khởi tạo với một từ khóa new thì constructor của class sẽ được gọi. Kiểu trông thế này:

something = new MyClass(..);

JS cũng có toán tử new, và cái cách gọi cũng tương tự nên nhiều dev nghĩ rằng cơ chế sử dụng toán tử new này của JS giống kiểu các ngôn ngữ OOP khác. Tuy nhiên sự thực là không có tí liên quan nào trong việc sử dụng giữa 2 bên cả.

Đầu tiên, ta phải định nghĩa lại "constructor" trong JS là gì?

  • Trong JS, constructor chỉ đơn thuần là một hàm được gọi kèm với toán tử new đứng trước. Có nghĩa là giờ ông cứ gọi hàm có kèm theo new thì hàm đấy nó thành constructor.
  • Hàm này không gắn với class hay khởi tạo ra class, thậm chí nó cũng chẳng phải một hàm nào đặc biệt, nó chỉ một function bình thường mà được dùng kèm với new.
  • Khi gọi hàm với từ khóa new đứng trước, chúng ta đã gọi lên một constructor call. Đúng ra mà nói, không có cái gì gọi là "constructor function", mà nó chỉ đơn thuần là constructor call của function.

Khi có constructor call, những hành động sau sẽ được thực hiện:

  1. Một object mới được tạo ra (hay constructed)
  2. object mới này được gắn prototype
  3. object mới được set thành this cho hàm gọi
  4. nếu hàm không trả về object nào khác của nó, thì cái object mới tạo ra kia sẽ được trả về.

Xem xét đoạn code:

function foo(a) {
    this.a = a;
}
var bar = new foo(2)
console.log(bar.a) // 2

Bằng việc gọi foo(..) với new đứng trước, chúng ta đã tạo một object mới và set nó làm object cho this trong hàm foo. new là cách cuối cùng để bind this. Người ta gọi đó là new Binding.

Thứ tự ưu tiên giữa các luật:

  • Về cơ bản, bạn chỉ cần nhớ Explicit Binding > Implicit Binding > Default Binding
  • Còn new Binding thường không được dùng kết hợp với các loại binding khác.

Tổng kết:

Tóm tắt lại, nguyên tắc để xác định this được bind thành gì thì đầu tiên phải xem xét call-site, sau đó đặt ra những câu hỏi sau theo thứ tự, và dừng lại khi có luật tương ứng phù hợp:

  1. Hàm có được gọi với new không ?
  2. Hàm có được gọi với call(), apply() hay bind() không ?
  3. Hàm có được chỉ định context object không ?
  4. Nếu non-strict mode, thì this chính là global object.

Hy vọng với những kiến thức thu được sẽ giúp ích cho bạn 😃


Dịch và tóm tắt từ cuốn You Don't Know JS - this & Object Prototypes của Kyle Simpson