Học bằng cách đọc theo thứ tự

this binding — this thay đổi theo cách gọi và cách cố định nó

this thay đổi khi bạn tách phương thức ra biến hay truyền nó làm callback. Kèm TypeError, bind, arrow function field và setTimeout.

Đôi khi bạn truyền phương thức tính tổng tiền giỏ hàng cho một đoạn code khác để gọi sau. Một phương thức chạy tốt khi gọi cart.total() có thể báo lỗi khi bạn gán nó vào biến hoặc truyền cho setTimeout, vì this đã thay đổi.

Bài này nói về this binding — giá trị của this phụ thuộc vào cách gọi hàm — và cách cố định this bằng bind hoặc arrow function.

Tách phương thức ra thì hỏng — this trong phương thức

Giả sử bạn muốn dùng lại cart.total() cho ô tổng tiền trên trang, nên gán nó vào biến bằng const calcTotal = cart.total;. Nhưng khi gọi calcTotal(), code báo lỗi trước cả khi kịp cộng gì, dù giỏ hàng vẫn như cũ.

Giá trị của this bên trong phương thức do dòng gọi quyết định, chứ không phụ thuộc vào nơi phương thức được viết. Điều này gọi là this binding (this được gắn với một giá trị). Nếu bạn đọc cart.total mà không có (), bạn chỉ nhận được hàm; cart không đi kèm theo.

Khi gọi calcTotal() mà không có gì trước dấu chấm, this bên trong class là undefined.

class Cart {
  constructor(items) {
    this.items = items;   // Tạm tính của từng sản phẩm
  }
  total() {
    return this.items.reduce((sum, price) => sum + price, 0);
  }
}
const cart = new Cart([1980, 3000]);

// Gọi với cart đứng trước dấu chấm
console.log(cart.total());       // 4980

// Gán vào biến / tách ra bằng destructuring
const calcTotal = cart.total;
const { total } = cart;
console.log(typeof calcTotal);   // function
// calcTotal();                  // TypeError: Cannot read properties of undefined (reading 'items')
// total();                      // TypeError (cùng lý do)
Cùng một total, nhưng this khác nhau
Thân của total()đọc this.itemscart.total()(cart đứng trước)calcTotal()(đã gán vào biến)total()(từ destructuring)this làcartthis làundefinedthis làundefinedTrả về 4980Báo lỗiTypeErrorBáo lỗiTypeError
Cả ba đều chạy cùng phần thân của total. this chỉ là cart ở lời gọi có cart đứng trước dấu chấm.

Cột giữa và cột phải đều báo lỗi TypeError: Cannot read properties of undefined (reading 'items'). Destructuring bằng const { total } = cart; cũng chỉ sao chép hàm vào một biến, nên total() lỗi vì cùng lý do với calcTotal().

Tại quầy tiệm giặt ủi, bạn dùng lại một phương thức tạo dòng trạng thái cho từng món đồ. LaundryItem, shirt và coat đã được khai báo sẵn.

① Hãy gọi statusLine của shirt như một phương thức và hiển thị kết quả.

② Hãy gán chính phương thức đó của shirt vào property line của coat, rồi gọi nó từ coat và hiển thị kết quả.

③ Hãy tách cùng phương thức đó ra khỏi shirt bằng destructuring và gọi riêng nó.

(Nếu chạy đúng, phần giải thích sẽ xuất hiện.)

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Giữ this khi gán vào biến — phương thức bind

Giả sử bạn muốn truyền phép tính tổng tiền dưới dạng một hàm cho đoạn code hiển thị tổng. Như phần trước đã cho thấy, gán cart.total vào biến thì cart bị bỏ lại, nên khi đoạn code nhận hàm gọi nó, bạn sẽ gặp TypeError.

Khi gọi bind (phương thức mà mọi hàm đều có, trả về một hàm mới với this được cố định vào giá trị bạn truyền vào) dưới dạng cart.total.bind(cart), bạn nhận được một hàm luôn giữ thiscart, kể cả khi gọi mà không có gì trước dấu chấm.

Chỉ hàm được trả về mới được cố định, nên khi gọi hoặc truyền đi, hãy dùng chính hàm đó.

class Cart {
  constructor(items) { this.items = items; }   // Tạm tính của từng sản phẩm
  total() { return this.items.reduce((sum, price) => sum + price, 0); }
}
const cart = new Cart([1980, 3000]);

// Hàm vừa tách ra sẽ có this là undefined khi được gọi
const unbound = cart.total;
// unbound();                             // TypeError

// bind trả về một hàm mới có this là cart
const boundTotal = cart.total.bind(cart);
console.log(boundTotal());                // 4980
console.log(boundTotal === cart.total);   // false

// bind cố định chính object cart, nên hàm đọc được cả khoản thêm vào sau
cart.items.push(500);
console.log(boundTotal());                // 5480
Hàm do bind trả về và khoản thêm vào sau
cart.total.bind(cart)Hàm mới cóthis là cartKhác cart.total=== là falseboundTotal()là 4980cart.items.push(500)Gọi lạiboundTotal()Đọc items của cartở thời điểm đóTrả về 5480
bind không thay đổi cart.total mà trả về một hàm mới. Thứ bị cố định là object cart, không phải tổng tiền.

total không nhận đối số, nhưng khi bạn cố định một phương thức có nhận đối số, mọi đối số bạn truyền cho hàm do bind trả về sẽ được chuyển nguyên tới phương thức gốc. Chỉ this bị cố định, nên mỗi lần gọi bạn có thể truyền đối số khác nhau.

Bạn tách phương thức tính tiền của một gói đăng ký định kỳ ra. Subscription, coffee và tea đã được khai báo sẵn.

① Hãy đưa bill của coffee vào billCoffee với this được cố định là coffee.

② Hãy dùng billCoffee để hiển thị số tiền cho 3 tháng.

③ Hãy tạo billTea cho tea theo cách tương tự và hiển thị số tiền cho 6 tháng.

④ Hãy chuyển đổi từng phần tử của [1, 12] bằng billCoffee, nối kết quả bằng " / " và hiển thị.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Viết phương thức vẫn chạy khi bị tách ra — arrow function

Giả sử bạn gắn cùng một phương thức làm hàm xử lý click cho nhiều nút trên trang, chẳng hạn các nút thích dưới mỗi bài viết. Nếu lần nào cũng phải thêm bind, chỉ cần quên một lần là nút đó sẽ báo lỗi TypeError.

Arrow function field (class field có giá trị khởi tạo là một arrow function) được tạo riêng cho từng instance mỗi lần bạn gọi new. Trong biểu thức khởi tạo field, this là instance mới, giống như trong constructor, và arrow function vốn không có this riêng nên sẽ dùng chính this đó.

Vì vậy bạn có thể tách nó ra và gọi mà không cần bind.

class LikeButton {
  likes = 0;
  label = `Lượt thích: ${this.likes}`;   // this trong biểu thức khởi tạo là button đang được tạo
  // Phương thức thường: this do dòng gọi quyết định
  addLikeMethod() { this.likes += 1; return this.likes; }
  // Arrow function field: dùng cùng this với biểu thức khởi tạo
  addLike = () => { this.likes += 1; return this.likes; };
}
const button = new LikeButton();
console.log(button.label);                     // Lượt thích: 0

// Tách ra và gọi mà không cần bind
const onPress = button.addLike;
console.log(onPress());                        // 1
console.log(onPress());                        // 2
console.log(Object.keys(button).join(", "));   // likes, label, addLike

// Tách phương thức thường ra thì hỏng
const onPressMethod = button.addLikeMethod;
// onPressMethod();                            // TypeError
addLike lấy this từ đâu
Thân class LikeButton
Khởi tạo field (chạy mỗi lần new)
  • likes = 0 — nằm trên button
  • this trong biểu thức khởi tạo label — là button đang được tạo (giống trong constructor)
Bên trong addLike = () => { ... }
  • Không có this riêng
  • this.likes — đọc giá trị trên button từ scope bao ngoài
  • Kể cả khi gọi onPress(), this vẫn là button
Bên trong addLikeMethod() { ... }
  • this — object đứng trước dấu chấm ở dòng gọi
  • button khi gọi button.addLikeMethod()
  • undefined nếu tách ra rồi gọi
Arrow function dùng this của phần khởi tạo field. Chỉ trong addLikeMethod, this mới thay đổi theo dòng gọi.

addLike xuất hiện trong Object.keys(button) vì arrow function field nằm ngay trên instance, giống như likes; còn addLikeMethod thì không. Điều đó cũng có nghĩa là tạo 100 instance thì sẽ có 100 hàm addLike được tạo ra.

Arrow function trong object literal không nhận object đó làm this

Nếu bạn viết const counter = { add: () => { this.likes += 1 } };, thì this bên trong counter.add() không phải là counter. Object literal không tạo ra this riêng, nên arrow function dùng this ở bên ngoài. Hãy viết thành add() { ... }.

Bạn tạo một bảng ánh xạ tên từng nút số lượng với hàm được gọi khi bấm nút đó. QuantityStepper, mug, buttons và pressed đã được khai báo sẵn.

① Hãy định nghĩa increase (tăng số lượng thêm 1 rồi trả về) sao cho nó vẫn chạy khi bị tách ra.

② Hãy viết decrease (giảm đi 1) theo cách tương tự.

③ Hãy gọi các hàm trong buttons theo thứ tự của pressed và hiển thị số lượng.

④ Hãy hiển thị kết quả của buttons.oldPlus() và số lượng của mug.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Truyền phương thức cho code sẽ gọi nó sau — setTimeout

Giả sử bạn muốn hiển thị lời cảm ơn kèm tên khách hàng 100ms sau khi đơn hàng được đặt. Nếu viết setTimeout(this.showMessage, 100) bên trong một phương thức, 100ms sau lời nhắn vẫn hiện ra, nhưng tên khách hàng lại là undefined.

setTimeout tự gọi hàm sau 100ms, và trong trình duyệt, nó đặt this của lần gọi đó là window (window là object toàn cục mà trình duyệt tạo sẵn cho mỗi trang). Nếu bạn bọc nó trong một arrow function, this trong () => this.showMessage() chính là this của phương thức bao ngoài, nên phương thức được gọi trên instance.

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

class OrderNotice {
  constructor(customerName) { this.customerName = customerName; }
  showMessage() { console.log(`Cảm ơn ${this.customerName} đã đặt hàng`); }

  // Truyền nguyên phương thức. setTimeout sẽ gọi nó sau 100ms
  notifyDirect() { setTimeout(this.showMessage, 100); }

  // Bọc trong arrow function / cố định bằng bind rồi mới truyền
  notifyWithArrow() { setTimeout(() => this.showMessage(), 100); }
  notifyWithBind() { setTimeout(this.showMessage.bind(this), 100); }
}

const notice = new OrderNotice("Minh");
notice.notifyDirect();      // Cảm ơn undefined đã đặt hàng
notice.notifyWithArrow();   // Cảm ơn Minh đã đặt hàng
notice.notifyWithBind();    // Cảm ơn Minh đã đặt hàng
await delay(300);           // Trên trang này, đợi đến khi mọi output đã hiện ra
this khi setTimeout gọi hàm
setTimeout(this.showMessage)Gọi showMessagesau 100msthis được đặtlà windowCảm ơn undefinedđã đặt hàngsetTimeout(() =>this.showMessage())Sau 100ms, gọiarrow functionnotifyWithArrowcó this là noticeCảm ơn Minhđã đặt hàng
Ở cả hai hàng, setTimeout thực hiện lời gọi sau 100ms. Bên trong arrow function, bạn có thể gọi phương thức với notice đứng trước dấu chấm.

Hàng trên không báo lỗi TypeError: khác với calcTotal(), this ở đây không phải undefined mà là window. Vì window không có customerName nên undefined được hiển thị. Bảng dưới đây liệt kê giá trị của this với từng cách gọi đã gặp trong bài.

Cách gọiGiá trị của thisKết quả ở đây
Gọi cart.total()cart (trước dấu chấm)Trả về 4980
Tách ra rồi gọi calcTotal()undefinedBáo lỗi TypeError
Hàm do cart.total.bind(cart) trả vềcart (truyền cho bind)Trả về 4980
Tách arrow field, gọi onPress()button (chứa field)Lượt thích tăng lên
setTimeout(this.showMessage, 100)window (trên trình duyệt)Cảm ơn undefined đã đặt hàng
setTimeout(() => this.showMessage(), 100)this của phương thức ngoàiCảm ơn Minh đã đặt hàng

Bọc trong function không giữ được this

Nếu bạn bọc thành setTimeout(function () { this.showMessage(); }, 100), this bên trong là window, nên code báo lỗi TypeError. Console trên trang này hoàn toàn không hiển thị lỗi đó; bạn chỉ thấy Uncaught TypeError trong developer tools của trình duyệt. Hãy bọc bằng arrow function.

Tại quầy lễ tân khách sạn, bạn hiển thị thông báo phòng đã sẵn sàng sau 100ms. RoomGuide, minh, linh và delay đã được khai báo sẵn.

① Hãy truyền nguyên announce của minh cho setTimeout.

② Với announce của linh, hãy cố định this trước khi truyền.

③ Hãy bọc announce của minh trong một arrow function rồi truyền.

④ Hãy hiển thị "Đã check-in xong" và so sánh thời điểm nó xuất hiện với ①–③.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra
QUIZ

Kiểm tra kiến thức

Hãy trả lời từng câu hỏi một.

Câu 1Sau const calc = cart.total;, khi gọi calc() thì this bên trong total là gì?

Câu 2Sau khi chỉ viết cart.total.bind(cart);, điều gì xảy ra nếu bạn gán cart.total vào biến rồi gọi?

Câu 3Khi truyền một phương thức cho setTimeout từ bên trong một phương thức khác, cách nào KHÔNG giữ this là instance?