JavaScriptクロージャの実践パターン | 業務で使える具体的なサンプルコード集

未分類

JavaScriptクロージャの実践パターン|業務で使える具体的なサンプルコード集

JavaScriptのクロージャは、多くの開発者にとって理解が難しい概念とされていますが、実務で正しく使いこなせばコード品質を大きく向上させることができます。本記事では、教科書的な説明ではなく、実際のプロジェクトで活用できる実践的なパターンを紹介します。

クロージャの簡易的な解説

クロージャは、関数とその関数が定義された時点でのレキシカル環境(外側のスコープ)を組み合わせたものです。簡単に言えば、「関数の外で定義された変数に、関数の内側からアクセスできる」という特性です。

この特性により、以下のようなことが可能になります:

  • プライベート変数の実装
  • 関数のカスタマイズ(高階関数)
  • イベントハンドラ内での状態管理
  • モジュールパターンの実装

では、簡単な例を見てみましょう:

// シンプルなクロージャの例
function createCounter() {
  let count = 0; // プライベート変数
  
  return {
    increment: () => ++count,
    decrement: () => --count,
    getCount: () => count
  };
}

const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.decrement()); // 1

ここで重要なのは、count変数は外部からは直接アクセスできないということです。incrementdecrementメソッドを通してのみ操作できます。これがクロージャの力です。

業務でのユースケース

実際のプロジェクトでクロージャが活躍する場面は多くあります。以下は実務で頻繁に遭遇するシナリオです:

1. API呼び出しのレート制限管理

外部APIを呼び出す際、レート制限に引っかからないよう管理する必要があります。クロージャを使うことで、安全に状態管理ができます。

2. イベントハンドラでのコンテキスト保持

複数のボタンやフォーム要素を扱う場合、各要素に固有のデータを紐付ける必要があります。

3. 設定値の安全な管理

アプリケーション全体で使用する設定値を、外部からの変更から保護しながら提供する必要があります。

4. キャッシング機能の実装

計算結果やAPIレスポンスをキャッシュして、パフォーマンスを向上させる場合があります。

実装コード|実務パターン集

パターン1: レート制限付きAPI呼び出し

実務で非常に重要なパターンです。APIのリクエスト数を制限しながら、呼び出しを管理します:

interface ApiRequest {
  endpoint: string;
  params?: Record;
}

interface RateLimiterConfig {
  maxRequests: number;
  windowMs: number;
}

function createApiClient(config: RateLimiterConfig) {
  let requestCount = 0;
  let lastResetTime = Date.now();

  const resetWindow = () => {
    const now = Date.now();
    if (now - lastResetTime > config.windowMs) {
      requestCount = 0;
      lastResetTime = now;
    }
  };

  const canMakeRequest = (): boolean => {
    resetWindow();
    return requestCount < config.maxRequests;
  };

  const makeRequest = async (request: ApiRequest): Promise => {
    resetWindow();

    if (!canMakeRequest()) {
      throw new Error(
        `Rate limit exceeded. Max ${config.maxRequests} requests per ${config.windowMs}ms`
      );
    }

    requestCount++;
    console.log(`Request count: ${requestCount}/${config.maxRequests}`);

    // 実際のAPI呼び出し
    return fetch(`/api${request.endpoint}`, {
      method: 'GET',
      headers: { 'Content-Type': 'application/json' }
    }).then(res => res.json());
  };

  return {
    makeRequest,
    getStatus: () => ({
      requestCount,
      maxRequests: config.maxRequests,
      remainingRequests: config.maxRequests - requestCount
    })
  };
}

// 使用例
const apiClient = createApiClient({ maxRequests: 10, windowMs: 60000 });

async function fetchUserData() {
  try {
    const data = await apiClient.makeRequest({ endpoint: '/users' });
    console.log(data);
  } catch (error) {
    console.error(error.message);
  }
}

パターン2: フォーム検証での状態管理

複数のフォームフィールドを扱う際、各フィールドの検証状態を安全に管理します:

interface FormField {
  value: string;
  error: string | null;
  touched: boolean;
}

interface ValidationRule {
  validator: (value: string) => boolean;
  message: string;
}

function createFormValidator(initialValues: Record) {
  let fields: Record = {};
  let validationRules: Record = {};

  // 初期化
  Object.entries(initialValues).forEach(([key, value]) => {
    fields[key] = {
      value,
      error: null,
      touched: false
    };
  });

  const setFieldValue = (fieldName: string, value: string): void => {
    if (fieldName in fields) {
      fields[fieldName].value = value;
      fields[fieldName].touched = true;
      validateField(fieldName);
    }
  };

  const registerValidation = (
    fieldName: string,
    rules: ValidationRule[]
  ): void => {
    validationRules[fieldName] = rules;
  };

  const validateField = (fieldName: string): boolean => {
    const field = fields[fieldName];
    const rules = validationRules[fieldName] || [];

    for (const rule of rules) {
      if (!rule.validator(field.value)) {
        field.error = rule.message;
        return false;
      }
    }

    field.error = null;
    return true;
  };

  const validateAll = (): boolean => {
    return Object.keys(fields).every(fieldName => validateField(fieldName));
  };

  const getFormData = (): Record => {
    const data: Record = {};
    Object.entries(fields).forEach(([key, field]) => {
      data[key] = field.value;
    });
    return data;
  };

  const getFieldState = (fieldName: string): FormField | null => {
    return fields[fieldName] || null;
  };

  const resetForm = (): void => {
    Object.entries(initialValues).forEach(([key, value]) => {
      fields[key] = {
        value,
        error: null,
        touched: false
      };
    });
  };

  return {
    setFieldValue,
    registerValidation,
    validateAll,
    getFormData,
    getFieldState,
    resetForm
  };
}

// 使用例
const userForm = createFormValidator({
  email: '',
  password: '',
  username: ''
});

// 検証ルールを登録
userForm.registerValidation('email', [
  {
    validator: (value) => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value),
    message: '有効なメールアドレスを入力してください'
  }
]);

userForm.registerValidation('password', [
  {
    validator: (value) => value.length >= 8,
    message: 'パスワードは8文字以上である必要があります'
  }
]);

userForm.registerValidation('username', [
  {
    validator: (value) => value.length > 0,
    message: 'ユーザー名を入力してください'
  },
  {
    validator: (value) => value.length <= 20,
    message: 'ユーザー名は20文字以下である必要があります'
  }
]);

// 使用方法
userForm.setFieldValue('email', 'user@example.com');
userForm.setFieldValue('password', 'securePassword123');
userForm.setFieldValue('username', 'john_doe');

if (userForm.validateAll()) {
  console.log('フォームは有効です', userForm.getFormData());
} else {
  console.log('エラー:', userForm.getFieldState('email'));
}

パターン3: イベントリスナーマネージャー

複数のイベントリスナーを効率的に管理し、メモリリークを防ぎます:

type EventHandler = (data: T) => void;

function createEventManager() {
  const listeners: Map> = new Map();
  const eventHistory: Array<{ event: string; timestamp: number }> = [];

  const on = (eventName: string, handler: EventHandler): (() => void) => {
    if (!listeners.has(eventName)) {
      listeners.set(eventName, new Set());
    }

    listeners.get(eventName)!.add(handler);

    // リスナー削除関数を返す(アンサブスクライブ)
    return () => {
      listeners.get(eventName)?.delete(handler);
      if (listeners.get(eventName)?.size === 0) {
        listeners.delete(eventName);
      }
    };
  };

  const emit = (eventName: string, data?: any): void => {
    eventHistory.push({
      event: eventName,
      timestamp: Date.now()
    });

    const handlers = listeners.get(eventName);
    if (handlers) {
      handlers.forEach(handler => {
        try {
          handler(data);
        } catch (error) {
          console.error(`Error in event handler for ${eventName}:`, error);
        }
      });
    }
  };

  const once = (eventName: string, handler: EventHandler): (() => void) => {
    const wrappedHandler = (data: any) => {
      handler(data);
      unsubscribe();
    };

    const unsubscribe = on(eventName, wrappedHandler);
    return unsubscribe;
  };

  const removeAllListeners = (eventName?: string): void => {
    if (eventName) {
      listeners.delete(eventName);
    } else {
      listeners.clear();
    }
  };

  const getListenerCount = (eventName: string): number => {
    return listeners.get(eventName)?.size || 0;
  };

  const getEventHistory = (limit: number = 10): typeof eventHistory => {
    return eventHistory.slice(-limit);
  };

  return {
    on,
    emit,
    once,
    removeAllListeners,
    getListenerCount,
    getEventHistory
  };
}

// 使用例
const eventBus = createEventManager();

// リスナーを登録
const unsubscribe = eventBus.on('user:login', (userData) => {
  console.log('ユーザーがログインしました:', userData);
});

// 一度だけ実行されるリスナー
eventBus.once('app:ready', () => {
  console.log('アプリケーションの初期化が完了しました');
});

// イベントを発火
eventBus.emit('user:login', { id: 1, name: 'John Doe' });
eventBus.emit('app:ready');

// リスナーを削除
unsubscribe();

console.log('ログイン時のリスナー数:', eventBus.getListenerCount('user:login'));

パターン4: キャッシング機能付きデータフェッチャー

計算結果やAPIレスポンスをキャッシュして、パフォーマンスを向上させます:

interface CacheOptions {
  ttl: number; // Time To Live(ミリ秒)
  maxSize?: number;
}

interface CacheEntry {
  value: T;
  timestamp: number;
  hits: number;
}

function createMemoizedFunction Promise>(
  fn: T,
  options: CacheOptions
) {
  const cache = new Map>();

  const generateKey = (...args: any[]): string => {
    return JSON.stringify(args);
  };

  const isCacheExpired = (entry: CacheEntry): boolean => {
    return Date.now() - entry.timestamp > options.ttl;
  };

  const memoized = async (...args: any[]): Promise => {
    const key = generateKey(...args);
    const cachedEntry = cache.get(key);

    if (cachedEntry && !isCacheExpired(cachedEntry)) {
      cachedEntry.hits++;
      console.log(`Cache hit for key: ${key} (hits: ${cachedEntry.hits})`);
      return cachedEntry.value;
    }

    console.log(`Cache miss for key: ${key}`);
    const result = await fn(...args);

    cache.set(key, {
      value: result,
      timestamp: Date.now(),
      hits: 0
    });

    // maxSizeを超えた場合、最もヒット数が少ないエントリを削除
    if (options.maxSize && cache.size > options.maxSize) {
      let minHits = Infinity;
      let minKey = null;

      for (const [k, entry] of cache) {
        if (entry.hits < minHits) {
          minHits = entry.hits;
          minKey = k;
        }
      }

      if (minKey) {
        cache.delete(minKey);
        console.log(`Evicted cache entry: ${minKey}`);
      }
    }

    return result;
  };

  const clearCache = (): void => {
    cache.clear();
  };

  const getCacheStats = () => {
    const stats = {
      size: cache.size,
      entries: Array.from(cache.entries()).map(([key, entry]) => ({
        key,
        hits: entry.hits,
        age: Date.now() - entry.timestamp
      }))
    };
    return stats;
  };

  return {
    memoized,
    clearCache,
    getCacheStats
  };
}

// 使用例
async function fetchUserFromAPI(userId: number): Promise {
  console.log(`Fetching user ${userId} from API...`);
  // 実際のAPI呼び出しをシミュレート
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({ id: userId, name: `User ${userId}` });
    }, 1000);
  });
}

const cachedFetch = createMemoizedFunction(fetchUserFromAPI, {
  ttl: 60000, // 60秒
  maxSize: 100
});

// 実行例
(async () => {
  const user1 = await cachedFetch.memoized(1); // API呼び出し
  const user1Again = await cachedFetch.memoized(1); // キャッシュから取得
  const user2 = await cachedFetch.memoized(2); // API呼び出し

  console.log('Cache stats:', cachedFetch.getCacheStats());
})();

よくある応用パターン

パターン5: プライベートメソッドを持つクラス的な実装

JavaScriptのクロージャを使って、クラスのようなプライベートメソッドを実装できます:

function createBankAccount(initialBalance: number) {
  let balance = initialBalance;
  let transactionHistory: Array<{ type: string; amount: number; date: Date }> = [];

  // プライベートメソッド
  const recordTransaction = (type: string, amount: number): void => {
    transactionHistory.push({
      type,
      amount,
      date: new Date()
    });
  };

  const validateAmount = (amount: number): boolean => {
    return amount > 0 && Number.isFinite(amount);
  };

  // パブリックメソッド
  const deposit = (amount: number): number => {
    if (!validateAmount(amount)) {
      throw new Error('Invalid deposit amount');
    }
    balance += amount;
    recordTransaction('deposit', amount);
    return balance;
  };

  const withdraw = (amount: number): number => {
    if (!validateAmount(amount)) {
      throw new Error('Invalid withdrawal amount');
    }
    if (amount > balance) {
      throw new Error('Insufficient funds');
    }
    balance -= amount;
    recordTransaction('withdrawal', amount);
    return balance;
  };

  const getBalance = (): number => {
    return balance;
  };

  const getTransactionHistory = () => {
    return [...transactionHistory]; // コピーを返す(内部データ保護)
  };

  return {
    deposit,
    withdraw,
    getBalance,
    getTransactionHistory
  };
}

// 使用例
const account = createBankAccount(1000);
console.log('Initial balance:', account.getBalance()); // 1000

account.deposit(500);
console.log('After deposit:', account.getBalance()); // 1500

account.withdraw(200);
console.log('After withdrawal:', account.getBalance()); // 1300

console.log('Transaction history:', account.getTransactionHistory());

パターン6: デバウンス・スロットル関数

実務で頻繁に使われるデバウンスとスロットルの実装:

function createDebounce void>(
  func: T,
  delay: number
): (...args: Parameters) => void {
  let timeoutId: NodeJS.Timeout | null = null;

  return (...args: Parameters): void => {
    if (timeoutId) {
      clearTimeout(timeoutId);
    }
    timeoutId = setTimeout(() => {
      func(...args);
      timeoutId = null;
    }, delay);
  };
}

function createThrottle void>(
  func: T,
  limit: number
): (...args: Parameters) => void {
  let lastCallTime = 0;

  return (...args: Parameters): void => {
    const now = Date.now();
    if (now - lastCallTime >= limit) {
      func(...args);
      lastCallTime = now;
    }
  };
}

// 使用例
const handleSearch = createDebounce((query: string) => {
  console.log(`Searching for: ${query}`);
}, 300);

const handleResize = createThrottle(() => {
  console.log('Window resized');
}, 1000);

// シミュレーション
handleSearch('java');
handleSearch('javascript'); // 前の呼び出しがキャンセルされる
handleSearch('typescript');

window.addEventListener('resize', handleResize);

注意点

1. メモリリーク

クロージャは外側のスコープの変数を保持し続けるため、不要になったクロージャは明示的に削除する必要があります:

// 悪い例
function createListeners() {
  const listeners: Array<() => void> = [];

  return {
    addListener: (handler: () => void) => {
      listeners.push(handler);
    },
    getListeners: () => listeners.length
  };
}

// 良い例
function createListeners() {
  const listeners: Set<() => void> = new Set();

  return {
    addListener: (handler: () => void) => {
      listeners.add(handler);
      return () => listeners.delete(handler); // アンサブスクライブ関数を返す
    },
    removeAllListeners: () => {
      listeners.clear();
    }
  };
}

2. パフォーマンスへの影響

過度なクロージャの作成は、特にループ内での作成に注意が必要です:

// 悪い例:各イテレーションでクロージャが作成される
for (let i = 0; i < 1000; i++) {
  document.getElementById(`button-${i}`).addEventListener('click', () => {
    console.log(`Button ${i} clicked`);
  });
}

// 良い例:イベント委譲を使用
document.addEventListener('click', (e) => {
  const button = e.target as HTMLButtonElement;
  if (button.classList.contains('btn')) {
    const index = button.dataset.index;
    console.log(`Button ${index} clicked`);
  }
});

3. スコープチェーンの複雑性

ネストが深すぎるクロージャは可読性を低下させます:

// 避けるべき:深くネストされたクロージャ
function level1() {
  const a = 1;
  return () => {
    const b = 2;
    return () => {
      const c = 3;
      return () => {
        return a + b + c; // スコープチェーンが複雑
      };
    };
  };
}

// 推奨:単純で理解しやすい構造
function createCalculator() {
  const values = { a: 1, b: 2, c: 3 };

  return {
    sum: () => Object.values(values).reduce((x, y) => x + y, 0)
  };
}

4. this バインディング

クロージャ内でアロー関数を使う場合、thisのバインディングに注意が必要です:

// 例:イベントハンドラ内での this
function createComponent() {
  this.count = 0;

  // アロー関数は this を保持する
  this.increment = () => {
    this.count++;
  };

  // 通常の関数は呼び出し元によって this が決まる
  this.decrement = function() {
    this.count--; // この this は undefined か window になる可能性
  };
}

TypeScriptでの活用

TypeScriptを使う場合、型安全性を保ちながらクロージャを活用できます:

// ジェネリック型を使ったクロージャ
function createRepository(
  initialData: T[] = []
) {
  let data = [...initialData];
  let version = 0;

  const add = (item: T): T => {
    data.push(item);
    version++;
    return item;
  };

  const findById = (id: number): T | undefined => {
    return data.find(item => item.id === id);
  };

  const update = (id: number, updates: Partial): T | null => {
    const index = data.findIndex(item => item.id === id);
    if (index === -1) return null;

    data[index] = { ...data[index], ...updates };
    version++;
    return data[index];
  };

  const getAll = (): T[] => {
    return [...data];
  };

  const getVersion = (): number => {
    return version;
  };

  return {
    add,
    findById,
    update,
    getAll,
    getVersion
  };
}

// 使用例
interface User {
  id: number;
  name: string;
  email: string;
}

const userRepo = createRepository([
  { id: 1, name: 'John', email: 'john@example.com' }
]);

userRepo.add({ id: 2, name: 'Jane', email: 'jane@example.com' });
userRepo.update(1, { name: 'John Doe' });

console.log('All users:', userRepo.getAll());
console.log('Repository version:', userRepo.getVersion());

Pythonでの対応パターン

JavaScriptのクロージャに対応するPythonのパターンも確認しましょう:

from typing import Callable, Dict, Any
from functools import wraps
import time

# Python版クロージャ:レート制限
def create_rate_limited_client(max_requests: int, window_ms: int):
request_count = 0
last_reset_time = time.time() * 1000

def reset_window():
nonlocal request_count, last_reset_time
now = time.time() * 1000
if now - last_reset_time > window_ms:
request_count = 0
last_reset_time = now

def can_make_request() -> bool:
reset_window()
return request_count < max_requests def make_request(endpoint: str) -> Dict[str, Any]:
nonlocal request_count
reset_window()

if not can_make_request():
raise Exception(
f\"Rate limit exceeded. Max {max_requests} requests per {window_ms}ms\"
)

request_count += 1
print(f\"Request count: {request_count}/{max_requests}\")
# 実際のAPI呼び出しをここに記述
return {\"endpoint\": endpoint, \"status\": \"success\"}

def get_status() -> Dict[str, Any]:
return {
\"request_count\": request_count,
\"max_requests\": max_requests,
\"remaining_requests\": max_requests - request_count
}

return {
\"make_request\": make_request,
\"get_status\": get_status
}

# 使用例
api_client = create_rate_limited_client(max_requests=10, window_ms=60000)

try:
result = api_client[\"make_request\"](\"/users\")
print(result)
print(api_client[\"get_status\"]())
except Exception as e:
print(f\"Error: {e}\")

# Python版デコレータ(クロージャの応用)
def debounce(wait_ms: int):
def decorator(func: Callable) -> Callable:
last_call = [0] # リストでミュータブルにする
timer = [None]

@wraps(func)
def wrapper(*args, **kwargs):
def call_func():
func(*args, **kwargs)

if timer[0] is not None:
# タイマーをキャンセル(Pythonではthreading.Timerを使用)
pass

# 新しいタイマーを設定
# 実際にはthreadingモジュールを使用します

return wrapper

return decorator

# デコレータの使用例
@debounce(wait_ms=300)
def on_search(query: str):
print(f\

タイトルとURLをコピーしました