Q1Object.assign で部品を書き込んだ minutes の instanceof Document は?
合成と継承 — Object.assign とミックスイン
機能を部品に分けて 1 つのオブジェクトに組み込む合成を扱います。Object.assign での後付け、部品を返す関数とスプレッド構文、class に機能を重ねるミックスイン関数、継承との使い分けを学べます。
社内の文書管理で、議事録に PDF 出力、契約書に承認、見積書に両方を付けるとします。extends の親は 1 つだけなので、機能の組み合わせごとに class を用意することになります。
この記事では、機能を部品に分けて組み込む 合成 と、class に機能を重ねる ミックスイン関数 を扱います。
文書ごとに部品を選んで組み込む — Object.assign
承認できる ApprovableDocument を作り、PDF 出力も欲しい見積書のために、それを親にして ExportableDocument を作ったとします。PDF 出力だけ欲しい議事録は、使わない承認まで引き継ぐか、Document から別に引き継いで PDF 出力を書き写すことになります。
合成(機能ごとに作ったメソッドのまとまりを、1 つのオブジェクトに組み込む作り方)では、部品を Object.assign(書き込み先, 部品1, 部品2) に渡します。2 つ目以降の引数のキーと値が 1 つ目に書き込まれ、戻り値はその 1 つ目です。
class Document {
constructor(title) { this.title = title; }
}
// 機能ごとに、メソッドのまとまり(部品)を作る
const approvable = {
approve(name) { return `${this.title}: ${name} が承認`; },
};
const exportable = {
toPdfName() { return `${this.title}.pdf`; },
};
// 文書ごとに、必要な部品だけを書き込む
const minutes = Object.assign(new Document("定例会議の議事録"), exportable);
const estimate = Object.assign(new Document("保守の見積書"), approvable, exportable);
console.log(Object.keys(estimate).join(", ")); // title, approve, toPdfName
console.log(estimate.approve("佐藤")); // 保守の見積書: 佐藤 が承認
console.log(minutes.toPdfName()); // 定例会議の議事録.pdf
console.log(typeof minutes.approve); // undefined(議事録には承認を付けていない)
estimate.approve("佐藤") はドットの左が estimate なので、approve の中の this は estimate で、this.title は見積書のタイトルです。書き込み先は new Document のインスタンスのままなので、instanceof Document も true です。
コメントを文書ごとに持たせる — 部品を返す関数
コメントの一覧も付けようと、{ comments: [], addComment(text) { ... } } を部品にして、Object.assign で議事録と見積書に書き込んだとします。comments には同じ配列の参照が写るため、議事録に付けたコメントが見積書の一覧にも入ります。
コメントの部品を、呼ぶたびに新しく作って返す関数 commentable に書き換えると、comments の [] は呼び出しごとに別の配列です。approvable も関数にし、{ ...approvable(), ...commentable() } と並べると、スプレッド構文が部品のキーと値を新しいオブジェクトへ写します。
// 部品を返す関数: 呼ぶたびに新しいオブジェクトと配列を作る
const commentable = () => ({
comments: [],
addComment(text) { this.comments.push(text); },
});
const approvable = () => ({
approvedBy: "未承認",
approve(name) { this.approvedBy = name; },
});
// スプレッド構文で部品を並べ、新しい文書を作る
const minutes = { title: "定例会議の議事録", ...commentable() };
const estimate = { title: "保守の見積書", ...approvable(), ...commentable() };
minutes.addComment("次回は 10 日");
estimate.approve("佐藤");
console.log(minutes.comments.length, estimate.comments.length); // 1 0(配列は別々)
console.log(estimate.approvedBy); // 佐藤
console.log(Object.keys(estimate).join(", ")); // title, approvedBy, approve, comments, addComment
新しいオブジェクトを作るのは { } で、スプレッド構文はそこへ部品の戻り値のキーを写すだけです。Object.assign と違って書き込み先を先に用意しないので、できるのは class のインスタンスではない、普通のオブジェクトです。
同じ名前のキーは後に渡した部品が残る
Object.assign でもスプレッド構文でも、同じ名前のキーは後に渡した部品の値が残ります。approvable() と commentable() が両方 describe を持つと後に並べた側だけが残り、エラーも出ないので、部品同士でメソッド名が重ならないようにします。
class の定義で機能を重ねる — ミックスイン関数
見積書を class Estimate で定義し、画面ごとに new Estimate(...) で作っているとします。承認とコメントを Object.assign で後付けすると new のたびに書き込みが要り、書き忘れた見積書だけ approve で TypeError になります。
ミックスイン関数(class を受け取り、それを引き継いで機能を足した class を返す関数)は (Base) => class extends Base { ... } と書きます。class extends Base { ... } は名前を省いた class です。extends の後ろにはこの関数の呼び出しを書け、戻り値の class が親になります。
class Document {
constructor(title) { this.title = title; }
describe() { return this.title; }
}
// class を受け取り、その class を引き継いで機能を足した class を返す
const Approvable = (Base) => class extends Base {
approve(name) { this.approvedBy = name; }
describe() { return `${super.describe()}(${this.approvedBy ?? "未承認"})`; }
};
const Commentable = (Base) => class extends Base {
comments = []; // インスタンスごとに新しい配列
addComment(text) { this.comments.push(text); }
};
// 定義の時点で 2 つを重ねるので、new のたびに書き込まなくてよい
class Estimate extends Commentable(Approvable(Document)) {}
const estimate = new Estimate("保守の見積書");
estimate.approve("佐藤");
console.log(estimate.describe()); // 保守の見積書(佐藤)
Approvable の super.describe() は、受け取った Base である Document の describe を呼びます。重ねる順を Approvable(Commentable(Document)) に入れ替えても、承認とコメントの機能はどちらも付きます。
呼び出さずに extends すると TypeError
extends Approvable はアロー関数そのものを親にします。アロー関数は class の親にできず、TypeError: Class extends value ... is not a constructor or null で止まります。親には Approvable(Document) を書きます。
継承版と並べて選ぶ — extends との使い分け
機能ごとにミックスイン関数を作っていくと、class Estimate extends Commentable(Approvable(Document)) {} のように括弧が重なります。重ねる数が増えるほど、Estimate が何の一種で、どの機能を後から足したのかを宣言の 1 行から読み取りにくくなります。
承認の ApprovableDocument を親にしてコメントの CommentableDocument を作る継承版と比べると、どちらの class も instanceof Document は true で、super も使えます。違うのは、付ける機能を class ごとに選べるか です。
class Document { constructor(title) { this.title = title; } }
const Commentable = (Base) => class extends Base { // 前の章と同じミックスイン
comments = []; addComment(text) { this.comments.push(text); }
};
// 継承: 承認の class を親にして、コメントの class を作る
class ApprovableDocument extends Document {
approve(name) { this.approvedBy = name; }
}
class CommentableDocument extends ApprovableDocument {
comments = [];
addComment(text) { this.comments.push(text); }
}
const oldMinutes = new CommentableDocument("定例会議の議事録");
console.log(typeof oldMinutes.approve); // function(使わない承認まで引き継ぐ)
// ミックスインで重ねる
class Minutes extends Commentable(Document) {}
const minutes = new Minutes("定例会議の議事録");
console.log(typeof minutes.approve); // undefined(コメントの機能だけが付く)
console.log(minutes instanceof Document, oldMinutes instanceof Document); // true true
承認やコメントのように種類をまたいで付け外しする機能は部品に分け、「見積書は文書の一種」のような 1 本の親子で表せる関係は extends で書きます。下の表は、この記事で使った 4 つの作り方を並べたものです。
| 作り方 | 機能の付き方 | 向いている場面 |
|---|---|---|
| extends 親 | 親の機能をすべて引き継ぐ | 文書の一種と言える関係 |
| Object.assign(書き込み先, 部品) | 作成済みのオブジェクトに書き込む | 受け取った 1 件に足す |
| { ...部品() } | 新しいオブジェクトに写す | class を使わずに組む |
| extends ミックスイン(親) | 選んだ機能を段で重ねる | class の定義で足す |
理解度チェック
まずは1問ずつ答えてみましょう。
Q21 つの部品の comments: [] を Object.assign で 2 文書に写し、片方に push すると?
Q3Commentable(Approvable(Document)) で、Document を直接引き継ぐのは?