Wycieki pamięci odłączonego okna

Znajdź i napraw trudne wycieki pamięci spowodowane odłączeniem okien.

Bartek Nowierski
Bartek Nowierski

Co to jest wyciek pamięci w JavaScript?

Wyciek pamięci to niezamierzone zwiększenie ilości pamięci wykorzystywanej przez aplikację wraz z upływem czasu. W języku JavaScript wyciek pamięci ma miejsce, gdy obiekty nie są już potrzebne, ale nadal odwołują się do nich funkcje lub inne obiekty. Te odwołania zapobiegają odzyskaniu niepotrzebnych obiektów przez kolektor śmieci.

Zadaniem modułu czyszczenia pamięci jest identyfikowanie i odzyskiwanie obiektów, które nie są już dostępne z poziomu aplikacji. Działa to nawet wtedy, gdy obiekty odwołują się do siebie lub do siebie cyklicznie. Gdy nie ma już żadnych odwołań, za pomocą których aplikacja mogła uzyskać dostęp do grupy obiektów, może zostać zgromadzone śmieci.

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 trudna klasa wycieku pamięci ma miejsce, gdy aplikacja odwołuje się do obiektów, które mają swój własny cykl życia, takich jak elementy DOM czy wyskakujące okienka. Obiekty tego typu mogą stać się niewykorzystane bez wiedzy aplikacji, co oznacza, że kod aplikacji może mieć jedyne pozostałe odniesienia do obiektu, który w przeciwnym razie mógłby zostać usunięty.

Czym jest wolnostojące okno?

W poniższym przykładzie aplikacja do wyświetlania pokazów slajdów zawiera przyciski do otwierania i zamykania wyskakującego okienka z notatkami. Załóżmy, że użytkownik klika Pokaż notatki, a następnie bezpośrednio zamyka wyskakujące okienko, zamiast kliknąć przycisk Ukryj notatki – zmienna notesWindow nadal zawiera odniesienie do wyskakującego okienka, które było dostępne, mimo że wyskakujące 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 przykład oddzielnego okna. Wyskakujące okienko zostało zamknięte, ale w naszym kodzie znajduje się odwołanie, które uniemożliwia przeglądarce jego zniszczenie i odzyskanie tej pamięci.

Gdy strona wywołuje funkcję window.open(), aby utworzyć nowe okno lub kartę przeglądarki, zwracany jest obiekt Window reprezentujący okno lub kartę. Nawet po zamknięciu takiego okna lub zamknięciu go przez użytkownika obiekt Window zwrócony z window.open() może być nadal używany, aby uzyskać o nim informacje. To jeden z typów odłączonych okien: ponieważ kod JavaScript nadal może potencjalnie uzyskiwać dostęp do właściwości zamkniętego obiektu Window, musi być przechowywany w pamięci. Jeśli okno zawiera wiele obiektów JavaScript lub elementów iframe, nie będzie można odzyskać tej pamięci, dopóki nie będą już występować odwołania do właściwości okna z JavaScriptem.

Używanie Narzędzi deweloperskich w Chrome, aby pokazać, jak można zachować dokument po zamknięciu okna.

Ten sam problem może też wystąpić podczas korzystania z elementów <iframe>. Elementy iframe zachowują się jak zagnieżdżone okna, które zawierają dokumenty. Ich właściwość contentWindow zapewnia dostęp do zawartego w nich obiektu Window, podobnie jak wartość zwracana przez window.open(). Kod JavaScript może się odnosić do elementów contentWindow lub contentDocument elementu iframe nawet wtedy, gdy element iframe został usunięty z elementu DOM lub ze zmianą jego adresu URL. Zapobiega to gromadzeniu dokumentu, ponieważ nadal można uzyskać dostęp do jego właściwości.

Demonstracja, jak moduł obsługi zdarzeń może przechowywać dokument elementu iframe, nawet po przejściu elementu iframe na inny adres URL.

W przypadku, gdy odwołanie do elementu document w oknie lub elemencie iframe jest zachowywane z kodu JavaScript, dokument będzie przechowywany w pamięci nawet wtedy, gdy okno lub element iframe przejdzie pod nowy adres URL. Może to być szczególnie kłopotliwe, gdy kod JavaScript odwołujący się do tego odniesienia nie wykryje, że okno/ramka przeszedł(a) do nowego adresu URL, ponieważ nie wie, kiedy staje się ostatnim odwołaniem przechowującym dokument w pamięci.

Jak odłączenie okien powoduje wyciek pamięci

Podczas pracy z oknami i elementami iframe w tej samej domenie co strona podstawowa często nasłuchuje się zdarzeń lub uzyskuje dostęp do właściwości niezależnie od granic dokumentów. Ponownie zajmijmy się przykładem przeglądarki prezentacji z początku tego przewodnika. Widz otworzy drugie okno, w którym będą wyświetlane notatki. Okno z notatkami nasłuchuje zdarzeń click, gdy wskazuje im następny slajd. Jeśli użytkownik zamknie to okno notatek, kod JavaScript uruchomiony w pierwotnym oknie nadrzędnym nadal będzie miał pełny dostęp do dokumentu z notatkami:

<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 zamykamy okno przeglądarki utworzone przez użytkownika showNotes(). Żaden moduł obsługi zdarzeń nie wykrywa, że okno zostało zamknięte. Nic nie informuje nasz kod o konieczności wyczyszczenia odwołań do dokumentu. Funkcja nextSlide() jest nadal aktywna, ponieważ jest powiązana z modułem obsługi kliknięć na stronie głównej, a że nextSlide zawiera odniesienie do elementu notesWindow, oznacza to, że wciąż się do niego odwołuje się okno i nie można go gromadzić.

Ilustracja pokazująca, jak odniesienia do okna zapobiegają gromadzeniu śmieci po jego zamknięciu.

Istnieje wiele innych scenariuszy, w których pliki referencyjne są przypadkowo zachowywane, przez co odłączone okna nie kwalifikują się do czyszczenia pamięci:

  • Moduły obsługi zdarzeń można zarejestrować w początkowym dokumencie elementu iframe jeszcze przed przejściem ramki do odpowiedniego adresu URL. Powoduje to przypadkowe odwołania do dokumentu i elementu iframe, które utrzymują się po wyczyszczeniu innych odwołań.

  • Dokument, który pochłania dużo pamięci, może zostać przypadkowo zapisany w pamięci przez długi czas po otwarciu nowego adresu URL. Często jest to spowodowane tym, że strona nadrzędna przechowuje odniesienia do dokumentu, co umożliwia usunięcie detektora.

  • Gdy przekazujesz obiekt JavaScript do innego okna lub elementu iframe, łańcuch prototypu obiektu zawiera odwołania do środowiska, w którym został utworzony, w tym do okna, w którym go utworzono. Oznacza to, że równie ważne jest, aby unikać utrzymywania odniesień do obiektów z innych okien, co unikanie utrzymywania odniesień 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 odłączeniem okien

Wykrywanie wycieków pamięci może być trudne. Często trudno jest odtworzyć izolowane problemy, zwłaszcza gdy w reklamach jest wiele dokumentów lub okien. Aby to komplikować, zbadanie potencjalnych wycieków plików referencyjnych może w efekcie utworzyć dodatkowe odwołania, które uniemożliwią czyszczenie sprawdzanych obiektów. W związku z tym warto zacząć od narzędzi, które wyraźnie zapobiegają takiej ewentualności.

Dobrym punktem wyjścia do debugowania problemów z pamięcią jest zrobienie zrzutu stosu. Daje to widok pamięci używanej obecnie przez aplikację z określonego momentu w postaci wszystkich obiektów, które zostały utworzone, ale nie zostały jeszcze usunięte z kosza. Migawki sterty zawierają przydatne informacje o obiektach, w tym ich rozmiar oraz listę zmiennych i zamknięć, które się do nich odnoszą.

Zrzut ekranu ze zrzutem stosu w Narzędziach deweloperskich w Chrome i widocznymi odwołaniami, które zawierają duży obiekt.
Zrzut sterty przedstawiający odwołania, które zachowują duży obiekt.

Aby nagrać zrzut sterty, w Narzędziach deweloperskich w Chrome otwórz kartę Pamięć i na liście dostępnych typów profilowania wybierz Zrzut sterty. Po zakończeniu nagrywania w widoku Podsumowanie wyświetlą się aktualne obiekty w pamięci pogrupowane według konstruktora.

Demonstracja robienia zrzutu stosu w Narzędziach deweloperskich w Chrome.

Analizowanie zrzutu stosu może być trudnym zadaniem, a znalezienie właściwych informacji w ramach debugowania może być dość trudne. Aby im to ułatwić, inżynierowie Chromium yossik@ i peledni@ opracowali niezależne narzędzie Heap Cleaner, które pomaga wyróżnić określony węzeł, na przykład odłączone okno. Uruchomienie narzędzia Heap oczyszczającego ślady w logu czasu powoduje usunięcie z wykresu przechowywania innych niepotrzebnych informacji, dzięki czemu ślad jest bardziej przejrzysty i czytelniejszy.

Automatyczne pomiar pamięci

Zrzuty sterty zapewniają wysoki poziom szczegółów i doskonale nadają się do wykrywania miejsc wycieku. Jednak wykonywanie zrzutu stosu to proces ręczny. Innym sposobem na sprawdzenie wycieków pamięci jest pobranie obecnie używanego rozmiaru sterty JavaScriptu z interfejsu API performance.memory:

Zrzut ekranu przedstawiający sekcję interfejsu Narzędzi deweloperskich w Chrome.
Sprawdzaj rozmiar używanej sterty JS w Narzędziach deweloperskich w miarę tworzenia, zamykania i wyłączania wyskakującego okienka.

Interfejs performance.memory API podaje tylko informacje o rozmiarze stosu JavaScriptu, co oznacza, że nie uwzględnia pamięci używanej przez dokument i zasoby wyskakującego okienka. Aby uzyskać pełny obraz, użyj nowego interfejsu API performance.measureUserAgentSpecificMemory(), który jest obecnie testowany w Chrome.

Rozwiązania pozwalające uniknąć przecieków odłączonych okien

2 najczęstsze sytuacje, w których odłączenie okien powoduje wyciek pamięci, to gdy dokument nadrzędny zachowuje odniesienia do zamkniętego wyskakującego okienka lub usuniętego elementu iframe oraz gdy nieoczekiwana nawigacja po oknie lub elemencie iframe powoduje, że moduły obsługi zdarzeń nigdy nie są wyrejestrowane.

Przykład: zamykanie wyskakującego okienka

W przykładzie poniżej do otwierania i zamykania wyskakującego okienka służą 2 przyciski. Aby przycisk Zamknij wyskakujące okienko działał, w zmiennej jest zapisane odniesienie do otwartego wyskakującego okienka:

<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 wygląda na to, że wspomniany kod unika typowych błędów: nie są zachowywane odwołania do dokumentu z wyskakującym okienkiem i nie są w nim zarejestrowane żadne moduły obsługi zdarzeń. Jednak po kliknięciu przycisku Otwórz wyskakujące okienko zmienna popup odwołuje się teraz do otwartego okna. Zmienna jest dostępna w zakresie modułu obsługi kliknięcia przycisku Zamknij wyskakujące okienko. O ile element popup nie zostanie ponownie przypisany lub moduł obsługi kliknięć nie zostanie usunięty, umieszczone w nim odniesienie do elementu popup nie będzie mieć możliwości odśmiecania.

Rozwiązanie: odwołania bez ustawienia

Zmienne, które odwołują się do innego okna lub jego dokumentu, powodują, że zostaje ono zachowane w pamięci. Obiekty w JavaScripcie są zawsze odwołaniami, więc przypisanie nowej wartości do zmiennych spowoduje usunięcie ich odwołania do pierwotnego obiektu. Aby „cofnąć ustawienie” odwołań do obiektu, możemy przepisać te zmienne do wartości null.

Jeśli chodzi o poprzedni przykład wyskakującego okienka, możemy zmodyfikować moduł obsługi przycisku zamykania, by „cofnął” jego odniesienie do wyskakującego okienka:

let popup;
open.onclick = () => {
  popup = window.open('/login.html');
};
close.onclick = () => {
  popup.close();
  popup = null;
};

Pomaga to, ale odkrywa kolejny problem związany z oknami utworzonymi za pomocą open(): co się stanie, jeśli użytkownik zamknie okno, zamiast kliknąć nasz niestandardowy przycisk zamykania? Co więcej, co się stanie, jeśli użytkownik zacznie przeglądać inne witryny w otwartym oknie? Początkowo wydawało się, że cofnięcie działania odwołania popup po kliknięciu przycisku zamykania nadal występuje, ale w przypadku użytkowników, którzy nie zamknąli okna za pomocą konkretnego przycisku, nadal występuje wyciek pamięci. Aby rozwiązać ten problem, trzeba wykrywać takie przypadki, aby móc usuwać powtarzające się odwołania, gdy się pojawiają.

Rozwiązanie: monitorowanie i utylizacja

W wielu sytuacjach kod JavaScript odpowiedzialny za otwieranie okien i tworzenie ramek nie ma wyłącznej kontroli nad ich cyklem życia. Użytkownik może zamknąć wyskakujące okienka lub przejść do nowego dokumentu. Może to spowodować odłączenie dokumentu, który wcześniej znajdował się w oknie lub ramce. W obu przypadkach przeglądarka uruchamia zdarzenie pagehide sygnalizujące, że dokument jest wyładowywany.

Zdarzenie pagehide może służyć do wykrywania zamkniętych okien i opuszczenia bieżącego dokumentu. Jest jednak jedno ważne zastrzeżenie: wszystkie nowo utworzone okna i elementy iframe zawierają pusty dokument, a jeśli go podano, asynchronicznie przejdź do danego adresu URL. W związku z tym początkowe zdarzenie pagehide jest wywoływane krótko po utworzeniu okna lub ramki, tuż przed wczytaniem dokumentu docelowego. Nasz referencyjny kod czyszczenia musi być uruchamiany podczas wyładowywania dokumentu target, więc musimy zignorować to pierwsze zdarzenie pagehide. Można to zrobić na kilka sposobów, a najprostszym z nich jest ignorowanie zdarzeń pagehide, które pochodzą z adresu URL about:blank dokumentu początkowego. Tak będzie to wyglądać w naszym wyskakującym okienku:

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;
  });
};

Warto zauważyć, że ta technika działa tylko w przypadku okien i ramek, których efektywne źródło jest takie samo jak strona nadrzędna, na której działa nasz kod. Podczas wczytywania treści z innego źródła zarówno zdarzenie location.host, jak i zdarzenie pagehide są niedostępne ze względów bezpieczeństwa. Chociaż zwykle najlepiej unikać odniesień do innych źródeł, w rzadkich przypadkach, gdy jest to wymagane, można monitorować właściwości window.closed lub frame.isConnected. Gdy właściwości te zmieniają się w sposób wskazujący na zamknięte okno lub usunięte element iframe, warto usunąć do niego odniesienia.

let popup = window.open('https://example.com');
let timer = setInterval(() => {
  if (popup.closed) {
    popup = null;
    clearInterval(timer);
  }
}, 1000);

Rozwiązanie: użycie WeakRef

W języku JavaScript niedawno wprowadziliśmy obsługę nowego sposobu odwołań do obiektów, który umożliwia wykonywanie czyszczenia pamięci – o nazwie WeakRef. Obiekt WeakRef utworzony dla obiektu nie jest bezpośrednim odwołaniem, ale osobnym obiektem, który udostępnia specjalną metodę .deref(), która zwraca odwołanie do obiektu, o ile nie został on usunięty do kosza. Dzięki WeakRef można uzyskać dostęp do bieżącej wartości okna lub dokumentu, a jednocześnie nadal można je usuwać. Zamiast zachowywania odniesienia do okna, które należy ręcznie ustawić w odpowiedzi na zdarzenia takie jak pagehide lub właściwości (np. window.closed), dostęp do okna jest uzyskwany w razie potrzeby. Gdy okno jest zamknięte, mogą odzyskiwać śmieci, co powoduje, że metoda .deref() zwraca wartość 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 metody WeakRef do uzyskiwania dostępu do okien lub dokumentów warto pamiętać, że odwołanie zazwyczaj pozostaje dostępne przez krótki czas po zamknięciu okna lub usunięciu elementu iframe. Wynika to z faktu, że WeakRef w dalszym ciągu zwraca wartość, dopóki powiązany z nim obiekt nie zostanie usunięty, co następuje asynchronicznie w kodzie JavaScript i zwykle w czasie bezczynności. Na szczęście podczas sprawdzania odłączonych okien w panelu Pamięć w Narzędziach deweloperskich w Chrome wykonanie zrzutu stosu uruchamiającego czyszczenie pamięci i usuwa okno, do którego słabo zapisano. Możesz też sprawdzić, czy obiekt, do którego odwołuje się WeakRef, został usunięty z JavaScriptu. Możesz to zrobić, wykrywając, kiedy 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 zamknięcia okien lub usuwania wczytanych elementów z dokumentu pozwala nam usunąć moduły obsługi i odniesienia do niego. Dzięki temu odłączone okna mogą być zbierane śmieci. Są to jednak konkretne rozwiązania, które radzą sobie z trudniejszymi kwestiami: bezpośrednimi powiązaniami między stronami.

Dostępne jest bardziej holistyczne podejście alternatywne, które pozwala uniknąć nieaktualnych odwołań między oknami i dokumentami: rozdzielenie między dokumentami przez ograniczenie komunikacji między dokumentami do postMessage(). Wspominając o pierwotnym przykładzie notatek dla prelegenta, funkcje takie jak nextSlide() bezpośrednio zaktualizowały okno notatek, odwołując się do niego i manipulując jego treścią. Zamiast tego strona główna może przekazywać niezbędne informacje do okna notatek asynchronicznie i pośrednio przez interfejs 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;

Mimo że okna nadal muszą się do siebie odwoływać, żadne z nich nie zachowuje odniesienia do bieżącego dokumentu z innego okna. Sposób przekazywania wiadomości sprzyja też projektowaniu, w którym odwołania do okien są przechowywane w jednym miejscu. Oznacza to, że gdy okno jest zamykane lub opuszczane, wystarczy cofnąć ustawienie tylko jednego odwołania. W powyższym przykładzie tylko showNotes() zachowuje odniesienie do okna notatek i używa zdarzenia pagehide do jego wyczyszczenia.

Rozwiązanie: unikaj odniesień, używając elementu noopener

W sytuacji, gdy zostanie otwarte wyskakujące okienko, z którego strona nie musi się komunikować ani kontrolować, możesz uniknąć uzyskania odniesienia do okna. Jest to szczególnie przydatne przy tworzeniu okien lub elementów 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" dla 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 to też pobieraniu odwołania do okna nadrzędnego przez wyskakujące okienko, ponieważ właściwość window.opener będzie mieć wartość null.

Prześlij opinię

Mamy nadzieję, że niektóre sugestie w tym artykule pomogą Ci znaleźć i rozwiązać problemy z wyciekami pamięci. Jeśli masz inną metodę debugowania odłączonych okien lub ten artykuł pomógł Ci w wykryciu przecieków w Twojej aplikacji, chętnie się o tym dowiemy. Znajdziesz mnie na Twitterze: @_developit.