JavaScript WeakMapの実践的な使い方|メモリ効率を改善する実装パターン

未分類

JavaScript WeakMapの実践的な使い方|メモリ効率を改善する実装パターン

JavaScriptのWeakMapは、多くの開発者にとって理解が難しく、実際の業務で活用する機会が少ないデータ構造です。しかし、正しく理解して活用すると、メモリ効率の改善やセキュリティの向上につながる強力なツールになります。本記事では、WeakMapの基本から実務レベルのサンプルコード、応用パターンまで、実際のプロジェクトで使える知識をお伝えします。

1. WeakMapの基本的な特徴

WeakMapを理解する前に、通常のMapとの違いを押さえておくことが重要です。

WeakMapの3つの主な特徴:

  • キーはオブジェクトのみ(プリミティブ値は不可)
  • WeakMapが保持するキーへの参照は「弱参照」である
  • イテレーション(forEach、keys()など)ができない

弱参照とは、オブジェクトをメモリ上に留めておかない参照方式を意味します。つまり、キーとなるオブジェクトが他の場所で参照されていなくなると、ガベージコレクションの対象となり、自動的にメモリから削除されます。

// 通常のMap - キーが残っているとメモリを圧迫
const map = new Map();
let obj = { id: 1 };
map.set(obj, 'data');
obj = null; // objを削除しても、Mapがキーを保持しているため、メモリに残る

// WeakMap - キーが参照されなくなると自動でメモリから削除
const weakMap = new WeakMap();
let obj2 = { id: 2 };
weakMap.set(obj2, 'data');
obj2 = null; // objへの参照がなくなると、WeakMapのキーも自動で削除される

2. 業務でのユースケース

ユースケース1: DOMノードのプライベートデータ管理

React やVueなどのフレームワークを使わない古いプロジェクトでは、DOM要素に関連するデータを管理する必要があります。このような場合、WeakMapは非常に有効です。

従来の方法では、DOM要素に直接プロパティを追加したり、グローバルなオブジェクトで管理していました。WeakMapを使うことで、DOM要素が削除されると自動的にデータもメモリから解放されます。

ユースケース2: ユーザーメタデータのキャッシング

Webアプリケーション内で、ユーザーオブジェクトに対するメタデータ(最終アクセス日時、ロール情報など)をキャッシュする場合、WeakMapが有効です。ユーザーがシステムから削除されれば、自動的にメタデータも消去されます。

ユースケース3: クラスインスタンスのプライベート状態管理

ECMAScript 2022で#によるプライベートフィールドが導入される前は、WeakMapを使ってクラスの真のプライベートデータを管理するパターンが一般的でした。今でも、外部からアクセスできないデータ構造が必要な場合に使用されます。

3. 実装コード|実務レベルのサンプル

サンプル1: DOMノード関連データの管理

// DOMノードにメタデータを紐付ける実装
class DOMMetadataManager {
  constructor() {
    this.metadata = new WeakMap();
  }

  // ノードにメタデータを設定
  setMetadata(node, data) {
    if (!(node instanceof Node)) {
      throw new Error('キーはDOMノードである必要があります');
    }
    this.metadata.set(node, {
      createdAt: new Date(),
      ...data
    });
  }

  // ノードのメタデータを取得
  getMetadata(node) {
    return this.metadata.get(node);
  }

  // ノードのメタデータが存在するか確認
  hasMetadata(node) {
    return this.metadata.has(node);
  }
}

// 使用例
const manager = new DOMMetadataManager();
const button = document.querySelector('button');

if (button) {
  manager.setMetadata(button, {
    clickCount: 0,
    owner: 'admin'
  });

  const data = manager.getMetadata(button);
  console.log(data); // { createdAt: Date, clickCount: 0, owner: 'admin' }
}

サンプル2: ユーザーセッション情報のキャッシング

// 実際の業務で使える、ユーザーセッション管理
class UserSessionCache {
  constructor() {
    this.sessions = new WeakMap();
  }

  // ユーザーオブジェクトのセッション情報を設定
  createSession(userObject, sessionData) {
    this.sessions.set(userObject, {
      sessionId: this.generateSessionId(),
      createdAt: Date.now(),
      lastAccessed: Date.now(),
      data: sessionData
    });
  }

  // セッション情報を更新
  updateSession(userObject, updates) {
    const session = this.sessions.get(userObject);
    if (session) {
      session.lastAccessed = Date.now();
      Object.assign(session.data, updates);
    }
  }

  // セッション情報を取得
  getSession(userObject) {
    return this.sessions.get(userObject);
  }

  // セッションの有効期限チェック
  isSessionValid(userObject, timeoutMs = 30 * 60 * 1000) {
    const session = this.sessions.get(userObject);
    if (!session) return false;
    return Date.now() - session.lastAccessed < timeoutMs;
  }

  generateSessionId() {
    return 'sess_' + Math.random().toString(36).substr(2, 9);
  }
}

// 使用例
const cache = new UserSessionCache();
const user = { id: 1, name: 'Taro Yamada' };

cache.createSession(user, { theme: 'dark', language: 'ja' });
cache.updateSession(user, { language: 'en' });

const session = cache.getSession(user);
console.log(session);
// {
//   sessionId: 'sess_xxx...',
//   createdAt: 1234567890,
//   lastAccessed: 1234567890,
//   data: { theme: 'dark', language: 'en' }
// }

サンプル3: クラスのプライベート状態管理(従来パターン)

// WeakMapを使ったプライベート状態管理
// (現在は#を使うべきだが、レガシーコードや特殊な用途では有効)
const privateData = new WeakMap();

class BankAccount {
  constructor(accountNumber, initialBalance) {
    // プライベート状態をWeakMapに保存
    privateData.set(this, {
      accountNumber: accountNumber,
      balance: initialBalance,
      transactions: []
    });
  }

  deposit(amount) {
    const data = privateData.get(this);
    if (amount > 0) {
      data.balance += amount;
      data.transactions.push({
        type: 'deposit',
        amount: amount,
        timestamp: new Date()
      });
      return true;
    }
    return false;
  }

  withdraw(amount) {
    const data = privateData.get(this);
    if (amount > 0 && data.balance >= amount) {
      data.balance -= amount;
      data.transactions.push({
        type: 'withdraw',
        amount: amount,
        timestamp: new Date()
      });
      return true;
    }
    return false;
  }

  getBalance() {
    return privateData.get(this).balance;
  }

  getTransactionHistory() {
    return privateData.get(this).transactions;
  }
}

// 使用例
const account = new BankAccount('123456789', 100000);
account.deposit(50000);
account.withdraw(20000);

console.log(account.getBalance()); // 130000
console.log(account.getTransactionHistory()); 
// [
//   { type: 'deposit', amount: 50000, timestamp: Date },
//   { type: 'withdraw', amount: 20000, timestamp: Date }
// ]

サンプル4: APIリクエストのキャッシング(実務的)

// 実務で使える、API呼び出し結果のキャッシング機構
class APIRequestCache {
  constructor() {
    // キーをリクエストオブジェクト、値をレスポンスキャッシュ
    this.cache = new WeakMap();
    // キャッシュの有効期限を管理
    this.timestamps = new WeakMap();
  }

  // キャッシュにデータを保存
  set(requestKey, response, ttlMs = 5 * 60 * 1000) {
    this.cache.set(requestKey, response);
    this.timestamps.set(requestKey, {
      createdAt: Date.now(),
      ttl: ttlMs
    });
  }

  // キャッシュからデータを取得(有効期限チェック付き)
  get(requestKey) {
    const timestamp = this.timestamps.get(requestKey);
    
    if (!timestamp) return null;
    
    const age = Date.now() - timestamp.createdAt;
    if (age > timestamp.ttl) {
      // 有効期限切れ - WeakMapなので手動削除は不要だが、明示的に処理
      return null;
    }

    return this.cache.get(requestKey);
  }

  has(requestKey) {
    const cached = this.get(requestKey);
    return cached !== null;
  }
}

// 使用例
const apiCache = new APIRequestCache();

// リクエスト用のオブジェクトキー
const userListRequest = {
  endpoint: '/api/users',
  params: { page: 1 }
};

// キャッシュに保存
const mockResponse = {
  status: 200,
  data: [
    { id: 1, name: 'User 1' },
    { id: 2, name: 'User 2' }
  ]
};

apiCache.set(userListRequest, mockResponse, 10 * 60 * 1000); // 10分有効

// キャッシュから取得
const cachedData = apiCache.get(userListRequest);
console.log(cachedData); // mockResponse

4. TypeScriptでの型安全な実装

TypeScriptを使う場合は、型定義をすることでより安全なWeakMapの使用が可能です。

// TypeScriptでの型安全なWeakMap実装
interface SessionData {
  sessionId: string;
  theme: 'light' | 'dark';
  language: string;
  preferences: Record<string, unknown>;
}

interface UserObject {
  id: number;
  email: string;
  [key: string]: any;
}

class TypeSafeSessionManager {
  private sessions: WeakMap<UserObject, SessionData>;

  constructor() {
    this.sessions = new WeakMap();
  }

  createSession(user: UserObject, data: Omit<SessionData, 'sessionId'>): void {
    this.sessions.set(user, {
      sessionId: this.generateId(),
      ...data
    });
  }

  getSession(user: UserObject): SessionData | undefined {
    return this.sessions.get(user);
  }

  hasSession(user: UserObject): boolean {
    return this.sessions.has(user);
  }

  private generateId(): string {
    return 'sess_' + Math.random().toString(36).substr(2, 9);
  }
}

// 使用例
const user: UserObject = { id: 1, email: 'user@example.com' };
const manager = new TypeSafeSessionManager();

manager.createSession(user, {
  theme: 'dark',
  language: 'ja',
  preferences: { fontSize: 14 }
});

const session = manager.getSession(user);
// TypeScriptがSessionData型を推論できる

5. よくある応用パターン

パターン1: 複数のWeakMapを組み合わせた多層キャッシュ

// 複数のメタデータを階層的に管理
class MultiLayerCache {
  constructor() {
    this.primaryCache = new WeakMap();    // メインデータ
    this.metadataCache = new WeakMap();   // メタデータ
    this.statsCache = new WeakMap();      // アクセス統計
  }

  set(key, data, metadata = {}, stats = {}) {
    this.primaryCache.set(key, data);
    this.metadataCache.set(key, {
      createdAt: new Date(),
      ...metadata
    });
    this.statsCache.set(key, {
      accessCount: 0,
      lastAccessed: null,
      ...stats
    });
  }

  get(key) {
    // アクセス統計を更新
    const stats = this.statsCache.get(key);
    if (stats) {
      stats.accessCount++;
      stats.lastAccessed = new Date();
    }

    return {
      data: this.primaryCache.get(key),
      metadata: this.metadataCache.get(key),
      stats: stats
    };
  }
}

// 使用例
const cache = new MultiLayerCache();
const resourceKey = { resourceId: 'res123' };

cache.set(resourceKey, 
  { content: 'important data' },
  { owner: 'admin', isPublic: false },
  {}
);

const result = cache.get(resourceKey);
console.log(result);
// {
//   data: { content: 'important data' },
//   metadata: { createdAt: Date, owner: 'admin', isPublic: false },
//   stats: { accessCount: 1, lastAccessed: Date }
// }

パターン2: WeakMapとProxyを組み合わせた監視機構

// 実務的な例:オブジェクトの変更を監視しながらデータを管理
class ObservableDataManager {
  constructor() {
    this.data = new WeakMap();
    this.observers = new WeakMap();
  }

  create(key, initialData = {}) {
    const handlers = {
      get: (target, property) => {
        console.log(`Getting property: ${String(property)}`);
        return target[property];
      },
      set: (target, property, value) => {
        const oldValue = target[property];
        target[property] = value;
        
        // オブザーバーに通知
        const observerList = this.observers.get(key) || [];
        observerList.forEach(callback => {
          callback({
            property,
            oldValue,
            newValue: value
          });
        });
        
        return true;
      }
    };

    const proxy = new Proxy(initialData, handlers);
    this.data.set(key, proxy);
    this.observers.set(key, []);

    return proxy;
  }

  subscribe(key, callback) {
    const observerList = this.observers.get(key) || [];
    observerList.push(callback);
    this.observers.set(key, observerList);
  }

  getData(key) {
    return this.data.get(key);
  }
}

// 使用例
const manager = new ObservableDataManager();
const user = { id: 1, name: 'Taro' };

const userData = manager.create(user, { age: 30, city: 'Tokyo' });

manager.subscribe(user, (change) => {
  console.log(`Changed: ${change.property} from ${change.oldValue} to ${change.newValue}`);
});

userData.age = 31; // Changed: age from 30 to 31
userData.city = 'Osaka'; // Changed: city from Tokyo to Osaka

6. WeakMapの注意点と落とし穴

注意点1: デバッグが難しい

WeakMapはイテレーション機能がないため、デバッグ時に内容を確認できません。開発時は通常のMapを使い、本番環境でWeakMapに切り替えるアプローチも検討してください。

// デバッグ用のラッパークラス
class DebugWeakMap {
  constructor(enableDebug = false) {
    this.weakMap = new WeakMap();
    this.debugMap = enableDebug ? new Map() : null;
  }

  set(key, value) {
    this.weakMap.set(key, value);
    if (this.debugMap) {
      this.debugMap.set(key, value);
    }
  }

  get(key) {
    return this.weakMap.get(key);
  }

  // デバッグ用:内容を確認
  debugLog() {
    if (this.debugMap) {
      console.table([...this.debugMap.entries()]);
    }
  }
}

const store = new DebugWeakMap(true); // 開発環境ではtrue
const obj = { id: 1 };
store.set(obj, 'data');
store.debugLog(); // テーブル形式で出力

注意点2: ガベージコレクションのタイミングは予測不可

WeakMapのキーがいつメモリから削除されるかは、JavaScriptエンジンの実装によって異なります。メモリ削除のタイミングに依存した処理は避けましょう。

注意点3: キーはオブジェクトのみ

プリミティブ値(文字列、数値など)をキーにしたい場合は、WeakMapではなく通常のMapを使う必要があります。

// ❌ エラー:プリミティブ値はWeakMapのキーにできない
const weakMap = new WeakMap();
weakMap.set('key', 'value'); // TypeError: Invalid value used as weak map key

// ✅ 正しい:オブジェクトをキーとする
const obj = { id: 'key' };
weakMap.set(obj, 'value'); // OK

注意点4: WeakMapはシリアライズできない

WeakMapの内容をJSON化したり、外部に送信したりすることはできません。データの永続化が必要な場合は、別の方法を検討してください。

7. 実務での実装チェックリスト

WeakMapを業務コードに導入する際のチェックリストです。

  • ✅ キーとなるオブジェクトの生存期間を理解しているか
  • ✅ メモリリークの防止が目的か、それともプライベートデータ隔離か、目的が明確か
  • ✅ チーム内でWeakMapの使用規約を定めているか
  • ✅ デバッグやテスト時の対応は済んでいるか
  • ✅ パフォーマンス測定は実施したか
  • ✅ TypeScriptの型定義は適切か
  • ✅ ドキュメント(なぜWeakMapを使ったか)は残しているか

8. WeakMapとの比較:他のデータ構造

特性 WeakMap Map Object
キーの種類 オブジェクトのみ 任意の値 文字列/Symbol
弱参照 はい いいえ いいえ
イテレーション 不可 可能 可能
シリアライズ 不可 不可 可能
プライベート性 高い 低い 中程度

まとめ

WeakMapは、単なる「メモリ効率がよいMap」ではなく、特定のユースケースに最適化されたデータ構造です。実務での活用シーンは限定的ですが、DOMノード管理、セッションキャッシング、プライベートデータの隔離など、適切な場面で使用するとコードの品質と安全性が大幅に向上します。

重要なのは、「なぜWeakMapを使うのか」という目的を明確にすることです。パフォーマンス最適化、メモリ管理、セキュリティ強化など、目的に応じて適切に活用してください。

特にレガシーコード(ES2022の#プライベートフィールドが使えない環境)やDOM操作が中心のプロジェクトでは、WeakMapの価値が大きいでしょう。本記事で紹介したサンプルコードを参考に、実際のプロジェクトで検討してみてください。

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