サイトアイコンmaita tomoya dev io

#55 Stimulus.jsでミックスイン(Mixin)パターンを活用してコードの再利用性を高める

id: 55

Stimulus.jsでミックスイン(Mixin)パターンを活用してコードの再利用性を高める

Rails 7以降で標準となったStimulus.jsを使った開発において、複数のコントローラーで同じ機能を使いたい場面が頻繁に出てきます。そんな時に威力を発揮するのが「ミックスイン(Mixin)パターン」です。今回は、Stimulus.jsでミックスインを実装する方法と、実際のプロジェクトでの活用例について詳しく解説します。

ミックスインとは何か

ミックスイン(Mixin)とは、**複数のクラスで共通して使用したい機能を外部のモジュールとして切り出し、必要なクラスに「混ぜ込む(mix-in)」**設計パターンです。

従来のクラス継承との違い

// クラス継承の場合(制限が多い)
class BaseController extends Controller {
  commonMethod() { /* 共通処理 */ }
}

class UserController extends BaseController {
  // BaseControllerの機能しか継承できない
}

// ミックスインの場合(柔軟性が高い)
class UserController extends Controller {
  connect() {
    useCommonFeature(this)  // 必要な機能だけを取り込み
    useAnotherFeature(this) // 複数のミックスインを組み合わせ可能
  }
}

ミックスインのメリット:

  • 多重継承のような柔軟性:複数の機能を自由に組み合わせ
  • コードの再利用:同じ処理を複数のコントローラーで共有
  • 保守性の向上:共通処理の修正が一箇所で済む

Stimulus.jsでのミックスイン実装パターン

基本的なミックスインの構造

// app/javascript/mixins/useModalControl.js
export const useModalControl = (controller) => {
  // 共通のプロパティ
  const modalElement = document.querySelector('.modal')
  
  // 共通のメソッド
  const showModal = () => {
    modalElement.classList.remove('hidden')
    document.body.style.overflow = 'hidden'
  }
  
  const hideModal = () => {
    modalElement.classList.add('hidden')
    document.body.style.overflow = 'auto'
  }
  
  // コントローラーにメソッドを追加
  Object.assign(controller, {
    showModal,
    hideModal
  })
}

コントローラーでミックスインを使用

// app/javascript/controllers/product_controller.js
import { Controller } from "@hotwired/stimulus"
import { useModalControl } from "../mixins/useModalControl"

export default class extends Controller {
  connect() {
    useModalControl(this)  // ミックスインを適用
  }
  
  handleProductClick(event) {
    // ミックスインで追加されたメソッドを使用
    this.showModal()
  }
}

HTMLでの使用

<div data-controller="product">
  <button data-action="product#handleProductClick">商品詳細を表示</button>
</div>

実際のプロジェクトでの活用例

SMS認証機能のミックスイン

実際のプロジェクトでよくある「SMS認証」を例に、具体的なミックスインの実装を見てみましょう。

// app/javascript/mixins/useSmsAuth.js
import { signInWithPhoneNumber, RecaptchaVerifier } from "firebase/auth"

export const useSmsAuth = (controller) => {
  // reCAPTCHA設定
  const recaptchaVerifier = new RecaptchaVerifier(controller.auth, 'sms-submit-button', {
    size: 'invisible',
  })
  
  // SMS送信処理
  const sendSmsCode = async (event) => {
    event.preventDefault()
    
    const phoneNumber = controller.phoneNumberInputTarget.value
    const formattedNumber = formatPhoneNumber(phoneNumber)
    
    try {
      // Firebase SMS認証
      const confirmationResult = await signInWithPhoneNumber(
        controller.auth, 
        formattedNumber, 
        recaptchaVerifier
      )
      
      // 成功時の処理
      window.confirmationResult = confirmationResult
      showVerificationForm()
      
    } catch (error) {
      handleSmsError(error)
    }
  }
  
  // SMS認証コード確認
  const verifySmsCode = async (event) => {
    event.preventDefault()
    
    const code = controller.verificationCodeInputTarget.value
    
    try {
      const result = await window.confirmationResult.confirm(code)
      handleAuthSuccess(result)
    } catch (error) {
      handleVerificationError(error)
    }
  }
  
  // UI制御用のヘルパー関数
  const showVerificationForm = () => {
    controller.phoneNumberFormTarget.classList.add('hidden')
    controller.verificationFormTarget.classList.remove('hidden')
  }
  
  const formatPhoneNumber = (phoneNumber) => {
    // 日本の電話番号を国際形式に変換
    return '+81' + phoneNumber.replace(/^0/, '')
  }
  
  const handleSmsError = (error) => {
    let message = 'SMS送信に失敗しました'
    switch (error.code) {
      case 'auth/invalid-phone-number':
        message = '電話番号の形式が正しくありません'
        break
      case 'auth/too-many-requests':
        message = '送信回数が上限に達しました。しばらく経ってから再試行してください'
        break
    }
    alert(message)
  }
  
  // コントローラーにメソッドを追加
  Object.assign(controller, {
    sendSmsCode,
    verifySmsCode,
    showVerificationForm
  })
}

複数のコントローラーで同じミックスインを使用

// 新規登録時のSMS認証
// app/javascript/controllers/registration_controller.js
import { Controller } from "@hotwired/stimulus"
import { useSmsAuth } from "../mixins/useSmsAuth"

export default class extends Controller {
  static targets = ["phoneNumberForm", "verificationForm", "phoneNumberInput", "verificationCodeInput"]
  
  connect() {
    useSmsAuth(this)  // SMS認証機能を追加
  }
  
  handleRegistration(event) {
    // ミックスインのメソッドを使用
    this.sendSmsCode(event)
  }
}
// ログイン時のSMS認証
// app/javascript/controllers/login_controller.js
import { Controller } from "@hotwired/stimulus"
import { useSmsAuth } from "../mixins/useSmsAuth"

export default class extends Controller {
  static targets = ["phoneNumberForm", "verificationForm", "phoneNumberInput", "verificationCodeInput"]
  
  connect() {
    useSmsAuth(this)  // 同じミックスインを使用
  }
  
  handleLogin(event) {
    // 登録と同じSMS認証ロジックを再利用
    this.sendSmsCode(event)
  }
}

高度なミックスインパターン

設定可能なミックスイン

// app/javascript/mixins/useApiClient.js
export const useApiClient = (controller, options = {}) => {
  const baseUrl = options.baseUrl || '/api/v1'
  const timeout = options.timeout || 5000
  
  const apiCall = async (endpoint, method = 'GET', data = null) => {
    const config = {
      method,
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
      }
    }
    
    if (data) {
      config.body = JSON.stringify(data)
    }
    
    const response = await fetch(`${baseUrl}${endpoint}`, config)
    
    if (!response.ok) {
      throw new Error(`API Error: ${response.status}`)
    }
    
    return await response.json()
  }
  
  Object.assign(controller, {
    apiCall
  })
}

// 使用例
export default class extends Controller {
  connect() {
    useApiClient(this, { 
      baseUrl: '/api/v2',  // カスタム設定
      timeout: 10000 
    })
  }
}

ミックスインの組み合わせ

// app/javascript/controllers/user_management_controller.js
import { Controller } from "@hotwired/stimulus"
import { useApiClient } from "../mixins/useApiClient"
import { useModalControl } from "../mixins/useModalControl"
import { useFormValidation } from "../mixins/useFormValidation"

export default class extends Controller {
  connect() {
    // 複数のミックスインを組み合わせ
    useApiClient(this)
    useModalControl(this)
    useFormValidation(this)
  }
  
  async handleUserCreate(event) {
    // フォームバリデーション(ミックスイン)
    if (!this.validateForm()) return
    
    try {
      // API呼び出し(ミックスイン)
      const userData = this.getFormData()
      await this.apiCall('/users', 'POST', userData)
      
      // モーダル制御(ミックスイン)
      this.hideModal()
      
    } catch (error) {
      this.showErrorMessage(error.message)
    }
  }
}

ミックスインのベストプラクティス

1. 命名規則の統一

// useXXX形式で統一
export const useModalControl = (controller) => { /* */ }
export const useApiClient = (controller) => { /* */ }
export const useFormValidation = (controller) => { /* */ }

2. 依存関係の明示

export const useAdvancedFeature = (controller) => {
  // 依存するミックスインのチェック
  if (!controller.apiCall) {
    throw new Error('useAdvancedFeature requires useApiClient mixin')
  }
  
  // 実装
}

3. メソッド名の衝突回避

export const useModalControl = (controller) => {
  Object.assign(controller, {
    // プレフィックスで名前空間を分ける
    modal_show: showModal,
    modal_hide: hideModal
  })
}

4. 初期化オプションの提供

export const useDataTable = (controller, options = {}) => {
  const config = {
    sortable: true,
    filterable: true,
    pageSize: 10,
    ...options  // デフォルト設定をオーバーライド
  }
  
  // configを使った実装
}

ミックスインを使う際の注意点

1. メソッド名の衝突

// 問題のある例
export const useMixin1 = (controller) => {
  Object.assign(controller, {
    handleClick: () => { /* 処理A */ }  // 同じ名前
  })
}

export const useMixin2 = (controller) => {
  Object.assign(controller, {
    handleClick: () => { /* 処理B */ }  // 衝突!
  })
}

// 解決方法:名前空間の使用
export const useMixin1 = (controller) => {
  Object.assign(controller, {
    mixin1_handleClick: () => { /* 処理A */ }
  })
}

2. 過度な複雑化を避ける

// 避けるべき例:ミックスインが多すぎる
connect() {
  useMixin1(this)
  useMixin2(this)
  useMixin3(this)
  useMixin4(this)  // 複雑になりすぎ
}

// 推奨:関連するミックスインをまとめる
export const useCompleteFeature = (controller) => {
  useMixin1(controller)
  useMixin2(controller)
  // 内部で組み合わせて提供
}

3. テスタビリティの確保

// ミックスインもテスト可能にする
// tests/mixins/useModalControl.test.js
import { useModalControl } from '../app/javascript/mixins/useModalControl'

describe('useModalControl mixin', () => {
  let mockController
  
  beforeEach(() => {
    mockController = {}
    useModalControl(mockController)
  })
  
  test('should add showModal method', () => {
    expect(typeof mockController.showModal).toBe('function')
  })
})

ミックスインの活用場面

よく使われるパターン

パターン用途
API通信サーバーとの通信処理useApiClient
フォーム処理バリデーション・送信useFormValidation
UI制御モーダル・ドロワー制御useModalControl
認証処理ログイン・SMS認証useSmsAuth
データ処理フォーマット・変換useDataFormatter

実際のプロジェクトでの効果

導入前:

// registration_controller.js (300行)
// login_controller.js (250行)
// profile_controller.js (200行)
// 合計:750行、重複コード多数

導入後:

// registration_controller.js (50行) + useSmsAuth (100行)
// login_controller.js (40行) + useSmsAuth (共通)
// profile_controller.js (30行) + useFormValidation (50行)
// 合計:270行、重複なし

メリット:

  • コード量64%削減
  • 保守性大幅向上
  • バグ修正が一箇所で完了

まとめ

Stimulus.jsでのミックスインパターンは、以下のような場面で大きな威力を発揮します:

  1. コードの再利用:同じ機能を複数のコントローラーで使いたい場合
  2. 保守性の向上:共通処理の修正が一箇所で済む
  3. テストの効率化:ミックスイン単体でテストが可能
  4. 開発効率の向上:一度作れば使い回せる

特にRailsアプリケーションでは、認証処理、API通信、フォーム処理など、多くのページで共通する機能があります。これらをミックスインとして切り出すことで、より保守しやすく拡張性の高いフロントエンドコードを書くことができます。

最初は小さな共通処理から始めて、徐々にミックスインの活用範囲を広げていくことをおすすめします。適切に設計されたミックスインは、開発チーム全体の生産性向上に大きく貢献するはずです。

参考リンク