跳至內容

JavaScript デザインパターンノート - SOLID 原則と Vue.js への応用 ​

2024年初めに JavaScript デザインパターンの読書会に参加しました。テーマは Addy Osmani の『JavaScript 設計模式學習手冊』でした。
最近ブログを再構築しているときに、この読書会の記録を残していないのはもったいないと思い、
内容は今でも通用するものなので、この機会に当時の内容を整理して、記事を水増し 皆さんと共有することにしました。

私は冒頭の導読を担当したので、Addy Osmani の本では React を例に挙げていますが、私の個人的な好みで、最も慣れている Vue.js の観点からデザインパターンの概念と応用を説明しました。

この記事では、当時の読書会の要点をまとめています。デザインパターンの基本概念、SOLID 原則、そしていくつかの一般的なデザインパターンの Vue.js への応用が含まれています。

開発者にとって、デザインパターンは身近なようで意外と理解が難しいテーマです。
馴染みがあるのは、私たちが日常的に使っている可能性があるからです。
馴染みがないのは、使っていることに気づかないまま使っていることが多いからです。


なぜデザインパターンを学ぶのか? ​

Good code is like a love letter to the next developer who will maintain it!
良いコードは、次にそれを保守する開発者へのラブレターのようなものです!
— Addy Osmani

この言葉は本当に名言です。
他人のプロジェクトを引き継ぐたびに「先人の豪邸」を期待しますが、実際には「危険な廃墟」であることも少なくありません。
デザインパターンは、保守しやすく拡張しやすいコードを書くための強力なツールです。

デザインパターンの利点:

  • 機能の追加・削除が容易
  • 保守が容易
  • コードの再利用が容易
  • チーム開発時のコミュニケーションと分業がしやすい

ただし、過剰な使用には注意:

  • 過度に複雑になり、他の開発者が理解しにくくなる
  • 過剰設計(Over-engineering)
  • 不適切なデザインパターンの選択

デザインパターンとは? ​

Wikipedia の定義によると:

ソフトウェア工学において、ソフトウェアデザインパターンは、ソフトウェア設計の特定のコンテキストにおける一般的な問題に対する汎用的で再利用可能な解決策です。

簡単に言えば:「誰かがすでにあなたの問題を解決してくれています!」

デザインパターンは、プログラミングの問題を解決するための汎用的な解決策の集合です。それらは:

  • 異なる状況やプログラミング言語で利用できる抽象的なテンプレート
  • アルゴリズムに似ていますが、アーキテクチャレベルの問題を解決することに焦点を当てています
  • 繰り返し現れる構造や現象を「パターン」と呼びます
  • 問題を解決でき、再利用可能であれば、自分自身のデザインパターンを作成することも可能です

古典的な GoF ​

1995年に4人の著者(Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides)によって出版された『Design Patterns』は「ソフトウェアエンジニアの聖書」と称されています。
本書では23の古典的なデザインパターンが提案されており、この4人の著者は GoF(Gang of Four)と呼ばれます。

デザインパターンの三つのカテゴリ:

  1. 生成パターン(Creational Patterns) - オブジェクトの生成に使用

    • ファクトリーパターン、抽象ファクトリーパターン、シングルトンパターン、ビルダーパターン、プロトタイプパターン
  2. 構造パターン(Structural Patterns) - オブジェクトの構造や組み合わせを表現

    • アダプタパターン、ブリッジパターン、コンポジットパターン、デコレータパターン、ファサードパターン、フライウェイトパターン、プロキシパターン
  3. 振る舞いパターン(Behavioral Patterns) - オブジェクト間の通信に使用

    • 責任の連鎖パターン、コマンドパターン、インタプリタパターン、イテレータパターン、メディエータパターン、メメントパターン、オブザーバーパターン、ステートパターン、ストラテジーパターン、テンプレートメソッドパターン、ビジターパターン

アンチパターン(Anti-Pattern) ​

アンチパターンはパターンに似ていますが、問題を解決するどころか、
一見解決策のように見えて実際には問題を悪化させる手法を指します。

なぜアンチパターンが発生するのか? ​

  • 経験不足:より良い方法を知らない
  • 時間的プレッシャー:リリースを急ぐあまり、とりあえず動かすことを優先
  • デザインパターンの誤用:不適切な場面でデザインパターンを使用
  • 過剰設計:使うこと自体が目的になり、単純な問題を複雑にする

アンチパターンは、デザインパターンを使わないよりも危険です。
なぜなら、コードの品質を低下させ、保守コストを増加させるからです。
ときにはアンチパターンがデザインパターンと誤認されることがあり、これが最も危険な状況です。

JavaScript の一般的なアンチパターン ​

1. グローバル名前空間の汚染 ​

javascript
// ❌ アンチパターン:すべての変数がグローバルスコープに置かれている
var name = 'John'
var age = 30
var email = 'john@example.com'

// ✅ 良い方法:モジュールまたは IIFE を使用
const user = {
  name: 'John',
  age: 30,
  email: 'john@example.com'
}

2. setTimeout/setInterval に文字列を渡す ​

javascript
// ❌ アンチパターン:eval() を引き起こし、安全でなくパフォーマンスも悪い
setTimeout("console.log('これは安全ではありません!')", 1000)

// ✅ 良い方法:関数を渡す
setTimeout(() => console.log('これは安全です!'), 1000)

3. 組み込みプロトタイプの変更 ​

javascript
// ❌ アンチパターン:Array.prototype を変更
Array.prototype.first = function() {
  return this[0]
}

// ✅ 良い方法:ユーティリティ関数を使用
function getFirst(arr) {
  return arr[0]
}

4. 厳格モードを使用しない ​

javascript
// ❌ アンチパターン:'use strict' なし、エラーが発生しやすい
function calculateTotal() {
  totle = 100  // スペルミスでもエラーにならず、グローバル変数になる
}

// ✅ 良い方法:厳格モードを有効にする
'use strict'
function calculateTotal() {
  const total = 100  // スペルミスがあれば即エラーになる
}

5. Callback Hell ​

javascript
// ❌ アンチパターン:多層のネストされたコールバック
getData(function(a) {
  getMoreData(a, function(b) {
    getMoreData(b, function(c) {
      console.log(c)
    })
  })
})

// ✅ 良い方法:Promise または async/await を使用
async function fetchData() {
  const a = await getData()
  const b = await getMoreData(a)
  const c = await getMoreData(b)
  console.log(c)
}

アンチパターンを避ける方法 ​

  1. 継続的な学習:最新のベストプラクティスを理解する
  2. コードレビュー:チームでお互いにコードを確認する
  3. Linter の利用:一般的な問題を自動的に検出する
  4. テストの作成:テストは設計上の問題を明らかにし、品質を向上させる
  5. リファクタリング:定期的に設計を見直し、技術的負債を防ぐ

JavaScript のモジュール化 ​

初期の JavaScript にはモジュール化の概念がありませんでしたが、長年の進化を経て:

  1. IIFE(即時関数) - クロージャを使ってモジュールを封止し、プライベート空間を形成
  2. CommonJS - Node.js 登場後のモジュール規格
  3. AMD(Asynchronous Module Definition) - 非同期モジュール定義
  4. UMD(Universal Module Definition) - ユニバーサルモジュール定義
  5. ES6 Modules - 現代 JavaScript のモジュール化標準(import / export)

現在は Vite、Webpack、Rollup などのバンドラーと組み合わせることで、静的解析やツリーシェイキングにより、JavaScript のモジュール化はより盤石なものになっています。


SOLID 原則 ​

これらの原則を守れば、デザインパターンを知らなくても、十分に良いコードを書けます。

1. SRP - 単一責任原則(Single Responsibility Principle) ​

1つのクラスは1つの責任のみを負う

Vue.js では、

  • 異なる機能を異なるコンポーネントに分割する
  • 各コンポーネントが1つのタスクのみを処理するようにする
  • コンポーネントの再利用性を高める

例:

ショッピングカートページを想定すると、すべての機能を1つのコンポーネントに詰め込むべきではありません:

vue
<!-- ❌ SRP 違反:1つのコンポーネントで多くのことをしている -->
<template>
  <div>
    <div>商品リスト、価格計算、チェックアウトボタン、クーポン入力...</div>
  </div>
</template>

<!-- ✅ SRP に準拠:複数のコンポーネントに分割 -->
<CartItemList />      <!-- 商品リストの表示のみ -->
<CartSummary />       <!-- 合計価格の計算のみ -->
<CouponInput />       <!-- クーポン処理のみ -->
<CheckoutButton />    <!-- チェックアウトボタンのみ -->

2. OCP - 開放閉鎖原則(Open/Closed Principle) ​

拡張に対して開き、修正に対して閉じている

既存のコードを変更せずにコンポーネントの振る舞いを拡張できるのが理想です。

Vue.js では、

  • slots を使ってコンポーネントの機能を拡張する
  • composables(推奨)で再利用可能なロジックをカプセル化する
  • mixins(非推奨)の使用は避ける

例:

slot を使って拡張可能にし、元のコンポーネントを変更する必要がありません:

vue
<!-- ❌ OCP 違反:新しい要件ごとにコンポーネントを変更 -->
<Button v-if="type === 'primary'" class="btn-primary">送信</Button>
<Button v-else-if="type === 'danger'" class="btn-danger">削除</Button>
<!-- 新しいボタンタイプを追加するたびにここを変更 -->

<!-- ✅ OCP に準拠:slot で拡張 -->
<Button>
  <template #icon><IconPlus /></template>
  項目を追加
</Button>
<!-- 内容を自由に拡張でき、Button コンポーネントを変更する必要がない -->

3. LSP - リスコフの置換原則(Liskov Substitution Principle) ​

サブクラスは、プログラムの正確性に影響を与えることなく、基底クラスを置き換えられるべき

TypeScript を使う場合、共通の PaymentMethod インターフェースを定義して、すべての支払い戦略が同じ構造に従うことを保証できます
(ここでの共通インターフェースは TypeScript の interface に相当します)。

Vue.js では、

  • サブクラス(具体的な戦略)は、プログラム内で基底クラス(戦略インターフェース)を置き換えられる
  • 新しい商品処理のロジック(新しい戦略)を追加しやすく、既存のコンポーネントロジックを変更する必要がなくなる

例:

異なる支払い方法は相互に交換可能であるべきです:

javascript
// すべての支払い方法は同じインターフェースを持つ
const paymentMethods = {
  creditCard: { pay: (amount) => { /* クレジットカード支払い */ } },
  cash: { pay: (amount) => { /* 現金支払い */ } },
  linePay: { pay: (amount) => { /* LINE Pay 支払い */ } }
}

// 自由に切り替えでき、プログラムの動作に影響しない
function checkout(method, amount) {
  method.pay(amount)  // どの支払い方法でも正常に動作
}

4. ISP - インターフェース分離原則(Interface Segregation Principle) ​

クラス(コンポーネント)に、不要なメソッドの実装を強制すべきではない

Vue.js では、

  • 細かい粒度の props とイベントを設計する
  • コンポーネントに不要なプロパティやメソッドを受け取らせない
  • コンポーネントの独立性と再利用性を高める

例:

巨大なオブジェクト全体を渡さず、必要なプロパティのみを渡す:

vue
<!-- ❌ ISP 違反:user オブジェクト全体を渡す -->
<UserCard :user="user" />
<!-- UserCard は name と avatar だけが必要かもしれないのに、user 全体を受け取ることを強制される -->

<!-- ✅ ISP に準拠:必要なプロパティのみを渡す -->
<UserCard
  :name="user.name"
  :avatar="user.avatar"
/>
<!-- コンポーネントは本当に必要なデータのみを受け取る -->

5. DIP - 依存性逆転原則(Dependency Inversion Principle) ​

高レベルモジュールは低レベルモジュールに依存せず、両者とも抽象に依存すべき

簡単に言えば:特定の実装にコードを固定しない。

Vue.js では、

  • コンポーネントが状態を直接管理せず、Pinia の store を介して管理する
  • インタラクション部分は抽象クラスやインターフェースに寄せ、変更される実装はサブクラス側に置く
  • 将来の要件変更時は、コンポーネントを変えずに store の実装を差し替えられる

例:

コンポーネントはデータを直接処理せず、抽象的な store に依存する:

vue
<!-- ❌ DIP 違反:コンポーネントが API とデータを直接処理 -->
<script setup>
const products = ref([])
const fetchProducts = async () => {
  const res = await fetch('/api/products')  // 実装を固定
  products.value = await res.json()
}
</script>

<!-- ✅ DIP に準拠:抽象的な store に依存 -->
<script setup>
import { useProductStore } from '@/stores/product'
const productStore = useProductStore()
// コンポーネントはデータの取得方法を知らず、store 経由で取得するだけ
// 将来 GraphQL や別の手段に変更しても、コンポーネントを変更する必要がない
</script>

その他の一般的なデザインパターン ​

以下は、Vue.js でよく見られるデザインパターンです:

1. オブザーバーパターン(Observer Pattern) ​

状態が変化すると、それに依存するすべての対象が通知を受け、自動的に更新される。

Vue.js のリアクティブシステムは本質的にオブザーバーパターンの実装です。状態が変化すると、Vue.js は自動的に DOM を更新します。

2. プロキシパターン(Proxy Pattern) ​

他のオブジェクトへのアクセスを制御するために、そのオブジェクトの代理(プロキシ)を提供する。

Vue 3 の reactive はプロキシパターンの実装であり、JavaScript の Proxy API を用いて状態の変化を追跡します。

3. モジュールパターン ​

クラスのプライベートメンバーを擬似的に表現し、公開メンバーをクロージャ内にカプセル化する。

Vuex や Pinia は、モジュール、オブザーバー、シングルトンパターンを組み合わせて、集中型の状態管理を実現しています。

4. ファクトリーメソッドパターン(Factory Method Pattern) ​

コンストラクタを直接呼び出さずにオブジェクトを生成する。

Vue.js では、Props や Attr によってレンダリングするコンポーネントやスタイルを決定できます:

vue
<component :is="currentView" />

<component :is> は Vue の動的コンポーネント機構に属し、本質的にはファクトリーメソッドの概念を使って動的なコンポーネント生成・切り替えを行っています。
条件に応じた動的なコンポーネント切り替えは、Vue.js におけるファクトリーパターンの応用例です。

5. シングルトンパターン(Singleton Pattern) ​

クラスのインスタンスが1つだけであることを保証し、グローバルにアクセス可能にする。

グローバル状態管理(Pinia の store など)はシングルトンパターンの典型的な応用であり、アプリケーション全体が同じ状態インスタンスを共有します。

6. ストラテジーパターン(Strategy Pattern) ​

一連のアルゴリズムを定義し、それぞれをカプセル化して相互に交換可能にする。

たとえば、フォーム検証では、複数の検証戦略(email 検証、長さ検証、必須検証など)を定義し、必要に応じて組み合わせて利用できます。

7. コマンドパターン(Command Pattern) ​

リクエストをオブジェクトとしてカプセル化し、異なるリクエストによって他のオブジェクトをパラメータ化する。

元に戻す/やり直す機能の実装にしばしば用いられます。各操作をコマンドオブジェクトとしてカプセル化することで、このような機能を実装しやすくなります。

8. 責任の連鎖パターン(Chain of Responsibility Pattern) ​

複数のオブジェクトがリクエストを処理する機会を持ち、送信者と受信者の強い結合を避ける。

たとえば、一連の検証ルールが順に入力を検証し、あるルールが失敗を返すか、すべての条件が通過するまで続けるようなケースです。


複合パターン ​

実務では要件を分析する際、問題を解決するために複数のパターンを組み合わせる必要があることがよくあります。

  • 問題に対して1つのパターンしか使えないわけではない
  • 要件を分析すると、複数のパターンを組み合わせて解決することが多い
  • いくつかのパターンは複数の基本パターンを組み合わせたもので、複合パターンと呼ばれる
  • 例えば、伝統的な MVC はストラテジー、オブザーバー、コンポジットなどを用いる

まとめ ​

このノートをまとめ終えて、当時の読書会の議論を振り返ると、最大の収穫は「いくつのデザインパターンを覚えたか」ではなく、「なぜ使うのか」が「どう使うか」より重要だと理解したことです。

デザインパターンは銀の弾丸でも、技巧を誇示するためのものでもありません。最大の価値は:

  1. 共通の言語を提供 — 同僚に「ここはオブザーバーパターンを使っている」と言えば、すぐに意図が共有できる
  2. 先人の経験の集約 — 同じ轍を踏まず、巨人の肩に乗る
  3. アーキテクチャ思考の支援 — 複雑な問題に直面したとき、考えるためのフレームワークがある

ただし、いくつかの重要なポイントを覚えておきましょう:

  • まずシンプルさを求める:パターンを使うこと自体を目的にせず、まず問題を解く
  • SOLID に従う:パターンを知らなくても、SOLID を守れば良いコードに近づける
  • 過度な設計を避ける:早すぎる最適化(Premature Optimization)は諸悪の根源
  • 用語に縛られない:自分のコードがあるパターンの精神に合致していれば、すでに使いこなしていると言える

デザインパターンを学びたい方へ:

  1. すべてを一度に覚える必要はありません。よく使うものから始めましょう(オブザーバー、ファクトリー、シングルトン)
  2. 実プロジェクトで手を動かしましょう。問題に遭遇すると、自然と該当するパターンを思い出します
  3. 他人のコードをたくさん読みましょう。特にオープンソース(Vue、React などのフレームワークのソースコードはパターンの宝庫)
  4. 継続的にリファクタリングしましょう。パターンは一度で完璧にするものではなく、改善を続けるプロセスです

最後に、コードを書くことは文章を書くことと同じで、重要なのは読者(未来の自分を含む)が理解しやすいことです。デザインパターンはその目標を達成するための手段であり、目的そのものではありません。


関連リソース:

💬 留言討論