順番に読み進めながら学べます

合成と継承 — 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(議事録には承認を付けていない)
Object.assign が書き込む順番
new Document("保守の見積書")この時点のキーはtitle だけapprovable のapprove を書き込むapprovable 自体は変わらないexportable のtoPdfName を書き込む書き込んだ同じインスタンスが返る
部品のキーは、左の引数から順に同じインスタンスへ書き込まれます。新しいオブジェクトは作られず、戻り値は書き込み先そのものです。

estimate.approve("佐藤") はドットの左が estimate なので、approve の中の this は estimate で、this.title は見積書のタイトルです。書き込み先は new Document のインスタンスのままなので、instanceof Document も true です。

社内チャットで、届いたメッセージにピン留めの機能を足します。messages と pinnable は宣言済みです。

① 1 件目に pinnable を書き込み、戻り値を変数に入れてください。

② ① の変数でピン留めし、結果と 1 件目の pinned を表示してください。

③ ① の戻り値が 1 件目と同じオブジェクトかを表示してください。

④ 2 件目の pin の型を表示してください。

(正しく実行できれば解説が表示されます)

JavaScript / TypeScript エディタ

コードを実行してください

コメントを文書ごとに持たせる — 部品を返す関数

コメントの一覧も付けようと、{ 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
議事録のコメントが見積書に入るか
同じ部品を 2 文書にObject.assigncomments は同じ配列の参照minutes.addComment("次回は 10 日")estimate.commentsも 1 件commentable() を文書ごとに呼ぶcomments は呼ぶたびに別の []minutes.addComment("次回は 10 日")estimate.commentsは 0 件
議事録に 1 件足すと、上の段だけ見積書にも 1 件現れます。写るのは配列の参照なので、部品は文書ごとに作り直します。

新しいオブジェクトを作るのは { } で、スプレッド構文はそこへ部品の戻り値のキーを写すだけです。Object.assign と違って書き込み先を先に用意しないので、できるのは class のインスタンスではない、普通のオブジェクトです。

同じ名前のキーは後に渡した部品が残る

Object.assign でもスプレッド構文でも、同じ名前のキーは後に渡した部品の値が残ります。approvable() と commentable() が両方 describe を持つと後に並べた側だけが残り、エラーも出ないので、部品同士でメソッド名が重ならないようにします。

社内チャットで、リアクションと既読を持つメッセージを作ります。reactable は宣言済みです。

① 既読にした人の名前を配列に足す部品を、呼ぶたびに新しく返す関数を定義してください。同じ名前は 1 回だけ足してください。

② 2 つの部品を並べて、佐藤さんと鈴木さんのメッセージを作ってください。

③ 佐藤さんのメッセージにリアクションを付け、同じ人を含めて 3 回既読にしてください。

④ 2 件それぞれのリアクション数と既読の人数を表示してください。

JavaScript / TypeScript エディタ

コードを実行してください

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());   // 保守の見積書(佐藤)
Estimate の親に入る 2 つの class
class Estimate本体は空describe をここから探すCommentable が返した classcomments・addCommentApprovable が返した classapprove・describeがここで見つかるclass Documentsuper.describe() は保守の見積書
内側の Approvable ほど Document の近くに入ります。空の Estimate でも、4 つの class から順に探します。

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
継承と合成で議事録に付く機能
oldMinutes はCommentableDocumentその親はApprovableDocumentApprovableDocumentで approve が見つかるtypeof approveは functionminutes はMinutesその親はCommentable の段Document までapprove が無いtypeof approveは undefined
継承では、親の ApprovableDocument の approve まで付きます。ミックスインでは Commentable の機能だけです。

承認やコメントのように種類をまたいで付け外しする機能は部品に分け、「見積書は文書の一種」のような 1 本の親子で表せる関係は extends で書きます。下の表は、この記事で使った 4 つの作り方を並べたものです。

作り方機能の付き方向いている場面
extends 親親の機能をすべて引き継ぐ文書の一種と言える関係
Object.assign(書き込み先, 部品)作成済みのオブジェクトに書き込む受け取った 1 件に足す
{ ...部品() }新しいオブジェクトに写すclass を使わずに組む
extends ミックスイン(親)選んだ機能を段で重ねるclass の定義で足す

社内チャットで、ピン留めだけ・リアクションだけ・両方を持つ 3 種類のメッセージを作ります。Message と Reactable は宣言済みです。

① ピン留めを足した class を返すミックスイン関数を定義してください。

② 3 種類の class を定義してください。

③ 3 種類で 1 件ずつ作り、pin と react の型を表示してください。

④ 両方を持つメッセージにリアクションを付け、ピン留めの結果と件数を表示してください。

JavaScript / TypeScript エディタ

コードを実行してください
QUIZ

理解度チェック

まずは1問ずつ答えてみましょう。

Q1Object.assign で部品を書き込んだ minutes の instanceof Document は?

Q21 つの部品の comments: [] を Object.assign で 2 文書に写し、片方に push すると?

Q3Commentable(Approvable(Document)) で、Document を直接引き継ぐのは?