Tổng kết OOP — bảng tra nhanh cú pháp class và this

Các bảng tra nhanh cho cú pháp class của chuyên mục này: member nằm ở đâu, thứ tự tìm tên, cách this được xác định, chỗ thêm tính năng và thời điểm lỗi dừng code.

Trong code quản lý task, member static#, lớp con tạo bằng extends và phương thức được tách ra khỏi object đều nằm chung một file. Nếu chưa phân biệt rõ chúng khác nhau thế nào, bạn sẽ không lần ra được undefined hay TypeError đến từ đâu.

Bài này tổng hợp cách viết của chuyên mục bằng các bảng tra nhanh, tập trung vào cú pháp classthis.

Đọc member theo nơi nó nằm — bảng tra nhanh cú pháp class

Giả sử bạn đọc class Task do người khác viết. Các dòng có static, #get nằm cạnh nhau trong cùng cặp ngoặc nhọn, và đọc từng dòng một cũng không cho bạn biết vì sao task.fromJSON(text) báo lỗi TypeError, hay vì sao trạng thái hoàn thành không xuất hiện trong JSON.stringify(task).

Cách viết trong thân class quyết định nơi nó nằm: trên chính instance, trên Task.prototype (nơi đặt các phương thức dùng chung cho mọi instance), hay trên chính class. Khi đọc một tên, JavaScript tìm ở chính instance, rồi Task.prototype, rồi Object.prototype.

class Task {
  static count = 0;                              // Chỉ Task giữ đúng một giá trị
  priority = "bình thường";                      // Được gán vào từng task mỗi lần new
  #done = false;                                 // Nằm trên task, nhưng không đọc được từ ngoài class
  constructor(title) {
    this.title = title;
    Task.count += 1;
  }
  complete() { this.#done = true; }              // Nằm trên Task.prototype, mọi instance dùng chung
  get status() { return this.#done ? "đã xong" : "chưa bắt đầu"; }
  static fromJSON(text) { return new Task(JSON.parse(text).title); }
}

// Kiểm tra tên nào đọc được và tên nào không đọc được từ instance
const task = Task.fromJSON('{"title":"Gửi báo giá"}');
task.complete();
console.log(task.status, Task.count);                                 // đã xong 1
console.log(Object.keys(task).join(", "));                            // priority, title
console.log(JSON.stringify(task));                                    // {"priority":"bình thường","title":"Gửi báo giá"}
console.log(task.hasOwnProperty("complete"), typeof task.fromJSON);   // false undefined
Những tên tìm được từ task
Phạm vi tìm của task.tên
  • Tìm lần lượt: task → Task.prototype → Object.prototype
Chính task
  • title, priority — có trong danh sách key và xuất hiện trong JSON
Task.prototype (dùng chung)
  • complete(), get status() — tìm là thấy
Private field — task.tên không với tới
  • #done — nằm trên task, nhưng chỉ đọc được bằng this.#done bên trong class
Chính Task (static)
  • count, fromJSON() — nằm ngoài phạm vi tìm
  • task.fromJSONundefined
Tên có # và member static nằm ngoài phạm vi tìm của task. task.fromJSON không tìm thấy, nên hãy gọi qua tên class.

status nằm trên Task.prototype, còn #done nằm trên task nhưng không có trong danh sách key, nên cả hai đều không xuất hiện trong Object.keys hay JSON.stringify. Muốn đưa chúng vào JSON, hãy trả về các giá trị cần thiết từ toJSON. Bảng dưới đây liệt kê cách viết của chuyên mục này theo nơi mỗi member nằm.

Cách viếtNằm ở đâuĐọc từ task
this.title trong constructorChính taskCó trong key và JSON
Class field priorityChính taskCó trong key và JSON
Private field #doneChính task (chỉ trong class)Không có trong key, JSON
Phương thức complete()Task.prototypeGọi được, không có trong key
get / set statusTask.prototypeĐọc không cần ()
static count / fromJSON()Chính Taskundefined

Lần xem định nghĩa nào được chạy — extends và super

Lấy ví dụ một task lặp lại hằng tuần được tạo bằng class RecurringTask extends Task. Nhìn vào dòng weekly.describe(), bạn không biết được describe của lớp con hay của lớp cha sẽ chạy, cũng như vì sao gọi được complete dù lớp con không định nghĩa nó.

Một tên được tìm dọc theo chuỗi prototype, bắt đầu từ chính object, và định nghĩa được tìm thấy đầu tiên là định nghĩa sẽ chạy. Phương thức override ở lớp con được tìm thấy trước bản của lớp cha, còn super.describe() bắt đầu tìm từ lớp cha. Các phương thức viết riêng cho từng loại bằng polymorphism cũng được tìm theo đúng thứ tự này.

class Task {
  constructor(title) { this.title = title; }
  describe() { return `Công việc: ${this.title}`; }
  complete() { return `Đã xong: ${this.title}`; }
}

// Lớp con: gọi constructor của lớp cha bằng super() và chỉ override describe
class RecurringTask extends Task {
  constructor(title, weekday) { super(title); this.weekday = weekday; }
  describe() { return `${super.describe()} (mỗi ${this.weekday})`; }
}

// Gọi thử để xem định nghĩa nào chạy
const weekly = new RecurringTask("Nộp báo cáo tuần", "thứ Sáu");
console.log(weekly.describe());                              // Công việc: Nộp báo cáo tuần (mỗi thứ Sáu)
console.log(weekly.complete());                              // Đã xong: Nộp báo cáo tuần
console.log(`${weekly}`);                                    // [object Object]
console.log(weekly instanceof Task, typeof weekly.archive);  // true undefined
Thứ tự các tầng được tìm từ weekly
weekly(instance)Có titlevà weekdayRecurringTask.prototypeTìm thấy describe← weekly.describe()Task.prototypeTìm thấy complete(super.describe()cũng tới đây)Object.prototypeTìm thấy toString← ${weekly}
describe được tìm thấy ở tầng thứ hai, complete ở tầng thứ ba. describe của lớp cha chỉ chạy khi bạn thêm super.

Tên không có ở tầng nào, như archive, sẽ cho ra undefined, và gọi nó sẽ báo lỗi TypeError. ${weekly} cho ra [object Object]toString mặc định ở tầng cuối cùng được chạy.

Phân biệt this theo cách gọi — bảng tra nhanh this

Giờ giả sử bạn lưu phương thức hoàn thành task vào một biến để gọi sau, hoặc truyền nó cho setTimeout. Phương thức chạy đúng khi viết task.complete() có thể nhận một this không phải task tùy vào cách truyền, và khi đó nó báo lỗi TypeError hoặc this.title ra undefined.

Thời điểm giá trị của this được xác định phụ thuộc vào cách viết hàm. Với phương thức thường trong class, nó được xác định bởi object bên trái dấu chấm ở dòng gọi; với field là arrow function, nó được xác định khi new tạo instance. Giá trị mà bind trả về là một hàm khác, có this cố định là giá trị bạn truyền cho bind.

class Task {
  onComplete = () => `Đã xong: ${this.title}`;     // this là task lúc new
  constructor(title) { this.title = title; }
  complete() { return `Đã xong: ${this.title}`; }  // this được xác định ở dòng gọi
}
const task = new Task("Gửi hóa đơn");

// Gọi với task ở bên trái dấu chấm
console.log(task.complete());   // Đã xong: Gửi hóa đơn

// Lưu vào biến rồi mới gọi
const runMethod = task.complete;
const runArrow = task.onComplete;
const runBound = task.complete.bind(task);
console.log(runArrow());        // Đã xong: Gửi hóa đơn
console.log(runBound());        // Đã xong: Gửi hóa đơn
// runMethod();                 // TypeError: Cannot read properties of undefined (reading 'title')
this của hàm được tách ra xác định thế nào
const runMethod= task.completePhương thứcthường: xác địnhlúc gọirunMethod(): khôngcó gì bên trái dấuchấmthis là undefinedTypeErrorconst runArrow= task.onCompleteField arrowfunction: xác địnhlúc newDù gọi runArrow(),this vẫn là taskĐã xong:Gửi hóa đơn
this trong runMethod() là undefined. Nếu viết theo cách ở hàng dưới, hàm được tách ra để truyền đi vẫn giữ task.

runBound trong code cũng vẫn chạy với task sau khi được tách ra. Với setTimeout(task.complete, 100), trình duyệt gán window cho this, nên hãy bọc nó trong một arrow function. Bảng dưới đây cho thấy this được xác định vào lúc nào với từng cách gọi.

Cách gọithis xác định lúcKhi tách ra để truyền
Phương thức thườngBên trái dấu chấm khi gọiMất task, TypeError
Kết quả của bindKhi gọi bindVẫn chạy với task
Field arrow functionLúc newVẫn chạy với task
Bọc trong () => task.complete()Lúc hàm bọc gọi task.complete()Vẫn chạy với task
Phương thức từ Object.assignBên trái dấu chấm khi gọiMất object đích

Phương thức cùng tên viết ở lớp con không bao giờ chạy

Dù bạn viết class RecurringTask extends Task { onComplete() { … } }, field arrow function của lớp cha Task được gán lên chính weekly và được tìm thấy trước, nên weekly.onComplete() chạy hàm của lớp cha. Nếu lớp con cần override hàm này, hãy viết nó thành phương thức ở cả lớp cha.

Chọn nơi thêm tính năng — kế thừa, prototype hay composition

Bạn đang thêm tính năng cho một trình quản lý task đang chạy thực tế: ghim một task duy nhất, hiển thị nhãn cho mọi task đã tạo, hoặc chỉ thêm người phụ trách cho task của nhóm. Nếu viết tính năng sai chỗ, task mà bạn tưởng đã có tính năng đó sẽ báo lỗi TypeError.

Các cách thêm tính năng trong chuyên mục này khác nhau ở nơi tính năng được ghi vào: chính instance, Task.prototype, hay một tầng mới nối bằng extends. Biết tính năng được ghi vào đâu thì cũng biết những instance nào tìm thấy tên đó dọc theo chuỗi prototype của mình.

class Task {
  constructor(title) { this.title = title; }
}
const first = new Task("Gửi báo giá");            // Tạo sẵn 2 task
const second = new Task("Viết biên bản họp");

// Ghi vào đúng một task
Object.assign(first, { pin() { return `Đã ghim: ${this.title}`; } });
// Gán vào Task.prototype
Task.prototype.label = function () { return `Công việc: ${this.title}`; };
// Tạo class có thêm một tầng bằng mixin
const Assignable = (Base) => class extends Base {
  assign(name) { return `Đã giao cho ${name}: ${this.title}`; }
};
class TeamTask extends Assignable(Task) {}

console.log(typeof second.pin, second.label());          // undefined Công việc: Viết biên bản họp
console.log(typeof first.assign);                        // undefined
console.log(new TeamTask("Nộp báo cáo tuần").assign("Minh"));   // Đã giao cho Minh: Nộp báo cáo tuần
Nơi ghi tính năng quyết định nó tới được đâu
first và secondtạo từ TaskObject.assign(first, thànhPhần)Task.prototype.label = functionextendsAssignable(Task)Vào key riêngcủa firstThêm một lần vàoTask.prototypeThêm một tầngtrên TeamTaskChỉ first có,second: undefinedCả 2 task đã tạođều gọi đượcChỉ new TeamTask(first, secondkhông có)
Ghi vào chính first thì chỉ tới task đó, ghi vào Task.prototype thì tới cả 2 task đã tạo. Mixin chỉ tới instance của class mới.

label tới được cả second vốn đã tạo từ trước, vì second tìm Task.prototype dọc theo chuỗi prototype của nó. Gán vào prototype là cách dùng cho những class mà bạn không sửa được phần thân. Phương thức viết ở lớp con, giống như mixin, chỉ tới các instance tạo từ lớp con đó.

Tính năng thêm cho từng object thì kiểm tra bằng typeof

Object.assign chỉ ghi pin vào first, instanceof Task của cả first lẫn second đều là true. Vì class không phân biệt được hai object này, hãy kiểm tra phương thức có tồn tại bằng typeof task.pin === "function" trước khi gọi.

Tìm nguyên nhân theo thời điểm dừng — bảng tra nhanh lỗi

Khi code dùng class báo lỗi, dòng code dừng lại và dòng cần sửa có thể cách xa nhau. Viết sai constructor của lớp con thì lỗi xuất phát từ một dòng bên trong nó khi new chạy, còn khi viết tên # ở ngoài class, ngay cả console.log ở các dòng phía trên cũng không in ra gì.

Lỗi chia thành 3 nhóm theo thời điểm chúng dừng code: lúc code đang được nạp, lúc new chạy constructor, hoặc lúc gọi phương thức. Với SyntaxError lúc nạp, không dòng nào được chạy; với hai nhóm còn lại, các dòng trước dòng lỗi chạy xong rồi code mới dừng.

class Task {
  #done = false;
  constructor(title) { this.title = title; }
  complete() { return `Đã xong: ${this.title}`; }
}
class RecurringTask extends Task {
  constructor(title, weekday) {
    this.weekday = weekday;   // Dùng this trước super()
    super(title);
  }
}
console.log("Bắt đầu kiểm tra");   // In ra với ② và ③, không in ra nếu có dòng ①

// ① Lúc nạp: console.log(new Task("Gửi hóa đơn").#done);
//    SyntaxError: Private field '#done' must be declared in an enclosing class
// ② Lúc new: new RecurringTask("Nộp báo cáo tuần", "thứ Sáu");
//    ReferenceError: Must call super constructor in derived class before accessing 'this' …
// ③ Lúc gọi: const run = new Task("Gửi hóa đơn").complete; run();
//    TypeError: Cannot read properties of undefined (reading 'title')
3 thời điểm lỗi dừng code
① Lúc codeđang được nạpSyntaxErrorChưa chạy dòng nào② new chạyconstructorReferenceErrorCác dòng trênnew vẫn chạy③ Lúc gọiphương thứcTypeErrorCác dòng trênlời gọi vẫn chạy
Hàng càng ở trên thì dừng càng sớm, với ① thì cả thông báo kiểm tra cũng không in ra. Nếu không có gì được in ra, hãy nghi ngờ SyntaxError lúc nạp.

Với ② và ③, mọi thứ trước đó đều được in ra rồi code mới dừng, nên bạn tìm nguyên nhân ngay sau dòng in ra cuối cùng. Bảng dưới đây liệt kê các lỗi đã gặp trong chuyên mục này, xếp theo thời điểm dừng từ sớm đến muộn.

Thông báo lỗiNguyên nhânCách sửa
SyntaxError: Private fieldtask.#done ngoài classĐọc qua getter
SyntaxError: … export namedĐọc default export bằng { Cart }Bỏ ngoặc nhọn
ReferenceError: Must call superDùng this trước super()Gọi super() trước
TypeError: … without 'new'Gọi class không có newThêm new
TypeError: Cannot read propertiesGọi run() đã tách khỏi objectDùng bind / bọc arrow
RangeError: Maximum call stacksetter gán chính nó / thiếu super.Đổi tên / thêm super.
QUIZ

Kiểm tra kiến thức

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

Câu 1RecurringTask không định nghĩa complete. Định nghĩa của weekly.complete() được tìm thấy ở tầng nào?

Câu 2Gọi một field arrow function theo kiểu const run = task.onComplete; run(); thì this là gì?

Câu 3Nếu dòng new đứng sau một console.log và báo lỗi ReferenceError, thì console.log đó có in ra không?