DEV Community

This — this Binding

this binding: vì sao callback mất context và cách giữ lại

this trong JavaScript không trỏ tới nơi hàm được định nghĩa mà tới cách hàm được gọi. Cùng một hàm gọi theo bốn kiểu khác nhau cho bốn giá trị this khác nhau. Đây là khác biệt cốt lõi với lexical scope của biến thường: biến resolve theo nơi viết (static), this resolve theo nơi gọi (dynamic). Không nắm quy tắc này thì bug "callback mất this, gọi method báo undefined" sẽ lặp lại mãi, đặc biệt khi truyền method làm callback cho event handler, setTimeout, hay array method.

Cơ chế hoạt động

Bốn quy tắc quyết định this, xét theo thứ tự ưu tiên:

  1. new: this là object mới được tạo.
  2. call/apply/bind: this là đối số đầu được truyền vào (explicit binding).
  3. Gọi như method (obj.fn()): this là object bên trái dấu chấm.
  4. Gọi trơn (fn()): thisundefined trong strict mode (mặc định của module/class), hoặc global object ở sloppy mode.
const user = {
  name: 'an',
  greet() { return `hi ${this.name}` },
}
user.greet()            // 'hi an' — this = user (quy tắc 3)
const g = user.greet
g()                     // TypeError: this là undefined (quy tắc 4)
Enter fullscreen mode Exit fullscreen mode

Mấu chốt: guser.greetcùng một hàm. Chỉ khác cách gọi. Khi gán ra biến rồi gọi trơn, mối liên kết với user mất, this thành undefined.

Arrow function là ngoại lệ: nó không có this riêng, mà lấy this theo lexical scope — tức this của scope bao quanh lúc viết. Nên không bind lại được bằng call/apply/bind, và đó chính là điều khiến nó hữu ích để giữ context.

Vấn đề gặp trong production

Failure mode kinh điển: truyền method làm callback, this rơi mất.

class PaymentService {
  constructor() { this.retries = 3 }
  charge(order) {
    return `charging with ${this.retries} retries` // cần this
  }
}
const svc = new PaymentService()
queue.on('job', svc.charge) // BUG: gọi trơn -> this undefined -> crash
Enter fullscreen mode Exit fullscreen mode

Khi queue gọi svc.charge(job) nội bộ, nó gọi như một hàm trơn, không qua svc., nên thisundefinedthis.retries throw. Đây là một trong những lỗi hay gặp nhất khi nối code hướng object vào API dạng callback (event emitter, framework UI, job queue).

Ba cách sửa, theo thứ tự nên dùng:

queue.on('job', (job) => svc.charge(job))   // 1. wrap bằng arrow — this giữ qua lexical
queue.on('job', svc.charge.bind(svc))        // 2. bind tường minh
// 3. định nghĩa method là arrow field trong class:
class PaymentService {
  retries = 3
  charge = (order) => `charging with ${this.retries} retries` // this khóa theo instance
}
Enter fullscreen mode Exit fullscreen mode

Cách 3 (class field arrow) khóa this vào instance ngay lúc tạo, nên method luôn an toàn để truyền làm callback — đây là pattern phổ biến trong code React class component và service object. Đánh đổi: mỗi instance có một bản method riêng (không dùng chung trên prototype), tốn bộ nhớ hơn chút khi tạo nhiều instance.

call/apply dùng khi cần gọi ngay với this chỉ định: fn.call(ctx, a, b) truyền đối số rời, fn.apply(ctx, [a, b]) truyền mảng. bind trả về hàm mới đã khóa this, dùng khi cần truyền đi để gọi sau.

Cách debug và monitor

TypeError: Cannot read properties of undefined (reading 'x') ngay trong một method là chữ ký gần như chắc chắn của this bị mất — kiểm tra ngay xem method có bị truyền làm callback và gọi trơn ở đâu không. Khi nghi ngờ, console.log(this) ở đầu method cho biết ngay nó đang là instance, undefined, hay global. Lint rule và TypeScript bắt được nhiều trường hợp: TS báo lỗi khi gọi method mất this nếu khai báo kiểu this đúng. Quy tắc phòng ngừa: không bao giờ truyền obj.method trần làm callback — luôn bọc arrow hoặc bind.

Tradeoff

Arrow function tiện vì giữ this theo lexical, không bao giờ "mất context", nên lý tưởng cho callback. Nhưng chính vì không có this riêng và không bind lại được, nó không hợp làm method cần this động (như method trên prototype dùng chung, hay handler cần this trỏ tới phần tử DOM gọi nó). Function thường cho this linh hoạt theo nơi gọi — mạnh nhưng dễ sai. Quy tắc thực tế: callback và hàm cần giữ context bao quanh → arrow; method cần this trỏ tới object gọi → function thường (hoặc class method); và bất kỳ method nào sẽ được truyền đi làm callback thì bind hoặc khai báo arrow field.

Câu hỏi phỏng vấn

this được quyết định khi nào, và vì sao truyền obj.method làm callback lại làm this thành undefined?

this được quyết định lúc gọi, không phải lúc định nghĩa, theo bốn quy tắc ưu tiên: new → object mới; call/apply/bind → đối số truyền vào; gọi như method obj.fn() → object bên trái dấu chấm; gọi trơn fn()undefined ở strict mode. Truyền obj.method làm callback tách hàm khỏi object, nên khi nơi nhận gọi nó, nó gọi trơn, this thành undefined và truy cập this.x throw. Cách giữ context: bọc trong arrow function (arrow lấy this theo lexical), dùng .bind(obj), hoặc khai báo method là arrow field trong class. Điểm cộng là nêu đánh đổi của arrow field: an toàn khi truyền callback nhưng không dùng chung trên prototype.

Hands-on

Lấy một service class thật (ví dụ xử lý thanh toán hoặc gửi email, có state cấu hình như số lần retry) và đăng ký một method của nó làm listener cho một event emitter hoặc job queue, gọi trơn để tái tạo TypeError do this undefined. Sửa lần lượt bằng ba cách — arrow wrapper, bind, và arrow class field — xác nhận cả ba đều chạy. Sau đó so sánh bộ nhớ giữa method-trên-prototype và arrow-field khi tạo 100.000 instance để thấy đánh đổi, và thử bind một method rồi truyền nó qua nhiều lớp callback để xác nhận this được giữ ổn định.

Top comments (0)