Znajdowanie i naprawianie trudnych do wykrycia wycieków pamięci spowodowanych przez odłączone okna.
Czym jest wyciek pamięci w JavaScript?
Wyciek pamięci to niezamierzone zwiększanie przez aplikację wykorzystywanej pamięci na przestrzeni czasu. W JavaScript wycieki pamięci występują, gdy obiekty nie są już potrzebne, ale nadal są używane przez funkcje lub inne obiekty. Te odwołania uniemożliwiają odzyskanie niepotrzebnych obiektów przez zbieracz śmieci.
Zadaniem zbieracza jest identyfikowanie i odzyskiwanie obiektów, których nie można już osiągnąć z aplikacji. Działa to nawet wtedy, gdy obiekty odwołują się do siebie lub odwołują się cyklicznie do siebie nawzajem. Gdy nie ma żadnych odwołań, za pomocą których aplikacja mogłaby uzyskać dostęp do grupy obiektów, można je usunąć.
let A = {};
console.log(A); // local variable reference
let B = {A}; // B.A is a second reference to A
A = null; // unset local variable reference
console.log(B.A); // A can still be referenced by B
B.A = null; // unset B's reference to A
// No references to A are left. It can be garbage collected.
Szczególnie trudny rodzaj wycieku pamięci występuje, gdy aplikacja odwołuje się do obiektów, które mają własny cykl życia, takich jak elementy DOM czy okna wyskakujące. Te typy obiektów mogą stać się nieużywane bez wiedzy aplikacji, co oznacza, że kod aplikacji może zawierać tylko odwołania do obiektu, który mógłby zostać usunięty przez mechanizm garbage collection.
Czym jest oddzielone okno?
W tym przykładzie aplikacja do wyświetlania prezentacji zawiera przyciski do otwierania i zamykania wyskakującego okienka z notatkami prowadzącego. Wyobraź sobie, że użytkownik klika Pokaż notatki, a potem zamyka wyskakujące okienko, zamiast kliknąć przycisk Ukryj notatki. W tym przypadku zmienna notesWindow
nadal zawiera odwołanie do wyskakującego okienka, do którego można uzyskać dostęp, mimo że okienko nie jest już używane.
<button id="show">Show Notes</button>
<button id="hide">Hide Notes</button>
<script type="module">
let notesWindow;
document.getElementById('show').onclick = () => {
notesWindow = window.open('/presenter-notes.html');
};
document.getElementById('hide').onclick = () => {
if (notesWindow) notesWindow.close();
};
</script>
To jest przykład okna odłączonego. Okno zostało zamknięte, ale nasz kod zawiera odwołanie do tego okna, które uniemożliwia przeglądarce jego usunięcie i odzyskanie pamięci.
Gdy strona wywołuje funkcję window.open()
, aby utworzyć nowe okno lub nową kartę przeglądarki, zwracany jest obiekt Window
reprezentujący to okno lub tę kartę. Nawet po zamknięciu takiego okna lub po przejściu do innej strony przez użytkownika obiekt Window
zwrócony przez funkcję window.open()
może być nadal używany do uzyskiwania informacji o tym oknie. To jeden z typów okien oderwanych: ponieważ kod JavaScript może nadal uzyskiwać dostęp do właściwości zamkniętego obiektu Window
, musi być przechowywany w pamięci. Jeśli okno zawierało wiele obiektów JavaScript lub elementów iframe, pamięć nie zostanie odzyskana, dopóki nie znikną wszystkie odwołania JavaScript do właściwości okna.
Ten sam problem może wystąpić również podczas używania elementów <iframe>
. Ramki iframe działają jak okna zagnieżdżone, które zawierają dokumenty, a ich właściwość contentWindow
zapewnia dostęp do zawartego obiektu Window
, podobnie jak wartość zwracana przez funkcję window.open()
. Kod JavaScript może zachować odwołanie do contentWindow
lub contentDocument
elementu iframe, nawet jeśli iframe zostanie usunięty z DOM lub zmieni się jego adres URL. Zapobiega to usunięciu dokumentu przez funkcję garbage collection, ponieważ nadal można uzyskać dostęp do jego właściwości.
Jeśli odwołanie do document
w oknie lub iframe jest przechowywane w JavaScript, ten dokument będzie przechowywany w pamięci nawet wtedy, gdy okno lub iframe przejdzie do nowego adresu URL. Może to być szczególnie kłopotliwe, gdy kod JavaScript zawierający to odwołanie nie wykryje, że okno lub ramka przełączyła się na nowy adres URL, ponieważ nie wie, kiedy staje się ostatnim odwołaniem przechowującym dokument w pamięci.
Jak okna odłączone powodują wycieki pamięci
Podczas pracy z oknami i ramkami iframe w tej samej domenie co strona główna często zdarza się nasłuchiwać zdarzeń lub uzyskiwać dostęp do właściwości poza granicami dokumentu. Przyjrzyjmy się na przykład wariantowi przykładu przeglądarki prezentacji z początku tego przewodnika. Użytkownik otworzy drugie okno, w którym wyświetlane są notatki. Okno notatek dla lektora sprawdza, czy zdarzenie click
oznacza przejście do następnego slajdu. Jeśli użytkownik zamknie to okno z notatkami, kod JavaScript działający w oryginalnym oknie nadrzędnym nadal będzie miał pełny dostęp do dokumentu z notatkami dla mówców:
<button id="notes">Show Presenter Notes</button>
<script type="module">
let notesWindow;
function showNotes() {
notesWindow = window.open('/presenter-notes.html');
notesWindow.document.addEventListener('click', nextSlide);
}
document.getElementById('notes').onclick = showNotes;
let slide = 1;
function nextSlide() {
slide += 1;
notesWindow.document.title = `Slide ${slide}`;
}
document.body.onclick = nextSlide;
</script>
Załóżmy, że zamkniemy okno przeglądarki utworzone przez showNotes()
powyżej. Nie ma żadnego przetwarzacza zdarzeń, który wykrywałby zamknięcie okna, więc nic nie informuje naszego kodu, że powinien usunąć wszystkie odwołania do dokumentu. Funkcja nextSlide()
jest nadal „aktywna”, ponieważ jest powiązana z obsługą kliknięcia na stronie głównej. Fakt, że funkcja nextSlide
zawiera odwołanie do notesWindow
, oznacza, że okno jest nadal dostępne i nie można go usunąć.
Istnieją też inne scenariusze, w których odwołania są przypadkowo zatrzymywane, co uniemożliwia odłączenie okien i uniemożliwia usunięcie ich przez garbage collection:
Obsługa zdarzeń może zostać zarejestrowana w pierwotnym dokumencie iframe przed przejściem do jego docelowego adresu URL, co powoduje przypadkowe odwołania do dokumentu i iframe'a, które pozostają po usunięciu innych odwołań.
Dokument zajmujący dużo pamięci wczytany w oknie lub elemencie iframe może być przypadkowo przechowywany w pamięci przez długi czas po przejściu do nowego adresu URL. Jest to często spowodowane tym, że strona nadrzędna zachowuje odwołania do dokumentu, aby umożliwić usunięcie detektorów.
Gdy przekazujesz obiekt JavaScript do innego okna lub elementu iframe, łańcuch prototypów obiektu zawiera odwołania do środowiska, w którym został utworzony, w tym do okna, w którym został utworzony. Oznacza to, że unikanie odwołań do obiektów z innych okien jest równie ważne, co unikanie odwołań do samych okien.
index.html:
<script> let currentFiles; function load(files) { // this retains the popup: currentFiles = files; } window.open('upload.html'); </script>
upload.html:
<input type="file" id="file" /> <script> file.onchange = () => { parent.load(file.files); }; </script>
Wykrywanie wycieków pamięci spowodowanych przez odłączone okna
Wycieki pamięci mogą być trudne do wykrycia. Często trudno jest odtworzyć te problemy w izolacji, zwłaszcza gdy występuje wiele dokumentów lub okien. Co więcej, sprawdzanie potencjalnych wycieków odwołań może prowadzić do utworzenia dodatkowych odwołań, które uniemożliwiają usunięcie zbędących obiektów. Dlatego warto zacząć od narzędzi, które uniemożliwiają wprowadzenie tej opcji.
Dobrym punktem wyjścia do debugowania problemów z pamięcią jest zrobienie migawki stosu. Umożliwia to przegląd pamięci używanej obecnie przez aplikację – wszystkich obiektów, które zostały utworzone, ale nie zostały jeszcze usunięte. Zrzuty sterty zawierają przydatne informacje o obiektach, w tym ich rozmiar i listę zmiennych i zamkniętego kodu, które się do nich odwołują.

Aby zarejestrować zrzut pamięci, otwórz kartę Pamięć w Narzędziach deweloperskich w Chrome i na liście dostępnych typów profilowania wybierz Zrzut sterty. Po zakończeniu nagrywania widok Podsumowanie zawiera bieżące obiekty w pamięci, pogrupowane według konstruktora.
Analiza zrzutów stosu może być trudnym zadaniem, a w ramach debugowania trudno jest znaleźć odpowiednie informacje. Aby pomóc w tym zakresie, inżynierowie Chromium yossik@ i peledni@ opracowali samodzielne narzędzie Heap Cleaner, które może pomóc w wyróżnieniu konkretnego węzła, np. oddzielnego okna. Uruchomienie narzędzia Heap Cleaner na śladzie powoduje usunięcie z wykresu retencji innych niepotrzebnych informacji, dzięki czemu ślad staje się czystszy i znacznie łatwiejszy do odczytania.
Pomiar pamięci za pomocą kodu
Zrzuty sterty zapewniają wysoki poziom szczegółowości i są doskonałym narzędziem do wykrywania wycieków pamięci, ale ich wykonywanie wymaga ręcznej interwencji. Innym sposobem sprawdzenia wycieków pamięci jest uzyskanie aktualnie używanego rozmiaru stosu JavaScript za pomocą interfejsu performance.memory
API:

Interfejs API performance.memory
udostępnia tylko informacje o rozmiarze stosu JavaScript, co oznacza, że nie uwzględnia pamięci używanej przez dokument i zasoby wyskakującego okienka. Aby uzyskać pełny obraz, musimy użyć nowego interfejsu performance.measureUserAgentSpecificMemory()
API, który jest obecnie testowany w Chrome.
Rozwiązania zapobiegające wyciekom z oddzielonego okna
Dwa najczęstsze przypadki, w których odłączone okna powodują wycieki pamięci, to gdy dokument nadrzędny zachowuje odwołania do zamkniętego wyskakującego okienka lub usuniętego iframe, oraz gdy nieoczekiwana nawigacja okna lub iframe powoduje, że przetwarzacze zdarzeń nigdy nie są rejestrowane.
Przykład: zamykanie wyskakującego okienka
W tym przykładzie do otwierania i zamykania wyskakującego okienka służą 2 przyciski. Aby przycisk Zamknij wyskakujące okienko działał, w zmiennej jest przechowywane odwołanie do otwartego okna:
<button id="open">Open Popup</button>
<button id="close">Close Popup</button>
<script>
let popup;
open.onclick = () => {
popup = window.open('/login.html');
};
close.onclick = () => {
popup.close();
};
</script>
Na pierwszy rzut oka wydaje się, że kod powyżej unika typowych pułapek: nie ma żadnych odwołań do dokumentu wyskakującego okienka i nie ma zarejestrowanych modułów obsługi zdarzeń w wyskakującym okienku. Jednak po kliknięciu przycisku Otwórz wyskakujące okienko zmienna popup
odwołuje się do otwartego okna i jest dostępna w zakresie obsługi kliknięcia przycisku Zamknij wyskakujące okienko. Jeśli popup
nie zostanie przypisany ponownie lub nie usuniesz obsługi kliknięcia, zawarte w nim odwołanie do popup
oznacza, że nie może zostać usunięty przez mechanizm garbage collection.
Rozwiązanie: usuń odwołania
Zmienne, które odwołują się do innego okna lub jego dokumentu, powodują, że jest on przechowywany w pamięci. Ponieważ obiekty w JavaScript są zawsze odwołaniami, przypisanie nowej wartości do zmiennych powoduje usunięcie odwołania do pierwotnego obiektu. Aby „wyczyścić” odwołania do obiektu, możemy przypisać te zmienne do wartości null
.
W przypadku poprzedniego przykładu okna wyskakującego możemy zmodyfikować metodę obsługi przycisku Zamknij, aby „usunąć” odwołanie do okna wyskakującego:
let popup;
open.onclick = () => {
popup = window.open('/login.html');
};
close.onclick = () => {
popup.close();
popup = null;
};
To pomaga, ale ujawnia kolejny problem dotyczący okien utworzonych za pomocą open()
: co zrobić, jeśli użytkownik zamknie okno zamiast kliknąć nasz niestandardowy przycisk Zamknij? Co więcej, co się stanie, jeśli użytkownik zacznie przeglądać inne strony internetowe w otwartym oknie? Początkowo wydawało się, że wystarczy zresetować odwołanie popup
po kliknięciu przycisku zamykania, ale nadal występuje wyciek pamięci, gdy użytkownicy nie używają tego przycisku do zamykania okna. Aby rozwiązać ten problem, należy wykryć takie przypadki, aby móc usunąć niechciane odwołania, gdy się pojawią.
Rozwiązanie: monitorowanie i usuwanie
W wielu przypadkach kod JavaScript odpowiedzialny za otwieranie okien lub tworzenie ramek nie ma wyłącznej kontroli nad ich cyklem życia. Użytkownik może zamknąć wyskakujące okienka, a przejście do nowego dokumentu może spowodować odłączenie dokumentu, który był wcześniej wyświetlany w oknie lub ramce. W obu przypadkach przeglądarka powoduje wystąpienie zdarzenia pagehide
, aby zasygnalizować, że dokument jest wyładowy.
Zdarzenia pagehide
można używać do wykrywania zamkniętych okien i przechodzenia do innych dokumentów. Jest jednak jeden ważny wyjątek: wszystkie nowo utworzone okna i ramki iframe zawierają pusty dokument, a następnie asynchronicznie przechodzą do podanego adresu URL (jeśli jest podany). W rezultacie, tuż po utworzeniu okna lub ramki, tuż przed załadowaniem dokumentu docelowego, jest wywoływane pierwsze zdarzenie pagehide
. Ponieważ nasz kod czyszczenia odwołań musi być wykonywany po zwolnieniu pamięci z dokumentu docelowego, musimy zignorować to pierwsze zdarzenie pagehide
. Istnieje kilka metod, z których najprostszą jest zignorowanie zdarzeń pagehide pochodzących z adresu URL about:blank
dokumentu początkowego. Oto jak to wygląda w naszym przykładzie wyskakującego okienka:
let popup;
open.onclick = () => {
popup = window.open('/login.html');
// listen for the popup being closed/exited:
popup.addEventListener('pagehide', () => {
// ignore initial event fired on "about:blank":
if (!popup.location.host) return;
// remove our reference to the popup window:
popup = null;
});
};
Pamiętaj, że ta metoda działa tylko w przypadku okien i ramek, które mają tę samą docelową domenę co strona nadrzędna, na której działa nasz kod. Podczas wczytywania treści z innego źródła zdarzenie location.host
i zdarzenie pagehide
są niedostępne ze względów bezpieczeństwa. Ogólnie zalecamy unikanie wskazywania innych źródeł, ale w rzadkich przypadkach, gdy jest to konieczne, można monitorować usługi window.closed
lub frame.isConnected
. Gdy te właściwości ulegną zmianie, co wskazuje na zamknięte okno lub usunięty iframe, warto usunąć wszystkie odniesienia do niego.
let popup = window.open('https://example.com');
let timer = setInterval(() => {
if (popup.closed) {
popup = null;
clearInterval(timer);
}
}, 1000);
Rozwiązanie: użyj WeakRef
Niedawno JavaScript uzyskał obsługę nowego sposobu odwoływania się do obiektów, który umożliwia usuwanie elementów z pamięci. Nazywa się on WeakRef
. Obiekt WeakRef
utworzony dla obiektu nie jest bezpośrednim odwołaniem, ale oddzielnym obiektem, który udostępnia specjalną metodę .deref()
zwracającą odwołanie do obiektu, dopóki nie zostanie on usunięty przez mechanizm garbage collection. Dzięki WeakRef
można uzyskać dostęp do bieżącej wartości okna lub dokumentu, a jednocześnie umożliwić jego usunięcie. Zamiast przechowywać odwołanie do okna, które należy ręcznie usunąć w odpowiedzi na zdarzenia takie jak pagehide
lub właściwości takie jak window.closed
, dostęp do okna jest uzyskiwany w miarę potrzeby. Gdy okno jest zamknięte, może zostać usunięte przez garbage collectora, co spowoduje, że metoda .deref()
zacznie zwracać undefined
.
<button id="open">Open Popup</button>
<button id="close">Close Popup</button>
<script>
let popup;
open.onclick = () => {
popup = new WeakRef(window.open('/login.html'));
};
close.onclick = () => {
const win = popup.deref();
if (win) win.close();
};
</script>
Podczas korzystania z WeakRef
do uzyskiwania dostępu do okien lub dokumentów należy pamiętać, że odwołanie jest zazwyczaj dostępne przez krótki czas po zamknięciu okna lub usunięciu ramki. Dzieje się tak, ponieważ funkcja WeakRef
zwraca wartość do momentu, gdy powiązany z nią obiekt zostanie usunięty przez mechanizm usuwania elementów nieużywanych, co w JavaScriptzie odbywa się asynchronicznie i zwykle w czasie bezczynności. Na szczęście, gdy w Narzędziach deweloperskich Chrome sprawdzasz odłączone okna w panelu Pamięć, zrobienie migawki stosu powoduje wywołanie zbierania elementów zbędących i usunięcie okna z słabo powiązanymi odwołaniami. Możesz też sprawdzić, czy obiekt, do którego odwołuje się zmienna WeakRef
, został usunięty z JavaScriptu. Możesz to zrobić, wykrywając, kiedy funkcja deref()
zwraca wartość undefined
, lub używając nowego interfejsu FinalizationRegistry
API:
let popup = new WeakRef(window.open('/login.html'));
// Polling deref():
let timer = setInterval(() => {
if (popup.deref() === undefined) {
console.log('popup was garbage-collected');
clearInterval(timer);
}
}, 20);
// FinalizationRegistry API:
let finalizers = new FinalizationRegistry(() => {
console.log('popup was garbage-collected');
});
finalizers.register(popup.deref());
Rozwiązanie: komunikacja przez postMessage
Wykrywanie, kiedy okna są zamykane lub nawigacja zwalni z pamięci dokument, daje nam możliwość usuwania obsługiwanych obiektów i odwoływanych odwołań, aby można było usunąć z pamięci odłączone okna. Te zmiany to konkretne poprawki dotyczące problemu, który może być czasem bardziej fundamentalny: bezpośredniego powiązania między stronami.
Dostępne jest bardziej holistyczne podejście, które pozwala uniknąć nieaktualnych odniesień między oknami i dokumentami: ustanowienie oddzielenia przez ograniczenie komunikacji między dokumentami do postMessage()
. Wróćmy do przykładu notatek prowadzącego. Funkcje takie jak nextSlide()
aktualizowały okno notatek bezpośrednio, odwołując się do niego i manipulując jego zawartością. Zamiast tego strona główna może przekazywać niezbędne informacje do okna notatek asynchronicznie i pośrednio za pomocą interfejsu postMessage()
.
let updateNotes;
function showNotes() {
// keep the popup reference in a closure to prevent outside references:
let win = window.open('/presenter-view.html');
win.addEventListener('pagehide', () => {
if (!win || !win.location.host) return; // ignore initial "about:blank"
win = null;
});
// other functions must interact with the popup through this API:
updateNotes = (data) => {
if (!win) return;
win.postMessage(data, location.origin);
};
// listen for messages from the notes window:
addEventListener('message', (event) => {
if (event.source !== win) return;
if (event.data[0] === 'nextSlide') nextSlide();
});
}
let slide = 1;
function nextSlide() {
slide += 1;
// if the popup is open, tell it to update without referencing it:
if (updateNotes) {
updateNotes(['setSlide', slide]);
}
}
document.body.onclick = nextSlide;
Chociaż nadal wymaga to wzajemnego odwoływania się okien do siebie nawzajem, żadne z nich nie zachowuje odniesienia do bieżącego dokumentu z innego okna. Przekazywanie wiadomości zachęca też do tworzenia projektów, w których odwołania do okien są przechowywane w jednym miejscu, co oznacza, że po zamknięciu okien lub przekierowaniu na inną stronę trzeba odznaczyć tylko jedno odwołanie. W tym przykładzie tylko element showNotes()
zachowuje odwołanie do okna notatek i używa zdarzenia pagehide
, aby to odwołanie usunąć.
Rozwiązanie: unikaj odwołań za pomocą noopener
W przypadku otwierania wyskakującego okienka, z którym Twoja strona nie musi się komunikować ani nad nim kontrolować, możesz uniknąć uzyskania odwołania do tego okienka. Jest to szczególnie przydatne podczas tworzenia okien lub ramek iframe, które będą wczytywać treści z innej witryny. W takich przypadkach window.open()
akceptuje opcję "noopener"
, która działa tak samo jak atrybut rel="noopener"
w przypadku linków HTML:
window.open('https://example.com/share', null, 'noopener');
Opcja "noopener"
powoduje, że window.open()
zwraca wartość null
, co uniemożliwia przypadkowe zapisanie odwołania do wyskakującego okienka. Zapobiega też uzyskaniu przez wyskakujące okienko odwołania do okna nadrzędnego, ponieważ właściwość window.opener
będzie miała wartość null
.
Prześlij opinię
Mamy nadzieję, że niektóre z podanych w tym artykule sugestii pomogą Ci znaleźć i naprawić wycieki pamięci. Jeśli znasz inną metodę debugowania okien odłączonych lub ten artykuł pomógł Ci wykryć wycieki danych w aplikacji, daj nam znać. Znajdziesz mnie na Twitterze: @_developit.