1716855212
2024-05-27 12:19:29
私は〜で働いています ランブラー、ビデオ注釈用の複雑なReactアプリケーションを構築するAIスタートアップです。最近、JavaScriptのクロージャとReactの組み合わせによって引き起こされた複雑なメモリリークに遭遇しました。 useCallback フック。.NET のバックグラウンドを持つ私にとって、何が起こっているのか理解するのにかなり時間がかかりました。そこで、私が学んだことを共有しようと思いました。
クロージャについての簡単な復習を追加しましたが、JavaScript でのクロージャの動作をすでに理解している場合は、その部分をスキップしてください。
クロージャについての簡単な復習
クロージャは JavaScript の基本的な概念です。クロージャにより、関数は、関数が作成されたときにスコープ内にあった変数を記憶することができます。以下に簡単な例を示します。
function createCounter() {
const unused = 0; // This variable is not used in the inner function
let count = 0; // This variable is used in the inner function
return function () {
count++;
console.log(count);
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
この例では、 createCounter 関数は、アクセス可能な新しい関数を返します。 count 変数です。これが可能なのは、 count 変数は、 createCounter 内部関数が作成されるときに関数を実行します。
JavaScript クロージャは、関数が最初に作成されたときにスコープ内の変数への参照を保持するコンテキスト オブジェクトを使用して実装されます。コンテキスト オブジェクトに保存される変数は、JavaScript エンジンの実装の詳細であり、さまざまな最適化の対象となります。たとえば、Chrome で使用される JavaScript エンジンである V8 では、使用されていない変数はコンテキスト オブジェクトに保存されない可能性があります。
クロージャは他のクロージャ内にネストできるため、最も内側のクロージャは、アクセスする必要がある外部の関数スコープへの参照 (いわゆるスコープ チェーン経由) を保持します。例:
function first() {
const firstVar = 1;
function second() {
// This is a closure over the firstVar variable
const secondVar = 2;
function third() {
// This is a closure over the firstVar and secondVar variables
console.log(firstVar, secondVar);
}
return third;
}
return second();
}
const fn = first(); // This will return the third function
fn(); // logs 1, 2
この例では、 third() 関数は、 firstVar スコープ チェーンを通じて変数を取得します。

したがって、アプリが関数への参照を保持している限り、クロージャ スコープ内の変数はいずれもガベージ コレクションされません。スコープ チェーンにより、外側の関数スコープもメモリ内に残ります。
このトピックについてさらに詳しく知るには、この素晴らしい記事をご覧ください。 楽しみのために(そして利益のために?)V8 クロージャを理解する2012 年のものですが、今でも関連性があり、V8 でクロージャがどのように機能するかについての優れた概要を提供しています。
クロージャとReact
React では、すべての機能コンポーネント、フック、イベント ハンドラーでクロージャに大きく依存しています。コンポーネントのスコープから変数 (状態やプロパティなど) にアクセスする新しい関数を作成するときは、クロージャを作成する可能性が高くなります。
次に例を示します。
import { useState, useEffect } from "react";
function App({ id }) {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // This is a closure over the count variable
};
useEffect(() => {
console.log(id); // This is a closure over the id prop
}, [id]);
return (
div>
p>{count}p>
button onClick={handleClick}>Incrementbutton>
div>
);
}
ほとんどの場合、それ自体は問題ではありません。上記の例では、クロージャはレンダリングごとに再作成されます。 App 古いものはガベージ コレクションされます。不要な割り当てと割り当て解除が発生する可能性もありますが、それだけでは通常非常に高速です。
しかし、アプリケーションが大きくなり、次のようなメモ化技術を使い始めると、 useMemo そして useCallback 不要な再レンダリングを避けるために、いくつか注意すべき点があります。
閉鎖と useCallback
メモ化フックを使用すると、レンダリング パフォーマンスが向上する代わりに、メモリ使用量が増加します。 useCallback 依存関係が変更されない限り、関数への参照を保持します。例を見てみましょう。
import React, { useState, useCallback } from "react";
function App() {
const [count, setCount] = useState(0);
const handleEvent = useCallback(() => {
setCount(count + 1);
}, [count]);
return (
div>
p>{count}p>
ExpensiveChildComponent onMyEvent={handleEvent} />
div>
);
}
この例では、再レンダリングを避けたいのですが、 ExpensiveChildComponent. 私たちは、 handleEvent() 関数参照は安定しています。メモ化します handleEvent() と useCallback 新しい値を再割り当てするのは、 count 状態が変化すると、 ExpensiveChildComponent で React.memo() 親が再レンダリングされるのを避けるために、 App、レンダリング。ここまでは順調です。
しかし、この例に少し工夫を加えてみましょう。
import { useState, useCallback } from "react";
class BigObject {
public readonly data = new Uint8Array(1024 * 1024 * 10); // 10MB of data
}
function App() {
const [count, setCount] = useState(0);
const bigData = new BigObject();
const handleEvent = useCallback(() => {
setCount(count + 1);
}, [count]);
const handleClick = () => {
console.log(bigData.data.length);
};
return (
div>
button onClick={handleClick} />
ExpensiveChildComponent2 onMyEvent={handleEvent} />
div>
);
}
何が起こるかわかりますか?
以来 handleEvent() 閉鎖を作成する count 変数はコンポーネントのコンテキストオブジェクトへの参照を保持します。 私たちは決してアクセスしません bigData の中に handleEvent() 関数、 handleEvent() 参照は保持されます bigData コンポーネントのコンテキスト オブジェクトを通じて。
すべてのクロージャは、作成された時点から共通のコンテキストオブジェクトを共有します。 handleClick() 閉まる bigData、 bigData このコンテキストオブジェクトによって参照されます。つまり、 bigData ガベージコレクションの対象にはなりません handleEvent() 参照されています。この参照は count 変更と handleEvent() 再現されます。

無限のメモリリーク useCallback + クロージャ + 大きなオブジェクト
最後に、上記すべてを極端に表現した例を見てみましょう。この例は、アプリケーションで私が遭遇したものを簡略化したものです。したがって、この例は不自然に思えるかもしれませんが、一般的な問題を非常によく表しています。
import { useState, useCallback } from "react";
class BigObject {
public readonly data = new Uint8Array(1024 * 1024 * 10);
}
export const App = () => {
const [countA, setCountA] = useState(0);
const [countB, setCountB] = useState(0);
const bigData = new BigObject(); // 10MB of data
const handleClickA = useCallback(() => {
setCountA(countA + 1);
}, [countA]);
const handleClickB = useCallback(() => {
setCountB(countB + 1);
}, [countB]);
// This only exists to demonstrate the problem
const handleClickBoth = () => {
handleClickA();
handleClickB();
console.log(bigData.data.length);
};
return (
div>
button onClick={handleClickA}>Increment Abutton>
button onClick={handleClickB}>Increment Bbutton>
button onClick={handleClickBoth}>Increment Bothbutton>
p>
A: {countA}, B: {countB}
p>
div>
);
};
この例では、2つのメモ化されたイベントハンドラがあります。 handleClickA() そして handleClickB()機能もあります handleClickBoth() 両方のイベントハンドラを呼び出し、長さを記録します bigData。
「Increment A」ボタンと「Increment B」ボタンを交互にクリックすると何が起こるかわかりますか?
これらのボタンをそれぞれ 5 回クリックした後、Chrome DevTools でメモリ プロファイルを見てみましょう。

のように思える bigData ガベージコレクションは行われません。メモリ使用量はクリックするたびに増え続けます。私たちの場合、アプリケーションは11個の参照を保持しています。 BigObject インスタンスはそれぞれ 10 MB です。最初のレンダリング用に 1 つ、クリックごとに 1 つ。
保持ツリーは、何が起こっているかを示しています。参照の繰り返しチェーンを作成しているようです。ステップごとに確認してみましょう。
0. 最初のレンダリング:
いつ App 最初にレンダリングされると、 閉鎖範囲 少なくとも1つのクロージャですべての変数を使用するので、すべての変数への参照を保持します。これには以下が含まれます。 bigData、 handleClickA()、 そして handleClickB(). 参照先 handleClickBoth()クロージャスコープを呼び出しましょう AppScope#0。

1. 「増分A」をクリックします。
- 「Aを増分」を最初にクリックすると、
handleClickA()変化していくにつれて、再創造されるcountA– 新しいのを呼びましょうhandleClickA()#1。 handleClickB()#0意思 ない 以来再創造されるcountB変化しなかった。- しかし、これは
handleClickB()#0以前の参照が保持されますAppScope#0。 - 新しい
handleClickA()#1参照を保持するAppScope#1、参照を保持するhandleClickB()#0。

2. 「増分B」をクリックします。
- 「Bを増分」を最初にクリックすると、
handleClickB()変化していくにつれて、再創造されるcountB、こうしてhandleClickB()#1。 - 反応は ない 再現する
handleClickA()以来countA変化しなかった。 handleClickB()#1したがって、参照を保持するAppScope#2、参照を保持するhandleClickA()#1、参照を保持するAppScope#1、参照を保持するhandleClickB()#0。

3. 2回目に「増分A」をクリックします。
この方法では、互いに参照し合い、決してガベージコレクションされないクロージャの無限の連鎖を作成することができ、その間、別の10MBのメモリを持ち歩くことになる。 bigData オブジェクトはレンダリングごとに再作成されるためです。

一般的な問題を一言で言えば
一般的な問題は、 useCallback 単一のコンポーネント内のフックは、クロージャスコープを介して相互に参照したり、他の高価なデータを参照したりする可能性がある。クロージャは、 useCallback フックが再作成されます。 useCallback コンポーネントにフックがあると、メモリに何が保持され、いつ解放されるのかを判断するのが非常に難しくなります。コールバックの数が多いほど、この問題が発生する可能性が高くなります。
これはあなたにとって問題になるでしょうか?
この問題が発生する可能性が高くなる要因をいくつか示します。
- ほとんど再作成されることのない大きなコンポーネントがいくつかあります。たとえば、多くの状態を持ち込んだアプリ シェルなどです。
- あなたが頼りにしているのは
useCallback再レンダリングを最小限に抑えます。 - メモ化された関数から他の関数を呼び出します。
- 画像データや大きな配列などの大きなオブジェクトを処理します。
大きなオブジェクトを処理する必要がない場合は、いくつかの追加の文字列または数値を参照しても問題にならない可能性があります。これらのクロージャ相互参照のほとんどは、十分なプロパティが変更されると解消されます。ただし、アプリが予想よりも多くのメモリを保持する可能性があることに注意してください。
クロージャとメモリリークを回避する方法 useCallback?
この問題を回避するためのヒントをいくつか紹介します。
ヒント 1: クロージャ スコープをできるだけ小さく保ちます。
JavaScript では、キャプチャされているすべての変数を見つけるのが非常に困難です。変数をあまり多く保持しないようにする最善の方法は、クロージャ周辺の関数のサイズを小さくすることです。これは次のことを意味します。
- より小さなコンポーネントを書くこれにより、新しいクロージャを作成するときにスコープ内にある変数の数が減ります。
- カスタムフックを書くなぜなら、コールバックはフック関数のスコープ内でのみ閉じることができるからです。これは多くの場合、関数の引数のみを意味します。
ヒント 2: 他のクロージャ、特にメモ化されたクロージャをキャプチャすることは避けてください。
これは明らかなことのように思えますが、Reactではこの罠に陥りやすいのです。互いに呼び出す小さな関数を書くと、最初の関数に useCallback コンポーネントスコープ内で呼び出されたすべての関数がメモ化される連鎖反応が発生します。
ヒント 3: 必要がない場合はメモ化を避けてください。
useCallback そして useMemo は、不要な再レンダリングを回避するための優れたツールですが、コストがかかります。レンダリングによるパフォーマンスの問題に気付いた場合にのみ使用してください。
ヒント4(脱出口): useRef 大きなオブジェクトの場合。
これは、オブジェクトのライフサイクルを自分で処理し、適切にクリーンアップする必要があることを意味する場合があります。最適ではありませんが、メモリがリークするよりはましです。
結論
クロージャはReactで頻繁に使用されるパターンです。これにより、関数はコンポーネントが最後にレンダリングされたときにスコープ内にあったプロパティと状態を記憶することができます。これは、次のようなメモ化技術と組み合わせると、予期しないメモリリークにつながる可能性があります。 useCallback特に大きなオブジェクトを扱う場合には、メモリリークを避けるには、クロージャスコープをできるだけ小さくし、必要のない場合にはメモ化を避け、 useRef 大きなオブジェクトの場合。
2013年の記事を執筆してくれたDavid Glasser氏に感謝します。 Meteor で驚くべき JavaScript メモリリークが発見される それは私を正しい方向に導いてくれました。
フィードバック?
私が何かを見逃したか、間違っていたと思いますか? おそらく、あなたはその問題に対するより良い解決策を持っているか、そもそもその問題に遭遇したことがなかったのでしょう。
ご質問やご意見がございましたら、お気軽にご連絡ください。 リンクトイン または X/ツイッター。
デバッグを楽しんでください!
#こっそりとした #React #メモリ #リーク #useCallback #とクロージャがもたらす被害