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 this là mun:
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?
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: tom → MeoHamHoc.prototype → Meo.prototype → Object.prototype → null. 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 Meo là true dù tom 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
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ống | Nên dùng |
|---|---|
| Nhiều “bản” cùng hành vi: đàn mèo, nhân vật game, sản phẩm | class |
| Chỉ MỘT object: cấu hình app, trạng thái màn hình | object literal (Bài 5) |
| Một nhóm hàm xử lý, không giữ trạng thái | hà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 closure | factory 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
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.prototype là true. 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.
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Trong một class JavaScript, method (vd keu()) thực sự được lưu ở đâu?
- 1
Đàn mèo đầu tiên
Viết class
Meovớiconstructor(ten, mauLong)và methodkeu()trả về"<ten>: meo!". Tạo 2 instance, gọikeu(), rồi inmun.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
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ằngnew. - 3
Getter
Thêm getter
gioiThieutrả về chuỗi mô tả mèo. Đọcmun.gioiThieukhông ngoặc, rồi thử gọimun.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
extends & super
Viết
MeoHamHoc extends Meothêm fieldmonHoc, overridekeu()có gọisuper.keu(). Sau đó thử XOÁ dòngsuper(ten)trong constructor con.Hoàn thành khi: Override chạy kèm phần của cha; xoá
super(...)→ReferenceErrorvì đụngthistrước khi class cha khởi tạo object. - 5
Soi prototype chain
Với
tom = new MeoHamHoc(...), inObject.getPrototypeOftừng tầng cho tớinull. Đối chiếu vớitom instanceof Meo.Hoàn thành khi: Chuỗi:
MeoHamHoc.prototype→Meo.prototype→Object.prototype→null;tom instanceof MeolàtruevìMeo.prototypenằm trên chuỗi. - 6
Vá prototype
Tạo một instance TRƯỚC, rồi gán
Meo.prototype.thiTham = function () { ... }. Instance cũ gọithiTham()đượ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.