JavaScriptにおけるメソッドチェーン:メリットとデメリット
JavaScriptのメソッドチェーンの仕組み、読みやすくなる場面、長い連鎖がデバッグ、非同期処理、性能を難しくする理由を解説。
メソッドチェーンとは、同一オブジェクトに対して複数のメソッドを1つのシーケンスとして呼び出すことです。これが成立するのは、各メソッドが「呼び出せるメソッドをまだ持っているオブジェクト」を返すからです。
6段階のチェーンが黙ってundefinedを返し、ブレークポイントを置く場所が見当たらないまま画面を睨んだ経験があるなら、そのトレードオフはもうご存知でしょう。ドットは書くのは安いが、ほどくのは高い。配列や文字列のような組み込みメソッドでは、戻り値が次のメソッドを運んできます。自作オブジェクトの場合は、各メソッドの末尾でreturn thisとします。チェーンは可読性のためのツールであり、デフォルトの選択肢ではありません。短いパイプラインでは価値を発揮しますが、長くなるとコストが生じ始めます。本記事では、その仕組み、本当のメリット、現実的なデメリット(デバッグの摩擦、無駄な処理、混濁した非同期処理)、自作のチェーン可能オブジェクトを正しく構築する方法、そしてどこでやめるべきかの具体的な基準を扱います。
要点
- メソッドチェーンが成立するのは、各メソッドがさらにメソッドを持つオブジェクトを返すからです。組み込みメソッドは次のメソッドを運ぶ新しい値を返し、カスタムオブジェクトは各メソッドの末尾を
return thisとすることでチェーン可能になります。 - チェーン自体のパフォーマンスコストはごくわずかです。真のコストは余分な走査とアロケーションにあります。たとえば
.filter().map()[0]は配列を2回フルに走査しますが、.find()は1回で最初のマッチで停止します。 - アロー関数でメソッドを書くとチェーンは壊れます。アロー関数は自身の
thisを持たないためインスタンスを指すことがなく、call、bind、applyを使ってもこれは変わりません。 - 実用的な基準:1段階は常に問題なし、2段階も通常は問題なし、3〜4段階なら一度立ち止まるべき、5段階以上なら名前付きのステップに分割すべきです。
- チェーンは「書く速さ」に最適化されており、中間値に名前を付けることは「後から読む・デバッグする」ことに最適化されています。この2つは同じ目標ではありません。
メソッドチェーンとは何か、どう動くのか
メソッドチェーンが成立するのは、各メソッドが「呼び出せるメソッドをまだ持っているオブジェクト」を返すからです。組み込みの配列・文字列メソッドは、それ自身のメソッドを備えた新しい値(配列、文字列)を返すため、そのまま続けられます。
const topNames = users
.filter(user => user.active)
.map(user => user.name)
.sort();
filterは配列を返すのでmapが使え、mapは配列を返すのでsortが使えます。自作オブジェクトでは、各メソッドからインスタンスを返すことでこれを再現します。
class QueryBuilder {
constructor() { this.parts = []; }
where(clause) { this.parts.push(`WHERE ${clause}`); return this; }
limit(n) { this.parts.push(`LIMIT ${n}`); return this; }
build() { return this.parts.join(" "); }
}
new QueryBuilder().where("active = 1").limit(5).build();
// "WHERE active = 1 LIMIT 5"
whereとlimitがthisを返すため、次のメソッドは同じインスタンスに対して解決されます。これは、fluentなビルダーAPIを支えているのと同じ原理です。
Discover how at OpenReplay.com.
メリット:読みやすいパイプラインとfluent API
チェーンが最も輝くのは、各ステップが1つの明確な変換を構成する短いパイプラインです。左から右へシーケンスとして読め(filter、次にmap、次にsort)、二度と参照しない使い捨ての中間変数に名前を付ける必要もありません。2段階の変換であれば、チェーンはしばしば意図を最も直接的に表現します。
const activeNames = users.filter(u => u.active).map(u => u.name);
fluent APIやビルダーAPIは、文章のように読める記述を実現するために同じ仕組みに頼っています。query.where(...).limit(...).build()やexpect(value).to.be.an('array')などです。チェーン全体が1つの一貫した操作を記述している場合、この構文は読み手にとって実際に役立っています。
デメリット:デバッグ、無駄な処理、混濁した非同期処理
チェーンのコストが表面化するのは、チェーンが長くなったとき、関心事が混在したとき、あるいはどれだけの処理をしているかを隠してしまうときです。これらが、デフォルトでチェーンに手を伸ばすべきでない理由です。
デバッグの摩擦。 デバッグが最も難しいチェーンは、最終値が誤っているケースです。チェーンを分解しない限り、ブレークポイントを置いたり中間値をログ出力する自然な場所がありません。結果としてコールバック内にconsole.logを差し込み、デバッグコードとロジックを混在させたり、結局チェーンをステップに分解することになります。本番のフロントエンドコードでは、これはよくある失敗パターンです。誤った出力は見えるのに、どのステップがそれを生んだのかは見えません。ここでセッションリプレイが役立ちます。不正な状態を生んだ操作を再生することで、折りたたまれたチェーンが隠していた入力を取り戻せます。これは、チェーンを名前付きステップに分割していれば得られたはずの情報と同じものです。
無駄な処理。 チェーンは、意図していなくても「すべてを処理する」方向へ導きがちです。.filter().map()[0]は配列を2回フルに走査し、中間配列をアロケートしたうえで、1要素以外すべてを捨てます。最初のマッチだけが欲しいなら、Array.prototype.find()が適切なツールです。これはコールバックが要素を受け入れるまでしか配列を走査せず、その要素を返してそれ以上進みません。
const name = users.find(u => u.active)?.name;
戻り値の型の不透明さ。 data.transform().normalize().validate().save()のような長いチェーンでは、実行時に型注釈のない状態で各ステップが何を返すかを追跡しなければなりません。途中のステップが予期しないものを返すと、チェーン全体の形が静かに変わってしまいます。
混濁した非同期処理。 非同期制御フローとデータ変換を1つの.then()チェーンに混ぜると、意図が曖昧になります。フェッチとパースを変換処理から分離すると、より明快に読めます。
const res = await fetchUsers();
const users = await res.json();
const activeNames = users.filter(u => u.active).map(u => u.name);
これは可読性の判断であり、パフォーマンスの話ではありません。awaitと.then()は同じ処理を行います。
パフォーマンス:ドットは無料、走査は有料
チェーン自体の本質的なパフォーマンスコストはごくわずかです。1ステップあたりのメソッド呼び出しとプロパティアクセスは、コレクションを反復処理するコストに比べれば些細です。実際にコストになるのは、必要以上の処理をすることです。.filter().map()[0]はO(n)のフル走査を2回行い、さらに中間配列を作ります。find()は1回の走査で早期に停止します。教訓は、ドットではなく反復回数とアロケーションを数えることです。データを1回だけ走査する5メソッドのチェーンは、2回走査する2メソッドのチェーンより速いことがあります。最初に条件を満たす結果だけが必要なときは、findやsomeのような短絡評価メソッドを使いましょう。
自作のチェーン可能APIをどう構築するか
オブジェクトをチェーン可能にするには、チェーンを継続すべきすべてのメソッドからthisを返します。確実にこれを壊す唯一の落とし穴は、メソッドをアロー関数として書くことです。アロー関数は自身のthisを持ちません。アロー関数が書かれた時点で周囲のコードが持っていたthisを借用するため、return thisは誤ったオブジェクトを返します。しかも、その関数をcall、bind、applyに渡してもこれは変わりません。
const counter = {
count: 0,
// Broken: arrow `this` is the enclosing scope, not `counter`
incArrow: () => { this.count++; return this; },
// Correct: method shorthand binds `this` to the instance
inc() { this.count++; return this; }
};
counter.inc().inc(); // works, count === 2
クラスもプロトタイプも同じパターンをサポートします。状態をコンストラクタ内で代入する代わりにクラスフィールド(parts = [])として宣言したい場合、その構文はES2022以降標準です。プロトタイプ版の挙動は同一です。
// Prototype form — identical behavior
function Query() { this.parts = []; }
Query.prototype.where = function (c) { this.parts.push(c); return this; };
新規コードではクラスを使いましょう。プロトタイプ形式は、古いコードベースを扱う際や、クラスがどのようなコードに展開(desugar)されるかを理解するために知っておく価値があります。
チェーンの長さに関する経験則
チェーンは「書く速さ」に最適化されており、中間値に名前を付けることは「後から読む・デバッグする」ことに最適化されています。この2つは同じ目標ではありません。長さに関する実用的な基準は次のとおりです。
| チェーンの長さ | 対応 | 理由 |
|---|---|---|
| 1段階 | 自由にチェーンする | ほどくものが何もない |
| 2段階 | 通常は問題なし | まだ1つの明確な変換である |
| 3〜4段階 | 一旦立ち止まり、中間値に名前を付けることを検討 | 可読性とブレークポイントの設置しやすさが損なわれ始める |
| 5段階以上 | 名前付きステップに分割する | 戻り値の型と関心事の追跡が難しい |
デバッグ中のとき、あるステップの戻り値の型が不明確なとき、あるいはチェーンが非同期制御フローとデータ変換を混在させているときは、チェーンを分解しましょう。そして、結果が1つだけ必要な場合は、filterしてインデックス参照するのではなく、短絡評価するfindやsomeを優先してください。
各ステップが1つの変換として読め、かつ短く収まるときはシーケンスをチェーンしましょう。チェーンが3〜4段階を超えて伸びたり、求めた以上の処理をし始めたりした瞬間に、中間値へ名前を付けましょう。次にチェーンがその一線を越えたら、分割してください。そのコードを読む未来のあなたは、解読に費やす時間を減らし、修正に費やす時間を増やせるはずです。
FAQ
メソッドチェーンは、メソッドを個別に呼び出すより遅いのですか?
いいえ。1ステップあたりのメソッド呼び出しとプロパティアクセスは、コレクションの反復処理に比べれば些細なので、チェーンの本質的なコストはごくわずかです。パフォーマンスを左右するのはドットの数ではなく、データに対して何回走査し、どれだけアロケートするかです。データを1回だけ走査するチェーンは、2回走査する個別呼び出しより速いことがあります。
メソッドをアロー関数で書くとなぜチェーンが壊れるのですか?
アロー関数は自身の 'this' を一切持ちません。周囲のコードの 'this' を借用するため、'return this' は誤ったオブジェクトを返し、次のメソッドは解決先として有効なものを持てません。call、bind、apply に関数を渡しても解決しません。これらのメソッドはアロー関数に新しい 'this' を与えられないからです。'this' がインスタンスにバインドされるよう、メソッドの短縮記法または通常の関数を使いましょう。
filter().map()[0] ではなく find() を使うべきなのはどんなときですか?
最初にマッチした要素だけが欲しいときは常に find() を使いましょう。Array.prototype.find() はコールバックが要素を受け入れるまでしか配列を走査せず、その要素を返してそれ以上進まないため、走査は1回で済みます。対して filter().map()[0] は配列を2回フルに走査し、中間配列をアロケートしたうえで最初の1件以外をすべて捨てます。boolean だけが必要な場合は、'some' メソッドが同じ短絡評価のロジックを適用します。
.then() でPromiseをチェーンすると、await を使うよりパフォーマンスが劣るのですか?
いいえ。'.then()' チェーンと 'await' は同じ処理を行うため、違いはパフォーマンスではなく可読性です。'.then()' の呼び出しをチェーンすると、非同期制御フローとデータ変換が1つのシーケンスに混在しがちで、意図が曖昧になります。'await' でフェッチとパースを変換処理から分離したほうが通常は明快に読めますが、どちらの方法も計測可能なレベルで速いわけではありません。