JavaScriptにおける実用的なメモ化パターン
アプリケーションをプロファイリングした結果、同一の入力で何千回も実行されている関数を発見したとします。メモ化は明らかな解決策に思えるでしょう。しかし、すべてをキャッシュでラップする前に知っておくべきことがあります。誤ったメモ化は、最初に抱えていたパフォーマンス問題よりも発見が困難なバグを生み出します。
本記事では、実用的なJavaScriptメモ化パターン、JavaScript開発者が遭遇する一般的なメモ化の落とし穴、そして非同期メモ化やReact useMemoのベストプラクティスを含む、これらの技術を本番コードで安全に適用する方法について解説します。
重要なポイント
- メモ化は引数に基づいて関数の結果をキャッシュし、メモリと速度をトレードオフしますが、純粋関数とプリミティブ引数でのみ確実に機能します。
- オブジェクト参照はデータが変更されても古いキャッシュヒットを引き起こします。カスタムキー生成にはfast-memoizeのようなライブラリを使用するか、プリミティブ型に限定してください。
- 無制限のキャッシュは長時間実行されるアプリでメモリリークを引き起こします。LRU削除、TTL有効期限、またはコンポーネントライフサイクルへのキャッシュスコープ設定を実装してください。
- 非同期メモ化では、重複リクエストを防ぎリトライを可能にするため、Promiseを即座にキャッシュし、失敗したエントリを削除する必要があります。
- ReactのuseMemoは対象を絞った最適化であり、デフォルトではありません。まずプロファイリングを行い、計算が測定可能なほど遅い場合にのみ適用してください。
メモ化が実際に行うこと
メモ化は引数に基づいて関数の結果をキャッシュします。同じ入力で再度関数を呼び出すと、再計算する代わりにキャッシュされた結果を取得します。
JavaScriptには組み込みのメモ化機能がありません。TC39は提案(Function.prototype.memoなど)を議論していますが、まだ本番環境で使用できるものはありません(提案)。自分で実装するか、ライブラリを使用する必要があります。
単一引数関数の基本的なパターンは次のとおりです:
function memoize(fn) {
const cache = new Map()
return (...args) => {
const key = args[0]
if (cache.has(key)) return cache.get(key)
const result = fn(...args)
cache.set(key, result)
return result
}
}
これはプリミティブ引数に対しては機能します。それ以外のすべてに対しては、微妙な形で破綻します。
オブジェクト参照の問題
オブジェクトは値ではなく参照によってキャッシュされます。これは開発者を常に困らせます:
const memoizedFn = memoize(processData)
const config = { threshold: 10 }
memoizedFn(config) // 計算実行
config.threshold = 20
memoizedFn(config) // 古いキャッシュ結果を返す
同じ参照はキャッシュヒットを意味し、データが変更されていてもそうなります。
一部の開発者はJSON.stringify(args)をキャッシュキーとして使用してこれを修正しようとします。これは単純なデータには機能しますが、循環参照で失敗し、関数やシンボルを削除し、大きなオブジェクトでは遅くなる可能性があります。
解決策: プリミティブ引数を持つ関数のみをメモ化するか、より複雑なケースに対応するカスタムキーリゾルバ/シリアライザをサポートするfast-memoizeのようなライブラリを使用してください。
メモ化が問題を引き起こす場合
非純粋関数
非純粋関数をメモ化すると、デバッグ不可能な問題が発生します:
// 長期キャッシュのためにこれをメモ化してはいけません
const getData = memoize(() => {
return fetch('/api/data').then(r => r.json())
})
最初の呼び出しでPromiseがキャッシュされます。以降のすべての呼び出しは、サーバーデータが変更されていても、同じPromiseを返します。
非同期メモ化は、意図的に同時リクエストを重複排除したい場合、または無効化やTTLベースの有効期限も実装している場合(後述)にのみ安全です。
無制限のキャッシュ増加
キャッシュ削除戦略がないと、キャッシュは永遠に増加します:
// メモリリークが待ち受けている
const processUserInput = memoize((input) => expensiveOperation(input))
すべての一意の入力がキャッシュに追加されます。長時間実行されるアプリでは、これはメモリリークを引き起こします。
解決策:
- 最大キャッシュサイズを設定(LRU削除)
- TTL(有効期限)を追加
- キャッシュをコンポーネントライフサイクルにスコープ設定
function memoizeWithLimit(fn, maxSize = 100) {
const cache = new Map()
return (...args) => {
const key = args[0]
if (cache.has(key)) return cache.get(key)
if (cache.size >= maxSize) {
const firstKey = cache.keys().next().value
cache.delete(firstKey)
}
const result = fn(...args)
cache.set(key, result)
return result
}
}
Discover how at OpenReplay.com.
正しい非同期メモ化
非同期メモ化には、同時呼び出しと失敗に対する特別な処理が必要です:
function memoizeAsync(fn) {
const cache = new Map()
return async (...args) => {
const key = args[0]
if (cache.has(key)) return cache.get(key)
const promise = fn(...args).catch(err => {
cache.delete(key) // 失敗をキャッシュしない
throw err
})
cache.set(key, promise)
return promise
}
}
Promiseを即座にキャッシュします。これにより、重複する同時リクエストを防ぎます。失敗時には削除して、リトライが機能するようにします。
React useMemoのベストプラクティス
useMemoとReact.memoは対象を絞った最適化であり、デフォルトではありません。誤用すると複雑さが増し、パフォーマンスを損なう可能性があります(公式Reactドキュメントを参照: https://react.dev/reference/react/useMemo)。
useMemoを使用する場合:
- props/stateから派生データを計算することが測定可能なほど遅い
- メモ化された子コンポーネントにオブジェクトを渡している
useMemoをスキップする場合:
- 計算が些細なもの
- パフォーマンス問題を測定していない
// おそらく不要
const doubled = useMemo(() => value * 2, [value])
// 有用な可能性あり
const sortedItems = useMemo(
() => items.slice().sort((a, b) => a.name.localeCompare(b.name)),
[items]
)
Reactの依存関係比較はObject.isを使用します—参照等価性です。新しいオブジェクトリテラルは、レンダリングごとにメモ化を破壊します。
意思決定フレームワーク
メモ化する前に、以下を確認してください:
- 関数は純粋か? 副作用がなく、同じ入力は常に同じ出力を生成する。
- 実際に遅いか? まずプロファイリングを行う。推測しない。
- 引数はプリミティブまたは安定した参照か? オブジェクト引数には慎重な処理が必要。
- キャッシュの有効期間は? 無制限のキャッシュはメモリリークを引き起こす。
まとめ
メモ化はメモリと速度をトレードオフします。良い取引をしているか確認してください。まずプロファイリングを行って実際のパフォーマンス問題が存在することを確認し、次に予測可能な引数を持つ純粋関数に選択的にメモ化を適用します。メモリリークを防ぐためにキャッシュ制限を実装し、非同期操作を慎重に処理し、ReactのuseMemoは最適化ツールであってデフォルトパターンではないことを覚えておいてください。正しく行えば、メモ化は冗長な計算を排除します。誤って行えば、パフォーマンス向上よりも長く残る微妙なバグを導入します。
よくある質問
サーバーデータは時間とともに変化するため、API呼び出しのメモ化はリスクがあります。Promiseをキャッシュすると、以降の呼び出しは古いデータを返します。同時リクエストを明示的に重複排除したい場合で、かつキャッシュ無効化またはTTL有効期限を実装してデータを定期的に更新する場合にのみ、API呼び出しをメモ化してください。
オブジェクトは値ではなく参照で比較されます。オブジェクトを変更してメモ化関数を再度呼び出すと、参照が変更されていないためキャッシュされた結果を返します。不変データパターンを使用し、変更する代わりに新しいオブジェクトを作成するか、単純なケースではJSON.stringifyで引数をシリアライズしてください。
ブラウザDevToolsまたはNode.jsプロファイリングツールを使用して、前後でプロファイリングを行います。実行時間とメモリ使用量を測定します。関数の実行頻度が低い、または計算が高速な場合、メモ化のオーバーヘッドが節約を上回る可能性があります。キャッシュヒット率も重要です—ヒット率が低いということは、最小限の利益で無駄なメモリを意味します。
いいえ。useMemoは依存関係の追跡と比較のオーバーヘッドを追加します。基本的な数学演算や文字列連結のような単純な計算では、メモ化のコストが計算コストを上回ります。大きな配列のソート、複雑なフィルタリング、またはメモ化された子コンポーネントに渡すオブジェクトの作成など、高コストな操作にuseMemoを予約してください。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Check our GitHub repo and join the thousands of developers in our community.