Câu 1Với minutes đã được ghi thêm thành phần bằng Object.assign, minutes instanceof Document cho ra gì?
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)
Trong estimate.approve("Minh"), bên trái dấu chấm là estimate, nên this bên trong approve là estimate, 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.
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
{ } 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() và 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.
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)
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
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ết | Tính năng được gắn | Phù hợp khi |
|---|---|---|
| extends Cha | Nhận hết từ cha | Quan hệ "là một loại" |
| Object.assign(đích, thànhPhần) | Ghi vào object có sẵn | Thêm cho 1 object có sẵn |
| { ...thànhPhần() } | Chép sang object mới | Ghép mà không dùng class |
| extends Mixin(Cha) | Xếp chồng tính năng cần | Thêm khi định nghĩa class |
Kiểm tra kiến thức
Hãy trả lời từng câu hỏi một.
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?