Wycieki pamięci odłączonego okna

znajdować i naprawiać trudne wycieki pamięci spowodowane przez odłączone okna;

Bartek Nowierski
Bartek Nowierski

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ż dosię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 w przeciwnym razie mógłby zostać usunięty przez mechanizm garbage collection.

Co to jest oddzielone okno?

W tym przykładzie aplikacja do wyświetlania prezentacji zawiera przyciski do otwierania i zamykania wyskakującego okienka z notatkami prezentera. 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, co uniemożliwia przeglądarce jego usunięcie i zwolnienie pamięci.

Gdy strona wywołuje funkcję window.open(), aby utworzyć nowe okno lub nową kartę przeglądarki, zwracany jest obiekt Window, który reprezentuje okno lub 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 może zostać odzyskana, dopóki nie znikną wszystkie odwołania JavaScript do właściwości okna.

Za pomocą Narzędzi deweloperskich w Chrome pokazujemy, jak można zachować dokument po zamknięciu 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 zawierające 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.

Demonstracja tego, jak obciążnik zdarzenia może zachować dokument iframe nawet po przejściu do innego adresu URL.

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 otwiera drugie okno, aby wyświetlić notatki. Okno notatek dla mówców reaguje na zdarzenia click, które są sygnałem do przejścia 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ąć.

Ilustracja pokazująca, jak odwołania do okna zapobiegają jego usunięciu przez garbage collector po zamknięciu.

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. Dzięki temu można uzyskać aktualny widok pamięci używanej przez aplikację – wszystkich obiektów, które zostały utworzone, ale nie zostały jeszcze objęte funkcją usuwania śmieci. 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ą.

Zrzut ekranu przedstawiający migawka stosu w Narzędziach deweloperskich w Chrome, pokazujący odwołania, które zawierają duży obiekt.
Sterta pokazująca odwołania, które przechowują duży obiekt.

Aby zarejestrować zrzut sterty, 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.

Demonstracja wykonywania zrzutu pamięci w Narzędziach deweloperskich w Chrome.

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@peledni@ opracowali samodzielne narzędzie Heap Cleaner, które może pomóc w wyróżnieniu konkretnego węzła, np. okna odłączonego. Uruchomienie narzędzia Heap Cleaner na śladzie usuwa z wykresu retencji inne niepotrzebne informacje, 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 pracy. Innym sposobem sprawdzenia wycieków pamięci jest uzyskanie aktualnie używanego rozmiaru stosu JavaScript za pomocą interfejsu performance.memory API:

Zrzut ekranu przedstawiający część interfejsu Narzędzi deweloperskich w Chrome.
Sprawdzanie rozmiaru sterty JS w Narzędziach deweloperskich podczas tworzenia, zamykania i usuwania odwołania do wyskakującego okienka.

Interfejs API performance.memory udostępnia 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, musimy użyć nowego interfejsu performance.measureUserAgentSpecificMemory() API, który jest obecnie testowany w Chrome.

Rozwiązania zapobiegające wyciekom danych z okna odłączonego

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 modułu obsługi kliknięcia, zawarte w nim odwołanie do popup oznacza, że nie można go usunąć.

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ąć 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 uruchamia zdarzenie 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 został podany). W rezultacie, tuż po utworzeniu okna lub ramki, tuż przed załadowaniem dokumentu docelowego, jest wywoływane pierwsze zdarzeniepagehide. Ponieważ nasz kod czyszczenia odwołania 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 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 zdarzenia location.host i pagehide są niedostępne ze względów bezpieczeństwa. Zazwyczaj lepiej jest unikać odwoływania się do innych usług, 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ż WeakRef nadal zwraca wartość, dopóki powiązany z nim obiekt nie zostanie usunięty, co dzieje się asynchronicznie w JavaScript 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 za pomocą 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ć czasami bardziej fundamentalny: bezpośrednie powiązanie stron.

Dostępne jest bardziej holistyczne podejście, które pozwala uniknąć nieaktualnych odniesień między oknami i dokumentami: ustanowienie separacji 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 odwołania 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 przejściu do innej strony trzeba zmienić tylko jedno odwołanie. W tym przykładzie tylko 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 to również 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 pamięci w aplikacji, daj nam znać. Znajdziesz mnie na Twitterze: @_developit.