"this" trong JavaScript

Bài viết gốc: https://manhhomienbienthuy.bitbucket.io/2016/Mar/28/understanding-and-mastering-javascripts-this-keyword.html

Từ khóa this của JavaScript là một trong những khái niệm cơ bản nhưng cũng dễ gây nhầm lẫn nhất của ngôn ngữ này. Trong bài viết này, chúng ta sẽ dần dần tìm hiểu và làm sáng tỏ this, sao cho từ bây giờ, bạn không cần phải lo lắng về nó nữa. Chúng ta sẽ tìm hiểu cách sử dụng this một cách chính xác trong các tình huống khác nhau, kể cả những trường hợp nhạy cảm, nơi mà this rất khó nắm bắt.

Về cơ bản, chúng ta sử dụng this tương tự như cách mà chúng ta sử dụng đại từ nhân xưng trong tiếng Anh. Ví dụ câu tiếng Anh như sau:

John is running fast because he is trying to catch the train.

Đại từ nhân xưng "he" trong câu này, là để chỉ John. Chúng ta có thể viết nó lại như sau mà ý nghĩa không thay đổi:

John is running fast because John is trying to catch the train.

Tuy nhiên, chúng ta không cần thiết phải gọi lại John trong tình huống này, như vậy câu bị lặp lại và nghe không được hay lắm. Trong trường hợp này, chúng ta có thể dùng đại từ nhân xưng để thay thế, câu văn nghe sẽ hay hơn.

Cũng tương tự như vậy, trong JavaScript, chúng ta dùng từ khóa this để đại diện cho một đối tượng. Đối tượng đó là chủ thế của ngữ cảnh, hoặc là chủ thế của code đang được chạy.

Chúng ta hãy xem xét ví dụ sau:

var person = {
    firstName: "Anh",
    lastName: "Tranngoc",
    fullName: function() {
        // Việc sử dụng "this" cũng tương tự như việc sử dụng "he"
        // trong câu tiếng Anh ở trên.
        console.log(this.firstName + " " + this.lastName);
        // Chúng ta cũng có thể viết thế này.
        console.log(person.firstName + " " + person.lastName);
    }
}

Nếu chúng ta sử dụng person.firstNameperson.lastName như ví dụ ở trên, code của chúng ta có thể sẽ trở nên khó hiểu. Tất nhiên, nếu chỉ có code đơn giản như ví dụ trên thì nó sẽ chạy tốt.

Tuy nhiên, hãy thử tưởng tượng, sau một thời gian phát triển, code JavaScript của bạn đã khá lớn. Và chẳng biết vô tình hay cố ý, person ở trên là một biến cục bộ trong một hàm nào đó, và tồn tại một biến toàn cục khác cũng có tên là person.

Lúc này, nếu như có lỗi xảy ra, việc debug có thể khá khó khăn. Rất có thể, bạn sẽ mất kha khá thời gian thắc mắc xem, việc tham chiếu person.firstName có thể sẽ gọi đến thuộc tính firstName của biến toàn cục hay biến cục bộ khi có 2 biến với tên giống nhau.

Để tránh tự làm khó mình (và làm khó cả những người cùng team), chúng ta nên sử dụng từ khóa this, không chỉ vì sự thẩm mỹ mà còn vì sự chính xác. Việc sử dụng this làm code của chúng ta rõ ràng hơn, giống như đại từ nhân xưng trong tiếng Anh vậy.

Cũng giống như đại từ nhân xưng "he" dùng để chỉ các tiền đề (danh từ mà đại từ đó chỉ đến), từ khóa this của JavaScript cũng tương tự, được sử dụng để tham chiếu đến các đối tượng mà hàm (nơi mà this được sử dụng) bị ràng buộc.

Từ khóa this không chỉ tham chiếu đến đối tượng, mà nó còn chứa giá trị của đối tượng đó. Cũng như một đại từ nhân xưng, this có thể hiểu là một sự thay thế rõ ràng để tham chiếu đến các đối tượng ở trong ngữ cảnh (đối tượng tiền đề). Chúng ta sẽ tìm hiểu thêm về ngữ cảnh ở những phần sau.

Những điều cơ bản về từ khóa this của JavaScript

Trước hết, bạn phải hiểu rằng, tất cả các hàm của JavaScript đều có thuộc tính, giống như mọi đối tượng khác. Và khi một hàm được thực thi, nó sẽ có thuộc tính this - một biến với giá trị là đối tượng đã gọi hàm sử dụng this.

this luôn luôn tham chiếu (và có giá trị của) đối tượng - một đối tượng duy nhất - và nó thường được sử dụng bên trong hàm hay phương thức, mặc dù nó có thể dùng được một cách toàn cục bên ngoài hàm.

Lưu ý rằng, nếu chúng ta sử dụng strict mode, this sẽ là undefined trong các hàm toàn cục và trong các hàm vô danh không ràng buộc đối tượng nào cả.

Nếu this được sử dụng bên trong một hàm (tạm gọi là hàm A) thì nó sẽ chứa giá trị của đối tượng gọi hàm A. Chúng ta cần this để truy cập đến các phương thức và thuộc tính của đối tượng gọi hàm A, bởi vì chúng ta thường không biết tên của đối tượng đó. Thậm chí có lúc không có tên nào có thể dùng để tham chiếu đến đối tượng, chúng ta chỉ có thể dùng this mà thôi. Thực tế, this có thể coi là một tham chiếu đến "đối tượng tiền đề" - đối tượng gọi hàm.

Dưới đây là một ví dụ rất cơ bản minh họa cho việc sử dụng this trong JavaScript:

var person = {
    firstName: "Anh",
    lastName: "Tranngoc",
    // Bởi vì từ khóa "this" được sử dụng trong phương thức
    // showFullName dưới đây, và phương thức này được định nghĩa trong
    // đối tượng person, "this" sẽ có giá trị của đối tượng person bởi
    // vì đối tượng này sẽ gọi showFullName()
    showFullName: function() {
        console.log(this.firstName + " " + this.lastName);
    }
}

person.showFullName(); // Anh Tranngoc

Một ví dụ khác về việc sử dụng this với jQuery:

// Code rất thông dụng khi sử dụng jQuery
$("button").click(function(event) {
    // $(this) sẽ có giá trị của đối tượng button (`$("button")`) bởi
    // vì đối tượng này gọi phương thức click()
    console.log($(this).prop("name"));
});

Việc sử dụng jQuery tương đối đặc biệt một chút. Cú pháp $(this) là cú pháp của jQuery cho từ khóa this của JavaScript, được sử dụng bên trong hàm vô danh (hàm này được thực thi trong phương thức click của button). Lý do $(this) được ràng buộc với button là bởi vì thư viện jQuery đã "gán" $(this) với đối tượng gọi phương thức click. Vì vậy, $(this) sẽ có giá trị của đối tượng button trong jQuery ($("button")), ngay cả khi $(this) được sử dụng bên trong một hàm vô danh và đúng ra nó không thể tham chiếu đến đối tượng "this" ở ngoài.

Chú ý rằng, button là một phần tử DOM trong trang HTML, nên nó cũng là một đối tượng. Trong trường hợp này, nó là một đối tượng jQuery bởi vì nó được bao với hàm $() của jQuery.

Lưu ý với từ khóa this

Nếu bạn đã hiểu những điều cơ bản về từ khóa this của JavaScript, bạn sẽ hiểu thêm một sự thật thú vị sau: this không được gán giá trị cho đến khi một đối tượng gọi một hàm có có chứa this bên trong. Để đơn giản, nhưng hàm có chứa this chúng ta sẽ gọi là "hàm this".

Ngay cả khi this xuất hiện và tham chiếu đến đối tượng nơi mà nó được định nghĩa, nó không thực sự được gán giá trị cho đến khi một đối tượng gọi "hàm this". Và giá trị được gán đó chỉ dựa vào đối tượng đã gọi "hàm this". this có giá trị của đối tượng này trong hầu hết các hoàn cảnh. Tuy nhiên, vẫn có một vài trường hợp, giá trị của this rất khó xác định. Chúng ta sẽ dần dần tìm hiểu trong phần tiếp theo.

Sử dụng this một cách toàn cục

Với phạm vi toàn cục, khi code được thực thi ở trình duyệt, mọi biến và hàm toàn cục đều được định nghĩa trên đối tượng window. Vì vậy, khi chúng ta sử dụng this trong các hàm toàn cục, nó tham chiếu đến (và có giá trị của) đối tượng window. (Tuy nhiên, nếu sử dụng strict mode thì chúng ta không thể dùng this được.) Đối tượng window là container chính của toàn bộ code JavaScript của trang Web.

Do đó:

var firstName = "Anh", lastName = "Tranngoc";

function showFullName() {
    // "this" trong hàm này sẽ có giá trị của đối tượng window bởi vì
    // showFullName() là hàm được định nghĩa một cách toàn cục, cũng
    // như các biến firstName và lastName
    console.log(this.firstName + " " + this.lastName);
}

var person = {
    firstName: "VietPhuong",
    lastName: "Doan",
    showFullName: function() {
        // "this" trong hàm này tham chiếu đến đối tượng person, bởi
        // vì showFullName là hàm được gọi bởi đói tượng này
        console.log(this.firstName + " " + this.lastName);
    }
}

showFullName(); // Anh Tranngoc

// window là đối tượng mà mọi biến và hàm toàn cục được định nghĩa
// trên đó, vì thế:
window.showFullName(); // Anh Tranngoc

// "this" bên trong phương thức showFullName định nghĩa bên trong đối
// tượng person sẽ tham chiếu đến đối tượng đó, vì vậy:
person.showFullName(); // VietPhuong Doan

Một số trường hợp dễ nhầm lẫn về this

Những điều về this rất cơ bản đã được trình bày ở trên. Tuy nhiên, khi làm việc thực tế, có những trường hợp this trở nên rất khó hiểu và khó nắm bắt. Trong phần này, chúng ta sẽ cùng tìm hiểu những tình huống như vậy, và cách xử lý tương ứng.

Một vài vấn đề về "ngữ cảnh"

"Ngữ cảnh" trong Javascritp cũng tượng tự như chủ ngữ trong câu tiếng Anh. Chúng ta sẽ lấy ví dụ một câu tiếng Anh như sau:

John is the winner who returned the money.

Chủ ngữ trong câu này là John, và chúng ta có thể nói rằng "ngữ cảnh" của câu là John bởi vì mục tiêu chính của câu, mọi sự tập trung đều nhằm đến John. Ở đây, đại từ nhân xưng "who" cũng là chỉ John. Chúng ta có thể ngắt mệnh đề ở đây. Chúng ta có một đối tượng đang được nói đến là John (ngữ cảnh hiện tại), chúng ta có thể chuyển mệnh đề sang cho một đối tượng khác (ngữ cảnh mới).

Tương tự, với JavaScript, chúng ta có:

var person = {
    firstName: "Anh",
    lastName: "Tranngoc",
    showFullName: function() {
        // "Ngữ cảnh"
        console.log(this.firstName + " " + this.lastName);
    }
}

// "Ngữ cảnh", khi chúng ta gọi showFullName là đối tượng person, khi
// chúng ta gọi showFullName cho đối tượng này.  Và "this" trong
// phương thức này sẽ có giá trị của đối tượng person.
person.showFullName(); // Anh Tranngoc

//  Nếu chúng ta gọi showFullName với đối tượng khác:
var anotherPerson = {
    firstName: "VietPhuong",
    lastName: "Doan"
};

// Chúng ta có thể sử dụng phương thức apply để gán giá trị "this" một
// cách rõ ràng hơn trong phương thức apply.  "this" sẽ có giá trị của
// bất cứ thứ gì gọi hàm this.  Vì thế:
person.showFullName.apply(anotherPerson); // VietPhuong Doan

// Ngữ cảnh ở đây đã thay đổi thành anotherPerson bởi vì đây là đối
// tượng gọi phương thức person.showFullName() thông qua phương thức
// apply()

Điểm cốt yếu ở đây là đối tượng gọi "hàm this" sẽ ở trong ngữ cảnh, và chúng ta có thể thay đổi ngữ cảnh bằng cách gọi hàm this bằng đối tượng khác. Đây sẽ là đối tượng trong ngữ cảnh mới.

Dưới đây là một vài tình huống mà this sẽ trở nên rất khó nắm bắt, tất cả đều bắt nguồn từ việc ngữ cảnh đã thay đổi.

Sử dụng this trong phương thức được truyền như callback

Mọi chuyện sẽ rối tung rối mù khi chúng ta truyền phương thức (có sử dụng this) như một tham số để sử dụng như callback. Ví dụ:

// Chúng ta có một đối tượng đơn giản với phương thức clickHandler.
// Đây là phương thức là chúng ta muốn gọi khi một button được click.
var user = {
    data: [
        {name: "T. Woods", age: 37},
        {name: "P. Mickelson", age: 43}
    ],
    clickHandler:function(event) {
        // Chọn ngẫu nhiên 0 hoặc 1
        var randomNum = ((Math.random() * 2 | 0) + 1) - 1;

        // Dòng dưới đây sẽ in ra tên và tuối của một user ngẫu nhiên
        // lấy từ array
        console.log(this.data[randomNum].name + " " + this.data[randomNum].age);
    }
}

// button được bảo bởi hàm jQuery $, nên nó trở thành một đối tượng
// jQuery.  Và output sẽ la undefined bởi vì không có thuộc tính data
// cho đối tượng button

$("button").click(user.clickHandler); // Cannot read property '0' of undefined

Với code trên, bởi vì button ($("button")) là một đối tượng, và chúng ta truyền phương thức user.clickHandler vào phương thức click của button như một callback. Và chúng ta đã biết rằng, this trong phương thức user.clickHandler sẽ không tham chiếu đến đối tượng user nữa mà bây giờ, nó sẽ tham chiếu đến đối tượng mà nó được gọi. Trong trường hợp này, nó sẽ tham chiếu đến đối tượng button, bởi vì phương thức user.clickHandler sẽ được thực thi trong phương thức click của đối tượng button.

Lưu ý rằng, kể cả chúng ta đã gọi phương thức clickHandler() kèm với đối tượng user (điều này là bắt buộc bởi clickHandler là phương thức của user), bản thân phương thức clickHandler được thực thi với đối tượng là button, và đây là ngữ cảnh của nó. Vì vậy, this sẽ tham chiếu đến đối tượng này ($("button")).

Giải pháp

Bởi vì chúng ta cần this.data tham chiếu đến thuộc tính data của đối tượng user, chúng ta có thể sử dụng các phương thức bind, apply, call để chỉ định giá trị của this.

Để giải quyết vấn đề trên, chúng ta có thể sử dụng bind. Thay vì:

$("button").click(user.clickHandler);

Chúng ta cần bind phương thức clickHandler vào đối tượng user như sau:

$("button").click(user.clickHandler.bind(user)); // P. Mickelson 43

Sử dụng this trong closure

Một trường hợp khác cũng rất dễ nhầm lẫn this, đó là chúng ta sử dụng nó ở một hàm lồng trong hàm khác (một closure). Một điểm cần lưu ý là closure không thể truy cập biến this ở bên ngoài thông qua từ khóa this, bởi vì this chỉ có thể truy cập ở phạm vi chính hàm mà nó được sử dụng mà thôi. Ví dụ:

var user = {
    tournament: "The Masters",
    data: [
        {name: "T. Woods", age: 37},
        {name: "P. Mickelson", age: 43}
    ],

    clickHandler: function() {
        // Việc sử dụng this.data ở đây không vấn đề gì, bởi vì "this"
        // tham chiếu đến đối tượng user và data là một thuộc tính của
        // đối tượng.
        this.data.forEach(function(person) {
            // Nhưng ở trong hàm vô danh này (nó được truyền cho
            // phương thức forEach), "this" không tham chiếu đến đối
            // tượng user nữa.  Hàm này không thể truy cập "this" của
            // hàm bên ngoài.
            console.log("What is This referring to? " + this); //[object Window]
            console.log(person.name + " is playing at " + this.tournament);
            // T. Woods is playing at undefined
            // P. Mickelson is playing at undefined
        })
    }
}

user.clickHandler(); // What is "this" referring to? [object Window]

this bên trong hàm vô danh không thể truy cập this của hàm bên ngoài, nên nó sẽ được gán cho đối tượng window (nếu không sử dụng strict mode).

Giải pháp

Để ứng phó với vấn đề sử dụng this bên trong hàm vô danh được truyền cho phương thức forEach, chúng ta sử dụng một kỹ thuật rất phổ biến trong JavaScript, đó là gán giá trị của this cho một biến khác trước khi chúng ta vào hàm forEach.

var user = {
    tournament: "The Masters",
    data: [
        {name: "T. Woods", age: 37},
        {name: "P. Mickelson", age: 43}
    ],

    clickHandler:function(event) {
        // Để lưu trữ giá trị của "this" (đang tham chiếu đối tương
        // user), chúng ta gán nó cho một biến khác để sử dụng về sau.
        var theUserObj = this;
        this.data.forEach(function(person) {
            // Bây giờ chúng ta có thể sử dụng theUserObj.tournament
            // thay vì this.tournament
            console.log(person.name + " is playing at " + theUserObj.tournament);
        })
    }
}

user.clickHandler();
// T. Woods is playing at The Masters
// P. Mickelson is playing at The Masters

Rất nhiều người thường gán giá trị this cho biến khác có tên là that, có lẽ bởi vì ý nghĩa của chúng gần giống nhau, như ví dụ sau đây. Tuy nhiên, để code dễ đọc và dễ hiểu hơn, chúng ta nên gán this với một biến có tên dễ hiểu để chỉ đối tượng mà nó đang tham chiếu.

var that = this;

Sử dụng this khi phương thức được gán cho biến

Giá trị của this sẽ vượt xa sự tưởng tượng của chúng ta và được gán giá trị khác, nếu chúng ta gán phương thức sử dụng this cho một biến. Hãy xem ví dụ sau đây:

// Biến data là biến toàn cục
var data = [
    {name: "Samantha", age: 12},
    {name: "Alexis", age: 14}
];

var user = {
    // data ở đây là một thuộc tính của đối tượng user
    data: [
        {name: "T. Woods", age: 37},
        {name: "P. Mickelson", age: 43}
    ],

    showData: function(event) {
        // chọn ngẫu nhiên 0 hoặc 1
        var randomNum = ((Math.random() * 2 | 0) + 1) - 1;

        // In ra một người chọn ngẫu nhiên từ user.data
        console.log(this.data[randomNum].name + " " + this.data[randomNum].age);
    }
}

// gán phương thức cho một biến
var showUserData = user.showData;

// Khi chúng ta thực thi hàm showUserData, giá trị được in ra lại được
// lấy ra từ biến data toàn cục, chứ không phải từ thuộc tính data của
// đối tượng user.
showUserData(); // Samantha 12 (dữ liệu lấy từ biến data toàn cục)

Giải pháp

Chúng ta có thể giải quyết vấn đề trên bằng cánh chỉ định giá trị của this bằng phương thức bind:

// Bind showData cho đối tượng user
var showUserData = user.showData.bind(user);

// Bây giờ chúng ta sẽ lấy giá trị từ đối tượng user, vì "this" đã
// được gán cho đối tượng này:
showUserData(); // P. Mickelson 43

Dùng this khi mượn phương thức

Việc mượn các phương thức là việc rất phổ biến trong viết code JavaScript. Và nếu bạn làm việc với JavaScript, thì chắc chắn bạn đã mượn các phương thức rất nhiều lần. Việc mượn các phương thức sẽ giúp chúng ta tiết kiệm thời gian phát triển những tính năng đã có người làm giúp chúng ta rồi. Tuy nhiên, tự tiện lợi đó đi kèm với một số vấn đề khi sử dụng this.

// Chúng ta có 2 đối tượng.  Một đối tượng có phương thức avg() và đối
// tượng còn lại không có.  Vì thế chúng ta sẽ mượn phương thức avg().
var gameController = {
    scores: [20, 34, 55, 46, 77],
    avgScore: null,
    players: [
        {name: "Tommy", playerID: 987, age: 23},
        {name: "Pau", playerID: 87, age: 33}
    ]
}

var appController = {
    scores: [900, 845, 809, 950],
    avgScore: null,
    avg: function() {
        var sumOfScores = this.scores.reduce(function(prev, cur, index, array) {
            return prev + cur;
        });

        this.avgScore = sumOfScores / this.scores.length;
    }
}

// Nếu chúng ta chạy code dưới đây, thuộc tính gameController.avgScore
// sẽ được gán giá trị trung bình của đối tượng appController
gameController.avgScore = appController.avg();

this trong phương thức avg không tham chiếu đến đối tượng gameController mà nó tham chiếu đến đối tượng appController bởi vì nó được gọi bởi appController.

Giải pháp

Để giải quyết vấn đề trên, và biến giá trị this trong appController.avg() tham chiếu đến gameController, chúng ta có thể sử dụng phương thức apply:

// Lưu ý, chúng ta sử dụng phương thức apply(), nên tham số thứ 2 phải
// là một array - tham số sẽ được truyền cho phương thức
// appController.avg().
appController.avg.apply(gameController, gameController.scores);

// avgScore được tính cho đối tượng gameController, ngay cả khi chúng
// ta mượn phương thức avg() từ đối tượng appController.
console.log(gameController.avgScore); // 46.4

// appController.avgScore vẫn null, chỉ có gameController.avgScore
// được tính toàn mà thôi.
console.log(appController.avgScore); // null

Đối tượng gameController mượn phương thức avg() từ đối tượng appController. Giá trị của this trong phương thức này sẽ được gán cho đối tượng gameController bởi vì chúng ta truyền đối tượng này là tham số đầu tiên của apply. Tham số đầu tiên của apply luôn được sử dụng là this.

Kết luận

Bài viết trình bày những hiểu biết về từ khóa this của JavaScript. Và tôi cũng trình thêm một số công cụ (bind, apply, v.v...) để gán giá trị cho this trong những tình huống đặc biệt.

Như các bạn đã thấy, this có thể trở nên phức tạp trong một số tình huống mà ngữ cảnh ban đầu bị thay đổi. Tuy nhiên, có một điều không bao giờ thay đổi, đó là this được gán giá trị của đối tượng gọi hàm this.

Hy vọng bài viết giúp ích các bạn trong quá trình code JavaScript của mình.