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

Composition và kế thừa — Object.assign và mixin

Chia tính năng thành từng thành phần rồi ghép vào một object. Bài viết gồm Object.assign, hàm trả về thành phần, hàm mixin và khi nào nên dùng kế thừa.

Hệ thống quản lý tài liệu của công ty cần xuất PDF cho biên bản họp, duyệt cho hợp đồng, và cả hai cho báo giá. Sau extends chỉ ghi được một lớp cha, nên mỗi tổ hợp tính năng lại cần một class riêng.

Bài này nói về composition, cách chia tính năng thành từng thành phần rồi gắn vào object, và hàm mixin, cách xếp chồng tính năng lên một class.

Chỉ gắn thành phần mà mỗi tài liệu cần — Object.assign

Giả sử bạn đã tạo class ApprovableDocument có chức năng duyệt, rồi vì báo giá cần thêm xuất PDF, bạn tạo ExportableDocument kế thừa từ nó. Biên bản họp chỉ cần xuất PDF, nên hoặc phải kế thừa cả chức năng duyệt không bao giờ dùng, hoặc kế thừa riêng từ Document rồi chép lại phần xuất PDF.

Với composition (cách tạo object bằng cách gắn các nhóm phương thức, mỗi nhóm cho một tính năng, vào cùng một object), bạn truyền các thành phần vào Object.assign(đích, thànhPhần1, thànhPhần2). Key và giá trị của các đối số từ thứ hai trở đi được ghi vào đối số đầu tiên, và giá trị trả về chính là object đầu tiên đó.

class Document {
  constructor(title) { this.title = title; }
}

// Mỗi tính năng là một nhóm phương thức (một thành phần)
const approvable = {
  approve(name) { return `${this.title}: ${name} đã duyệt`; },
};
const exportable = {
  toPdfName() { return `${this.title}.pdf`; },
};

// Mỗi tài liệu chỉ nhận những thành phần nó cần
const minutes = Object.assign(new Document("Biên bản họp định kỳ"), exportable);
const estimate = Object.assign(new Document("Báo giá bảo trì"), approvable, exportable);

console.log(Object.keys(estimate).join(", "));   // title, approve, toPdfName
console.log(estimate.approve("Minh"));          // Báo giá bảo trì: Minh đã duyệt
console.log(minutes.toPdfName());               // Biên bản họp định kỳ.pdf
console.log(typeof minutes.approve);            // undefined (biên bản không có chức năng duyệt)
Thứ tự Object.assign ghi vào
new Document("Báo giá bảo trì")Lúc này chỉ cókey titleGhi approvecủa approvableapprovablekhông bị thay đổiGhi toPdfNamecủa exportableTrả về đúnginstance vừa ghi
Key của các thành phần được ghi vào cùng một instance, lần lượt từ đối số bên trái. Không có object mới nào được tạo, giá trị trả về chính là object đích.

Trong estimate.approve("Minh"), bên trái dấu chấm là estimate, nên this bên trong approveestimate, và this.title là tiêu đề của báo giá. Object đích vẫn là instance của new Document, nên instanceof Document cũng là true.

Trong ứng dụng chat nội bộ, bạn sẽ thêm chức năng ghim cho tin nhắn nhận được. messages và pinnable đã được khai báo sẵn.

① Hãy ghi pinnable vào tin nhắn đầu tiên và lưu giá trị trả về vào một biến.

② Hãy ghim tin nhắn qua biến ở ①, rồi in kết quả trả về và pinned của tin nhắn đầu tiên.

③ Hãy in xem giá trị trả về ở ① có phải cùng object với tin nhắn đầu tiên hay không.

④ Hãy in kiểu của pin ở tin nhắn thứ hai.

(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

Mỗi tài liệu có danh sách comment riêng — hàm trả về thành phần

Giờ bạn muốn thêm danh sách comment, nên biến { comments: [], addComment(text) { ... } } thành một thành phần rồi dùng Object.assign ghi vào cả biên bản họp lẫn báo giá. Nhưng thứ comments nhận được chỉ là tham chiếu tới cùng một mảng, nên comment thêm vào biên bản họp cũng xuất hiện trong danh sách của báo giá.

Nếu viết lại thành phần comment thành commentable, một hàm tạo mới thành phần và trả về mỗi lần được gọi, thì mỗi lần gọi, [] của comments là một mảng mới. Nếu biến cả approvable thành hàm rồi viết { ...approvable(), ...commentable() }, cú pháp spread sẽ chép key và giá trị của từng thành phần sang một object mới.

// Hàm trả về thành phần: mỗi lần gọi tạo object và mảng mới
const commentable = () => ({
  comments: [],
  addComment(text) { this.comments.push(text); },
});
const approvable = () => ({
  approvedBy: "chưa duyệt",
  approve(name) { this.approvedBy = name; },
});

// Dùng cú pháp spread xếp các thành phần để tạo tài liệu mới
const minutes = { title: "Biên bản họp định kỳ", ...commentable() };
const estimate = { title: "Báo giá bảo trì", ...approvable(), ...commentable() };

minutes.addComment("Hẹn ngày 10");
estimate.approve("Minh");
console.log(minutes.comments.length, estimate.comments.length);   // 1 0 (hai mảng riêng)
console.log(estimate.approvedBy);                                  // Minh
console.log(Object.keys(estimate).join(", "));   // title, approvedBy, approve, comments, addComment
Comment của biên bản có lan sang báo giá không
Object.assignmột thành phầncho cả 2 tài liệucomments trỏ tớicùng một mảngminutes.addComment("Hẹn ngày 10")estimate.commentscũng có 1Gọi commentable()cho mỗi tài liệuMỗi lần gọi,comments là [] mớiminutes.addComment("Hẹn ngày 10")estimate.commentsvẫn là 0
Ở hàng trên, chỉ thêm 1 comment vào biên bản họp mà báo giá cũng có thêm 1 comment. Chỉ tham chiếu tới mảng được chép sang, nên hãy tạo thành phần mới cho từng tài liệu.

{ } tạo ra object mới, còn cú pháp spread chỉ chép key từ giá trị trả về của từng thành phần vào đó. Khác với Object.assign, bạn không chuẩn bị object đích trước, nên kết quả là một object thường, không phải instance của class nào.

Key trùng tên thì thành phần truyền sau được giữ lại

Với cả Object.assign lẫn cú pháp spread, khi hai thành phần có key trùng tên, giá trị của thành phần truyền sau sẽ được giữ lại. Nếu approvable()commentable() cùng có describe, chỉ bản của thành phần đứng sau được giữ lại mà không hề báo lỗi, nên hãy đặt tên phương thức không trùng nhau giữa các thành phần.

Trong ứng dụng chat nội bộ, bạn sẽ tạo tin nhắn có cảm xúc và trạng thái đã xem. reactable đã được khai báo sẵn.

① Hãy định nghĩa một hàm mỗi lần gọi trả về một thành phần mới, thành phần này thêm tên người đã xem vào một mảng. Mỗi tên chỉ được thêm một lần.

② Hãy ghép hai thành phần để tạo tin nhắn của Minh và của Linh.

③ Hãy thêm một cảm xúc vào tin nhắn của Minh và đánh dấu đã xem 3 lần, trong đó có một người xem hai lần.

④ Hãy in số cảm xúc và số người đã xem của cả hai tin nhắn.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Xếp chồng tính năng ngay trong định nghĩa class — hàm mixin

Ở một chỗ khác trong hệ thống, báo giá được định nghĩa bằng class Estimate và tạo bằng new Estimate(...) ở từng màn hình. Nếu gắn thêm chức năng duyệt và comment sau bằng Object.assign, bạn phải ghi lại sau mỗi lần new, và báo giá nào bị quên ghi sẽ gặp lỗi TypeError khi gọi approve.

Hàm mixin (hàm nhận vào một class và trả về một class kế thừa class đó có thêm tính năng) được viết là (Base) => class extends Base { ... }. class extends Base { ... } là một class không có tên. Bạn có thể viết lời gọi hàm này ngay sau extends, và class mà nó trả về sẽ trở thành lớp cha.

class Document {
  constructor(title) { this.title = title; }
  describe() { return this.title; }
}

// Nhận một class, trả về class kế thừa class đó và có thêm tính năng
const Approvable = (Base) => class extends Base {
  approve(name) { this.approvedBy = name; }
  describe() { return `${super.describe()} (${this.approvedBy ?? "chưa duyệt"})`; }
};
const Commentable = (Base) => class extends Base {
  comments = [];                                  // Mỗi instance có một mảng mới
  addComment(text) { this.comments.push(text); }
};

// Xếp chồng cả hai ngay khi định nghĩa, nên không phải ghi lại sau mỗi lần new
class Estimate extends Commentable(Approvable(Document)) {}
const estimate = new Estimate("Báo giá bảo trì");
estimate.approve("Minh");
console.log(estimate.describe());   // Báo giá bảo trì (Minh)
Hai class nằm giữa Estimate và Document
class Estimatethân rỗngBắt đầu tìmdescribe từ đâyClass doCommentable trả vềcomments vàaddCommentClass doApprovable trả vềTìm thấy approvevà describe ở đâyclass Documentsuper.describe() →"Báo giá bảo trì"
Approvable nằm ở lớp trong nên gần Document hơn. Dù thân Estimate rỗng, phương thức vẫn được tìm lần lượt qua cả 4 class.

super.describe() trong Approvable gọi describe của Document, tức Base mà nó nhận vào. Dù bạn đổi thứ tự xếp chồng thành Approvable(Commentable(Document)), cả chức năng duyệt lẫn comment vẫn được gắn đủ.

extends mà không gọi hàm mixin sẽ báo lỗi TypeError

extends Approvable lấy chính arrow function làm lớp cha. Arrow function không thể làm lớp cha, nên bạn sẽ gặp lỗi TypeError: Class extends value ... is not a constructor or null. Hãy viết Approvable(Document) làm lớp cha.

So sánh với kế thừa — khi nào dùng extends

Khi viết mỗi tính năng thành một hàm mixin, các cặp ngoặc bắt đầu lồng nhau, như class Estimate extends Commentable(Approvable(Document)) {}. Càng xếp chồng nhiều, bạn càng khó đọc ra từ một dòng khai báo đó rằng Estimate thuộc loại gì và tính năng nào được thêm vào sau.

Hãy so với bản kế thừa, trong đó CommentableDocument (comment) kế thừa ApprovableDocument (duyệt). Ở cả hai cách, instanceof Document đều là true và đều dùng được super. Điểm khác nhau là có chọn được tính năng cho từng class hay không.

class Document { constructor(title) { this.title = title; } }
const Commentable = (Base) => class extends Base {   // Cùng mixin với phần trước
  comments = []; addComment(text) { this.comments.push(text); }
};

// Kế thừa: tạo class comment dựa trên class duyệt
class ApprovableDocument extends Document {
  approve(name) { this.approvedBy = name; }
}
class CommentableDocument extends ApprovableDocument {
  comments = [];
  addComment(text) { this.comments.push(text); }
}
const oldMinutes = new CommentableDocument("Biên bản họp định kỳ");
console.log(typeof oldMinutes.approve);    // function (kế thừa cả chức năng duyệt không dùng tới)

// Xếp chồng bằng mixin
class Minutes extends Commentable(Document) {}
const minutes = new Minutes("Biên bản họp định kỳ");
console.log(typeof minutes.approve);       // undefined (chỉ có tính năng comment)
console.log(minutes instanceof Document, oldMinutes instanceof Document);   // true true
Biên bản nhận tính năng gì: kế thừa và composition
oldMinutes làCommentableDocumentLớp cha làApprovableDocumentTìm thấy approve ởApprovableDocumenttypeof approvelà functionminutes làMinutesLớp cha: class doCommentable trả vềTới Document vẫnkhông có approvetypeof approvelà undefined
Với kế thừa, biên bản nhận cả approve từ lớp cha ApprovableDocument. Với mixin, chỉ có tính năng của Commentable.

Những tính năng chỉ một số loại tài liệu cần, như duyệt hay comment, nên tách thành thành phần. Còn quan hệ nằm gọn trên một đường cha–con, kiểu "báo giá là một loại tài liệu", thì viết bằng extends. Bảng dưới đây liệt kê 4 cách đã dùng trong bài.

Cách viếtTính năng được gắnPhù hợp khi
extends ChaNhận hết từ chaQuan hệ "là một loại"
Object.assign(đích, thànhPhần)Ghi vào object có sẵnThêm cho 1 object có sẵn
{ ...thànhPhần() }Chép sang object mớiGhép mà không dùng class
extends Mixin(Cha)Xếp chồng tính năng cầnThêm khi định nghĩa class

Trong ứng dụng chat nội bộ, bạn sẽ tạo 3 loại tin nhắn: chỉ ghim, chỉ cảm xúc, và có cả hai. Message và Reactable đã được khai báo sẵn.

① Hãy định nghĩa một hàm mixin trả về class có thêm chức năng ghim.

② Hãy định nghĩa 3 class.

③ Hãy tạo mỗi loại một tin nhắn và in kiểu của pin và react.

④ Hãy thêm một cảm xúc vào tin nhắn có cả hai, rồi in kết quả ghim và số cảm xúc.

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 1Với minutes đã được ghi thêm thành phần bằng Object.assign, minutes instanceof Document cho ra gì?

Câu 2Dùng Object.assign chép comments: [] của cùng một thành phần vào 2 tài liệu, rồi push vào một tài liệu. Điều gì xảy ra?

Câu 3Trong Commentable(Approvable(Document)), class nào kế thừa trực tiếp Document?