Bàn về this trong JavaScript - Tại sao lại là "this"

Một trong những cơ chế gây nhầm lẫn nhiều nhất trong JS, ngay cả với những developer có kinh nghiệm, đó là từ khóa this. Nó là một định danh rất đặc biệt được mặc định có ở tất cả các scope của function, nếu bạn từng chạy debug JS sẽ thấy Không khó để hiểu this, tuy nhiên khi mà phần lớn chúng ta đều đã từng học các ngôn ngữ hướng đối tượng thuần như Java, C#,... thì khi chuyển sang tìm hiểu JS, this trở thành một vấn nạn bởi chính cái tên của nó.

Tại sao lại là "this"

Nếu this rất dễ gây nhầm lẫn, thậm chí với developer có kinh nghiệm, vậy tại sao this được sử dụng? Có phải rắc rối của nó nhiều hơn là lợi ích mang lại? Trước khi tim hiểu this hoạt động thế nào, chúng ta hãy xem tại sao người ta dùng this. Đoạn code dưới đây minh họa về tiện ích của this.

function identify() {
    return this.name.toUpperCase()
}
function speak() {
    var greeting = "Hello, I'm " + identify.call(this);
    console.log(greeting)
}
var me = {
    name: "Huy"
}
var you = {
    name: "Reader"
}
identify.call(me)   // HUY
identify.call(you)  // READER
speak.call(me)      // Hello, I'm HUY
speak.call(you)     // Hello, I'm READER

Đoạn code trên cho phép các hàm identify() và speak() được sử dụng lại dưới nhiều ngữ cảnh khác nhau (meyou), hơn là việc phải chia tách phiên bản của mỗi hàm cho mỗi ngữ cảnh. Nhiều người cũng thấy ngay rằng thay vì dựa vào this, mình có thể thay bằng như sau:

function identify(context) {
    return context.name.toUpperCase()
}
function speak(context) {
    var greeting = "Hello, I'm " + identify(context);
    console.log(greeting)
}
var me = {
    name: "Huy"
}
var you = {
    name: "Reader"
}
identify(you);  // READER
speak(me);      // Hello, I'm HUY

Hiện tại, mình chỉ có thể nói về mặt lý thuyết, sử dụng this cung cấp một cách truyền object vào function gọn gàng hơn, giúp ta xây dựng một giao diện ứng dụng trông clean hơn và dễ dàng reuse. Khi các mô hình lập trình bạn sử dụng ngày càng phức tạp, thì bạn sẽ thấy rõ ràng hơn rằng việc truyền ngữ cảnh sử dụng explicit parameter thường lộn xộn hơn là sử dụng this. Đến khi tìm hiểu object và prototype, bạn sẽ thấy những lợi ích của việc sử dụng các function có khả năng tự động tham chiếu tới ngữ cảnh thích hợp (chính bởi việc sử dụng this). 😌

Các hiểu lầm thường thấy về "this"

Cái tên this gây ra nhiều rắc rối khi developer cố gắng hiểu nó theo nghĩa đen. Người ta thường hiểu nó theo 2 cách sau, mà cả hai đều sai:

Itself

Sai lầm đầu tiên đó là nghĩ rằng this tham chiếu tới function "object" (trong JS tất cả các function đều là object) mà nó được gọi. Đây có thể là điều dễ hiểu vì nó liên quan tới ngữ nghĩa. Tại sao bạn muốn tham chiếu tới function từ bên trong chính nó? Lý do phổ biến nhất chắc là dùng đệ quy. Nhưng rất tiếc this lại không làm được thế. Xem xét đoạn code:

function foo(num) {
    console.log("foo: " + num)
    // keep track of how many times 'foo' is called
    this.count++
}
foo.count = 0
var i
for (i = 0; i < 10; i++) {
    if (i > 5) {
        foo(i)
    }
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// how many times was 'foo' called?
console.log(foo.count) // 0 -- WTF?

foo.count vẫn bằng 0, mặc dù console.log đã xác định cho ta thấy foo(...) được gọi 4 lần.

Khi câu lệnh foo.count = 0 chạy, nó sẽ thêm vào function object foo một biến count, ô kê cái này chuẩn không lệch đi đâu được. Nhưng với lệnh this.count bên trong thân hàm mục đích dùng để tăng count lên, thì this lại không trỏ đến function object, thế nên mặc dù tên property có vẻ giống đấy, nhưng object gốc lại khác nhau, và thế là chúng ta lại ngớ người ra không hiểu tại sao 😆 Tuy nhiên thay vì tìm hiểu tại sao this nó sida, nhiều developer thường tránh vấn đề này và sử dụng một cách giải quyết khác, ví dụ:

function foo(num) {
    console.log("foo: " + num)
    // keep track of how many times 'foo' is called
    count++
}
var count = 0
var i
for (i = 0; i < 10; i++) {
    if (i > 5) {
        foo(i)
    }
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// how many times was 'foo' called?
console.log(count) // 4

Đây là cách đơn giản nhất mà có thể giải quyết được vấn đề, cách thứ 2 bạn có thể làm như sau:

function foo(num) {
    console.log("foo: " + num)
    // keep track of how many times 'foo' is called
    foo.count++
}
foo.count = 0
var i
for (i = 0; i < 10; i++) {
    if (i > 5) {
        foo(i)
    }
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// how many times was 'foo' called?
console.log(foo.count) // 4

Và cách thứ 3 chúng ta sẽ giải quyết ông this:

function foo(num) {
    console.log("foo: " + num)
    // keep track of how many times 'foo' is called
    this.count++
}
foo.count = 0
var i
for (i = 0; i < 10; i++) {
    if (i > 5) {
        foo.call(foo, i)
    }
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// how many times was 'foo' called?
console.log(foo.count) // 4

Its Scope

Quan niệm sai lầm phổ biến tiếp theo là nghĩ rằng this tham chiếu tới lexical scope của function. this không thể làm vậy. Nếu xem xét bên trong JS, "scope" là một loại object với các thuộc tính chính là các biến và hàm được khai báo trong scope, nhưng bạn không thể truy cập tới scope "object" trong code, đó là phần bên trong mà chỉ engine mới động chạm vào được. Do đó, việc nghĩ rằng this tham chiếu tới scope "object" là hoàn toàn sai. Xem xét đoạn code sau:

function foo() {
    var a = 2
    this.bar()
}
function bar() {
    console.log(this.a)
}
foo() // ReferenceError: a is not defined

Có 2 vấn đề trong đoạn code ở trên. Trông thì có vẻ chẳng có ai code thế này bao giờ, nhưng thực ra đoạn code này đã từng được chia sẻ trên những forums hỏi đáp lập trình. Đây là một mình chứng cho việc nhầm lẫn khi sử dụng this. Đầu tiên là việc gọi hàm bar() thông qua this.bar(). Trên browser, mọi thứ vẫn hoạt động bình thường (trong Node thì không), tuy nhiên ví dụ trên chỉ là một trường hợp đặc biệt khiến hàm bar() có thể chay đúng. Còn cách đúng để gọi hàm bar() là phải bỏ "this." đi. Thứ hai, người viết đoạn code trên đang cố gắng tạo một cầu nối giữa scope của foo() và bar(), từ đó bar() có thể truy cập vào biến a vì scope của bar là inner scope của foo() => Sai. Nếu muốn gọi this.a có nghĩa là this phải tham chiếu tới scope "object", và như đã nói, scope "object" là không thể truy cập trong code và this cũng không được thiết kế để đi ngược chân lý.

"this" là gì?

Như đã nói, biến this có mặt ở scope của tất cả các function, và this vốn là một cơ chế binding, this được bind tại runtime, không phải author-time. Ngữ cảnh của this dựa trên điều kiện gọi hàm. Việc bind this không liên quan gì tới nơi hàm được khai báo, mà phụ thuộc hoàn toàn vào cách hàm được gọi. Khi một hàm được gọi, một ngữ cảnh thực thi sẽ được tạo ra. Nó bao gồm các thông tin về hàm được gọi ra ở đâu, như thế nào, biến nào được truyền vào,... Một trong những thuộc tính của ngữ cảnh này chính là this, mà sẽ được dùng trong khoảng thời gian hàm thực thi. Trong bài sau, mình sẽ bàn đến việc làm thế nào để tìm ra this.

Call-site

Để hiểu cơ chế binding của this, ta phải hiểu call-site: vị trí mà hàm được gọi (không phải chỗ hàm được khai báo nhé). Để trả lời cho câu hỏi: this tham chiếu tới cái gì ?, chúng ta cần tìm ra call-site của hàm. Tìm call-site đơn thuần là "đi tới nơi mà hàm được gọi", tuy nhiên không phải lúc nào cũng đơn giản như cái nghĩa đen của nó. Điều quan trọng để tìm ra call-site là phải chú ý tới call-stack, call-site mà chúng ta quan tâm nằm bên trong lời gọi phía trước hàm đang thực hiện. Có vẻ hơi khó hiểu, mình sẽ minh họa bằng đoạn code sau:

function baz() {
    // call-stack is: 'baz'
    // so, out call-site is in the global scope
    console.log("baz")
    bar() // <-- call-site for 'bar'
}
function bar() {
    // call-stack is: 'baz' -> 'bar'
    // so, out call-site is in 'baz'
    console.log("bar")
    foo() // <-- call-site for 'foo'
}
function foo() {
    // call-stack is: 'baz' -> 'bar' -> 'foo'
    // so, our call-site is in 'bar'
    console.log("foo")
}
baz(); // <-- call-site for 'baz'

Kết

this thực tế là một cơ chế binding mà được tạo ra khi function được gọi, và nó tham chiếu đến cái gì hoàn toàn phụ thuộc vào call-site, nơi function được gọi. Ở bài sau mình sẽ trình bày cách dựa vào call-site để tìm ra this.


Trong bài viết mình có đề cập đến lexical scope, đây là một khái niệm tương đối hàn lâm trong JS, bạn nào chưa hiểu có thể google hoặc chờ trong những bài blog sau của mình ^^