← Lập trình JavaScript cơ bản

Bài 6 · Vận dụng · 22 phút

Class & prototype

Biên soạn bởi Nguyễn Anh Tuấn

Class trong JavaScript: constructor, method, extends & super - và bản chất là prototype chain: vì sao OOP của JavaScript dựa trên prototype.

Ở bài 5 - “Mảng & object” - bạn đã gom dữ liệu và method vào một object literal. Cách đó ổn khi chỉ cần MỘT object. Nhưng khi cần nhiều object cùng hình dạng - cả một đàn mèo con, con nào cũng có tên và biết kêu - thì viết tay từng object là lặp code. Cách chữa quen thuộc: viết một hàm trả về object, gọi là factory function:

viết tay từng object → factory function

// hai meo con - viet tay tung object: lap code!
const mun = { ten: "Mun", keu() { return this.ten + ": meo!"; } };
const miu = { ten: "Miu", keu() { return this.ten + ": meo!"; } };

// factory function: mot ham thuong "dap" object cung hinh dang
function taoMeo(ten) {
  return { ten, keu() { return this.ten + ": meo!"; } };
}
const bong = taoMeo("Bong");
console.log(bong.keu());                      // Bong: meo!
console.log(taoMeo("Na").keu === bong.keu);   // false - moi object mot ban keu RIENG

Kết quả khi chạy

Bong: meo!
false

Factory chạy tốt, nhưng dòng cuối lộ điểm yếu: keu của hai object là hai hàm khác nhau - mỗi lần gọi taoMeo, JS tạo một bản mới. Một nghìn con mèo là một nghìn bản sao của cùng một hành vi. class - cú pháp “khuôn” chuẩn của JS hiện đại - giải quyết đúng chỗ này: dữ liệu riêng từng con, method chung một bản (Bước 4 sẽ chỉ ra bản chung đó nằm ở đâu).

  • Nhiều object cùng hình dạng → cần một “khuôn”, đừng viết tay từng object.
  • Factory function là khuôn bằng hàm thường - nhưng mỗi object một bản sao method.
  • class là cú pháp chuẩn của JS hiện đại: dữ liệu riêng, method dùng chung.

Cú pháp class gom mọi thứ vào một khối. constructor chạy khi bạn viết new Meo(...); bên trong nó, this là object mới đang được tạo - đúng dòng “new Fn() → object mới” trong bảng this bạn đã lập ở bài 4 “Hàm, closure & this”. Còn khi gọi mun.keu(), quy tắc quen thuộc “object đứng trước dấu chấm” cho biết thismun:

class Meo - đủ bộ constructor, field, method, getter

class Meo {
  constructor(ten, mauLong) {   // chay MOT lan luc new
    this.ten = ten;             // field: du lieu RIENG tung instance
    this.mauLong = mauLong;
  }
  keu() {                       // method: hanh vi DUNG CHUNG
    return this.ten + ": meo!";
  }
  get gioiThieu() {             // getter: doc nhu thuoc tinh
    return this.ten + " long " + this.mauLong;
  }
}

const mun = new Meo("Mun", "den");
const miu = new Meo("Miu", "trang");
console.log(mun.keu());            // Mun: meo!
console.log(miu.gioiThieu);        // Miu long trang   (khong can ngoac)
console.log(mun.keu === miu.keu);  // true - chung MOT ham (Buoc 4)

Kết quả khi chạy

Mun: meo!
Miu long trang
true

Hai bẫy quen mặt từ bài 4, nhắc nhanh - class bắt buộc new, và tách method ra khỏi object thì this “rơi mất”:

hai bẫy: quên new · tách method khỏi object

// Meo("Tom", "xam");
// -> TypeError: Class constructor Meo cannot be invoked without 'new'

// const f = mun.keu; f();   // tach method khoi object (Bai 4) -> this roi mat
// -> TypeError: Cannot read properties of undefined (reading 'ten')
  • constructor chạy lúc new; this trong đó là instance mới (quy tắc this - Bài 4).
  • Field (this.ten) là dữ liệu riêng từng instance; method là hành vi dùng chung.
  • getter đọc như thuộc tính, không ngoặc - anh em với nó là setter (từ khoá set).
  • mun.keu === miu.keu là true - method KHÔNG bị sao chép vào từng instance.

Khi một loại mèo “là một” mèo nhưng có thêm điểm riêng, ta dùng extends để kế thừa (inheritance): class con có đủ field, method của class cha, rồi thêm hoặc override (ghi đè) phần của mình. Trong constructor của class con phải gọi super(...) - constructor của cha - trước khi đụng tới this; còn super.keu() gọi bản gốc của cha:

MeoHamHoc extends Meo

class Meo {
  constructor(ten) { this.ten = ten; }
  keu() { return this.ten + ": meo!"; }
  ngu() { return this.ten + " dang ngu..."; }
}

class MeoHamHoc extends Meo {
  constructor(ten, monHoc) {
    super(ten);               // BAT BUOC truoc khi dung this
    this.monHoc = monHoc;
  }
  keu() {                     // override: ghi de ban cua cha
    return super.keu() + " (vua keu vua hoc)";
  }
  hoc() { return this.ten + " hoc " + this.monHoc; }
}

const tom = new MeoHamHoc("Tom", "JavaScript");
console.log(tom.keu());   // Tom: meo! (vua keu vua hoc)
console.log(tom.hoc());   // Tom hoc JavaScript
console.log(tom.ngu());   // Tom dang ngu...   (thua huong tu Meo)

Kết quả khi chạy

Tom: meo! (vua keu vua hoc)
Tom hoc JavaScript
Tom dang ngu...
  • extends: class con thừa hưởng field & method của class cha.
  • Constructor con phải gọi super(...) TRƯỚC khi dùng this.
  • Override = định nghĩa lại method trùng tên; super.method() gọi bản của cha.

Quên super(...) thì sao?

Xoá dòng super(ten) rồi chạy, JS báo ngay ReferenceError: Must call super constructor in derived class… - object phải được class cha khởi tạo xong thì this mới tồn tại cho class con gán thêm field.

Giờ tới phần đáng giá nhất. Nhìn cú pháp class, dễ tưởng JS sao chép method vào từng instance như Java đúc object từ bản thiết kế. Không phải: method nằm trên MỘT object tên là Meo.prototype, và mỗi instance chỉ giữ một đường nối tới object đó. Khi bạn gọi mun.keu(), JS tra cứu: tìm trong chính mun (chỉ thấy ten) → đi lên Meo.prototype (thấy - dùng luôn) → chưa thấy thì lên tiếp Object.prototype → hết chuỗi là null. Dãy các tầng đó là prototype chain (chuỗi tra cứu):

soi từng tầng bằng Object.getPrototypeOf

class Meo {
  constructor(ten) { this.ten = ten; }
  keu() { return this.ten + ": meo!"; }
}
const mun = new Meo("Mun");

console.log(Object.getPrototypeOf(mun) === Meo.prototype);               // true
console.log(Object.getPrototypeOf(Meo.prototype) === Object.prototype);  // true
console.log(Object.getPrototypeOf(Object.prototype));                    // null

console.log(mun instanceof Meo);     // true - Meo.prototype nam tren chuoi
console.log(mun instanceof Object);  // true - Object.prototype cung vay

Kết quả khi chạy

true
true
null
true
true

extends chỉ là nối dài chuỗi: tomMeoHamHoc.prototypeMeo.prototypeObject.prototypenull. Và instanceof không có gì bí ẩn: nó đi dọc chuỗi của object và hỏi “Meo.prototype có nằm trên đó không?” - vì thế tom instanceof Meotruetom sinh từ new MeoHamHoc(...). Tra cứu diễn ra lúc gọi chứ không phải lúc tạo, nên làm được thí nghiệm mà Java không cho phép:

sửa prototype SAU khi đã tạo instance

class Meo {
  constructor(ten) { this.ten = ten; }
}
const mun = new Meo("Mun");   // tao instance TRUOC

Meo.prototype.thiTham = function () {   // them method SAU
  return this.ten + " thi tham: meo meo";
};
console.log(mun.thiTham());   // Mun thi tham: meo meo

Kết quả khi chạy

Mun thi tham: meo meo
  • Method nằm MỘT bản trên Class.prototype; instance chỉ nối tới đó.
  • Tra cứu thuộc tính đi dọc prototype chain: instance → Class.prototype → Object.prototype → null.
  • Tra cứu xảy ra lúc GỌI - thêm method vào prototype sau, instance cũ vẫn thấy.
  • instanceof = hỏi “Class.prototype có nằm trên chuỗi của object không?”.

Trung thực

Bạn sẽ nghe câu “class chỉ là syntactic sugar (lớp cú pháp cho dễ viết) trên prototype”. Gần đúng: bên dưới vẫn là prototype chain như ta vừa soi - nhưng class KHÔNG chỉ là lớp trang trí. Khác biệt thật: method trong class không enumerable (không hiện ra khi duyệt Object.keys / for...in); class bắt buộc new; và code trong class luôn chạy ở strict mode (chế độ nghiêm ngặt - báo lỗi ngay thay vì lặng lẽ bỏ qua). Mức cần nhớ: OOP của JavaScript dựa trên prototype; class là cách viết chuẩn hiện đại của cơ chế đó.

class không phải đáp án cho mọi bài toán. Tiêu chí: dữ liệu + hành vi đi cùng nhau và cần nhiều instance thì class xứng đáng; còn lại thường có cách gọn hơn:

Tình huốngNên dùng
Nhiều “bản” cùng hành vi: đàn mèo, nhân vật game, sản phẩmclass
Chỉ MỘT object: cấu hình app, trạng thái màn hìnhobject literal (Bài 5)
Một nhóm hàm xử lý, không giữ trạng tháihàm thường - gom vào module (bài 11)
Ít object nhưng cần giấu kín dữ liệu bằng closurefactory function (Bài 4)

Cuối cùng, có những hành vi thuộc về cả class chứ không phải từng con mèo - hàm so sánh, hằng số chung… Khai báo chúng với static (có cả static field) và gọi qua TÊN class:

static: gắn với class, không phải instance

class Meo {
  static soSanhTuoi(a, b) {   // static method: thuoc ve CLASS
    return a.tuoi - b.tuoi;
  }
  constructor(ten, tuoi) { this.ten = ten; this.tuoi = tuoi; }
}

const dan = [new Meo("Mun", 3), new Meo("Miu", 1)];
dan.sort(Meo.soSanhTuoi);             // goi qua TEN class, khong qua instance
console.log(dan.map((m) => m.ten));   // [ 'Miu', 'Mun' ]

Kết quả khi chạy

[ 'Miu', 'Mun' ]
  • Dùng class khi dữ liệu + hành vi đi cùng nhau VÀ cần nhiều instance.
  • Một object duy nhất → object literal là đủ; nhóm hàm thuần → cứ để là hàm.
  • static gắn với class (gọi Meo.x) - không xuất hiện trên instance.

Tiếp theo

Class không chỉ để mô tả mèo: Error mà chương trình ném ra khi gặp sự cố cũng là một class - và bạn extends được nó để tạo loại lỗi riêng. Bài 7 - “Xử lý lỗi & debug” - sẽ dùng nó: try/catch, đọc stack trace, và debug có phương pháp.

Câu hỏi thường gặp

Cú pháp cố tình trông giống để dễ làm quen, nhưng cơ chế bên dưới khác: Java coi class như bản thiết kế cố định lúc biên dịch; JavaScript tra cứu thuộc tính LÚC CHẠY, dọc theo prototype chain - các object nối với nhau. Vì vậy JS mới cho phép thêm method vào prototype sau khi instance đã tồn tại (Bước 4). Viết code hằng ngày thì dùng gần như nhau; hiểu khác biệt để không bất ngờ khi debug.

Meo.prototype là thuộc tính của class Meo: object chứa các method, sẽ được gắn làm “tầng trên” cho mọi instance tạo bằng new Meo(...). Còn Object.getPrototypeOf(mun) trả về tầng trên THẬT của một object cụ thể. Hai bên gặp nhau ở chỗ: Object.getPrototypeOf(mun) === Meo.prototypetrue. Cách viết cũ mun.__proto__ vẫn chạy nhưng đã lỗi thời - dùng Object.getPrototypeOf.

Được. Factory function (Bước 1) kết hợp closure (Bài 4) cũng tạo nhiều object, thậm chí giấu dữ liệu riêng tư tốt hơn. Đổi lại, mỗi object mang bản sao method riêng và không dùng được extends/instanceof. (Class hiện đại cũng có field riêng tư với cú pháp # - bạn sẽ gặp khi đọc code thật, bài này chưa cần dùng.) Khi cần nhiều instance chung hành vi, class là lựa chọn chuẩn; khi chỉ cần một object hay vài hàm, đừng cố nhét class vào.

Không nên. Thí nghiệm “vá prototype” ở Bước 4 chỉ để hiểu cơ chế tra cứu. Sửa prototype của kiểu có sẵn (gọi là monkey patching) ảnh hưởng MỌI mảng/chuỗi trong chương trình, dễ va chạm với thư viện khác và với method chuẩn ra đời sau này. Với class của chính bạn, khai báo method ngay trong thân class cho rõ ràng.

Vì class ở khắp nơi dù bạn có tự viết hay không: Error, Date, Map/Set, nhiều thư viện và framework - và các ngôn ngữ bạn sẽ gặp sau này (TypeScript, Java, C#, Python) đều xoay quanh class. Hiểu class + prototype giúp bạn đọc được mọi codebase. Còn thiết kế OOP sao cho dễ bảo trì - lúc nào kế thừa, lúc nào không - là chủ đề của 5 nguyên tắc SOLID.

Khoá SOLID - 5 nguyên tắc thiết kế OOP →

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Trong một class JavaScript, method (vd keu()) thực sự được lưu ở đâu?

  1. 1

    Đàn mèo đầu tiên

    Viết class Meo với constructor(ten, mauLong) và method keu() trả về "<ten>: meo!". Tạo 2 instance, gọi keu(), rồi in mun.keu === miu.keu.

    Hoàn thành khi: Mỗi con kêu đúng tên mình; phép so sánh ra true - hai instance dùng chung MỘT method trên prototype.

  2. 2

    Quên new

    Gọi Meo("Tom", "xam") không có new. JS báo lỗi gì? Vì sao class chặt hơn hàm thường ở điểm này?

    Hoàn thành khi: Báo TypeError: Class constructor Meo cannot be invoked without 'new' - class bắt buộc tạo instance bằng new.

  3. 3

    Getter

    Thêm getter gioiThieu trả về chuỗi mô tả mèo. Đọc mun.gioiThieu không ngoặc, rồi thử gọi mun.gioiThieu() xem sao.

    Hoàn thành khi: Không ngoặc → ra chuỗi; thêm ngoặc → TypeError: ... is not a function (giá trị trả về là chuỗi, không phải hàm).

  4. 4

    extends & super

    Viết MeoHamHoc extends Meo thêm field monHoc, override keu() có gọi super.keu(). Sau đó thử XOÁ dòng super(ten) trong constructor con.

    Hoàn thành khi: Override chạy kèm phần của cha; xoá super(...)ReferenceError vì đụng this trước khi class cha khởi tạo object.

  5. 5

    Soi prototype chain

    Với tom = new MeoHamHoc(...), in Object.getPrototypeOf từng tầng cho tới null. Đối chiếu với tom instanceof Meo.

    Hoàn thành khi: Chuỗi: MeoHamHoc.prototypeMeo.prototypeObject.prototypenull; tom instanceof MeotrueMeo.prototype nằm trên chuỗi.

  6. 6

    Vá prototype

    Tạo một instance TRƯỚC, rồi gán Meo.prototype.thiTham = function () { ... }. Instance cũ gọi thiTham() được không? Giải thích bằng prototype chain.

    Hoàn thành khi: Gọi được - tra cứu method xảy ra LÚC GỌI và đi qua prototype chain, không sao chép lúc tạo instance.