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変数は外部からは直接アクセスできないということです。incrementやdecrementメソッドを通してのみ操作できます。これがクロージャの力です。
業務でのユースケース
実際のプロジェクトでクロージャが活躍する場面は多くあります。以下は実務で頻繁に遭遇するシナリオです:
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\

